diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2ca5caf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + env: + PYTHONUTF8: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Frontend tests + run: node --test "tests/*.test.cjs" + - name: Python tests + run: python -B -m unittest discover -s tests -p "test_*.py" diff --git a/.gitignore b/.gitignore index da0a802..9b56424 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ data/.update-backups/ data/.bookmark-backups/ data/.update-stage-*/ data/bookmarks.html +data/.data-build.json web/data.js .claude/ installer/ diff --git a/README.md b/README.md index 39b9081..f6a2fdf 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Chrome / Edge / Safari 书签同步 · 多主题与十六种布局 · 动态天 ### 分类与常用操作 - 点击“我的书签”旁的“整理分类”,拖动分类把手或使用箭头调整区块顺序;横向排列用左右箭头和 Alt+←/→,纵向排列用上下箭头和 Alt+↑/↓,窗口宽度变化时自动适配。完成后收起整理控件,顺序按当前分类保存在本机,也可恢复默认顺序。分组看板和分类瀑布流支持小、中、大列宽;搜索期间暂停整理,详细表格不提供分类排序入口。 -- 在“外观 → 外观方案”输入名称保存当前主题、图标、布局、密度、动效参数和专注状态;已有方案可应用、更新或删除。自动主题与跟随系统的动态效果仍保持自动,方案仅存于当前浏览器,不包含书签数据。 +- 在“外观 → 外观方案”输入名称保存当前主题、图标、布局、密度、动效参数和专注状态;已有方案可应用、更新或删除。自动主题与跟随系统的动态效果仍保持自动,方案仅存于当前浏览器,不包含书签数据。其他版本保存、当前版本无法使用的方案(例如引用了已移除的主题)会被隐藏并原样保留,不影响其余方案。 - 右击书签、按 Shift+F10,或点击卡片上的“更多操作”,可在新标签页打开、复制网址、查看完整名称与所属分类;触屏设备直接显示更多按钮。复制受限时会显示可手动复制的网址。 ### Windows @@ -55,6 +55,8 @@ Chrome / Edge / Safari 书签同步 · 多主题与十六种布局 · 动态天 点击右上角“同步书签”,选择浏览器,再点击“确认同步”。Windows 会检索有有效书签的 Chrome、Edge、Brave、Vivaldi、Opera、Opera GX、QQ、360 极速/极速 X、搜狗高速、夸克和 UC 浏览器;macOS 支持 Chrome、Safari。其他浏览器或使用专有收藏格式的版本,请先在书签管理中导出 HTML 文件,再选择“其他浏览器”并在弹出的窗口中选取该文件。默认识别当前浏览器类型,不支持或无法识别时需要手动选择,不会擅自切换同步来源。 +导入 HTML 时按标签解析,整份文件写在一行、属性顺序不同或标题跨行的导出文件也能识别;未识别到任何书签时不会替换现有主页。重复网址只在页面中显示一次,`data/bookmarks.html` 保持导入时的原样。 + 同步会替换**当前运行目录**内主页的书签和分类,不修改浏览器里的收藏夹;完成后刷新主页并回到“全部”,避免旧分类筛选隐藏新书签,其他外观设置保持不变。多账号按浏览器最近使用的配置读取,网页无法识别当前标签页所属账号。取消不执行同步,同步过程中禁止重复提交;文件模式和远程托管演示页不支持此入口,请通过本地快捷方式打开。 ### 安装版与源码版同时使用 @@ -130,6 +132,7 @@ Bookmark/ ## 天气刷新 - 天气数据来自 Open-Meteo,打开或刷新网页时立即获取;之后每分钟检查一次,天气满 10 分钟才重新获取。 +- 主天气源失败或 1.5 秒内没有结果时,才同时请求本地服务的备用源;任一来源成功后取消其余请求。本地服务对同一城市的查询结果保留 5 分钟,同时打开的多个页面共用一次上游请求。 - 点击天气旁的刷新按钮可立即更新;点击天气文字可设置或切换城市。 - 最近 10 分钟的天气由各标签页共用:一个页面刷新后,其他页面直接显示结果,不重复请求。打开网页和手动刷新始终重新获取;网络失败时保留已有天气,刷新按钮提示重试。 - 页面在后台时不定时请求,切回页面后如天气已过期会立即补上。浏览器休眠或冻结页面时,检查可能延后;刷新网页或手动点击可立即重新获取。 @@ -149,7 +152,7 @@ Bookmark/ - ZIP 通道从公开 API 读取 `main` 对应的 `APP_VERSION`,仅安装更高的三段版本号,不自动降级。发布 ZIP 更新时必须提高 `scripts/manage.py` 与 `web/index.html` 的版本号;同版本号的代码修改只会被 Git 通道检测到。 - Gitee 的 ZIP 下载 API 需要授权,因此免 Git 升级使用公开文件清单和原始文件接口,按固定提交下载有变化的程序文件;每个文件都校验大小和 Git blob 哈希,Gitee 下载失败时只切到 GitHub 的同一提交,不混装不同版本。请求保持 HTTPS 证书校验,不使用浏览器登录状态。 - ZIP 通道仅更新 `web/`、`scripts/`、`assets/`、`launchers/`、公开示例书签及说明文件。`web/data.js`、私人书签、窗口状态、快捷方式及更新清单之外的额外文件不会被覆盖或删除;浏览器外观设置保持原地址、原端口存储。 -- 替换前先完整下载校验,并将旧程序文件备份到 `data/.update-backups/`。替换失败时恢复已替换文件并保留备份;下载期间本地程序文件被修改则取消升级。ZIP 安装无法像 Git 一样判断原有代码修改,确认升级时会提示自改程序文件将被替换并备份。 +- 替换前先完整下载校验,并将旧程序文件备份到 `data/.update-backups/`,升级成功后只保留最近 5 份。替换失败时恢复已替换文件并保留备份;下载期间本地程序文件被修改则取消升级。ZIP 安装无法像 Git 一样判断原有代码修改,确认升级时会提示自改程序文件将被替换并备份。 - Windows 正在使用的便携 Python 不做在线替换:若新版要求不同的运行时文件,会在替换程序前停止并提示下载完整安装包;macOS/Linux 保持系统 Python 不变。 - 在线升级必须通过本地服务打开页面,直接双击 HTML 不支持。旧 ZIP 若尚未包含免 Git 升级模块,需要先换用包含该功能的新安装包。 - 多页面检查和升级串行执行,Git 通道合并、ZIP 通道下载均固定为本次检查得到的完整提交号;升级完成后沿用原端口自动重启及页面就绪检测。 @@ -216,11 +219,12 @@ runtime\python\python.exe -X utf8 scripts/manage.py - `data/bookmarks.html` 和 `web/data.js` 包含私人书签,已被 Git 忽略,不能使用 `git add -f` 强制提交。 - `web/data.js` 是本地生成文件,不建议手工编辑;公开示例位于 `data/bookmarks.example.html` 和 `web/data.example.js`。 -- `data/.update-backups/` 为 ZIP 升级的本地程序备份,不会被提交或由升级自动删除。 +- `data/.update-backups/` 为 ZIP 升级的本地程序备份,不会被提交;每次升级成功后只保留最近 5 份。 +- 打开快捷方式时,只在书签源文件或程序版本变化后才重新生成 `web/data.js`;内容没有变化时不改写文件,浏览器缓存保持有效。生成记录保存在 `data/.data-build.json`。 - 以 `javascript:`、`data:` 开头的书签脚本在主页中只显示、不可点击,避免在本地主页内运行脚本。 - 普通打开“书签”快捷方式或 `Bookmark.app` 不会同步浏览器;在主页确认同步或执行对应的手动同步脚本、命令时才会更新。 -- `Bookmark.app` 每次打开都会使用当前的 UI 和 `web/data.js`;如果书签源文件更新较晚,会先重新生成页面数据。 +- `Bookmark.app` 每次打开都会使用当前的 UI 和 `web/data.js`;如果书签源文件更新较晚,会先重新生成页面数据。macOS 安装版只在应用更新后才把页面文件复制到 `~/Library/Application Support/Bookmark`。 - 手动同步只读取浏览器本机缓存,不会修改浏览器内的书签;同步结果会覆盖 `data/bookmarks.html` 并保留为下次打开时的本地快照。 -- 页面默认端口是 `8765`。本地服务只响应 `127.0.0.1` 或 `localhost` 地址,自定义域名指向本机也会被拒绝;同步、升级、保存窗口大小和切换快捷方式图标只接受本主页发起的请求,其他网站无法触发。 +- 页面默认端口是 `8765`。本地服务只响应 `127.0.0.1` 或 `localhost` 地址,自定义域名指向本机也会被拒绝;同步、升级、保存窗口大小和切换快捷方式图标只接受本主页发起的请求,其他网站无法触发。其他网站也不能通过 `' + '', 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 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 = [] + page = {"Origin": self.page} + 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): + # 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) 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..b2ba558 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -119,3 +119,135 @@ 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_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
') + 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_match_restore_and_are_read_once(self): + manage.build() + 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.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): + 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..33e2bde 100644 --- a/web/js/appearance-presets.js +++ b/web/js/appearance-presets.js @@ -18,19 +18,28 @@ 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 }; - }); + // 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); + } + return { presets, incompatible }; } function captureAppearanceSnapshot() { @@ -95,6 +104,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 +115,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 +174,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..70844e9 100644 --- a/web/js/appearance.js +++ b/web/js/appearance.js @@ -92,6 +92,22 @@ function choicePreview(key, id) { } const appearanceChoices = {}; let appearanceDensity = null; +let shortcutIconTimer = 0; +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); + shortcutIconPending = skin; + shortcutIconTimer = setTimeout(sendShortcutIcon, 800); +} function setupChoice(list, key, containerId, resolve) { const root = document.documentElement; let stored = null; @@ -122,7 +138,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")); }; @@ -199,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") { @@ -206,7 +223,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); });