From 192eb1b8d1a2eebb852129a5ce5070cfdd65d0f7 Mon Sep 17 00:00:00 2001 From: "douboer@gmail.com" Date: Tue, 17 Mar 2026 10:37:27 +0800 Subject: [PATCH] update at 2026-03-17 10:37:27 --- assets/simple/book.svg | 6 + assets/simple/forecast-cloud.svg | 3 + assets/simple/humidity.svg | 3 + assets/simple/location.svg | 23 + assets/simple/pm25.svg | 8 + bootstrap-new-kindle.sh | 410 +++++++++++ calendar/config/themes.json | 306 ++++++++ calendar/package.json | 1 + calendar/scripts/export-kindle-background.sh | 2 +- .../scripts/export-kindle-background.swift | 157 ++++- calendar/scripts/export-theme-backgrounds.sh | 175 +++++ .../scripts/generate-dashboard-manifest.mjs | 97 ++- calendar/src/App.vue | 119 +++- calendar/src/components/CalendarCard.vue | 62 +- calendar/src/components/QuoteCard.vue | 17 +- calendar/src/components/SimpleAnalogClock.vue | 164 +++++ calendar/src/components/SimpleDashboard.vue | 656 ++++++++++++++++++ calendar/src/components/WeatherCard.vue | 123 ++-- calendar/src/lib/dashboard-theme.ts | 119 ++++ calendar/src/lib/icon-assets.ts | 32 + calendar/src/lib/weather.ts | 87 ++- calendar/src/style.css | 180 ++++- .../docs/kindle-voyage-5.13.6-bootstrap-zh.md | 115 +++ ...e-voyage-5.13.6-white-screen-handoff-zh.md | 23 + dash/docs/layered-clock-plan.zh.md | 2 +- dash/docs/pagepress-theme-menu-plan.zh.md | 415 +++++++++++ dash/docs/theme-switching-plan.zh.md | 462 ++++++++---- dash/src/dash.sh | 76 ++ dash/src/debug-off.sh | 1 + dash/src/debug-on.sh | 1 + dash/src/local/clock-index.sh | 42 +- dash/src/local/env.sh | 23 +- dash/src/local/fetch-dashboard.sh | 28 +- dash/src/local/render-clock.lua | 132 ++-- dash/src/local/render-clock.sh | 18 +- dash/src/local/theme-json.lua | 343 +++++++++ dash/src/local/theme-menu-service.sh | 271 ++++++++ dash/src/local/theme-sync.sh | 234 +++++++ dash/src/start.sh | 3 + dash/src/stop.sh | 1 + dash/src/switch-theme.sh | 46 ++ scripts/capture-kindle-screen.sh | 64 ++ scripts/sync-layered-clock-to-kindle.sh | 194 +++++- snapshot.sh | 367 ++++++++++ 44 files changed, 5208 insertions(+), 403 deletions(-) create mode 100644 assets/simple/book.svg create mode 100644 assets/simple/forecast-cloud.svg create mode 100644 assets/simple/humidity.svg create mode 100644 assets/simple/location.svg create mode 100644 assets/simple/pm25.svg create mode 100644 bootstrap-new-kindle.sh create mode 100644 calendar/config/themes.json create mode 100644 calendar/scripts/export-theme-backgrounds.sh create mode 100644 calendar/src/components/SimpleAnalogClock.vue create mode 100644 calendar/src/components/SimpleDashboard.vue create mode 100644 calendar/src/lib/dashboard-theme.ts create mode 100644 dash/docs/kindle-voyage-5.13.6-bootstrap-zh.md create mode 100644 dash/docs/pagepress-theme-menu-plan.zh.md create mode 100644 dash/src/local/theme-json.lua create mode 100644 dash/src/local/theme-menu-service.sh create mode 100644 dash/src/local/theme-sync.sh create mode 100644 dash/src/switch-theme.sh create mode 100644 scripts/capture-kindle-screen.sh create mode 100755 snapshot.sh diff --git a/assets/simple/book.svg b/assets/simple/book.svg new file mode 100644 index 0000000..f5ec897 --- /dev/null +++ b/assets/simple/book.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/simple/forecast-cloud.svg b/assets/simple/forecast-cloud.svg new file mode 100644 index 0000000..13b1486 --- /dev/null +++ b/assets/simple/forecast-cloud.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/simple/humidity.svg b/assets/simple/humidity.svg new file mode 100644 index 0000000..4c9881d --- /dev/null +++ b/assets/simple/humidity.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/simple/location.svg b/assets/simple/location.svg new file mode 100644 index 0000000..3cc0bc5 --- /dev/null +++ b/assets/simple/location.svg @@ -0,0 +1,23 @@ + + + + + diff --git a/assets/simple/pm25.svg b/assets/simple/pm25.svg new file mode 100644 index 0000000..2e6e679 --- /dev/null +++ b/assets/simple/pm25.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/bootstrap-new-kindle.sh b/bootstrap-new-kindle.sh new file mode 100644 index 0000000..f5b3c4d --- /dev/null +++ b/bootstrap-new-kindle.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +WATCHTHIS_DIR="$ROOT_DIR/dash/staging/watchthis" +POST_JAILBREAK_ROOT="$ROOT_DIR/dash/staging/post-jailbreak-root" +SSH_HELPERS_DIR="$ROOT_DIR/scripts/kindle" +SYNC_SCRIPT="$ROOT_DIR/scripts/sync-layered-clock-to-kindle.sh" +THEMES_JSON="$ROOT_DIR/calendar/config/themes.json" + +MODE="all" +VOLUME_PATH="/Volumes/Kindle" +HOST_TARGET="kindle" +THEME_ID="" +ORIENTATION="" +SHOW_BACKGROUND=true +START_DASHBOARD=false + +print_usage() { + cat <<'EOF' +用法: + sh bootstrap-new-kindle.sh [模式] [选项] + +模式: + all 默认。能预置 USB 存储就先预置;能连 SSH 就继续做 SSH 后半段 + prepare-storage 只做 USB 存储预置 + post-ssh 只做 SSH 打通后的自动化收尾 + +选项: + -v, --volume Kindle 挂载目录,默认 /Volumes/Kindle + -k, --kindle Kindle SSH 主机名,默认 kindle + -t, --theme SSH 阶段切换到指定主题;默认使用 themes.json 的默认主题 + -o, --orientation SSH 阶段切换到指定方向;默认使用 themes.json 的默认方向 + --no-background SSH 阶段不同步后立即切主题出图 + --start-dashboard SSH 阶段额外后台启动 dashboard 主循环 + -h, --help 查看帮助 + +说明: + 这不是 100% 零交互刷机脚本。 + 受 Kindle 本机流程限制,下面这些步骤仍然必须人工完成: + 1. 进入 demo mode / WatchThis 流程 + 2. 在正确时机执行 Sideload Content + 3. 触发 Get Started 完成越狱 + 4. 搜索 ;log mrpi 安装 KUAL / MRPI / USBNetwork + 5. 首次进 KTerm 执行 sh /mnt/us/ssh-force-dropbear-22.sh + +推荐操作顺序: + + 阶段 A:先做 USB 预置 + 1. 用 USB 线把 Kindle 接到 Mac。 + 2. 确认 Finder 里已经出现 Kindle,默认挂载目录是 /Volumes/Kindle。 + 3. 执行: + sh bootstrap-new-kindle.sh prepare-storage + 4. 预期结果: + - Kindle 根目录出现 Update_hotfix_watchthis_custom.bin + - Kindle 根目录出现 ssh-force-dropbear-22.sh 等脚本 + - Kindle 根目录出现 dashboard/、extensions/、mrpackages/ + - Kindle 根目录出现 .demo/KV-5.13.6.zip、.demo/demo.json、.demo/goodreads/ + 5. 安全弹出 Kindle。 + + 阶段 B:在 Kindle 上完成 WatchThis 和越狱 + 1. 恢复出厂。 + 2. 语言只选 English (United Kingdom)。 + 3. 到 Wi-Fi 页面时,不要真的联网;先按 WatchThis 文档进入 demo mode。 + 4. 第一次出现 Add Content / Sideload Content 时,只点 Done,不要接 USB。 + 5. 遇到 misconfiguration / Configure Device 时,执行隐藏手势回到可操作界面。 + 6. 再次进入 ;demo -> Sideload Content,这一次才是真正导入 payload 的时机。 + 因为阶段 A 已经预置好 .demo 内容,这里不需要再从 Mac 手工拷文件。 + 7. 退出 demo menu 后,进入 Help & User Guides -> Get Started 触发越狱。 + 8. 越狱成功后,Kindle 根目录应出现 mkk、libkh、rp。 + + 阶段 C:安装 KUAL / MRPI / USBNetwork / dashboard + 1. 在 Kindle 首页搜索: + ;log mrpi + 2. 等 MRPI 安装完成。 + 3. 预期结果: + - 首页出现 KUAL + - KUAL 菜单里有 Rename OTA Binaries + - KUAL 菜单里有 kindle-dash + 4. 先在 KUAL 中执行: + Rename OTA Binaries -> Rename + + 阶段 D:打通 Wi-Fi 和 SSH + 1. 让 Kindle 连到和 Mac 同一个主 Wi-Fi,不要用 Guest 网络。 + 2. 打开 KTerm。 + 3. 在 KTerm 执行: + sh /mnt/us/ssh-force-dropbear-22.sh + 4. 回到 Mac,先测试: + ssh kindle + 5. 如果这一步还不通,不要继续盲试,先回看: + dash/docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md + + 阶段 E:SSH 打通后自动同步并出图 + 1. 执行: + sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait + 2. 预期结果: + - 自动同步 dashboard 运行时和主题包 + - Kindle 立即切到 simple / portrait + - 屏幕马上显示背景和时钟 + 3. 如果还希望后台常驻 dashboard 主循环,再执行: + sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait --start-dashboard + +最省事的直接用法: + 1. USB 挂载时先运行: + sh bootstrap-new-kindle.sh all -t simple -o portrait + 这会先做完能做的 USB 预置。 + 2. 等你在 Kindle 上完成越狱、MRPI、Wi-Fi、KTerm 拉起 SSH 后, + 再重新运行同一条命令: + sh bootstrap-new-kindle.sh all -t simple -o portrait + 这时它会自动继续做 SSH 后半段。 + +最容易做错的地方: + 1. 第一次 Add Content / Sideload Content 只能点 Done,不能在这一步导 payload。 + 2. 真正导 payload 的时机,是隐藏手势返回后,再次进入 ;demo -> Sideload Content。 + 3. 首次 SSH 最稳的入口是 KTerm,不是 USB 直连盲试。 + 4. post-ssh 阶段如果不带 -t/-o,会退回 themes.json 里的默认主题和方向。 + +示例: + sh bootstrap-new-kindle.sh prepare-storage + sh bootstrap-new-kindle.sh all -t simple -o portrait + sh bootstrap-new-kindle.sh post-ssh -k kindle --start-dashboard +EOF +} + +log_step() { + printf '\n[%s] %s\n' "$1" "$2" +} + +require_path() { + target_path=$1 + label=$2 + if [ ! -e "$target_path" ]; then + echo "缺少${label}: $target_path" >&2 + exit 1 + fi +} + +kindle_volume_available() { + [ -d "$VOLUME_PATH" ] +} + +ssh_reachable() { + ssh -o BatchMode=yes -o ConnectTimeout=5 "$HOST_TARGET" true >/dev/null 2>&1 +} + +resolve_theme_selection() { + python3 - "$THEMES_JSON" "$THEME_ID" "$ORIENTATION" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +requested_theme = sys.argv[2] +requested_orientation = sys.argv[3] +data = json.loads(path.read_text()) + +themes = {theme["id"]: theme for theme in data.get("themes", [])} +theme_id = requested_theme or data.get("defaultThemeId", "") +if theme_id not in themes: + raise SystemExit(f"未知主题: {theme_id}") + +orientations = list(themes[theme_id].get("variants", {}).keys()) +if not orientations: + raise SystemExit(f"主题 {theme_id} 没有任何方向配置") + +orientation = requested_orientation or data.get("defaultOrientation", "") +if orientation not in orientations: + orientation = orientations[0] + +print(f"THEME_ID={theme_id}") +print(f"ORIENTATION={orientation}") +PY +} + +load_theme_selection() { + resolved_output="$(resolve_theme_selection)" + RESOLVED_THEME_ID="" + RESOLVED_ORIENTATION="" + + while IFS='=' read -r key value; do + case "$key" in + THEME_ID) + RESOLVED_THEME_ID=$value + ;; + ORIENTATION) + RESOLVED_ORIENTATION=$value + ;; + esac + done <&2 + exit 1 + fi +} + +copy_watchthis_payload() { + require_path "$WATCHTHIS_DIR/KV-5.13.6/KV-5.13.6.zip" "WatchThis payload" + require_path "$WATCHTHIS_DIR/KV-5.13.6/demo.json" "WatchThis demo.json" + require_path "$WATCHTHIS_DIR/Update_hotfix_watchthis_custom.bin" "WatchThis hotfix" + + mkdir -p "$VOLUME_PATH/.demo/goodreads" + cp "$WATCHTHIS_DIR/KV-5.13.6/KV-5.13.6.zip" "$VOLUME_PATH/.demo/" + cp "$WATCHTHIS_DIR/KV-5.13.6/demo.json" "$VOLUME_PATH/.demo/" + cp "$WATCHTHIS_DIR/Update_hotfix_watchthis_custom.bin" "$VOLUME_PATH/" +} + +copy_post_jailbreak_bundle() { + require_path "$POST_JAILBREAK_ROOT/extensions" "post-jailbreak extensions" + require_path "$POST_JAILBREAK_ROOT/mrpackages" "post-jailbreak mrpackages" + require_path "$POST_JAILBREAK_ROOT/dashboard" "post-jailbreak dashboard" + + mkdir -p "$VOLUME_PATH/extensions" "$VOLUME_PATH/mrpackages" "$VOLUME_PATH/dashboard" + + rsync -av --no-o --no-g "$POST_JAILBREAK_ROOT/extensions/" "$VOLUME_PATH/extensions/" + rsync -av --no-o --no-g "$POST_JAILBREAK_ROOT/mrpackages/" "$VOLUME_PATH/mrpackages/" + rsync -av --no-o --no-g "$POST_JAILBREAK_ROOT/dashboard/" "$VOLUME_PATH/dashboard/" +} + +copy_ssh_helpers() { + require_path "$SSH_HELPERS_DIR/ssh-force-dropbear-22.sh" "SSH helper scripts" + + rsync -av --no-o --no-g "$SSH_HELPERS_DIR/" "$VOLUME_PATH/" +} + +print_storage_next_steps() { + cat <<'EOF' + +下一步请在 Kindle 上继续: +1. 按 [dash/docs/kindle-voyage-5.13.6-watchthis-zh.md] 的流程进入 demo mode。 +2. 走到真正的 Sideload Content 时,脚本已预置好: + - .demo/KV-5.13.6.zip + - .demo/demo.json + - .demo/goodreads/ +3. 触发 Get Started 完成越狱。 +4. 搜索 `;log mrpi` 安装 KUAL / MRPI / USBNetwork / dashboard。 +5. 先在 KUAL 里执行 `Rename OTA Binaries -> Rename`。 +6. 连上 Wi-Fi。 +7. 打开 KTerm,执行: + sh /mnt/us/ssh-force-dropbear-22.sh +8. 回到 Mac 后,再运行: + sh bootstrap-new-kindle.sh post-ssh +EOF +} + +prepare_storage() { + if ! kindle_volume_available; then + echo "未找到 Kindle 挂载目录: $VOLUME_PATH" >&2 + exit 1 + fi + + log_step "USB" "预置 WatchThis payload" + copy_watchthis_payload + + log_step "USB" "预置越狱后安装包" + copy_post_jailbreak_bundle + + log_step "USB" "预置 SSH 恢复脚本" + copy_ssh_helpers + + log_step "完成" "USB 存储预置已完成:$VOLUME_PATH" + print_storage_next_steps +} + +prepare_remote_helpers() { + ssh "$HOST_TARGET" "chmod +x /mnt/us/ssh-collect.sh /mnt/us/ssh-fix-all-keys.sh /mnt/us/ssh-force-dropbear-22.sh /mnt/us/ssh-force-openssh-22.sh /mnt/us/ssh-stop-all.sh 2>/dev/null || true" + ssh "$HOST_TARGET" "if [ -f /mnt/us/ssh-fix-all-keys.sh ]; then sh /mnt/us/ssh-fix-all-keys.sh; fi" +} + +sync_dashboard_runtime() { + if [ -n "$THEME_ID" ]; then + if [ -n "$ORIENTATION" ]; then + sh "$SYNC_SCRIPT" "$HOST_TARGET" --theme "$THEME_ID" --orientation "$ORIENTATION" + else + sh "$SYNC_SCRIPT" "$HOST_TARGET" --theme "$THEME_ID" + fi + else + sh "$SYNC_SCRIPT" "$HOST_TARGET" + fi +} + +show_background_once() { + load_theme_selection + ssh "$HOST_TARGET" "/mnt/us/dashboard/switch-theme.sh '$RESOLVED_THEME_ID' '$RESOLVED_ORIENTATION'" +} + +start_dashboard_loop() { + # 当前仓库里最稳的是 SSH 触发启动;这里后台拉起,便于 bootstrap 脚本直接返回。 + ssh "$HOST_TARGET" "mkdir -p /mnt/us/dashboard/logs && cd /mnt/us/dashboard && nohup ./start.sh >/mnt/us/dashboard/logs/bootstrap-start.log 2>&1 &2 + echo "请先在 Kindle 上连上 Wi-Fi,并在 KTerm 执行 sh /mnt/us/ssh-force-dropbear-22.sh" >&2 + exit 1 + fi + + log_step "SSH" "修复设备侧 SSH 辅助脚本权限与 authorized_keys" + prepare_remote_helpers + + log_step "同步" "同步 dashboard 运行时和主题包到 Kindle" + sync_dashboard_runtime + + if [ "$SHOW_BACKGROUND" = true ]; then + log_step "显示" "立即切到当前主题并出图" + show_background_once + fi + + if [ "$START_DASHBOARD" = true ]; then + log_step "启动" "后台启动 dashboard 主循环" + start_dashboard_loop + fi + + log_step "完成" "SSH 阶段自动化已完成" + print_post_ssh_summary +} + +while [ "$#" -gt 0 ]; do + case "$1" in + all|prepare-storage|post-ssh) + MODE=$1 + ;; + -v|--volume) + shift + VOLUME_PATH=${1:?"missing volume path"} + ;; + -k|--kindle) + shift + HOST_TARGET=${1:?"missing kindle host"} + ;; + -t|--theme) + shift + THEME_ID=${1:?"missing theme id"} + ;; + -o|--orientation) + shift + ORIENTATION=${1:?"missing orientation"} + ;; + --no-background) + SHOW_BACKGROUND=false + ;; + --start-dashboard) + START_DASHBOARD=true + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "未知参数: $1" >&2 + print_usage >&2 + exit 1 + ;; + esac + shift +done + +require_path "$THEMES_JSON" "themes.json" +require_path "$SYNC_SCRIPT" "sync-layered-clock-to-kindle.sh" + +case "$MODE" in + prepare-storage) + prepare_storage + ;; + post-ssh) + post_ssh + ;; + all) + did_anything=false + + if kindle_volume_available; then + prepare_storage + did_anything=true + else + log_step "跳过" "未检测到 Kindle 存储挂载:$VOLUME_PATH" + fi + + if ssh_reachable; then + post_ssh + did_anything=true + else + log_step "等待" "SSH 还未打通;等 Kindle 连上 Wi-Fi 并在 KTerm 执行 sh /mnt/us/ssh-force-dropbear-22.sh 后,再重跑本脚本或执行 post-ssh。" + fi + + if [ "$did_anything" != true ]; then + echo "既没有检测到 Kindle 存储挂载,也没有检测到可用 SSH。" >&2 + echo "请先通过 USB 挂载 Kindle,或先恢复 SSH 后再执行。" >&2 + exit 1 + fi + ;; +esac diff --git a/calendar/config/themes.json b/calendar/config/themes.json new file mode 100644 index 0000000..1cc35a0 --- /dev/null +++ b/calendar/config/themes.json @@ -0,0 +1,306 @@ +{ + "defaultThemeId": "default", + "defaultOrientation": "portrait", + "themes": [ + { + "id": "default", + "label": "Default", + "preview": { + "pageBackground": "#efe8db", + "paper": "#ffffff", + "panelBackground": "#fffdf9", + "frameStroke": "#8b6b47", + "frameStrokeStrong": "#6f5235", + "frameMuted": "rgba(139, 107, 71, 0.35)", + "mutedInk": "#4c4c4c", + "badgeFill": "#faf6ef", + "bodyFont": "'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif", + "displayFont": "'Iowan Old Style', 'Baskerville', serif", + "titleFont": "'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif", + "cardRadius": "2rem", + "panelRadius": "1.25rem" + }, + "variants": { + "portrait": { + "devicePlacement": "logo_bottom", + "viewport": { + "width": 1072, + "height": 1448 + }, + "backgroundPath": "themes/default/portrait/kindlebg.png", + "clock": { + "x": 347, + "y": 55, + "width": 220, + "height": 220, + "faceRadiusRatio": 0.47, + "faceStroke": 3, + "tickOuterInset": 6, + "majorTickLength": 14, + "minorTickLength": 7, + "majorTickThickness": 4, + "minorTickThickness": 2, + "hourLengthRatio": 0.48, + "minuteLengthRatio": 0.72, + "hourThickness": 9, + "minuteThickness": 5, + "centerRadius": 7 + } + }, + "landscape": { + "devicePlacement": "logo_right", + "viewport": { + "width": 1448, + "height": 1072 + }, + "backgroundPath": "themes/default/landscape/kindlebg.png", + "clock": { + "x": 659, + "y": 57, + "width": 220, + "height": 220, + "faceRadiusRatio": 0.47, + "faceStroke": 3, + "tickOuterInset": 6, + "majorTickLength": 14, + "minorTickLength": 7, + "majorTickThickness": 4, + "minorTickThickness": 2, + "hourLengthRatio": 0.48, + "minuteLengthRatio": 0.72, + "hourThickness": 9, + "minuteThickness": 5, + "centerRadius": 7 + } + } + } + }, + { + "id": "paper", + "label": "Paper", + "preview": { + "pageBackground": "#f2eee5", + "paper": "#fcfaf4", + "panelBackground": "#fffdf8", + "frameStroke": "#7e6b57", + "frameStrokeStrong": "#5f5143", + "frameMuted": "rgba(126, 107, 87, 0.32)", + "mutedInk": "#5a5148", + "badgeFill": "#f3ede0", + "bodyFont": "'Songti SC', 'STSong', serif", + "displayFont": "'Baskerville', 'Times New Roman', 'Songti SC', serif", + "titleFont": "'Songti SC', 'STSong', serif", + "cardRadius": "1.7rem", + "panelRadius": "1.1rem" + }, + "variants": { + "portrait": { + "devicePlacement": "logo_bottom", + "viewport": { + "width": 1072, + "height": 1448 + }, + "backgroundPath": "themes/paper/portrait/kindlebg.png", + "clock": { + "x": 347, + "y": 55, + "width": 220, + "height": 220, + "faceRadiusRatio": 0.47, + "faceStroke": 3, + "tickOuterInset": 6, + "majorTickLength": 14, + "minorTickLength": 7, + "majorTickThickness": 4, + "minorTickThickness": 2, + "hourLengthRatio": 0.48, + "minuteLengthRatio": 0.72, + "hourThickness": 9, + "minuteThickness": 5, + "centerRadius": 7 + } + }, + "landscape": { + "devicePlacement": "logo_right", + "viewport": { + "width": 1448, + "height": 1072 + }, + "backgroundPath": "themes/paper/landscape/kindlebg.png", + "clock": { + "x": 659, + "y": 57, + "width": 220, + "height": 220, + "faceRadiusRatio": 0.47, + "faceStroke": 3, + "tickOuterInset": 6, + "majorTickLength": 14, + "minorTickLength": 7, + "majorTickThickness": 4, + "minorTickThickness": 2, + "hourLengthRatio": 0.48, + "minuteLengthRatio": 0.72, + "hourThickness": 9, + "minuteThickness": 5, + "centerRadius": 7 + } + } + } + }, + { + "id": "classic", + "label": "Classic", + "preview": { + "pageBackground": "#ece6da", + "paper": "#ffffff", + "panelBackground": "#fefefe", + "frameStroke": "#3d352c", + "frameStrokeStrong": "#1f1a15", + "frameMuted": "rgba(61, 53, 44, 0.3)", + "mutedInk": "#3d352c", + "badgeFill": "#f3efe8", + "bodyFont": "'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', sans-serif", + "displayFont": "'Palatino Linotype', 'Book Antiqua', 'Songti SC', serif", + "titleFont": "'Palatino Linotype', 'Book Antiqua', 'Songti SC', serif", + "cardRadius": "1.25rem", + "panelRadius": "0.92rem" + }, + "variants": { + "portrait": { + "devicePlacement": "logo_bottom", + "viewport": { + "width": 1072, + "height": 1448 + }, + "backgroundPath": "themes/classic/portrait/kindlebg.png", + "clock": { + "x": 347, + "y": 55, + "width": 220, + "height": 220, + "faceRadiusRatio": 0.47, + "faceStroke": 3, + "tickOuterInset": 6, + "majorTickLength": 14, + "minorTickLength": 7, + "majorTickThickness": 4, + "minorTickThickness": 2, + "hourLengthRatio": 0.48, + "minuteLengthRatio": 0.72, + "hourThickness": 9, + "minuteThickness": 5, + "centerRadius": 7 + } + }, + "landscape": { + "devicePlacement": "logo_right", + "viewport": { + "width": 1448, + "height": 1072 + }, + "backgroundPath": "themes/classic/landscape/kindlebg.png", + "clock": { + "x": 659, + "y": 57, + "width": 220, + "height": 220, + "faceRadiusRatio": 0.47, + "faceStroke": 3, + "tickOuterInset": 6, + "majorTickLength": 14, + "minorTickLength": 7, + "majorTickThickness": 4, + "minorTickThickness": 2, + "hourLengthRatio": 0.48, + "minuteLengthRatio": 0.72, + "hourThickness": 9, + "minuteThickness": 5, + "centerRadius": 7 + } + } + } + }, + { + "id": "simple", + "label": "Simple", + "preview": { + "pageBackground": "#ffffff", + "paper": "#ffffff", + "panelBackground": "#ffffff", + "frameStroke": "#1e1e1e", + "frameStrokeStrong": "#000000", + "frameMuted": "rgba(10, 10, 10, 0.32)", + "mutedInk": "#4a5565", + "badgeFill": "#ffffff", + "bodyFont": "'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif", + "displayFont": "'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif", + "titleFont": "'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif", + "cardRadius": "32px", + "panelRadius": "32px" + }, + "variants": { + "portrait": { + "devicePlacement": "logo_bottom", + "viewport": { + "width": 1072, + "height": 1448 + }, + "backgroundPath": "themes/simple/portrait/kindlebg.png", + "clock": { + "x": 544, + "y": 32, + "width": 480, + "height": 480, + "faceRadiusRatio": 0.5, + "faceStroke": 2, + "tickOuterInset": 21.735, + "majorTickOuterInset": 21.735, + "minorTickOuterInset": 21.735, + "majorTickLength": 47.348, + "minorTickLength": 21.735, + "majorTickThickness": 14.4, + "minorTickThickness": 6.521, + "hourLengthRatio": 0.62, + "hourBackLengthRatio": 0.28, + "minuteLengthRatio": 0.9090585774058577, + "minuteBackLengthRatio": 0.3, + "hourThickness": 14.4, + "minuteThickness": 9.6, + "centerRadius": 4.8 + } + }, + "landscape": { + "devicePlacement": "logo_right", + "viewport": { + "width": 1448, + "height": 1072 + }, + "backgroundPath": "themes/simple/landscape/kindlebg.png", + "clock": { + "x": 29, + "y": 562, + "width": 480, + "height": 480, + "faceRadiusRatio": 0.5, + "faceStroke": 2, + "tickOuterInset": 21.735, + "majorTickOuterInset": 21.735, + "minorTickOuterInset": 21.735, + "majorTickLength": 47.348, + "minorTickLength": 21.735, + "majorTickThickness": 14.4, + "minorTickThickness": 6.521, + "hourLengthRatio": 0.62, + "hourBackLengthRatio": 0.28, + "minuteLengthRatio": 0.9090585774058577, + "minuteBackLengthRatio": 0.3, + "hourThickness": 14.4, + "minuteThickness": 9.6, + "centerRadius": 4.8 + } + } + } + } + ] +} diff --git a/calendar/package.json b/calendar/package.json index 7553407..13c063c 100644 --- a/calendar/package.json +++ b/calendar/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs", "export:background": "sh scripts/export-kindle-background.sh", + "export:themes": "sh scripts/export-theme-backgrounds.sh", "manifest": "node scripts/generate-dashboard-manifest.mjs", "typecheck": "vue-tsc --noEmit", "preview": "vite preview" diff --git a/calendar/scripts/export-kindle-background.sh b/calendar/scripts/export-kindle-background.sh index 160af85..77d8545 100644 --- a/calendar/scripts/export-kindle-background.sh +++ b/calendar/scripts/export-kindle-background.sh @@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)" CALENDAR_DIR="$ROOT_DIR/calendar" DIST_DIR="$CALENDAR_DIR/dist" PORT=${PORT:-4173} -URL=${1:-"http://127.0.0.1:$PORT/?mode=background"} +URL=${1:-"http://127.0.0.1:$PORT/?mode=background&theme=default&orientation=portrait"} OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"} OUT_REGION=${3:-"$DIST_DIR/clock-region.json"} diff --git a/calendar/scripts/export-kindle-background.swift b/calendar/scripts/export-kindle-background.swift index 45b2c79..82217bb 100644 --- a/calendar/scripts/export-kindle-background.swift +++ b/calendar/scripts/export-kindle-background.swift @@ -27,16 +27,28 @@ enum ExportError: Error, CustomStringConvertible { } final class SnapshotExporter: NSObject, WKNavigationDelegate { + private enum ExportOrientation { + case portrait + case landscape + } + + private struct ExportLayout { + let orientation: ExportOrientation + let viewportSize: CGSize + let outputSize: CGSize + } + private let url: URL private let pngOutputURL: URL private let regionOutputURL: URL private let completion: (Result) -> Void - // 直接按 Kindle Voyage 的系统屏保尺寸导出,避免额外旋转和补边。 - private let targetSize = CGSize(width: 1072, height: 1448) + // 网页渲染尺寸和 Kindle framebuffer 输出尺寸不是一回事。 + // landscape 需要先按 1448x1072 渲染,再旋转成 1072x1448 输出给设备。 + private let layout: ExportLayout private lazy var window: NSWindow = { let window = NSWindow( - contentRect: CGRect(origin: .zero, size: targetSize), + contentRect: CGRect(origin: .zero, size: layout.viewportSize), styleMask: [.borderless], backing: .buffered, defer: false @@ -48,7 +60,7 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { private lazy var webView: WKWebView = { let config = WKWebViewConfiguration() - let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config) + let view = WKWebView(frame: CGRect(origin: .zero, size: layout.viewportSize), configuration: config) view.navigationDelegate = self return view }() @@ -58,9 +70,29 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { self.pngOutputURL = pngOutputURL self.regionOutputURL = regionOutputURL self.completion = completion + self.layout = SnapshotExporter.resolveLayout(url: url) super.init() } + private static func resolveLayout(url: URL) -> ExportLayout { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let orientation = components?.queryItems?.first(where: { $0.name == "orientation" })?.value + + if orientation == "landscape" { + return ExportLayout( + orientation: .landscape, + viewportSize: CGSize(width: 1448, height: 1072), + outputSize: CGSize(width: 1072, height: 1448) + ) + } + + return ExportLayout( + orientation: .portrait, + viewportSize: CGSize(width: 1072, height: 1448), + outputSize: CGSize(width: 1072, height: 1448) + ) + } + func start() { window.contentView = webView window.orderBack(nil) @@ -79,12 +111,22 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { (() => { const node = document.querySelector('[data-clock-region="true"]'); if (!node) return null; + const dashboard = document.querySelector('.dashboard-frame'); const rect = node.getBoundingClientRect(); + const frameRect = dashboard ? dashboard.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; return { - x: Math.round(rect.left), - y: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height) + clock: { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height) + }, + frame: { + x: Math.round(frameRect.left), + y: Math.round(frameRect.top), + width: Math.round(frameRect.width), + height: Math.round(frameRect.height) + } }; })(); """ @@ -95,14 +137,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { return } - guard let region = value as? [String: NSNumber] else { + guard + let result = value as? [String: Any], + let region = result["clock"] as? [String: NSNumber], + let frame = result["frame"] as? [String: NSNumber] + else { self.finish(.failure(ExportError.clockRegionMissing)) return } let snapshotConfig = WKSnapshotConfiguration() - snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize) - snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width)) + snapshotConfig.rect = CGRect(origin: .zero, size: self.layout.viewportSize) + snapshotConfig.snapshotWidth = NSNumber(value: Float(self.layout.viewportSize.width)) self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in if let snapshotError { @@ -116,8 +162,8 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { } do { - try self.savePNG(image: image, to: self.pngOutputURL) - try self.saveRegion(region: region) + try self.savePNG(image: image, to: self.pngOutputURL, frameOffset: frame) + try self.saveRegion(region: region, frameOffset: frame) self.finish(.success(())) } catch { self.finish(.failure(error)) @@ -126,18 +172,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { } } - private func savePNG(image: NSImage, to url: URL) throws { - let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height)) + private func savePNG(image: NSImage, to url: URL, frameOffset: [String: NSNumber]) throws { + let normalizedImage = NSImage(size: NSSize(width: layout.viewportSize.width, height: layout.viewportSize.height)) normalizedImage.lockFocus() - image.draw(in: NSRect(origin: .zero, size: targetSize)) + image.draw(in: NSRect(origin: .zero, size: layout.viewportSize)) normalizedImage.unlockFocus() guard let sourceCGImage = normalizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { throw ExportError.pngEncodingFailed(url.path) } - let width = Int(targetSize.width) - let height = Int(targetSize.height) + let width = Int(layout.outputSize.width) + let height = Int(layout.outputSize.height) let colorSpace = CGColorSpaceCreateDeviceGray() // 输出 8-bit 灰度 PNG,但页面本身仍按纯白底和纯黑字设计,避免额外灰阶装饰。 @@ -156,7 +202,38 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { context.setFillColor(gray: 1, alpha: 1) context.fill(CGRect(x: 0, y: 0, width: width, height: height)) context.interpolationQuality = .high - context.draw(sourceCGImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + let offsetX = CGFloat(frameOffset["x"]?.doubleValue ?? 0) + let offsetY = CGFloat(frameOffset["y"]?.doubleValue ?? 0) + + // Kindle framebuffer 固定是纵向;landscape 导出时这里把横向截图旋转成设备图。 + switch layout.orientation { + case .portrait: + context.draw( + sourceCGImage, + in: CGRect( + x: -offsetX, + y: -offsetY, + width: layout.viewportSize.width, + height: layout.viewportSize.height + ) + ) + case .landscape: + context.saveGState() + // 横向网页需要先顺时针旋转 90 度写入纵向 framebuffer, + // 这样 Kindle 在 logo_right 摆放时,最终看到的页面方向才是正的。 + context.translateBy(x: 0, y: layout.outputSize.height) + context.rotate(by: -.pi / 2) + context.draw( + sourceCGImage, + in: CGRect( + x: -offsetX, + y: -offsetY, + width: layout.viewportSize.width, + height: layout.viewportSize.height + ) + ) + context.restoreGState() + } guard let grayscaleImage = context.makeImage() else { throw ExportError.pngEncodingFailed(url.path) @@ -180,18 +257,50 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate { } } - private func saveRegion(region: [String: NSNumber]) throws { + private func saveRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) throws { + let transformedRegion = transformRegion(region: region, frameOffset: frameOffset) let jsonObject: [String: Int] = [ - "x": region["x"]?.intValue ?? 0, - "y": region["y"]?.intValue ?? 0, - "width": region["width"]?.intValue ?? 0, - "height": region["height"]?.intValue ?? 0, + "x": transformedRegion["x"]?.intValue ?? 0, + "y": transformedRegion["y"]?.intValue ?? 0, + "width": transformedRegion["width"]?.intValue ?? 0, + "height": transformedRegion["height"]?.intValue ?? 0, ] let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys]) try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: regionOutputURL) } + private func transformRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) -> [String: NSNumber] { + let normalizedX = CGFloat(region["x"]?.doubleValue ?? 0) - CGFloat(frameOffset["x"]?.doubleValue ?? 0) + let normalizedY = CGFloat(region["y"]?.doubleValue ?? 0) - CGFloat(frameOffset["y"]?.doubleValue ?? 0) + let normalizedRegion: [String: NSNumber] = [ + "x": NSNumber(value: Int(round(normalizedX))), + "y": NSNumber(value: Int(round(normalizedY))), + "width": region["width"] ?? 0, + "height": region["height"] ?? 0, + ] + + guard layout.orientation == .landscape else { + return normalizedRegion + } + + let x = CGFloat(normalizedRegion["x"]?.doubleValue ?? 0) + let y = CGFloat(normalizedRegion["y"]?.doubleValue ?? 0) + let width = CGFloat(normalizedRegion["width"]?.doubleValue ?? 0) + let height = CGFloat(normalizedRegion["height"]?.doubleValue ?? 0) + + // “logo 在右侧”对应设备图坐标系下,横向网页需要顺时针旋转 90 度写入 framebuffer。 + let transformedX = layout.viewportSize.height - (y + height) + let transformedY = x + + return [ + "x": NSNumber(value: Int(round(transformedX))), + "y": NSNumber(value: Int(round(transformedY))), + "width": NSNumber(value: Int(round(height))), + "height": NSNumber(value: Int(round(width))), + ] + } + private func finish(_ result: Result) { completion(result) NSApplication.shared.terminate(nil) diff --git a/calendar/scripts/export-theme-backgrounds.sh b/calendar/scripts/export-theme-backgrounds.sh new file mode 100644 index 0000000..b4c8f06 --- /dev/null +++ b/calendar/scripts/export-theme-backgrounds.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)" +CALENDAR_DIR="$ROOT_DIR/calendar" +DIST_DIR="$CALENDAR_DIR/dist" +PORT=${PORT:-4173} +SWIFT_SCRIPT="$CALENDAR_DIR/scripts/export-kindle-background.swift" +THEMES_SOURCE="$CALENDAR_DIR/config/themes.json" + +THEME_FILTER="" +ORIENTATION_FILTER="" + +print_usage() { + cat <<'EOF' +用法: + sh scripts/export-theme-backgrounds.sh [选项] + +选项: + --theme 只导出指定主题 + --orientation 只导出指定方向;必须和 --theme 一起使用 + -h, --help 查看帮助 + +示例: + sh scripts/export-theme-backgrounds.sh + sh scripts/export-theme-backgrounds.sh --theme simple + sh scripts/export-theme-backgrounds.sh --theme simple --orientation portrait +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --theme) + shift + THEME_FILTER=${1:?"missing theme id"} + ;; + --orientation) + shift + ORIENTATION_FILTER=${1:?"missing orientation"} + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "未知参数: $1" >&2 + echo >&2 + print_usage >&2 + exit 1 + ;; + esac + shift +done + +if [ -n "$ORIENTATION_FILTER" ] && [ -z "$THEME_FILTER" ]; then + echo "--orientation 必须和 --theme 一起使用。" >&2 + exit 1 +fi + +selection_output=$( + node --input-type=module -e " +import fs from 'node:fs'; + +const data = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); +const requestedTheme = process.argv[2]; +const requestedOrientation = process.argv[3]; +const themes = data.themes ?? []; +const themeMap = new Map(themes.map((theme) => [theme.id, theme])); + +if (requestedTheme && !themeMap.has(requestedTheme)) { + console.error(\`未知主题: \${requestedTheme}\`); + process.exit(1); +} + +if (requestedOrientation && !requestedTheme) { + console.error('--orientation 必须和 --theme 一起使用。'); + process.exit(1); +} + +const filteredThemes = requestedTheme ? themes.filter((theme) => theme.id === requestedTheme) : themes; +const items = []; + +for (const theme of filteredThemes) { + const orientations = Object.keys(theme.variants ?? {}); + if (requestedOrientation) { + if (!orientations.includes(requestedOrientation)) { + console.error( + \`主题 \${theme.id} 不支持方向 \${requestedOrientation},可用方向: \${orientations.join(', ')}\`, + ); + process.exit(1); + } + } + + const filteredOrientations = requestedOrientation ? [requestedOrientation] : orientations; + for (const orientation of filteredOrientations) { + const variant = theme.variants[orientation]; + items.push([theme.id, orientation, variant.backgroundPath].join('\t')); + } +} + +if (items.length === 0) { + console.error('没有可导出的主题背景。'); + process.exit(1); +} + +console.log(\`DEFAULT_THEME_ID=\${data.defaultThemeId}\`); +console.log(\`DEFAULT_ORIENTATION=\${data.defaultOrientation}\`); +for (const item of items) { + console.log(\`ITEM=\${item}\`); +} +" "$THEMES_SOURCE" "$THEME_FILTER" "$ORIENTATION_FILTER" +) + +DEFAULT_THEME_ID="" +DEFAULT_ORIENTATION="" +EXPORT_ITEMS="" + +while IFS= read -r line; do + case "$line" in + DEFAULT_THEME_ID=*) + DEFAULT_THEME_ID=${line#DEFAULT_THEME_ID=} + ;; + DEFAULT_ORIENTATION=*) + DEFAULT_ORIENTATION=${line#DEFAULT_ORIENTATION=} + ;; + ITEM=*) + if [ -n "$EXPORT_ITEMS" ]; then + EXPORT_ITEMS="${EXPORT_ITEMS} +${line#ITEM=}" + else + EXPORT_ITEMS=${line#ITEM=} + fi + ;; + esac +done <&2 + exit 1 +fi + +cd "$CALENDAR_DIR" +npm run build >/dev/null + +python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 & +SERVER_PID=$! +trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM + +sleep 1 + +printf '%s\n' "$EXPORT_ITEMS" | while IFS="$(printf '\t')" read -r theme_id orientation background_path; do + out_png="$DIST_DIR/$background_path" + out_region="${out_png%.png}.clock-region.json" + url="http://127.0.0.1:$PORT/?mode=background&theme=$theme_id&orientation=$orientation" + /usr/bin/swift "$SWIFT_SCRIPT" "$url" "$out_png" "$out_region" >/dev/null + + # 根目录的 kindlebg.png / clock-region.json 只给默认主题兜底使用。 + # 定向导出其它主题时不覆盖它,避免把默认主题的运行时入口意外改掉。 + if [ "$theme_id" = "$DEFAULT_THEME_ID" ] && [ "$orientation" = "$DEFAULT_ORIENTATION" ]; then + cp "$out_png" "$DIST_DIR/kindlebg.png" + cp "$out_region" "$DIST_DIR/clock-region.json" + fi + + printf 'Exported %s %s -> %s\n' "$theme_id" "$orientation" "$out_png" +done + +node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null + +if [ -f "$DIST_DIR/clock-region.json" ]; then + printf 'Default region saved to %s\n' "$DIST_DIR/clock-region.json" +else + printf 'Skipped default region update (default theme not exported this run)\n' +fi diff --git a/calendar/scripts/generate-dashboard-manifest.mjs b/calendar/scripts/generate-dashboard-manifest.mjs index 9b7f5c1..999d14e 100644 --- a/calendar/scripts/generate-dashboard-manifest.mjs +++ b/calendar/scripts/generate-dashboard-manifest.mjs @@ -6,13 +6,28 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url)); const distDir = path.resolve(currentDir, '../dist'); const manifestPath = path.join(distDir, 'dashboard-manifest.json'); const clockRegionPath = path.join(distDir, 'clock-region.json'); +const themesSourcePath = path.resolve(currentDir, '../config/themes.json'); +const themesDistPath = path.join(distDir, 'themes.json'); +const themesDir = path.join(distDir, 'themes'); +const dashboardBaseUrl = 'https://shell.biboer.cn:20001'; -const defaultClockRegion = { - x: 313, - y: 0, - width: 220, - height: 220, -}; +const themesSource = JSON.parse(fs.readFileSync(themesSourcePath, 'utf8')); +const generatedAt = new Date().toISOString(); +const defaultVariant = themesSource.themes.find((theme) => theme.id === themesSource.defaultThemeId)?.variants?.[themesSource.defaultOrientation]; +const defaultDeviceClock = defaultVariant ? toDeviceClock(defaultVariant, themesSource.defaultOrientation) : null; +const defaultClockRegion = defaultVariant + ? { + x: defaultDeviceClock.x, + y: defaultDeviceClock.y, + width: defaultDeviceClock.width, + height: defaultDeviceClock.height, + } + : { + x: 313, + y: 0, + width: 220, + height: 220, + }; const clockRegion = fs.existsSync(clockRegionPath) ? { @@ -22,10 +37,15 @@ const clockRegion = fs.existsSync(clockRegionPath) : defaultClockRegion; const manifest = { + theme: { + id: themesSource.defaultThemeId, + orientation: themesSource.defaultOrientation, + themesUrl: `${dashboardBaseUrl}/themes.json`, + }, background: { path: 'kindlebg.png', - url: 'https://shell.biboer.cn:20001/kindlebg.png', - updatedAt: new Date().toISOString(), + url: `${dashboardBaseUrl}/kindlebg.png`, + updatedAt: generatedAt, refreshIntervalMinutes: 120, }, clockRegion, @@ -45,6 +65,67 @@ const manifest = { }, }; +const themesIndex = { + updatedAt: generatedAt, + defaultThemeId: themesSource.defaultThemeId, + defaultOrientation: themesSource.defaultOrientation, + themes: themesSource.themes.map((theme) => ({ + id: theme.id, + label: theme.label, + configUrl: `${dashboardBaseUrl}/themes/${theme.id}.json`, + orientations: Object.keys(theme.variants), + })), +}; + +function toDeviceClock(variant, orientation) { + if (orientation !== 'landscape') { + return { + ...variant.clock, + rotationDegrees: 0, + }; + } + + return { + ...variant.clock, + x: variant.viewport.height - (variant.clock.y + variant.clock.height), + y: variant.clock.x, + width: variant.clock.height, + height: variant.clock.width, + rotationDegrees: 90, + }; +} + +function buildThemeConfig(theme) { + return { + id: theme.id, + label: theme.label, + updatedAt: generatedAt, + variants: Object.fromEntries( + Object.entries(theme.variants).map(([orientation, variant]) => [ + orientation, + { + devicePlacement: variant.devicePlacement, + background: { + path: variant.backgroundPath, + url: `${dashboardBaseUrl}/${variant.backgroundPath}`, + refreshIntervalMinutes: 120, + }, + clock: toDeviceClock(variant, orientation), + }, + ]), + ), + }; +} + fs.mkdirSync(distDir, { recursive: true }); +fs.mkdirSync(themesDir, { recursive: true }); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +fs.writeFileSync(themesDistPath, `${JSON.stringify(themesIndex, null, 2)}\n`, 'utf8'); + +for (const theme of themesSource.themes) { + const themePath = path.join(themesDir, `${theme.id}.json`); + fs.writeFileSync(themePath, `${JSON.stringify(buildThemeConfig(theme), null, 2)}\n`, 'utf8'); +} + console.log(`Wrote ${manifestPath}`); +console.log(`Wrote ${themesDistPath}`); diff --git a/calendar/src/App.vue b/calendar/src/App.vue index ea8167c..4dc1e91 100644 --- a/calendar/src/App.vue +++ b/calendar/src/App.vue @@ -4,18 +4,30 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import AnalogClock from '@/components/AnalogClock.vue'; import CalendarCard from '@/components/CalendarCard.vue'; import QuoteCard from '@/components/QuoteCard.vue'; +import SimpleDashboard from '@/components/SimpleDashboard.vue'; import WeatherCard from '@/components/WeatherCard.vue'; import { buildCalendarModel } from '@/lib/calendar'; import { resolveDashboardMode } from '@/lib/dashboard-mode'; +import { + DASHBOARD_THEMES, + buildDashboardSearch, + getDashboardTheme, + getDashboardVariant, + resolveDashboardOrientation, + resolveDashboardThemeId, + type DashboardOrientation, +} from '@/lib/dashboard-theme'; import { getQuoteForDate } from '@/lib/quotes'; import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather'; const now = ref(new Date()); const mode = ref(resolveDashboardMode(window.location.search)); +const themeId = ref(resolveDashboardThemeId(window.location.search)); +const orientation = ref(resolveDashboardOrientation(window.location.search)); const location = ref({ - latitude: 31.2304, - longitude: 121.4737, - label: '上海', + latitude: 30.274084, + longitude: 120.15507, + label: '杭州', }); const weather = ref(null); const weatherStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle'); @@ -26,6 +38,38 @@ let weatherTimer = 0; const calendarModel = computed(() => buildCalendarModel(now.value)); const quoteEntry = computed(() => getQuoteForDate(now.value)); const isClockFaceMode = computed(() => mode.value === 'clock-face'); +const isSimpleTheme = computed(() => themeId.value === 'simple'); +const showPreviewControls = computed(() => mode.value === 'full'); +const selectedTheme = computed(() => getDashboardTheme(themeId.value)); +const selectedVariant = computed(() => getDashboardVariant(themeId.value, orientation.value)); +const dashboardStyle = computed(() => ({ + '--dashboard-width': `${selectedVariant.value.viewport.width}px`, + '--dashboard-height': `${selectedVariant.value.viewport.height}px`, + '--dashboard-aspect': `${selectedVariant.value.viewport.width} / ${selectedVariant.value.viewport.height}`, + '--page-background': selectedTheme.value.preview.pageBackground, + '--paper': selectedTheme.value.preview.paper, + '--panel-background': selectedTheme.value.preview.panelBackground, + '--frame-stroke': selectedTheme.value.preview.frameStroke, + '--frame-stroke-strong': selectedTheme.value.preview.frameStrokeStrong, + '--frame-muted': selectedTheme.value.preview.frameMuted, + '--muted-ink': selectedTheme.value.preview.mutedInk, + '--badge-fill': selectedTheme.value.preview.badgeFill, + '--body-font': selectedTheme.value.preview.bodyFont, + '--display-font': selectedTheme.value.preview.displayFont, + '--title-font': selectedTheme.value.preview.titleFont, + '--card-radius': selectedTheme.value.preview.cardRadius, + '--panel-radius': selectedTheme.value.preview.panelRadius, + // default 主题整体字号翻倍,但鸡汤正文保持原尺寸。 + '--theme-font-scale': themeId.value === 'default' ? '2' : '1', + '--quote-content-font-scale': '1', + // 四天天气小卡在 default 主题下单独收紧,避免放大后溢出。 + '--forecast-pill-scale': themeId.value === 'default' ? '0.78' : '1', +})); + +const orientationOptions: Array<{ value: DashboardOrientation; label: string }> = [ + { value: 'portrait', label: '纵向(Logo 下)' }, + { value: 'landscape', label: '横向(Logo 右)' }, +]; async function refreshWeather() { weatherStatus.value = 'loading'; @@ -41,6 +85,18 @@ async function refreshWeather() { function syncMode() { mode.value = resolveDashboardMode(window.location.search); + themeId.value = resolveDashboardThemeId(window.location.search); + orientation.value = resolveDashboardOrientation(window.location.search); +} + +function updateSearch() { + const nextSearch = buildDashboardSearch({ + mode: mode.value, + themeId: themeId.value, + orientation: orientation.value, + }); + + window.history.replaceState({}, '', nextSearch); } onMounted(async () => { @@ -71,20 +127,55 @@ onBeforeUnmount(() => {