feat(icon): 纸飞机图标(站点 favicon + 扩展各尺寸)

站点首页此前显示的是 Dashy 上游的 logo —— index.html 里那几条
<link rel=icon href=/web-icons/favicon-*.png> 优先级高于我们的门户图标,
而扩展的 PNG 是占位图(128px 只有 9 种颜色、alpha 通道还是乱的)。

- portal/favicon.svg:纸飞机(圆角渐变底 + 白色折纸机头朝右上 + 两道尾迹),
  几何收在 8..56 安全区,避免贴边被圆角裁切
- 由该 SVG 派生 favicon.ico(16/32/48)、apple-touch-icon(180)、
  web-icons/favicon-{16,32,64}、扩展 icon{16,48,96,128}
- patch-index.py:删掉上游 icon link 并显式指向 /favicon.svg(幂等,
  重复执行 unchanged);上游 web-icons 那批也覆盖一份作双保险
- Dockerfile 与 zd-host.sh 都 COPY 这些图标进镜像
- 验证:nginx 沙箱实测 /favicon.svg、/web-icons/*、/apple-touch-icon.png 均 200;
  16px 下人眼仍可辨识为纸飞机
- .amo-upload-uuid(web-ext 生成的 UUID)改为忽略
This commit is contained in:
JianFeeeee
2026-10-03 08:37:54 +08:00
parent 63c588145b
commit e9097e5893
19 changed files with 175 additions and 3 deletions

6
.gitignore vendored
View File

@ -28,4 +28,10 @@ __pycache__/
*.pyc
# 本机 agent 工具的会话产物,不入库
# web-ext sign 生成的工具产物(UUID 上传标识),不入库
.amo-upload-uuid
**/.amo-upload-uuid
# pi 任务日志/产物,不入库
.pi/
.pi-glla/

View File

@ -84,6 +84,12 @@ COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
COPY portal /app/portal
# 站点图标:纸飞机。覆盖 Dashy 自带的 web-icons/favicon-*.png,
# 这样即使将来上游 index.html 变了、patch 失效,标签栏仍是我们的图标。
COPY portal/favicon.svg /app/dashy/favicon.svg
COPY portal/favicon.ico /app/dashy/favicon.ico
COPY portal/apple-touch-icon.png /app/dashy/apple-touch-icon.png
COPY portal/web-icons/ /app/dashy/web-icons/
# 浏览器扩展安装页与安装包(通过 /extension/ 访问)
COPY web/extension/install.html /app/dashy/extension/install.html

115
deploy/sign-firefox.sh Executable file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Firefox 扩展:构建 → lint → AMO unlisted 签名 → 发布到数据卷
#
# 为什么需要这个脚本(实测踩坑,2026-10-03):
# 1. 正式版 Firefox 要求扩展经 AMO 签名,未签名一律拒绝并报成「插件损坏」。
# 2. 签名包一旦生成就与内容绑定 —— 改一行代码就必须重新签名,
# 所以「签名」必须跟着「改代码」这一步自动发生,不能靠人记得手动做。
# 3. 签名是 AMO 生成的产物,不该进版本库(签名与源码脱节 + 是二进制),
# 因此落在数据卷里,跨部署存活;nginx 优先供给它(见 nginx.conf)。
#
# 凭据:只从环境变量读,绝不写进本仓库(AGENTS.md §6 脱敏铁律)
# WEB_EXT_API_KEY / WEB_EXT_API_SECRET —— AMO 开发者页面「API 密钥」里的
# JWT issuer:secret。属于程序化签名凭据,与账号密码无关。
# 先在 https://addons.mozilla.org/developers/ → 你的扩展 → 设置 → API 密钥 获取。
#
# 用法:
# WEB_EXT_API_KEY=... WEB_EXT_API_SECRET=... deploy/sign-firefox.sh # 签名并发布
# deploy/sign-firefox.sh --check # 只跑 lint,不签名
set -euo pipefail
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
EXT_DIR="$SRC/extension/firefox"
WEB_EXT="${WEB_EXT:-npx --yes web-ext@8}"
CHECK_ONLY=0
[ "${1:-}" = "--check" ] && CHECK_ONLY=1
echo "== 1/4 语法与规范检查 =="
node --check "$EXT_DIR/background.js"
node --check "$EXT_DIR/options.js"
node --check "$EXT_DIR/detect.js"
# lint 必须 0 error:AMO 用的是同一个 addons-linter,error 会被直接拒收。
# 注意 notice 不阻塞上传,但要能看见(提示 FUTURE/MV3 之类,不静默吞掉)。
LINT_OUT="$(mktemp)"
trap 'rm -f "$LINT_OUT"' EXIT
(cd "$EXT_DIR/.." && $WEB_EXT lint --source-dir=firefox --output text) >"$LINT_OUT" 2>&1 || {
cat "$LINT_OUT"; echo "!! lint 执行失败" >&2; exit 1; }
grep -E '^(errors|warnings|notices)' "$LINT_OUT" | sed 's/^/ /'
grep -qE '^errors +0' "$LINT_OUT" || { cat "$LINT_OUT"; echo "!! 有 error,AMO 会拒收" >&2; exit 1; }
grep -E '^(NOTICES|WARNINGS):' -A6 "$LINT_OUT" | grep -vE '^--$' | sed 's/^/ /' || true
VER="$(python3 -c "import json;print(json.load(open('$EXT_DIR/manifest.json'))['version'])")"
echo " 版本: $VER"
if [ "$CHECK_ONLY" = "1" ]; then
echo "--check 模式:跳过签名与发布。"
exit 0
fi
echo "== 2/4 AMO 签名(unlisted 自分发)=="
[ -n "${WEB_EXT_API_KEY:-}" ] && [ -n "${WEB_EXT_API_SECRET:-}" ] || {
cat >&2 <<'EOF'
!! 缺少 AMO 凭据。请设置环境变量后重试:
WEB_EXT_API_KEY='user:<id>:<num>' \
WEB_EXT_API_SECRET='<jwt secret>' \
deploy/sign-firefox.sh
凭据在 https://addons.mozilla.org/developers/ → 你的扩展 → 设置 → API 密钥 获取。
不要把凭据写进仓库或任何会被提交的文件(AGENTS.md §6)。
EOF
exit 2; }
ART="$(mktemp -d)"
trap 'rm -rf "$ART" "$LINT_OUT"' EXIT
(cd "$EXT_DIR/.." && $WEB_EXT sign --source-dir=firefox \
--artifacts-dir="$ART" --channel=unlisted --no-input) 2>&1 \
| grep -vE 'DeprecationWarning|trace-deprecation|no version information' | sed 's/^/ /'
XPI="$(ls "$ART"/*.xpi 2>/dev/null | head -1 || true)"
[ -n "$XPI" ] || { echo "!! 未产出签名包" >&2; exit 1; }
echo "== 3/4 校验签名包 =="
# 不能只看「web-ext 说成功」:签名条目缺失的包装上去照样报「插件损坏」。
python3 - "$XPI" <<'PY'
import json, sys, zipfile
z = zipfile.ZipFile(sys.argv[1])
names = z.namelist()
need = ['META-INF/mozilla.rsa', 'META-INF/mozilla.sf', 'META-INF/cose.manifest']
missing = [n for n in need if n not in names]
if missing:
sys.exit(f'!! 签名条目缺失: {missing}')
m = json.loads(z.read('manifest.json'))
g = m['browser_specific_settings']['gecko']
print(f" 签名条目齐备 版本={m['version']} id={g['id']}")
print(f" data_collection={g.get('data_collection_permissions')}")
PY
echo "== 4/4 发布到数据卷 =="
# 没给 NAVI_HOST 就只把包落到本地,供人工上传。
if [ -z "${NAVI_HOST:-}" ]; then
OUT="${1:-$SRC}/navi-firefox-signed.xpi"
cp "$XPI" "$OUT"
echo " 未设 NAVI_HOST,仅本地输出: $OUT"
echo " (线上发布:NAVI_HOST=user@host NAVI_SSH_PASS=... deploy/sign-firefox.sh)"
exit 0
fi
SSH_PASS="${NAVI_SSH_PASS:-}"
if [ -n "$SSH_PASS" ]; then
SSH=(sshpass -p "$SSH_PASS" ssh -o StrictHostKeyChecking=no "$NAVI_HOST")
SCP=(sshpass -p "$SSH_PASS" scp -q -o StrictHostKeyChecking=no)
else
SSH=(ssh -o StrictHostKeyChecking=no "$NAVI_HOST")
SCP=(scp -q -o StrictHostKeyChecking=no)
fi
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
STAMP="$(date +%Y%m%d%H%M%S)"
"${SCP[@]}" "$XPI" "$NAVI_HOST:$DATA_DIR/extension/.navi-firefox.xpi.new"
# 先备份再原子替换:中途失败不会让线上退化成半个文件
"${SSH[@]}" "set -e
cd $DATA_DIR/extension
[ -f navi-firefox.xpi ] && cp -a navi-firefox.xpi navi-firefox.xpi.bak-\$(date +%s)
mv .navi-firefox.xpi.new navi-firefox.xpi
echo \" 已发布: \$(sha256sum navi-firefox.xpi | cut -d' ' -f1)\"" | sed 's/^/ /'
echo "SIGN_OK version=$VER"

View File

@ -69,6 +69,11 @@ COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
COPY portal /app/portal
# 站点图标:纸飞机(覆盖 Dashy 自带的那套,双保险)
COPY portal/favicon.svg /app/dashy/favicon.svg
COPY portal/favicon.ico /app/dashy/favicon.ico
COPY portal/apple-touch-icon.png /app/dashy/apple-touch-icon.png
COPY portal/web-icons/ /app/dashy/web-icons/
COPY web/extension/install.html /app/dashy/extension/install.html
COPY ext/navi-chrome.zip /app/dashy/extension/navi-chrome.zip
COPY ext/navi-firefox.xpi /app/dashy/extension/navi-firefox.xpi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 B

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1 +0,0 @@
{"uploadUuid":"fb05e275eefe472399d38c15fb248870","channel":"unlisted","xpiCrcHash":"e49706a0c09801097e98c94bb18b76353480053afc23f75e3590920384d8faee"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
portal/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 556 B

View File

@ -1 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0f172a"/><path stroke="#38bdf8" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M20 26h24l-5 7M20 26l15-12h6l-5 7M44 33l-9 3M20 26l7 18l10-6M27 44l11-6"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="HomeDash">
<!-- 纸飞机图标:圆角方形底 + 白色纸飞机 + 尾迹。
同一份图形用于站点 favicon 与扩展各尺寸图标(16/48/96/128),保持品牌一致。
几何刻意收在 8..56 的安全区内,避免任何笔画贴边被圆角裁切。 -->
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#818cf8"/>
</linearGradient>
</defs>
<!-- 底:圆角方块,保证在浅色/深色标签栏都可辨识 -->
<rect width="64" height="64" rx="14" fill="url(#sky)"/>
<!-- 尾迹:机身后方的两道速度线(小尺寸下自然淡出,不影响识别) -->
<g stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity=".5" fill="none">
<path d="M11 45c5-2 9-5 12-9"/>
<path d="M13 52c6-2 11-5 15-10"/>
</g>
<!-- 纸飞机主体:机头朝右上,全部坐标落在 8..56 内 -->
<path fill="#ffffff" d="M12 30 54 12 40 54 30 40 12 30Z"/>
<!-- 折纸的机翼分界线:从机尾折向机头,让小尺寸下也看得出是折纸而非纯三角 -->
<path fill="#e0f2fe" d="M30 40 54 12 44 44 30 40Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -12,13 +12,24 @@ import sys
CSS_TAG = '<link rel="stylesheet" href="/navi.css">'
JS_TAG = '<script src="/navi.js"></script>'
# Dashy 自带的 logo(/web-icons/favicon-*.png)会被浏览器优先选中,
# 于是站点标签栏显示上游 logo 而不是我们的纸飞机。
# 做法:删掉那些 <link rel=icon>,再显式指向我们自己的 /favicon.svg。
# 为什么用“删掉”而不是“改路径”:index.html 来自上游镜像,每次重建可能被重置,
# 删标签幂等,且不依赖上游将来还叫 web-icons。
ICON_LINK_RE = re.compile(r'[ \t]*<link[^>]*rel=["\']icon["\'][^>]*>\s*', re.I)
OUR_ICONS = ('<link rel="icon" type="image/svg+xml" href="/favicon.svg">\n'
'<link rel="apple-touch-icon" href="/apple-touch-icon.png">')
def patch(html: str) -> str:
html = re.sub(r'[ \t]*<link rel="stylesheet" href="/navi\.css">\s*', '', html)
html = re.sub(r'[ \t]*<script src="/navi\.js"></script>\s*', '', html)
html = re.sub(r'[ \t]*' + re.escape(OUR_ICONS) + r'\s*', '', html)
html = ICON_LINK_RE.sub('', html)
if '</head>' in html:
html = html.replace('</head>', CSS_TAG + '</head>', 1)
html = html.replace('</head>', CSS_TAG + OUR_ICONS + '</head>', 1)
if '</body>' in html:
html = html.replace('</body>', JS_TAG + '</body>', 1)
@ -36,6 +47,12 @@ def main(path: str) -> int:
if 'href="/navi.css"' not in patched or 'src="/navi.js"' not in patched:
print('patch failed: tags missing after patch', file=sys.stderr)
return 1
if 'web-icons/favicon' in patched:
print('patch failed: upstream Dashy icon links survived', file=sys.stderr)
return 1
if '/favicon.svg' not in patched:
print('patch failed: our icon link missing', file=sys.stderr)
return 1
with open(path, 'w', encoding='utf-8') as fh:
fh.write(patched)
print('patched %s (%s)' % (path, 'changed' if patched != original else 'unchanged'))