Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Chrome / Edge / Safari 书签同步 · 多主题与十六种布局 · 动态天
### 分类与常用操作

- 点击“我的书签”旁的“整理分类”,拖动分类把手或使用箭头调整区块顺序;横向排列用左右箭头和 Alt+←/→,纵向排列用上下箭头和 Alt+↑/↓,窗口宽度变化时自动适配。完成后收起整理控件,顺序按当前分类保存在本机,也可恢复默认顺序。分组看板和分类瀑布流支持小、中、大列宽;搜索期间暂停整理,详细表格不提供分类排序入口。
- 在“外观 → 外观方案”输入名称保存当前主题、图标、布局、密度、动效参数和专注状态;已有方案可应用、更新或删除。自动主题与跟随系统的动态效果仍保持自动,方案仅存于当前浏览器,不包含书签数据。
- 在“外观 → 外观方案”输入名称保存当前主题、图标、布局、密度、动效参数和专注状态;已有方案可应用、更新或删除。自动主题与跟随系统的动态效果仍保持自动,方案仅存于当前浏览器,不包含书签数据。其他版本保存、当前版本无法使用的方案(例如引用了已移除的主题)会被隐藏并原样保留,不影响其余方案。
- 右击书签、按 Shift+F10,或点击卡片上的“更多操作”,可在新标签页打开、复制网址、查看完整名称与所属分类;触屏设备直接显示更多按钮。复制受限时会显示可手动复制的网址。

### Windows
Expand All @@ -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` 保持导入时的原样。

同步会替换**当前运行目录**内主页的书签和分类,不修改浏览器里的收藏夹;完成后刷新主页并回到“全部”,避免旧分类筛选隐藏新书签,其他外观设置保持不变。多账号按浏览器最近使用的配置读取,网页无法识别当前标签页所属账号。取消不执行同步,同步过程中禁止重复提交;文件模式和远程托管演示页不支持此入口,请通过本地快捷方式打开。

### 安装版与源码版同时使用
Expand Down Expand Up @@ -130,6 +132,7 @@ Bookmark/
## 天气刷新

- 天气数据来自 Open-Meteo,打开或刷新网页时立即获取;之后每分钟检查一次,天气满 10 分钟才重新获取。
- 主天气源失败或 1.5 秒内没有结果时,才同时请求本地服务的备用源;任一来源成功后取消其余请求。本地服务对同一城市的查询结果保留 5 分钟,同时打开的多个页面共用一次上游请求。
- 点击天气旁的刷新按钮可立即更新;点击天气文字可设置或切换城市。
- 最近 10 分钟的天气由各标签页共用:一个页面刷新后,其他页面直接显示结果,不重复请求。打开网页和手动刷新始终重新获取;网络失败时保留已有天气,刷新按钮提示重试。
- 页面在后台时不定时请求,切回页面后如天气已过期会立即补上。浏览器休眠或冻结页面时,检查可能延后;刷新网页或手动点击可立即重新获取。
Expand All @@ -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 通道下载均固定为本次检查得到的完整提交号;升级完成后沿用原端口自动重启及页面就绪检测。
Expand Down Expand Up @@ -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` 地址,自定义域名指向本机也会被拒绝;同步、升级、保存窗口大小和切换快捷方式图标只接受本主页发起的请求,其他网站无法触发。其他网站也不能通过 `<script>`、图片或请求读取本服务的文件和接口(包括 `web/data.js` 里的私人书签);从其他网站点击链接打开主页不受影响。
- Windows 启动脚本统一使用 `runtime/python/python.exe`,隐藏启动使用同目录的 `pythonw.exe`。
- 便携运行时适用于 64 位 Windows,并包含项目所需的 Pillow;macOS 仍使用系统的 `python3`。
15 changes: 15 additions & 0 deletions scripts/archive_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
TIMEOUT = 15
DOWNLOAD_SECONDS = 180
REQUIRED = {"scripts/manage.py", "scripts/archive_update.py", "web/index.html", "web/js/update.js"}
# Successful upgrades keep this many program backups; older ones only take disk space.
BACKUP_KEEP = 5


class ArchiveUpdateError(RuntimeError):
Expand Down Expand Up @@ -271,6 +273,15 @@ def _apply(root, stage, changes, originals, progress, source):
return str(backup)


def prune_backups(backup_root, keep=BACKUP_KEEP):
"""Drop the oldest upgrade backups; a failed upgrade never reaches this point."""
folders = [path for path in Path(backup_root).glob("backup-*")
if path.is_dir() and not path.is_symlink()]
folders.sort(key=lambda path: path.stat().st_mtime, reverse=True)
for old in folders[keep:]:
shutil.rmtree(old, ignore_errors=True)


def install(root, version, progress=None):
root = Path(root).resolve()
status = update_status(version, progress)
Expand Down Expand Up @@ -323,5 +334,9 @@ def download(candidate):
backup = _apply(root, stage, changes, originals, progress, source)
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}
36 changes: 33 additions & 3 deletions scripts/macos_app.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
"""PyInstaller entry point for the self-contained macOS application."""
from __future__ import annotations

import hashlib
import json
import os
import shutil
import sys
import uuid
import webbrowser
from pathlib import Path

BUNDLE_TREES = ("web", "assets", "data")
# Records what the previous app bundle installed, so only our own files are pruned.
BUNDLE_MANIFEST = "data/.bundle-files.json"
# Fingerprint of the bundle last copied; an unchanged app skips copying on every launch.
BUNDLE_STAMP = "data/.bundle-stamp"
# Never shipped in a bundle, so never pruned even if a manifest claims otherwise.
PRIVATE_FILES = frozenset({"web/data.js", "data/bookmarks.html", "data/.window-state.json", BUNDLE_MANIFEST})
PRIVATE_FILES = frozenset({"web/data.js", "data/bookmarks.html", "data/.window-state.json",
"data/.data-build.json", BUNDLE_MANIFEST, BUNDLE_STAMP})
PRIVATE_TREES = ("data/.update-backups/", "data/.update-stage-")


Expand All @@ -39,11 +44,25 @@ def bundle_files(bundle: Path) -> list[str]:
return sorted(names)


def bundle_fingerprint(bundle: Path, names: list[str]) -> str:
digest = hashlib.sha256()
for name in names:
stat = (bundle / name).stat()
digest.update(f"{name}\0{stat.st_size}\0{stat.st_mtime_ns}\n".encode("utf-8"))
return digest.hexdigest()


def copy_resources(bundle: Path, root: Path, names: list[str]) -> None:
for name in names:
target = root / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bundle / name, target)
# A running service may be sending this file; swap it in whole rather than overwrite it.
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex[:8]}.tmp")
try:
shutil.copy2(bundle / name, temporary)
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)


def installed_files(root: Path) -> list[str]:
Expand Down Expand Up @@ -86,17 +105,28 @@ def prepare_runtime() -> Path:
root = user_root()
bundle = bundle_root()
names = bundle_files(bundle)
fingerprint = bundle_fingerprint(bundle, names)
stamp = root / BUNDLE_STAMP
try:
current = stamp.read_text(encoding="utf-8") == fingerprint
except OSError:
current = False
# Both the launcher and its background service start here; copy only when the app changed
# or a public file went missing.
if current and all((root / name).is_file() for name in names):
return root
previous = installed_files(root)
copy_resources(bundle, root, names)
prune_removed_files(root, previous, set(names))
manifest = root / BUNDLE_MANIFEST
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text(json.dumps(names, ensure_ascii=False, indent=2), encoding="utf-8")
stamp.write_text(fingerprint, encoding="utf-8")
return root


def open_page(manage) -> None:
manage.build()
manage.build_if_stale()
webbrowser.open(manage.local_url())


Expand Down
Loading
Loading