update at 2026-03-17 10:37:27

This commit is contained in:
douboer@gmail.com
2026-03-17 10:37:27 +08:00
parent e5becf63cf
commit 192eb1b8d1
44 changed files with 5208 additions and 403 deletions

6
assets/simple/book.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 38.4 38.4" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M19.2 11.2V33.6" stroke="var(--stroke-0, black)" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M4.80005 28.7999C4.3757 28.7999 3.96874 28.6314 3.66868 28.3313C3.36862 28.0313 3.20005 27.6243 3.20005 27.1999V6.39995C3.20005 5.9756 3.36862 5.56864 3.66868 5.26858C3.96874 4.96852 4.3757 4.79995 4.80005 4.79995H12.8C14.4974 4.79995 16.1253 5.47423 17.3255 6.67447C18.5258 7.8747 19.2 9.50257 19.2 11.2C19.2 9.50257 19.8743 7.8747 21.0746 6.67447C22.2748 5.47423 23.9027 4.79995 25.6 4.79995H33.6C34.0244 4.79995 34.4314 4.96852 34.7314 5.26858C35.0315 5.56864 35.2 5.9756 35.2 6.39995V27.1999C35.2 27.6243 35.0315 28.0313 34.7314 28.3313C34.4314 28.6314 34.0244 28.7999 33.6 28.7999H24C22.727 28.7999 21.5061 29.3057 20.6059 30.2058C19.7058 31.106 19.2 32.3269 19.2 33.5999C19.2 32.3269 18.6943 31.106 17.7942 30.2058C16.894 29.3057 15.6731 28.7999 14.4 28.7999H4.80005Z" stroke="var(--stroke-0, black)" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 69.9873 41.1429" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M52.8246 38.5714H25.2632C21.054 38.5706 16.928 37.6415 13.3463 35.8881C9.76458 34.1346 6.86824 31.6259 4.98094 28.6421C3.09364 25.6584 2.28973 22.3173 2.65905 18.9921C3.02836 15.6669 4.55635 12.4887 7.07228 9.81252C9.5882 7.13637 12.9929 5.06775 16.906 3.83782C20.8191 2.60789 25.0863 2.2651 29.2309 2.84776C33.3755 3.43042 37.2342 4.91558 40.3757 7.13727C43.5172 9.35896 45.8178 12.2297 47.0205 15.4286H52.8246C56.6944 15.4286 60.4058 16.6477 63.1422 18.8178C65.8786 20.9878 67.4159 23.9311 67.4159 27C67.4159 30.0689 65.8786 33.0122 63.1422 35.1822C60.4058 37.3523 56.6944 38.5714 52.8246 38.5714Z" stroke="var(--stroke-0, #0A0A0A)" stroke-width="5.14286" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 915 B

View File

@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 54.5037 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M41.5738 48.8077C34.4329 48.8077 28.644 43.0188 28.644 35.8779C28.644 25.5768 37.6891 16.2829 40.648 13.5181C40.6566 13.5267 40.6662 13.5344 40.6752 13.5429C40.7917 13.415 40.9333 13.3125 41.0913 13.2418C41.2492 13.1712 41.4199 13.1338 41.5929 13.1321C41.7659 13.1303 41.9374 13.1643 42.0967 13.2318C42.256 13.2993 42.3996 13.3989 42.5187 13.5245C45.4968 16.2751 54.5037 25.4557 54.5037 35.8779C54.5037 43.0188 48.7148 48.8077 41.5738 48.8077ZM42.4625 18.7291L42.4584 18.7326C42.3586 18.601 42.2296 18.4942 42.0817 18.4207C41.9337 18.3472 41.7707 18.309 41.6055 18.309C41.4402 18.309 41.2773 18.3472 41.1293 18.4207C40.9813 18.4942 40.8524 18.601 40.7525 18.7326C40.7431 18.7239 40.7329 18.716 40.7237 18.707C38.4479 21.2226 32.3881 28.601 32.3881 35.8412C32.3881 40.9074 36.5008 45.0143 41.5742 45.0143C46.6476 45.0143 50.7599 40.9073 50.7599 35.8412C50.7599 28.4558 44.7669 21.2335 42.4625 18.7291ZM42.805 41.9211H42.5524C41.4732 41.921 40.4088 41.6695 39.4437 41.1864C38.4786 40.7033 37.6395 40.0019 36.9927 39.1379C36.346 38.2738 35.9096 37.271 35.718 36.2089C35.5265 35.1468 35.5851 34.0546 35.8892 33.0191H35.919C36.074 32.6121 36.3667 32.2723 36.7462 32.0587C37.1258 31.8452 37.5682 31.7714 37.9965 31.8502C38.4249 31.929 38.812 32.1554 39.0907 32.4901C39.3694 32.8248 39.522 33.2465 39.522 33.6821C39.522 33.854 39.3957 34.2557 39.3957 34.9763C39.3957 35.8135 39.7283 36.6164 40.3203 37.2084C40.9123 37.8004 41.7152 38.133 42.5524 38.133C42.6375 38.133 42.7216 38.1287 42.805 38.122V38.133C43.3073 38.133 43.789 38.3326 44.1442 38.6878C44.4994 39.043 44.699 39.5247 44.699 40.027C44.699 40.5294 44.4994 41.0111 44.1442 41.3663C43.789 41.7215 43.3073 41.9211 42.805 41.9211ZM20.2029 44.6358C20.8182 44.6357 21.4318 44.5716 22.0338 44.4443V44.456C22.0961 44.4496 22.1593 44.4464 22.2232 44.4464C22.7034 44.4464 23.1656 44.6289 23.5163 44.9569C23.867 45.2848 24.0799 45.7338 24.112 46.2129C24.1441 46.692 23.993 47.1653 23.6892 47.5372C23.3854 47.909 22.9516 48.1515 22.4758 48.2155V48.2193C21.7258 48.3555 20.9651 48.424 20.2029 48.4239C13.2293 48.4239 7.5761 42.7707 7.5761 35.7971C7.57582 34.8295 7.68651 33.865 7.90597 32.9227C7.9342 32.6707 8.01277 32.427 8.137 32.2059C8.26124 31.9849 8.42861 31.7911 8.62919 31.636C8.82976 31.4809 9.05946 31.3677 9.30463 31.3031C9.5498 31.2384 9.80546 31.2237 10.0564 31.2598C10.3074 31.2959 10.5486 31.382 10.7656 31.5131C10.9826 31.6441 11.1711 31.8175 11.3199 32.0228C11.4687 32.2281 11.5747 32.4612 11.6316 32.7083C11.6886 32.9554 11.6953 33.2114 11.6514 33.4611H11.6767C11.3182 34.7728 11.2671 36.1495 11.5273 37.4842C11.7874 38.8189 12.3519 40.0756 13.1767 41.1567C14.0016 42.2377 15.0646 43.114 16.2832 43.7174C17.5018 44.3208 18.8431 44.6351 20.2029 44.6358ZM31.1251 16.6674C30.8067 16.6674 30.4934 16.587 30.2143 16.4335C29.9352 16.2801 29.6994 16.0587 29.5287 15.7898C29.5211 15.7997 29.5126 15.8088 29.5055 15.8193C26.2632 11.0329 22.7892 7.51857 21.1556 5.94091C21.1468 5.95107 21.1367 5.89703 21.1275 5.90694C21.0227 5.75934 20.8841 5.63897 20.7232 5.55592C20.5623 5.47286 20.3839 5.42952 20.2029 5.42952C20.0218 5.42952 19.8434 5.47286 19.6825 5.55592C19.5217 5.63897 19.383 5.75934 19.2782 5.90694C19.2726 5.90094 19.2664 5.89558 19.2609 5.88939C15.7729 9.33525 3.78805 22.1664 3.78805 35.7971C3.78805 44.8627 11.1372 52.2119 20.2029 52.2119C23.5322 52.2169 26.7835 51.2047 29.5215 49.3108C29.7084 49.1368 29.9286 49.0025 30.1688 48.9159C30.409 48.8292 30.6643 48.7921 30.9192 48.8068C31.1741 48.8214 31.4235 48.8875 31.6522 49.001C31.8809 49.1146 32.0843 49.2732 32.25 49.4674C32.4158 49.6617 32.5405 49.8874 32.6168 50.1311C32.693 50.3748 32.7191 50.6314 32.6935 50.8855C32.6679 51.1396 32.5912 51.3858 32.4679 51.6094C32.3446 51.8331 32.1774 52.0294 31.9762 52.1867C31.9805 52.1926 31.9845 52.1989 31.9888 52.2047C28.5572 54.6781 24.433 56.0062 20.2029 56C9.04517 56 0 46.9548 0 35.7971C0 19.0702 15.2658 4.04057 19.257 0.396908C19.262 0.401833 19.2677 0.406063 19.2727 0.410924C19.3855 0.287137 19.5218 0.187102 19.6737 0.116682C19.8257 0.0462617 19.9901 0.00687211 20.1575 0.00082172C20.3248 -0.00522867 20.4917 0.0221816 20.6483 0.0814465C20.8049 0.140711 20.9481 0.230639 21.0695 0.345959C21.0749 0.340087 21.0809 0.334784 21.0862 0.328786C23.088 2.12615 28.1414 6.94777 32.5777 13.5267L32.5638 13.5435C32.7994 13.8185 32.9511 14.1552 33.0011 14.5138C33.051 14.8723 32.997 15.2377 32.8456 15.5665C32.6942 15.8954 32.4516 16.1739 32.1466 16.369C31.8417 16.5641 31.4872 16.6677 31.1251 16.6674Z" fill="var(--fill-0, #333333)"/>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,23 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6 15.5L16 7L26 15.5"
stroke="black"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 14.5V25H23V14.5"
stroke="black"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 25V19H18V25"
stroke="black"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

8
assets/simple/pm25.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 60.3448 58.9474" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Vector">
<path d="M5.31034 11.6878C3.86207 11.6878 2.89655 11.1797 1.93103 10.1633C1.44828 9.65517 0.965518 8.63884 0.965518 7.11434C0.965518 4.5735 2.89655 2.54083 5.31034 2.54083C6.75862 2.54083 7.72414 3.049 8.68965 4.06534C9.65517 5.08167 10.1379 6.098 10.1379 7.62251C10.1379 8.63884 9.65517 10.1633 8.68965 11.1797C7.72414 11.1797 6.75862 11.6878 5.31034 11.6878ZM11.5862 27.9492C8.68965 27.9492 5.7931 24.9002 5.7931 21.343C5.7931 17.7858 8.68966 14.7368 12.069 14.7368C14 14.7368 15.931 15.7532 16.8966 16.7695C17.8621 17.7858 18.3448 19.3103 17.8621 20.8348C17.8621 22.3593 17.3793 23.8838 15.931 25.4083C14.9655 26.9329 13.5172 27.9492 11.5862 27.9492ZM56.4828 16.7695C55.5172 16.7695 54.5517 16.2613 54.069 15.245C53.1034 14.2287 53.1034 13.2123 53.1034 12.196C53.1034 10.1633 55.0345 9.14701 56.4828 9.14701C58.4138 9.14701 60.3448 10.6715 60.3448 12.7042C59.8621 14.7368 58.4138 16.7695 56.4828 16.7695ZM42.9655 12.7042C39.5862 12.196 37.1724 9.65517 37.1724 6.60617C37.1724 3.55717 39.5862 0 43.4483 0C46.8276 0 49.2414 3.049 49.2414 6.60617C49.2414 9.65517 46.3448 12.7042 42.9655 12.7042ZM19.7931 14.2287C16.8966 14.2287 14.9655 12.196 14.4828 9.14701C14.4828 6.098 16.8966 3.55717 19.7931 3.55717H20.2759C23.1724 3.55717 25.5862 5.58984 25.5862 8.63884C26.069 11.6878 23.6552 14.2287 20.7586 14.2287H19.7931ZM49.2414 31.5064C45.8621 31.5064 43.931 28.9655 43.931 25.9165C43.931 22.8675 46.3448 20.3267 49.2414 20.3267C52.6207 20.3267 54.5517 22.8675 54.5517 25.9165C55.0345 28.4574 52.1379 31.5064 49.2414 31.5064ZM32.8276 25.9165C30.4138 25.9165 28.4828 23.8839 28 21.343C28 18.8022 29.4483 16.2613 32.3448 16.2613C34.7586 16.2613 37.1724 18.294 37.1724 20.8348C37.1724 23.3757 35.7241 25.9165 32.8276 25.9165Z" fill="black"/>
<path d="M43.931 42.1779C37.6552 42.1779 32.8276 39.637 32.3448 38.6207C27.5172 35.5717 22.6897 34.0472 17.8621 34.0472C9.65517 34.0472 5.31035 38.6207 5.31035 39.1289V39.637H4.82759C3.86207 39.637 3.37931 40.1452 2.89655 40.1452C1.93103 40.1452 0.965517 39.637 0.482758 38.6207C-3.35704e-07 38.6207 -6.59533e-08 37.6044 0 37.0962C0 36.0799 2.51778e-07 35.0635 0.965517 34.5554C2.41379 33.0309 8.68966 27.441 18.3448 27.441C24.1379 27.441 29.931 29.4737 36.2069 33.539C36.6897 34.0472 39.5862 35.5717 43.931 35.5717C47.7931 35.5717 51.6552 34.0472 55.0345 31.5064H56.9655C57.931 31.5064 58.8966 32.0145 59.3793 32.5227C60.3448 34.0472 59.8621 35.5717 59.3793 37.0962C53.1034 41.1615 47.3103 42.1779 43.931 42.1779Z" fill="black"/>
<path d="M2.89655 58.9474V43.1942H8.2069C13.0345 43.1942 14 46.2432 14 48.2759C14 51.833 11.5862 53.8657 8.2069 53.8657H6.75862V58.9474H2.89655ZM7.72414 49.8004C9.65517 49.8004 9.65517 48.784 9.65517 47.7677C9.65517 46.7514 9.65517 46.2432 7.24138 46.2432H6.75862V49.8004H7.72414ZM24.6207 58.9474V52.3412L22.6897 57.4229H20.2759L18.3448 52.3412V58.9474H14.9655V43.1942H19.3103L21.7241 50.3085C21.7241 50.8167 21.7241 50.8167 22.2069 51.3249C22.2069 50.8167 22.2069 50.8167 22.6897 50.3085L25.1035 43.1942H29.4483V58.9474H24.6207Z" fill="var(--fill-0, #231815)"/>
<path d="M29.4483 58.9474L29.4483 56.4065C31.8621 53.8657 35.2414 50.8167 35.2414 48.784C35.2414 47.7677 34.7586 47.2595 33.7931 47.2595C33.3103 47.2595 32.3448 47.7677 31.8621 48.2759L31.3793 48.784L28.9655 46.2432L29.4483 45.735C30.8965 44.2105 32.3448 43.1942 34.2759 43.1942C37.1724 43.1942 39.1034 45.2269 39.1034 48.2759C39.5862 49.8004 38.1379 52.3412 35.7241 54.882H39.5862V58.4392H29.4483L29.4483 58.9474ZM43.4483 58.9474C42 58.9474 41.0345 57.931 41.0345 56.4065C41.0345 54.882 42 53.8657 43.4483 53.8657C44.8966 53.8657 45.8621 54.882 45.8621 56.4065C45.8621 57.931 44.4138 58.9474 43.4483 58.9474ZM51.6552 58.9474C49.7241 58.9474 47.7931 58.4392 46.8276 56.9147L46.3448 56.4065L48.2759 53.3575L48.7586 53.8657C49.7241 54.882 50.2069 54.882 51.1724 54.882C52.1379 54.882 53.1034 54.3739 53.1034 52.8494C53.1034 51.833 52.6207 50.8167 51.6552 50.8167C51.1724 50.8167 50.6897 50.8167 50.2069 51.3249H49.7241L47.7931 49.8004L48.2759 41.6697H56.9655V45.2269H52.1379V46.7514H53.1034C56 46.7514 57.931 48.784 57.931 51.833C56.9655 56.9147 54.5517 58.9474 51.6552 58.9474Z" fill="var(--fill-0, #231815)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

410
bootstrap-new-kindle.sh Normal file
View File

@@ -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 <path> Kindle 挂载目录,默认 /Volumes/Kindle
-k, --kindle <host> Kindle SSH 主机名,默认 kindle
-t, --theme <theme-id> SSH 阶段切换到指定主题;默认使用 themes.json 的默认主题
-o, --orientation <value> 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
阶段 ESSH 打通后自动同步并出图
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 <<EOF
$resolved_output
EOF
if [ -z "$RESOLVED_THEME_ID" ] || [ -z "$RESOLVED_ORIENTATION" ]; then
echo "无法解析主题和方向。" >&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 </dev/null &"
}
print_post_ssh_summary() {
load_theme_selection
cat <<EOF
SSH 阶段已完成:
- 主机:$HOST_TARGET
- 已同步 dashboard 运行时与主题包
- 当前用于出图的主题:$RESOLVED_THEME_ID / $RESOLVED_ORIENTATION
如果你要继续验证:
- SSH 登录ssh $HOST_TARGET
- 立即切主题ssh $HOST_TARGET '/mnt/us/dashboard/switch-theme.sh $RESOLVED_THEME_ID $RESOLVED_ORIENTATION'
- 抓图到本地sh snapshot.sh -t $RESOLVED_THEME_ID -o $RESOLVED_ORIENTATION
EOF
}
post_ssh() {
if ! ssh_reachable; then
echo "当前还无法通过 SSH 连接 $HOST_TARGET" >&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

306
calendar/config/themes.json Normal file
View File

@@ -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
}
}
}
}
]
}

View File

@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs", "build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs",
"export:background": "sh scripts/export-kindle-background.sh", "export:background": "sh scripts/export-kindle-background.sh",
"export:themes": "sh scripts/export-theme-backgrounds.sh",
"manifest": "node scripts/generate-dashboard-manifest.mjs", "manifest": "node scripts/generate-dashboard-manifest.mjs",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"preview": "vite preview" "preview": "vite preview"

View File

@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
CALENDAR_DIR="$ROOT_DIR/calendar" CALENDAR_DIR="$ROOT_DIR/calendar"
DIST_DIR="$CALENDAR_DIR/dist" DIST_DIR="$CALENDAR_DIR/dist"
PORT=${PORT:-4173} 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_PNG=${2:-"$DIST_DIR/kindlebg.png"}
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"} OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}

View File

@@ -27,16 +27,28 @@ enum ExportError: Error, CustomStringConvertible {
} }
final class SnapshotExporter: NSObject, WKNavigationDelegate { 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 url: URL
private let pngOutputURL: URL private let pngOutputURL: URL
private let regionOutputURL: URL private let regionOutputURL: URL
private let completion: (Result<Void, Error>) -> Void private let completion: (Result<Void, Error>) -> Void
// Kindle Voyage // Kindle framebuffer
private let targetSize = CGSize(width: 1072, height: 1448) // landscape 1448x1072 1072x1448
private let layout: ExportLayout
private lazy var window: NSWindow = { private lazy var window: NSWindow = {
let window = NSWindow( let window = NSWindow(
contentRect: CGRect(origin: .zero, size: targetSize), contentRect: CGRect(origin: .zero, size: layout.viewportSize),
styleMask: [.borderless], styleMask: [.borderless],
backing: .buffered, backing: .buffered,
defer: false defer: false
@@ -48,7 +60,7 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
private lazy var webView: WKWebView = { private lazy var webView: WKWebView = {
let config = WKWebViewConfiguration() 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 view.navigationDelegate = self
return view return view
}() }()
@@ -58,9 +70,29 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
self.pngOutputURL = pngOutputURL self.pngOutputURL = pngOutputURL
self.regionOutputURL = regionOutputURL self.regionOutputURL = regionOutputURL
self.completion = completion self.completion = completion
self.layout = SnapshotExporter.resolveLayout(url: url)
super.init() 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() { func start() {
window.contentView = webView window.contentView = webView
window.orderBack(nil) window.orderBack(nil)
@@ -79,12 +111,22 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
(() => { (() => {
const node = document.querySelector('[data-clock-region="true"]'); const node = document.querySelector('[data-clock-region="true"]');
if (!node) return null; if (!node) return null;
const dashboard = document.querySelector('.dashboard-frame');
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
const frameRect = dashboard ? dashboard.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
return { return {
clock: {
x: Math.round(rect.left), x: Math.round(rect.left),
y: Math.round(rect.top), y: Math.round(rect.top),
width: Math.round(rect.width), width: Math.round(rect.width),
height: Math.round(rect.height) 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 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)) self.finish(.failure(ExportError.clockRegionMissing))
return return
} }
let snapshotConfig = WKSnapshotConfiguration() let snapshotConfig = WKSnapshotConfiguration()
snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize) snapshotConfig.rect = CGRect(origin: .zero, size: self.layout.viewportSize)
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width)) snapshotConfig.snapshotWidth = NSNumber(value: Float(self.layout.viewportSize.width))
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
if let snapshotError { if let snapshotError {
@@ -116,8 +162,8 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
} }
do { do {
try self.savePNG(image: image, to: self.pngOutputURL) try self.savePNG(image: image, to: self.pngOutputURL, frameOffset: frame)
try self.saveRegion(region: region) try self.saveRegion(region: region, frameOffset: frame)
self.finish(.success(())) self.finish(.success(()))
} catch { } catch {
self.finish(.failure(error)) self.finish(.failure(error))
@@ -126,18 +172,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
} }
} }
private func savePNG(image: NSImage, to url: URL) throws { private func savePNG(image: NSImage, to url: URL, frameOffset: [String: NSNumber]) throws {
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height)) let normalizedImage = NSImage(size: NSSize(width: layout.viewportSize.width, height: layout.viewportSize.height))
normalizedImage.lockFocus() normalizedImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: targetSize)) image.draw(in: NSRect(origin: .zero, size: layout.viewportSize))
normalizedImage.unlockFocus() normalizedImage.unlockFocus()
guard let sourceCGImage = normalizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { guard let sourceCGImage = normalizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ExportError.pngEncodingFailed(url.path) throw ExportError.pngEncodingFailed(url.path)
} }
let width = Int(targetSize.width) let width = Int(layout.outputSize.width)
let height = Int(targetSize.height) let height = Int(layout.outputSize.height)
let colorSpace = CGColorSpaceCreateDeviceGray() let colorSpace = CGColorSpaceCreateDeviceGray()
// 8-bit PNG // 8-bit PNG
@@ -156,7 +202,38 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
context.setFillColor(gray: 1, alpha: 1) context.setFillColor(gray: 1, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: width, height: height)) context.fill(CGRect(x: 0, y: 0, width: width, height: height))
context.interpolationQuality = .high 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 { guard let grayscaleImage = context.makeImage() else {
throw ExportError.pngEncodingFailed(url.path) 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] = [ let jsonObject: [String: Int] = [
"x": region["x"]?.intValue ?? 0, "x": transformedRegion["x"]?.intValue ?? 0,
"y": region["y"]?.intValue ?? 0, "y": transformedRegion["y"]?.intValue ?? 0,
"width": region["width"]?.intValue ?? 0, "width": transformedRegion["width"]?.intValue ?? 0,
"height": region["height"]?.intValue ?? 0, "height": transformedRegion["height"]?.intValue ?? 0,
] ]
let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys])
try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: regionOutputURL) 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<Void, Error>) { private func finish(_ result: Result<Void, Error>) {
completion(result) completion(result)
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)

View File

@@ -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 <theme-id> 只导出指定主题
--orientation <value> 只导出指定方向;必须和 --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 <<EOF
$selection_output
EOF
if [ -z "$DEFAULT_THEME_ID" ] || [ -z "$DEFAULT_ORIENTATION" ] || [ -z "$EXPORT_ITEMS" ]; then
echo "无法解析导出目标。" >&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

View File

@@ -6,8 +6,23 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(currentDir, '../dist'); const distDir = path.resolve(currentDir, '../dist');
const manifestPath = path.join(distDir, 'dashboard-manifest.json'); const manifestPath = path.join(distDir, 'dashboard-manifest.json');
const clockRegionPath = path.join(distDir, 'clock-region.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 = { 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, x: 313,
y: 0, y: 0,
width: 220, width: 220,
@@ -22,10 +37,15 @@ const clockRegion = fs.existsSync(clockRegionPath)
: defaultClockRegion; : defaultClockRegion;
const manifest = { const manifest = {
theme: {
id: themesSource.defaultThemeId,
orientation: themesSource.defaultOrientation,
themesUrl: `${dashboardBaseUrl}/themes.json`,
},
background: { background: {
path: 'kindlebg.png', path: 'kindlebg.png',
url: 'https://shell.biboer.cn:20001/kindlebg.png', url: `${dashboardBaseUrl}/kindlebg.png`,
updatedAt: new Date().toISOString(), updatedAt: generatedAt,
refreshIntervalMinutes: 120, refreshIntervalMinutes: 120,
}, },
clockRegion, 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(distDir, { recursive: true });
fs.mkdirSync(themesDir, { recursive: true });
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); 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 ${manifestPath}`);
console.log(`Wrote ${themesDistPath}`);

View File

@@ -4,18 +4,30 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import AnalogClock from '@/components/AnalogClock.vue'; import AnalogClock from '@/components/AnalogClock.vue';
import CalendarCard from '@/components/CalendarCard.vue'; import CalendarCard from '@/components/CalendarCard.vue';
import QuoteCard from '@/components/QuoteCard.vue'; import QuoteCard from '@/components/QuoteCard.vue';
import SimpleDashboard from '@/components/SimpleDashboard.vue';
import WeatherCard from '@/components/WeatherCard.vue'; import WeatherCard from '@/components/WeatherCard.vue';
import { buildCalendarModel } from '@/lib/calendar'; import { buildCalendarModel } from '@/lib/calendar';
import { resolveDashboardMode } from '@/lib/dashboard-mode'; 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 { getQuoteForDate } from '@/lib/quotes';
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather'; import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
const now = ref(new Date()); const now = ref(new Date());
const mode = ref(resolveDashboardMode(window.location.search)); const mode = ref(resolveDashboardMode(window.location.search));
const themeId = ref(resolveDashboardThemeId(window.location.search));
const orientation = ref<DashboardOrientation>(resolveDashboardOrientation(window.location.search));
const location = ref<LocationCoordinates>({ const location = ref<LocationCoordinates>({
latitude: 31.2304, latitude: 30.274084,
longitude: 121.4737, longitude: 120.15507,
label: '上海', label: '杭州',
}); });
const weather = ref<WeatherSnapshot | null>(null); const weather = ref<WeatherSnapshot | null>(null);
const weatherStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle'); const weatherStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
@@ -26,6 +38,38 @@ let weatherTimer = 0;
const calendarModel = computed(() => buildCalendarModel(now.value)); const calendarModel = computed(() => buildCalendarModel(now.value));
const quoteEntry = computed(() => getQuoteForDate(now.value)); const quoteEntry = computed(() => getQuoteForDate(now.value));
const isClockFaceMode = computed(() => mode.value === 'clock-face'); 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() { async function refreshWeather() {
weatherStatus.value = 'loading'; weatherStatus.value = 'loading';
@@ -41,6 +85,18 @@ async function refreshWeather() {
function syncMode() { function syncMode() {
mode.value = resolveDashboardMode(window.location.search); 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 () => { onMounted(async () => {
@@ -71,20 +127,55 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<main :class="['page-shell', `page-shell--${mode}`]"> <main
:class="['page-shell', `page-shell--${mode}`, `page-shell--${orientation}`, `page-shell--${themeId}`]"
:style="dashboardStyle"
>
<div class="page-stack">
<header v-if="showPreviewControls" class="preview-toolbar">
<label class="preview-toolbar__field">
<span class="preview-toolbar__label">主题</span>
<select v-model="themeId" class="preview-toolbar__select" @change="updateSearch">
<option v-for="theme in DASHBOARD_THEMES" :key="theme.id" :value="theme.id">
{{ theme.label }}
</option>
</select>
</label>
<label class="preview-toolbar__field">
<span class="preview-toolbar__label">方向</span>
<select v-model="orientation" class="preview-toolbar__select" @change="updateSearch">
<option v-for="option in orientationOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</header>
<section v-if="isClockFaceMode" class="clock-face-stage"> <section v-if="isClockFaceMode" class="clock-face-stage">
<AnalogClock :date="now" mode="clock-face" :size="220" /> <AnalogClock :date="now" mode="clock-face" :size="220" />
</section> </section>
<div v-else class="dashboard-frame"> <div v-else :class="['dashboard-frame', `dashboard-frame--${orientation}`]">
<div class="dashboard-grid"> <SimpleDashboard
<CalendarCard :model="calendarModel" :date="now" :mode="mode" /> v-if="isSimpleTheme"
:model="calendarModel"
:date="now"
:mode="mode"
:orientation="orientation"
:location-label="location.label"
:weather="weather"
:quote="quoteEntry.text"
/>
<div v-else :class="['dashboard-grid', `dashboard-grid--${orientation}`]">
<CalendarCard class="dashboard-grid__calendar" :model="calendarModel" :date="now" :mode="mode" />
<WeatherCard <WeatherCard
class="dashboard-grid__weather"
:weather="weather" :weather="weather"
:status="weatherStatus" :status="weatherStatus"
:location-label="location.label" :location-label="location.label"
/> />
<QuoteCard :quote="quoteEntry.text" /> <QuoteCard class="dashboard-grid__quote" :quote="quoteEntry.text" />
</div>
</div> </div>
</div> </div>
</main> </main>

View File

@@ -43,7 +43,6 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
<div class="calendar-card__panel-header"> <div class="calendar-card__panel-header">
<div> <div>
<p class="calendar-card__panel-title">{{ model.gregorianLabel }}</p> <p class="calendar-card__panel-title">{{ model.gregorianLabel }}</p>
<p class="calendar-card__panel-subtitle">{{ model.lunarYearLabel }}</p>
</div> </div>
<div v-if="model.summaryBadges.length" class="calendar-card__badges"> <div v-if="model.summaryBadges.length" class="calendar-card__badges">
<span <span
@@ -98,13 +97,16 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
height: 100%; height: 100%;
gap: 1rem; gap: 1rem;
padding: 1.28rem 1.28rem 1.16rem; padding: 0;
border: 0;
border-radius: 0;
background: transparent;
} }
.calendar-card__hero { .calendar-card__hero {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
align-items: flex-start; align-items: flex-start;
justify-content: space-between;
gap: 1rem; gap: 1rem;
} }
@@ -124,27 +126,24 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
} }
.calendar-card__day { .calendar-card__day {
font-family: font-family: var(--display-font);
'Iowan Old Style', font-size: calc(6.9rem * var(--theme-font-scale, 1));
'Baskerville',
serif;
font-size: 6.9rem;
line-height: 0.88; line-height: 0.88;
letter-spacing: -0.08em; letter-spacing: -0.08em;
color: #000000; color: var(--ink);
} }
.calendar-card__lunar-day, .calendar-card__lunar-day,
.calendar-card__weekday { .calendar-card__weekday {
margin: 0; margin: 0;
font-size: 1.88rem; font-size: calc(1.88rem * var(--theme-font-scale, 1));
line-height: 1.02; line-height: 1.02;
color: #000000; color: var(--ink);
white-space: nowrap; white-space: nowrap;
} }
.calendar-card__weekday { .calendar-card__weekday {
font-size: 1.88rem; font-size: calc(1.88rem * var(--theme-font-scale, 1));
} }
.calendar-card__panel { .calendar-card__panel {
@@ -153,9 +152,9 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
gap: 0.55rem; gap: 0.55rem;
min-height: 0; min-height: 0;
padding: 0.88rem 0.94rem 0.94rem; padding: 0.88rem 0.94rem 0.94rem;
border-radius: 1.25rem; border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke); border: 2px solid var(--frame-stroke);
background: #ffffff; background: var(--panel-background);
} }
.calendar-card__panel-header { .calendar-card__panel-header {
@@ -165,20 +164,13 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
gap: 1rem; gap: 1rem;
} }
.calendar-card__panel-title, .calendar-card__panel-title {
.calendar-card__panel-subtitle {
margin: 0; margin: 0;
} }
.calendar-card__panel-title { .calendar-card__panel-title {
font-size: 0.9rem; font-size: calc(0.9rem * var(--theme-font-scale, 1));
color: #4c4c4c; color: var(--muted-ink);
}
.calendar-card__panel-subtitle {
margin-top: 0.2rem;
font-size: 0.84rem;
color: #000000;
} }
.calendar-card__badges { .calendar-card__badges {
@@ -191,11 +183,11 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
.calendar-card__badge { .calendar-card__badge {
padding: 0.14rem 0.46rem; padding: 0.14rem 0.46rem;
border-radius: 999px; border-radius: 999px;
font-size: 0.72rem; font-size: calc(0.72rem * var(--theme-font-scale, 1));
line-height: 1.2; line-height: 1.2;
color: #000000; color: var(--ink);
border: 1.5px solid var(--frame-stroke); border: 1.5px solid var(--frame-stroke);
background: #ffffff; background: var(--badge-fill);
} }
.calendar-card__grid { .calendar-card__grid {
@@ -212,8 +204,8 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
display: grid; display: grid;
place-items: center; place-items: center;
padding-bottom: 0.14rem; padding-bottom: 0.14rem;
font-size: 0.82rem; font-size: calc(0.82rem * var(--theme-font-scale, 1));
color: #000000; color: var(--ink);
} }
.calendar-card__cell { .calendar-card__cell {
@@ -223,7 +215,7 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
padding: 0.16rem 0 0.18rem; padding: 0.16rem 0 0.18rem;
border-radius: 0.9rem; border-radius: 0.9rem;
border: 1.5px solid transparent; border: 1.5px solid transparent;
color: #000000; color: var(--ink);
} }
.calendar-card__cell-copy { .calendar-card__cell-copy {
@@ -238,21 +230,21 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
} }
.calendar-card__solar { .calendar-card__solar {
font-size: 0.98rem; font-size: calc(0.98rem * var(--theme-font-scale, 1));
line-height: 1.05; line-height: 1.05;
} }
.calendar-card__sub { .calendar-card__sub {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
font-size: 0.84rem; font-size: calc(0.84rem * var(--theme-font-scale, 1));
line-height: 1; line-height: 1;
color: #000000; color: var(--ink);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.calendar-card__cell--muted { .calendar-card__cell--muted {
border-color: rgba(139, 107, 71, 0.35); border-color: var(--frame-muted);
} }
</style> </style>

View File

@@ -11,18 +11,18 @@ const quoteFontSize = computed(() => {
const length = props.quote.length; const length = props.quote.length;
if (length > 120) { if (length > 120) {
return '1.05rem'; return 'calc(1.05rem * var(--quote-content-font-scale, 1))';
} }
if (length > 80) { if (length > 80) {
return '1.22rem'; return 'calc(1.22rem * var(--quote-content-font-scale, 1))';
} }
if (length > 48) { if (length > 48) {
return '1.4rem'; return 'calc(1.4rem * var(--quote-content-font-scale, 1))';
} }
return '1.6rem'; return 'calc(1.6rem * var(--quote-content-font-scale, 1))';
}); });
</script> </script>
@@ -42,7 +42,7 @@ const quoteFontSize = computed(() => {
display: grid; display: grid;
gap: 0.34rem; gap: 0.34rem;
padding: 0.72rem 1.02rem; padding: 0.72rem 1.02rem;
background: #ffffff; background: var(--panel-background);
overflow: hidden; overflow: hidden;
} }
@@ -50,7 +50,7 @@ const quoteFontSize = computed(() => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
color: #000000; color: var(--ink);
} }
.quote-card__icon { .quote-card__icon {
@@ -60,13 +60,14 @@ const quoteFontSize = computed(() => {
} }
.quote-card__title { .quote-card__title {
font-size: 0.8rem; font-family: var(--title-font);
font-size: calc(0.8rem * var(--theme-font-scale, 1));
font-weight: 600; font-weight: 600;
} }
.quote-card__content { .quote-card__content {
margin: 0; margin: 0;
line-height: 1.34; line-height: 1.34;
color: #000000; color: var(--ink);
} }
</style> </style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildClockState } from '@/lib/clock';
import type { DashboardMode } from '@/lib/dashboard-mode';
const SIMPLE_CLOCK_SIZE = 480;
// 5 分钟刻度的外沿需要和普通刻度落在同一圈上,
// 所以这里按统一 outer inset 重新计算大刻度中心距离。
const SIMPLE_HOUR_TICK_DISTANCE = 194.59034156799316;
const SIMPLE_MINUTE_TICK_DISTANCE = 207.39662265777588;
const SIMPLE_HOUR_HAND_FRONT = 148.8;
const SIMPLE_HOUR_HAND_BACK = 67.2;
// 分针前端要求和分钟刻度外端严格对齐。
const SIMPLE_MINUTE_HAND_FRONT = 218.26450538635254;
const SIMPLE_MINUTE_HAND_BACK = 72;
const SIMPLE_MINUTE_HAND_THICKNESS = 9.6;
const props = withDefaults(
defineProps<{
date: Date;
mode: DashboardMode;
size?: number;
}>(),
{
size: SIMPLE_CLOCK_SIZE,
},
);
const clockState = computed(() => buildClockState(props.date));
const scale = computed(() => props.size / SIMPLE_CLOCK_SIZE);
// simple 主题的表盘不是图片,而是按 Figma 参数直接绘制。
const hourTicks = Array.from({ length: 12 }, (_, index) => index * 30);
const minuteTicks = Array.from({ length: 60 }, (_, index) => index).filter((index) => index % 5 !== 0);
const stageStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
function buildHourTickStyle(angle: number) {
return {
width: `${14.4 * scale.value}px`,
height: `${47.34832763671875 * scale.value}px`,
transform: `translate(-50%, -50%) rotate(${angle}deg) translateY(-${SIMPLE_HOUR_TICK_DISTANCE * scale.value}px)`,
};
}
function buildMinuteTickStyle(index: number) {
return {
width: `${6.520815372467041 * scale.value}px`,
height: `${21.73549461364746 * scale.value}px`,
transform: `translate(-50%, -50%) rotate(${index * 6}deg) translateY(-${SIMPLE_MINUTE_TICK_DISTANCE * scale.value}px)`,
};
}
const hourHandStyle = computed(() => ({
width: `${14.4 * scale.value}px`,
height: `${(SIMPLE_HOUR_HAND_FRONT + SIMPLE_HOUR_HAND_BACK) * scale.value}px`,
transformOrigin: `50% ${SIMPLE_HOUR_HAND_BACK * scale.value}px`,
transform: `translate(-50%, -${SIMPLE_HOUR_HAND_BACK * scale.value}px) rotate(${clockState.value.hourAngle}deg)`,
}));
const minuteHandStyle = computed(() => ({
width: `${SIMPLE_MINUTE_HAND_THICKNESS * scale.value}px`,
height: `${(SIMPLE_MINUTE_HAND_FRONT + SIMPLE_MINUTE_HAND_BACK) * scale.value}px`,
transformOrigin: `50% ${SIMPLE_MINUTE_HAND_BACK * scale.value}px`,
transform: `translate(-50%, -${SIMPLE_MINUTE_HAND_BACK * scale.value}px) rotate(${clockState.value.minuteAngle}deg)`,
}));
</script>
<template>
<div class="simple-analog-clock" :style="stageStyle" data-clock-region="true">
<template v-if="mode !== 'background'">
<div class="simple-analog-clock__face-shadow" />
<div class="simple-analog-clock__face" />
<div
v-for="angle in hourTicks"
:key="`hour-${angle}`"
class="simple-analog-clock__tick simple-analog-clock__tick--hour"
:style="buildHourTickStyle(angle)"
/>
<div
v-for="index in minuteTicks"
:key="`minute-${index}`"
class="simple-analog-clock__tick simple-analog-clock__tick--minute"
:style="buildMinuteTickStyle(index)"
/>
<div class="simple-analog-clock__hand simple-analog-clock__hand--minute" :style="minuteHandStyle" />
<div class="simple-analog-clock__hand simple-analog-clock__hand--hour" :style="hourHandStyle" />
<div class="simple-analog-clock__center simple-analog-clock__center--bottom" />
<div class="simple-analog-clock__center simple-analog-clock__center--top" />
</template>
</div>
</template>
<style scoped>
.simple-analog-clock {
position: relative;
flex: 0 0 auto;
}
.simple-analog-clock__face-shadow,
.simple-analog-clock__face {
position: absolute;
border-radius: 50%;
background: #ffffff;
}
.simple-analog-clock__face-shadow {
inset: -2.4px;
box-shadow: 0 1.8px 5.4px rgba(0, 0, 0, 0.3);
}
.simple-analog-clock__face {
inset: 0;
border: 1.6px solid #1e1e1e;
}
.simple-analog-clock__tick {
position: absolute;
top: 50%;
left: 50%;
background: #1e1e1e;
}
.simple-analog-clock__hand {
position: absolute;
top: 50%;
left: 50%;
background: #000000;
}
.simple-analog-clock__hand--minute {
box-shadow: 0 3.6px 10.8px rgba(0, 0, 0, 0.4);
}
.simple-analog-clock__hand--hour {
box-shadow: 2.4px 2.4px 10.8px rgba(0, 0, 0, 0.4);
}
.simple-analog-clock__center {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
transform: translate(-50%, -50%);
background: #000000;
}
.simple-analog-clock__center--bottom {
width: 4.8px;
height: 4.8px;
}
.simple-analog-clock__center--top {
width: 9.6px;
height: 9.6px;
}
</style>

View File

@@ -0,0 +1,656 @@
<script setup lang="ts">
import { computed } from 'vue';
import SimpleAnalogClock from '@/components/SimpleAnalogClock.vue';
import WeatherGlyph from '@/components/WeatherGlyph.vue';
import { weatherKindFromCode } from '@/lib/icon-assets';
import type { CalendarModel } from '@/lib/calendar';
import type { DashboardMode } from '@/lib/dashboard-mode';
import type { DashboardOrientation } from '@/lib/dashboard-theme';
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
import simpleBookIcon from '../../../assets/simple/book.svg';
import simpleHumidityIcon from '../../../assets/simple/humidity.svg';
import simpleLocationIcon from '../../../assets/simple/location.svg';
import simplePm25Icon from '../../../assets/simple/pm25.svg';
const props = defineProps<{
model: CalendarModel;
date: Date;
mode: DashboardMode;
orientation: DashboardOrientation;
locationLabel: string;
weather: WeatherSnapshot | null;
quote: string;
}>();
interface SimpleForecastItem {
label: string;
weatherCode: number;
high: number;
low: number;
}
const weeks = computed(() => {
const chunks: CalendarModel['cells'][] = [];
for (let index = 0; index < props.model.cells.length; index += 7) {
chunks.push(props.model.cells.slice(index, index + 7));
}
return chunks;
});
// simple 主题需要固定 5 个预报块;天气未返回时也要保住版面。
const forecastItems = computed<SimpleForecastItem[]>(() => {
if (props.weather) {
return props.weather.forecast.slice(0, 5);
}
return Array.from({ length: 5 }, (_, index) => {
const current = new Date(props.date);
current.setDate(current.getDate() + index);
return {
label:
index === 0
? '今天'
: index === 1
? '明天'
: new Intl.DateTimeFormat('zh-CN', { weekday: 'short' }).format(current),
weatherCode: 2,
high: 24,
low: 18,
};
});
});
const currentWeatherKind = computed(() => weatherKindFromCode(props.weather?.weatherCode ?? 2));
const currentCondition = computed(() => props.weather?.condition ?? '天气');
const currentAqi = computed(() => (props.weather?.aqi === null || props.weather?.aqi === undefined ? '--' : String(props.weather.aqi)));
const currentHumidity = computed(() => (props.weather ? `${props.weather.humidity}%` : '--'));
function forecastKind(day: ForecastDay | SimpleForecastItem) {
return weatherKindFromCode(day.weatherCode);
}
function forecastTemperature(day: ForecastDay | SimpleForecastItem) {
return `${day.low}°-${day.high}°`;
}
function isCompactCalendarLabel(label: string) {
return label.length > 4;
}
</script>
<template>
<section :class="['simple-dashboard', `simple-dashboard--${orientation}`]">
<template v-if="orientation === 'portrait'">
<section class="simple-dashboard__top">
<div class="simple-dashboard__summary simple-dashboard__summary--portrait">
<div class="simple-dashboard__headline">
<div class="simple-dashboard__day">{{ model.largeDay }}</div>
<div class="simple-dashboard__weather-hero">
<WeatherGlyph :kind="currentWeatherKind" large class="simple-dashboard__weather-hero-icon" />
<p class="simple-dashboard__weather-hero-label">{{ currentCondition }}</p>
</div>
</div>
<div class="simple-dashboard__weekday-line">
<p class="simple-dashboard__weekday simple-dashboard__weekday--portrait">{{ model.weekdayLabel }}</p>
<p class="simple-dashboard__location simple-dashboard__location--portrait">{{ locationLabel }}</p>
</div>
<section class="simple-forecast simple-forecast--portrait">
<article v-for="day in forecastItems" :key="day.label" class="simple-forecast__item">
<p class="simple-forecast__label">{{ day.label }}</p>
<WeatherGlyph :kind="forecastKind(day)" large class="simple-forecast__icon" />
<p class="simple-forecast__temp">{{ forecastTemperature(day) }}</p>
</article>
</section>
</div>
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
</section>
<section class="simple-calendar simple-calendar--portrait">
<header class="simple-calendar__weekdays">
<span v-for="label in model.weekLabels" :key="label" class="simple-calendar__weekday-label">{{ label }}</span>
</header>
<div class="simple-calendar__grid" :style="{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }">
<article
v-for="cell in model.cells"
:key="cell.date.toISOString()"
:class="['simple-calendar__cell', { 'simple-calendar__cell--muted': !cell.currentMonth }]"
>
<span class="simple-calendar__solar">{{ cell.day }}</span>
<span
:class="[
'simple-calendar__sub',
{ 'simple-calendar__sub--compact': isCompactCalendarLabel(cell.subLabel) },
]"
>
{{ cell.subLabel }}
</span>
</article>
</div>
</section>
<section class="simple-quote simple-quote--portrait">
<div class="simple-quote__header">
<img class="simple-quote__icon" :src="simpleBookIcon" alt="" aria-hidden="true" />
<span class="simple-quote__title">每日鸡汤</span>
</div>
<p class="simple-quote__body">{{ quote }}</p>
</section>
</template>
<template v-else>
<div class="simple-dashboard__column simple-dashboard__column--left">
<section class="simple-dashboard__summary simple-dashboard__summary--landscape">
<div class="simple-dashboard__landscape-headline">
<div class="simple-dashboard__day">{{ model.largeDay }}</div>
<p class="simple-dashboard__weekday simple-dashboard__weekday--landscape">{{ model.weekdayLabel }}</p>
</div>
<div class="simple-dashboard__location-row">
<img class="simple-dashboard__location-icon" :src="simpleLocationIcon" alt="" aria-hidden="true" />
<p class="simple-dashboard__location simple-dashboard__location--landscape">{{ locationLabel }}</p>
</div>
<div class="simple-dashboard__metrics">
<div class="simple-dashboard__metric">
<WeatherGlyph :kind="currentWeatherKind" large class="simple-dashboard__metric-icon simple-dashboard__metric-icon--weather" />
<p class="simple-dashboard__metric-value">{{ currentCondition }}</p>
</div>
<div class="simple-dashboard__metric">
<img class="simple-dashboard__metric-icon simple-dashboard__metric-icon--pm25" :src="simplePm25Icon" alt="" aria-hidden="true" />
<p class="simple-dashboard__metric-value">{{ currentAqi }}</p>
</div>
<div class="simple-dashboard__metric">
<img class="simple-dashboard__metric-icon simple-dashboard__metric-icon--humidity" :src="simpleHumidityIcon" alt="" aria-hidden="true" />
<p class="simple-dashboard__metric-value">{{ currentHumidity }}</p>
</div>
</div>
</section>
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
</div>
<div class="simple-dashboard__column simple-dashboard__column--right">
<section class="simple-forecast simple-forecast--landscape">
<article v-for="day in forecastItems" :key="day.label" class="simple-forecast__item">
<p class="simple-forecast__label">{{ day.label }}</p>
<WeatherGlyph :kind="forecastKind(day)" large class="simple-forecast__icon" />
<p class="simple-forecast__temp">{{ forecastTemperature(day) }}</p>
</article>
</section>
<section class="simple-calendar simple-calendar--landscape">
<header class="simple-calendar__weekdays">
<span v-for="label in model.weekLabels" :key="label" class="simple-calendar__weekday-label">{{ label }}</span>
</header>
<div class="simple-calendar__grid" :style="{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }">
<article
v-for="cell in model.cells"
:key="cell.date.toISOString()"
:class="['simple-calendar__cell', { 'simple-calendar__cell--muted': !cell.currentMonth }]"
>
<span class="simple-calendar__solar">{{ cell.day }}</span>
<span
:class="[
'simple-calendar__sub',
{ 'simple-calendar__sub--compact': isCompactCalendarLabel(cell.subLabel) },
]"
>
{{ cell.subLabel }}
</span>
</article>
</div>
</section>
<section class="simple-quote simple-quote--landscape">
<div class="simple-quote__header">
<img class="simple-quote__icon" :src="simpleBookIcon" alt="" aria-hidden="true" />
<span class="simple-quote__title">每日鸡汤</span>
</div>
<p class="simple-quote__body simple-quote__body--landscape">{{ quote }}</p>
</section>
</div>
</template>
</section>
</template>
<style scoped>
.simple-dashboard {
width: 100%;
height: 100%;
color: #0a0a0a;
background: #ffffff;
font-family: var(--body-font, 'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif);
}
.simple-dashboard--portrait {
display: grid;
grid-template-rows: 480px minmax(0, 1fr) auto;
gap: 32px;
padding: 32px;
}
.simple-dashboard--landscape {
display: grid;
grid-template-columns: 480px 876px;
gap: 34px;
padding: 30px 29px;
}
.simple-dashboard__top {
display: grid;
grid-template-columns: 480px 480px;
width: 1008px;
height: 480px;
justify-content: center;
gap: 16px;
padding: 0 16px;
justify-self: start;
align-self: start;
}
.simple-dashboard__column {
min-width: 0;
}
.simple-dashboard__column--left {
display: grid;
grid-template-rows: 464px 480px;
gap: 68px;
}
.simple-dashboard__column--right {
display: grid;
grid-template-rows: 161px minmax(0, 1fr) auto;
gap: 24px;
}
.simple-dashboard__summary {
min-width: 0;
}
.simple-dashboard__summary--portrait {
display: grid;
grid-template-rows: 156px 56px 111.668px;
align-content: start;
gap: 64px;
}
.simple-dashboard__summary--landscape {
display: grid;
grid-template-rows: 156px 57px 99px;
align-content: start;
gap: 64px;
padding-bottom: 24px;
}
.simple-dashboard__headline {
display: flex;
align-items: center;
gap: 61px;
}
.simple-dashboard__landscape-headline {
display: flex;
align-items: flex-end;
gap: 24px;
}
.simple-dashboard__day {
width: 236px;
font-size: 220px;
font-weight: 400;
line-height: 156px;
letter-spacing: -0.1504px;
color: #000000;
}
.simple-dashboard__weather-hero {
display: grid;
grid-template-rows: 56px 57px;
justify-items: center;
row-gap: 26px;
width: 139px;
height: 139px;
}
.simple-dashboard__weather-hero-icon {
width: 77.204px;
height: 56px;
}
.simple-dashboard__weather-hero-label {
width: 139px;
margin: 0;
font-size: 56px;
font-weight: 700;
line-height: 57px;
text-align: center;
white-space: nowrap;
}
.simple-dashboard__weekday-line {
display: flex;
align-items: flex-end;
gap: 24px;
width: 480px;
min-width: 0;
}
.simple-dashboard__weekday {
margin: 0;
font-weight: 700;
white-space: nowrap;
}
.simple-dashboard__weekday--portrait {
flex: 0 0 auto;
font-size: 80px;
line-height: 56px;
}
.simple-dashboard__weekday--landscape {
width: 191px;
font-size: 56px;
line-height: 57px;
}
.simple-dashboard__location-row {
display: flex;
align-items: center;
gap: 24px;
width: 480px;
min-width: 0;
}
.simple-dashboard__location-icon {
width: 32px;
height: 32px;
flex: 0 0 auto;
}
.simple-dashboard__location {
margin: 0;
min-width: 0;
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.simple-dashboard__location--portrait {
flex: 1 1 auto;
font-size: 56px;
line-height: 56px;
}
.simple-dashboard__location--landscape {
width: 424px;
font-size: 56px;
line-height: 57px;
}
.simple-dashboard__metrics {
display: flex;
align-items: flex-start;
gap: 24px;
width: 480px;
height: 99px;
overflow: visible;
}
.simple-dashboard__metric {
display: grid;
grid-template-rows: 58px 60px;
justify-items: center;
align-content: center;
row-gap: 22px;
width: 139px;
height: 140px;
transform: translateY(-20.5px);
}
.simple-dashboard__metric-icon {
object-fit: contain;
}
.simple-dashboard__metric-icon--weather {
width: 77.204px;
height: 57.226px;
}
.simple-dashboard__metric-icon--pm25 {
width: 60.345px;
height: 58.947px;
}
.simple-dashboard__metric-icon--humidity {
width: 54.504px;
height: 56px;
}
.simple-dashboard__metric-value {
width: 139px;
margin: 0;
font-size: 56px;
font-weight: 700;
line-height: 60px;
text-align: center;
white-space: nowrap;
}
.simple-forecast {
border: 1px solid #1e1e1e;
border-radius: 32px;
overflow: hidden;
}
.simple-forecast--portrait {
display: flex;
align-items: center;
gap: 22.201px;
padding: 9.952px 26.794px;
}
.simple-forecast--landscape {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: center;
padding: 16px 0;
}
.simple-forecast__item {
display: grid;
justify-items: center;
align-content: center;
text-align: center;
}
.simple-forecast--portrait .simple-forecast__item {
flex: 0 0 auto;
width: 64.612px;
row-gap: 9.952px;
}
.simple-forecast--portrait .simple-forecast__item:first-child {
width: 79.158px;
}
.simple-forecast--landscape .simple-forecast__item {
height: 129px;
row-gap: 16px;
}
.simple-forecast__label,
.simple-forecast__temp {
margin: 0;
white-space: nowrap;
}
.simple-forecast--portrait .simple-forecast__label {
font-size: 17.148px;
font-weight: 400;
line-height: 24.498px;
color: #4a5565;
}
.simple-forecast--landscape .simple-forecast__label {
font-size: 36px;
font-weight: 400;
line-height: 24.498px;
color: #4a5565;
}
.simple-forecast__icon {
object-fit: contain;
}
.simple-forecast--portrait .simple-forecast__icon {
width: 41.184px;
height: 22.864px;
}
.simple-forecast--landscape .simple-forecast__icon {
width: 64.844px;
height: 36px;
}
.simple-forecast--portrait .simple-forecast__temp {
font-size: 17.148px;
font-weight: 500;
line-height: 24.498px;
color: #0a0a0a;
}
.simple-forecast--landscape .simple-forecast__temp {
font-size: 24px;
font-weight: 500;
line-height: 24.498px;
color: #0a0a0a;
}
.simple-calendar {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
height: 100%;
gap: 8px;
padding: 8px;
border: 1px solid #000000;
border-radius: 32px;
overflow: hidden;
}
.simple-calendar__weekdays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
align-items: center;
padding: 8px;
}
.simple-calendar__weekday-label {
font-size: 32px;
font-weight: 500;
line-height: 20px;
text-align: center;
color: #000000;
}
.simple-calendar__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
min-height: 0;
padding: 8px;
}
.simple-calendar__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 0;
color: #000000;
}
.simple-calendar__cell--muted {
opacity: 0.32;
}
.simple-calendar__solar {
font-size: 32px;
font-weight: 500;
line-height: 23.077px;
}
.simple-calendar__sub {
font-size: 24px;
font-weight: 500;
line-height: 23.077px;
text-align: center;
max-width: 100%;
overflow-wrap: anywhere;
white-space: nowrap;
}
.simple-calendar__sub--compact {
font-size: 12px;
line-height: 13px;
white-space: normal;
}
.simple-quote {
display: grid;
grid-template-rows: 44.8px minmax(0, 1fr);
align-self: start;
gap: 19.2px;
padding: 25.6px;
border: 1.6px solid #000000;
border-radius: 51.2px;
overflow: hidden;
}
.simple-quote__header {
display: flex;
align-items: center;
gap: 12.8px;
height: 44.8px;
}
.simple-quote__icon {
width: 38.4px;
height: 38.4px;
}
.simple-quote__title {
font-size: 32px;
font-weight: 500;
line-height: 44.8px;
letter-spacing: -0.7188px;
white-space: nowrap;
}
.simple-quote__body {
margin: 0;
font-size: 21.333px;
font-style: italic;
font-weight: 400;
line-height: 36.533px;
letter-spacing: 0.1125px;
}
.simple-quote__body--landscape {
font-size: 16px;
line-height: 27.2px;
}
</style>

View File

@@ -8,6 +8,7 @@ import {
SUNSET_ICON_ASSET, SUNSET_ICON_ASSET,
VISIBILITY_ICON_ASSET, VISIBILITY_ICON_ASSET,
WIND_SPEED_ICON_ASSET, WIND_SPEED_ICON_ASSET,
weatherKindFromCode,
} from '@/lib/icon-assets'; } from '@/lib/icon-assets';
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather'; import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
@@ -17,38 +18,6 @@ const props = defineProps<{
locationLabel: string; locationLabel: string;
}>(); }>();
function weatherKind(code: number) {
if ([0].includes(code)) {
return 'clear';
}
if ([1, 2].includes(code)) {
return 'partly';
}
if ([3].includes(code)) {
return 'cloudy';
}
if ([45, 48].includes(code)) {
return 'fog';
}
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
return [65, 67, 81, 82].includes(code) ? 'heavy-rain' : 'rain';
}
if ([71, 73, 75, 77, 85, 86].includes(code)) {
return 'snow';
}
if ([95, 96, 99].includes(code)) {
return 'storm';
}
return 'cloudy';
}
const forecast = computed(() => props.weather?.forecast.slice(0, 4) ?? []); const forecast = computed(() => props.weather?.forecast.slice(0, 4) ?? []);
const metrics = computed(() => { const metrics = computed(() => {
@@ -99,10 +68,10 @@ const stateLabel = computed(() => {
return props.locationLabel; return props.locationLabel;
}); });
const currentWeatherKind = computed(() => weatherKind(props.weather?.weatherCode ?? 3)); const currentWeatherKind = computed(() => weatherKindFromCode(props.weather?.weatherCode ?? 3));
function forecastKind(day: ForecastDay) { function forecastKind(day: ForecastDay) {
return weatherKind(day.weatherCode); return weatherKindFromCode(day.weatherCode);
} }
</script> </script>
@@ -178,8 +147,11 @@ function forecastKind(day: ForecastDay) {
align-content: stretch; align-content: stretch;
height: 100%; height: 100%;
gap: 0.72rem; gap: 0.72rem;
padding: 1.08rem 1.12rem 0.98rem; padding: 0;
overflow: hidden; overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
} }
.weather-card__heading { .weather-card__heading {
@@ -194,16 +166,17 @@ function forecastKind(day: ForecastDay) {
} }
.weather-card__title { .weather-card__title {
font-size: 2.16rem; font-family: var(--title-font);
font-size: calc(2.16rem * var(--theme-font-scale, 1));
font-weight: 700; font-weight: 700;
color: #000000; color: var(--ink);
} }
.weather-card__subtitle { .weather-card__subtitle {
margin-top: 0.14rem; margin-top: 0.14rem;
font-size: 1.12rem; font-size: calc(1.12rem * var(--theme-font-scale, 1));
line-height: 1.08; line-height: 1.08;
color: #000000; color: var(--ink);
} }
.weather-card__hero { .weather-card__hero {
@@ -213,15 +186,16 @@ function forecastKind(day: ForecastDay) {
gap: 0.8rem; gap: 0.8rem;
min-height: 0; min-height: 0;
padding: 0.88rem 0.94rem; padding: 0.88rem 0.94rem;
border-radius: 1rem; border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke); border: 2px solid var(--frame-stroke);
background: #ffffff; background: var(--panel-background);
} }
.weather-card__hero--placeholder { .weather-card__hero--placeholder {
justify-content: center; justify-content: center;
min-height: 5.75rem; min-height: 5.75rem;
color: #000000; font-size: calc(1.12rem * var(--theme-font-scale, 1));
color: var(--ink);
} }
.weather-card__hero-main { .weather-card__hero-main {
@@ -232,16 +206,16 @@ function forecastKind(day: ForecastDay) {
} }
.weather-card__temperature { .weather-card__temperature {
font-size: 2.8rem; font-size: calc(2.8rem * var(--theme-font-scale, 1));
line-height: 0.94; line-height: 0.94;
color: #000000; color: var(--ink);
} }
.weather-card__condition { .weather-card__condition {
margin-top: 0.18rem; margin-top: 0.18rem;
font-size: 1.36rem; font-size: calc(1.36rem * var(--theme-font-scale, 1));
line-height: 1.05; line-height: 1.05;
color: #000000; color: var(--ink);
} }
.weather-card__facts { .weather-card__facts {
@@ -256,8 +230,8 @@ function forecastKind(day: ForecastDay) {
display: inline-flex; display: inline-flex;
align-items: flex-start; align-items: flex-start;
gap: 0.45rem; gap: 0.45rem;
color: #000000; color: var(--ink);
font-size: 1.08rem; font-size: calc(1.08rem * var(--theme-font-scale, 1));
line-height: 1.06; line-height: 1.06;
min-width: 0; min-width: 0;
} }
@@ -282,43 +256,60 @@ function forecastKind(day: ForecastDay) {
.weather-card__forecast { .weather-card__forecast {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: stretch;
gap: 0.46rem; gap: 0.46rem;
min-height: 0; min-height: 0;
} }
.forecast-pill { .forecast-pill {
display: grid; display: grid;
grid-template-rows: auto auto auto;
justify-items: center; justify-items: center;
align-content: center; align-content: center;
gap: 0.34rem; justify-content: center;
gap: 0.16rem;
min-height: 0; min-height: 0;
padding: 0.68rem 0.28rem; padding: 0.4rem 0.16rem;
border-radius: 1rem; border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke); border: 2px solid var(--frame-stroke);
background: #ffffff; background: var(--panel-background);
text-align: center;
overflow: hidden;
} }
.forecast-pill__label, .forecast-pill__label,
.forecast-pill__temp { .forecast-pill__temp {
margin: 0; margin: 0;
max-width: 100%;
} }
.forecast-pill__label { .forecast-pill__label {
font-size: 1rem; font-size: calc(1rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
line-height: 1; line-height: 1.05;
color: #000000; color: var(--ink);
white-space: nowrap;
} }
.forecast-pill__temp { .forecast-pill__temp {
font-size: 1.24rem; display: flex;
align-items: baseline;
justify-content: center;
gap: 0.08rem;
font-size: calc(1.24rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
line-height: 1; line-height: 1;
color: #000000; color: var(--ink);
white-space: nowrap;
} }
.forecast-pill__temp span { .forecast-pill__temp span {
margin-left: 0.12rem; margin-left: 0;
font-size: 1rem; font-size: calc(1rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
color: #000000; color: var(--ink);
}
.forecast-pill :deep(.glyph--large) {
width: calc(2.1rem * var(--forecast-pill-scale, 1));
height: calc(2.1rem * var(--forecast-pill-scale, 1));
} }
.weather-card__metrics { .weather-card__metrics {
@@ -335,10 +326,10 @@ function forecastKind(day: ForecastDay) {
gap: 0.3rem; gap: 0.3rem;
min-height: 0; min-height: 0;
padding: 0.68rem 0.74rem; padding: 0.68rem 0.74rem;
border-radius: 0.95rem; border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke); border: 2px solid var(--frame-stroke);
background: #ffffff; background: var(--panel-background);
color: #000000; color: var(--ink);
} }
.metric-pill__label, .metric-pill__label,
@@ -350,14 +341,14 @@ function forecastKind(day: ForecastDay) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
font-size: 0.88rem; font-size: calc(0.88rem * var(--theme-font-scale, 1));
line-height: 1.06; line-height: 1.06;
} }
.metric-pill__value { .metric-pill__value {
font-size: 1.34rem; font-size: calc(1.34rem * var(--theme-font-scale, 1));
line-height: 1.02; line-height: 1.02;
color: #000000; color: var(--ink);
} }
.weather-card :deep(.glyph--large) { .weather-card :deep(.glyph--large) {

View File

@@ -0,0 +1,119 @@
import themesConfigData from '../../config/themes.json';
export type DashboardOrientation = 'portrait' | 'landscape';
export interface DashboardPreviewPalette {
pageBackground: string;
paper: string;
panelBackground: string;
frameStroke: string;
frameStrokeStrong: string;
frameMuted: string;
mutedInk: string;
badgeFill: string;
bodyFont: string;
displayFont: string;
titleFont: string;
cardRadius: string;
panelRadius: string;
}
export interface DashboardClockConfig {
x: number;
y: number;
width: number;
height: number;
faceRadiusRatio: number;
faceStroke: number;
tickOuterInset: number;
majorTickOuterInset?: number;
minorTickOuterInset?: number;
majorTickLength: number;
minorTickLength: number;
majorTickThickness: number;
minorTickThickness: number;
hourLengthRatio: number;
hourBackLengthRatio?: number;
minuteLengthRatio: number;
minuteBackLengthRatio?: number;
hourThickness: number;
minuteThickness: number;
centerRadius: number;
}
export interface DashboardThemeVariant {
devicePlacement: string;
viewport: {
width: number;
height: number;
};
backgroundPath: string;
clock: DashboardClockConfig;
}
export interface DashboardThemeDefinition {
id: string;
label: string;
preview: DashboardPreviewPalette;
variants: Record<DashboardOrientation, DashboardThemeVariant>;
}
interface DashboardThemesConfig {
defaultThemeId: string;
defaultOrientation: DashboardOrientation;
themes: DashboardThemeDefinition[];
}
const themesConfig = themesConfigData as DashboardThemesConfig;
const orientationSet = new Set<DashboardOrientation>(['portrait', 'landscape']);
const themeMap = new Map(themesConfig.themes.map((theme) => [theme.id, theme]));
const fallbackTheme = themesConfig.themes[0];
if (!fallbackTheme) {
throw new Error('themes.json 未定义任何主题');
}
export const DASHBOARD_THEMES = themesConfig.themes;
export const DEFAULT_THEME_ID = themesConfig.defaultThemeId;
export const DEFAULT_ORIENTATION = themesConfig.defaultOrientation;
export function resolveDashboardThemeId(search: string): string {
const themeId = new URLSearchParams(search).get('theme');
if (themeId && themeMap.has(themeId)) {
return themeId;
}
return DEFAULT_THEME_ID;
}
export function resolveDashboardOrientation(search: string): DashboardOrientation {
const orientation = new URLSearchParams(search).get('orientation');
if (orientation && orientationSet.has(orientation as DashboardOrientation)) {
return orientation as DashboardOrientation;
}
return DEFAULT_ORIENTATION;
}
export function getDashboardTheme(themeId: string): DashboardThemeDefinition {
return themeMap.get(themeId) ?? themeMap.get(DEFAULT_THEME_ID) ?? fallbackTheme;
}
export function getDashboardVariant(themeId: string, orientation: DashboardOrientation): DashboardThemeVariant {
const theme = getDashboardTheme(themeId);
return theme.variants[orientation];
}
export function buildDashboardSearch(params: {
mode: string;
themeId: string;
orientation: DashboardOrientation;
}) {
const searchParams = new URLSearchParams();
searchParams.set('mode', params.mode);
searchParams.set('theme', params.themeId);
searchParams.set('orientation', params.orientation);
return `?${searchParams.toString()}`;
}

View File

@@ -41,6 +41,38 @@ export function weatherIconForKind(kind: WeatherIconKind) {
return WEATHER_ICON_MAP[kind]; return WEATHER_ICON_MAP[kind];
} }
export function weatherKindFromCode(code: number): WeatherIconKind {
if ([0].includes(code)) {
return 'clear';
}
if ([1, 2].includes(code)) {
return 'partly';
}
if ([3].includes(code)) {
return 'cloudy';
}
if ([45, 48].includes(code)) {
return 'fog';
}
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
return [65, 67, 81, 82].includes(code) ? 'heavy-rain' : 'rain';
}
if ([71, 73, 75, 77, 85, 86].includes(code)) {
return 'snow';
}
if ([95, 96, 99].includes(code)) {
return 'storm';
}
return 'cloudy';
}
export const QUOTE_ICON_ASSET = bookIcon; export const QUOTE_ICON_ASSET = bookIcon;
export const HUMIDITY_ICON_ASSET = humidityIcon; export const HUMIDITY_ICON_ASSET = humidityIcon;
export const WIND_SPEED_ICON_ASSET = windSpeedIcon; export const WIND_SPEED_ICON_ASSET = windSpeedIcon;

View File

@@ -27,11 +27,26 @@ export interface WeatherSnapshot {
} }
const DEFAULT_LOCATION: LocationCoordinates = { const DEFAULT_LOCATION: LocationCoordinates = {
latitude: 31.2304, latitude: 30.274084,
longitude: 121.4737, longitude: 120.15507,
label: '上海', label: '杭州',
}; };
interface ReverseGeocodeResponse {
address?: {
city?: string;
town?: string;
municipality?: string;
county?: string;
city_district?: string;
suburb?: string;
village?: string;
hamlet?: string;
state_district?: string;
state?: string;
};
}
const WEATHER_CODE_LABELS: Record<number, string> = { const WEATHER_CODE_LABELS: Record<number, string> = {
0: '晴朗', 0: '晴朗',
1: '晴间多云', 1: '晴间多云',
@@ -119,6 +134,65 @@ export function weatherCodeToLabel(code: number) {
return WEATHER_CODE_LABELS[code] ?? '天气'; return WEATHER_CODE_LABELS[code] ?? '天气';
} }
function normalizeLocationLabel(value: string) {
return value.trim().replace(/(|||||)$/u, '');
}
function pickLocationLabel(payload: ReverseGeocodeResponse) {
const address = payload.address;
if (!address) {
return null;
}
const rawLabel =
address.city ||
address.town ||
address.municipality ||
address.county ||
address.city_district ||
address.suburb ||
address.village ||
address.hamlet ||
address.state_district ||
address.state;
return rawLabel ? normalizeLocationLabel(rawLabel) : null;
}
async function reverseGeocodeLocation(latitude: number, longitude: number) {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
const params = new URLSearchParams({
lat: String(latitude),
lon: String(longitude),
format: 'jsonv2',
addressdetails: '1',
layer: 'address',
zoom: '10',
'accept-language': 'zh-CN',
});
try {
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params.toString()}`, {
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const payload = (await response.json()) as ReverseGeocodeResponse;
return pickLocationLabel(payload);
} catch (error) {
console.warn('逆地理编码失败,继续使用回退地点', error);
return null;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function resolveLocation(): Promise<LocationCoordinates> { export async function resolveLocation(): Promise<LocationCoordinates> {
if (!('geolocation' in navigator)) { if (!('geolocation' in navigator)) {
return DEFAULT_LOCATION; return DEFAULT_LOCATION;
@@ -126,11 +200,14 @@ export async function resolveLocation(): Promise<LocationCoordinates> {
return new Promise((resolve) => { return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { async (position) => {
// 定位坐标拿到后再做一次城市级逆地理编码,避免界面上只显示“当前位置”。
const label = await reverseGeocodeLocation(position.coords.latitude, position.coords.longitude);
resolve({ resolve({
latitude: position.coords.latitude, latitude: position.coords.latitude,
longitude: position.coords.longitude, longitude: position.coords.longitude,
label: '当前位置', label: label ?? '当前位置',
}); });
}, },
() => resolve(DEFAULT_LOCATION), () => resolve(DEFAULT_LOCATION),

View File

@@ -8,10 +8,20 @@
background: #ffffff; background: #ffffff;
--dashboard-width: 1072px; --dashboard-width: 1072px;
--dashboard-height: 1448px; --dashboard-height: 1448px;
--dashboard-aspect: 1072 / 1448;
--ink: #000000; --ink: #000000;
--muted-ink: #4c4c4c;
--paper: #ffffff; --paper: #ffffff;
--panel-background: #ffffff;
--page-background: #ffffff;
--frame-stroke: #8b6b47; --frame-stroke: #8b6b47;
--frame-stroke-strong: #6f5235; --frame-stroke-strong: #6f5235;
--frame-muted: rgba(139, 107, 71, 0.35);
--badge-fill: #faf6ef;
--display-font: 'Iowan Old Style', 'Baskerville', serif;
--title-font: 'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif;
--card-radius: 2rem;
--panel-radius: 1.25rem;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -33,6 +43,7 @@ body,
body { body {
min-height: 100vh; min-height: 100vh;
font-family: var(--body-font, 'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif);
} }
img { img {
@@ -44,34 +55,127 @@ img {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 1.1rem;
background: #ffffff; background: var(--page-background);
} }
.page-shell--clock-face { .page-shell--clock-face {
background: #ffffff; background: var(--page-background);
}
.page-shell--background {
padding: 0;
width: var(--dashboard-width);
height: var(--dashboard-height);
min-height: 0;
align-items: stretch;
justify-content: flex-start;
overflow: hidden;
}
.page-stack {
display: grid;
gap: 0.9rem;
justify-items: center;
width: 100%;
}
.page-shell--background .page-stack {
gap: 0;
width: var(--dashboard-width);
height: var(--dashboard-height);
justify-items: stretch;
}
.preview-toolbar {
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
padding: 0.65rem 0.8rem;
border: 1.5px solid var(--frame-stroke);
border-radius: 999px;
background: var(--paper);
}
.preview-toolbar__field {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.preview-toolbar__label {
font-family: var(--title-font);
font-size: calc(0.88rem * var(--theme-font-scale, 1));
color: var(--muted-ink);
}
.preview-toolbar__select {
min-width: 9.2rem;
padding: 0.36rem 0.7rem;
border: 1.5px solid var(--frame-stroke);
border-radius: 999px;
background: var(--paper);
color: var(--ink);
font: inherit;
font-size: calc(1rem * var(--theme-font-scale, 1));
} }
.dashboard-frame { .dashboard-frame {
width: min(100vw, var(--dashboard-width)); width: min(100vw, var(--dashboard-width));
aspect-ratio: 1072 / 1448; aspect-ratio: var(--dashboard-aspect);
background: var(--paper); background: var(--paper);
overflow: hidden;
}
.page-shell--background .dashboard-frame {
width: var(--dashboard-width);
height: var(--dashboard-height);
aspect-ratio: auto;
flex: 0 0 auto;
} }
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); gap: 1.15rem;
grid-template-rows: minmax(0, 1fr) 168px;
gap: 1.25rem;
height: 100%; height: 100%;
padding: 1.4rem; padding: 1.3rem;
align-content: start; align-content: start;
align-items: stretch; align-items: stretch;
} }
.dashboard-grid--portrait {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
grid-template-rows: minmax(0, 1fr) 168px;
}
.dashboard-grid--portrait .dashboard-grid__quote {
grid-column: 1 / -1;
}
.dashboard-grid--landscape {
grid-template-columns: minmax(0, 1.24fr) minmax(21rem, 0.76fr);
grid-template-rows: minmax(0, 1fr) 216px;
}
.dashboard-grid--landscape .dashboard-grid__calendar {
grid-column: 1;
grid-row: 1 / span 2;
}
.dashboard-grid--landscape .dashboard-grid__weather {
grid-column: 2;
grid-row: 1;
}
.dashboard-grid--landscape .dashboard-grid__quote {
grid-column: 2;
grid-row: 2;
}
.card { .card {
min-height: 0; min-height: 0;
border-radius: 2rem; border-radius: var(--card-radius);
background: var(--paper); background: var(--paper);
border: 2px solid var(--frame-stroke); border: 2px solid var(--frame-stroke);
box-shadow: none; box-shadow: none;
@@ -84,6 +188,56 @@ img {
aspect-ratio: 1; aspect-ratio: 1;
} }
.dashboard-grid--landscape .calendar-card {
gap: 0.82rem;
}
.dashboard-grid--landscape .calendar-card__day {
font-size: calc(6.2rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card {
grid-template-rows: auto minmax(0, 1fr) minmax(0, 0.86fr) minmax(0, 0.9fr);
gap: 0.58rem;
}
.dashboard-grid--landscape .weather-card__title {
font-size: calc(1.92rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__subtitle {
font-size: calc(1rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__temperature {
font-size: calc(2.4rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__condition {
font-size: calc(1.2rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__forecast {
gap: 0.36rem;
}
.dashboard-grid--landscape .forecast-pill {
padding: 0.32rem 0.12rem;
}
.dashboard-grid--landscape .forecast-pill__label {
font-size: calc(0.92rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
}
.dashboard-grid--landscape .forecast-pill__temp {
font-size: calc(1.1rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
}
.dashboard-grid--landscape .quote-card {
gap: 0.5rem;
padding: 0.92rem 1rem;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.page-shell { .page-shell {
padding: 0.75rem; padding: 0.75rem;
@@ -101,6 +255,14 @@ img {
padding: 0.9rem; padding: 0.9rem;
} }
.dashboard-grid--portrait .dashboard-grid__quote,
.dashboard-grid--landscape .dashboard-grid__calendar,
.dashboard-grid--landscape .dashboard-grid__weather,
.dashboard-grid--landscape .dashboard-grid__quote {
grid-column: auto;
grid-row: auto;
}
.page-shell { .page-shell {
align-items: stretch; align-items: stretch;
} }

View File

@@ -0,0 +1,115 @@
# Kindle Voyage 5.13.6 新机 Bootstrap 说明
## 目标
把“同型号新机拉齐能力”收敛成一个单入口脚本:
- 预置 `WatchThis` payload
- 预置 `KUAL / MRPI / USBNetwork / kindle-dash`
- 预置 SSH 恢复脚本
- SSH 打通后自动同步 dashboard、切主题、立即出图
对应脚本:
- [bootstrap-new-kindle.sh](/Users/gavin/kindle-dash/bootstrap-new-kindle.sh)
## 先说结论
这个脚本不是“100% 零交互刷机”。
当前仍然不能完全自动化的部分有:
1. `WatchThis` demo 流程本身需要设备端手势与点击
2. `;log mrpi` 需要在 Kindle 搜索栏手工触发
3. 首次 SSH 最稳的方式仍然是在 `KTerm` 里执行:
```sh
sh /mnt/us/ssh-force-dropbear-22.sh
```
所以这套 bootstrap 的真实定位是:
- 尽量把 Mac 侧和文件预置自动化
- 把设备侧必须手工的动作压到最少
## 脚本模式
### 1. `prepare-storage`
当 Kindle 以 USB 存储方式挂载到 Mac 后执行:
```sh
sh bootstrap-new-kindle.sh prepare-storage
```
它会自动:
-`KV-5.13.6.zip``demo.json` 放到 `.demo/`
- 创建 `.demo/goodreads/`
-`Update_hotfix_watchthis_custom.bin` 放到 Kindle 根目录
-`extensions/``mrpackages/``dashboard/` 预置到 Kindle
-`scripts/kindle/*.sh` 拷到 Kindle 根目录,供 `KTerm` 使用
### 2. `post-ssh`
当 Kindle 已连上 WiFi且你已经在 `KTerm` 拉起 `dropbear` 后执行:
```sh
sh bootstrap-new-kindle.sh post-ssh
```
它会自动:
- 修复设备侧 SSH 辅助脚本权限
- 尝试同步 `authorized_keys`
- 同步 dashboard 运行时和主题包
- 立即切到指定主题并把背景画到屏幕上
可选:
```sh
sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait
sh bootstrap-new-kindle.sh post-ssh --start-dashboard
```
### 3. `all`
默认模式:
```sh
sh bootstrap-new-kindle.sh
```
逻辑是:
- 如果检测到 `/Volumes/Kindle`,先做 `prepare-storage`
- 如果同时检测到 `ssh kindle` 可用,再继续做 `post-ssh`
- 哪一段当前做不了,就明确打印下一步人工动作
## 设备侧最短人工步骤
1. 恢复出厂并进入 demo mode
2. 到真正的 `Sideload Content` 时机
3. 让脚本已预置好的 `.demo` payload 生效
4. 通过 `Get Started` 完成越狱
5. 搜索 `;log mrpi`
6.`KUAL` 中先执行 `Rename OTA Binaries -> Rename`
7. 连上 WiFi
8. 打开 `KTerm`,执行:
```sh
sh /mnt/us/ssh-force-dropbear-22.sh
```
9. 回到 Mac执行
```sh
sh bootstrap-new-kindle.sh post-ssh
```
## 相关文档
- WatchThis 越狱路径:
[kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md)
- SSH 打通与 KTerm 兜底:
[kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md)

View File

@@ -12,6 +12,12 @@
- 白屏出现时dashboard 本身往往没有真正接管成功,更像是 `framework/KUAL` 启动链在中途被打断 - 白屏出现时dashboard 本身往往没有真正接管成功,更像是 `framework/KUAL` 启动链在中途被打断
- 当前最稳定的恢复路径,仍然是通过 SSH 执行 `./stop.sh` - 当前最稳定的恢复路径,仍然是通过 SSH 执行 `./stop.sh`
补充记录一个当前仍未修住、但边界已经比较清楚的问题:
-`KUAL` 进入 dashboard 后,再尝试回到 dashboard / KUAL 的原生 UI 路径,仍可能落入白屏
- 这个问题不应再继续归因到背景图、时钟或页面布局
- 当前更合理的判断,仍然是 `KUAL -> start.sh -> dash.sh` 的切换链路不稳定
这份文档只记录当前交接结论,不再继续尝试修复。 这份文档只记录当前交接结论,不再继续尝试修复。
## 已确认的事实 ## 已确认的事实
@@ -290,6 +296,23 @@ ssh kindle 'start webreader'
而不是继续怀疑背景图、时钟绘制或顶栏遮罩。 而不是继续怀疑背景图、时钟绘制或顶栏遮罩。
进一步说,当前建议把这个遗留问题固定表述为:
-`KUAL -> Kindle Dashboard``dashboard -> 原生 UI/KUAL` 之间的边界切换不稳定,表现为白屏”
建议的修复方向是:
1. 不要让 KUAL 直接同步执行 `/mnt/us/dashboard/start.sh`
2. 改成由一个独立 wrapper 先脱离 KUAL / framework 会话,再延迟启动 dashboard
3. 继续保留 `ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'` 作为唯一已验证稳定的进入方式
4. 继续保留 `./stop.sh` 作为唯一已验证稳定的退出恢复方式
等 SSH 恢复后,再围绕下面三点做实机验证:
1. KUAL wrapper 是否还能触发 `framework` 被 TERM
2. `start.sh` 的后台脱离方式是否足够彻底
3. `stop.sh` 后是否还需要补 `start webreader`
## 这轮涉及的关键文件 ## 这轮涉及的关键文件
- [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh) - [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh)

View File

@@ -499,7 +499,7 @@ export PRE_SLEEP_GRACE_SECONDS=10
补充两条运行期约束: 补充两条运行期约束:
- `clock-index.sh` 取当前时间时必须沿用 `TIMEZONE`,不能直接系统默认时区 - `TIMEZONE` 只用于 `next-wakeup` 的 cron 时区计算;`clock-index.sh` 需要单独走固定 UTC 偏移或设备可识别的时区配置,不能直接退回系统默认时区
- `PRE_SLEEP_GRACE_SECONDS` 这类“进入休眠前的可中断窗口”必须从实际休眠时长里扣掉,否则分钟刷新会长期落后一个节拍 - `PRE_SLEEP_GRACE_SECONDS` 这类“进入休眠前的可中断窗口”必须从实际休眠时长里扣掉,否则分钟刷新会长期落后一个节拍
当前实现里,时钟区域的适配已经分成两层: 当前实现里,时钟区域的适配已经分成两层:

View File

@@ -0,0 +1,415 @@
# Kindle Dashboard 双翻页键主题菜单方案
## 0. 当前状态
本文是评审方案,不是实机结论。
当前 Kindle 已掉出 Wi-FiSSH 中断,因此这份文档的目标是:
- 先把“左右同时按下翻页键,呼出主题选择页面”的方案固定下来
- 明确交互、进程模型、文件落点和风险
- 等 SSH 恢复后,再按这份方案做实机联调和裁剪
本文默认设备为:
- Kindle Voyage
- 固件 5.13.6
- dashboard 运行时会停掉 framework / webreader
## 1. 已确认事实
### 1.1 左右翻页键在内核里是两个独立键
实机上已经确认:
- 输入设备名为 `fsr_keypad`
- 设备节点为 `/dev/input/event2`
- `evtest /dev/input/event2` 可识别:
- `Event code 104 (PageUp)`
- `Event code 109 (PageDown)`
这意味着:
- 左右翻页键不是一个“翻页动作”
- 而是两个可以分别监听的 `EV_KEY`
- 软件层可以把它们组合成一个“组合键”
### 1.2 组合键不可能在同一时刻上报
Linux 输入事件一定是串行上报的。
所以“左右同时按下”在实现上必须解释为:
- 两个键在一个很短的时间窗口内先后进入 pressed
而不能解释为:
- 两个键共享同一个内核时间戳
推荐窗口:
- `300ms``350ms`
### 1.3 真正系统挂起时,用户态监听器不会继续工作
当前 dashboard 会在休眠路径里进入:
```sh
echo "mem" >/sys/power/state
```
一旦进入这个状态:
- 用户态 shell / Lua / evtest 监听器都会停掉
- 无法继续等待双键输入
因此本方案的有效范围是:
- dashboard 正在运行
- Kindle 尚未进入真正的 `mem` 挂起
这也是为什么本方案先聚焦:
- 运行态主题菜单
而不把目标直接扩展为:
- 真休眠态唤醒菜单
## 2. 目标
目标能力如下:
1. 用户在 dashboard 运行态下,同时按下左右翻页键
2. 屏幕弹出一个 KUAL 风格的主题选择页面
3. 用户通过翻页键在主题列表中移动选中项
4. 用户再次同时按下左右翻页键,确认并切换主题
5. 切换完成后,立即刷新背景和时钟
本阶段非目标:
- 不做真正休眠态下的按键唤醒菜单
- 不在 Kindle 上实现复杂触摸手势
- 不做缩略图式主题画廊
- 不做 theme + orientation 同时编辑的完整设置页
## 3. 推荐交互
### 3.1 触发方式
触发手势:
- 左右翻页键在 `350ms` 内同时按下
解释规则:
- 先按左,再按右,且间隔不超过 `350ms`,视为组合键
- 先按右,再按左,且间隔不超过 `350ms`,也视为组合键
- 单独按左或单独按右,不触发菜单
### 3.2 菜单打开后的操作
推荐第一版交互保持纯物理键可用:
- `PageUp`:向上移动
- `PageDown`:向下移动
- 再次双键同时按下:确认当前主题
这样做的原因:
- 不依赖 framework
- 不依赖系统触摸 UI
- 行为闭环简单,便于在无 SSH 时本机恢复
### 3.3 方向处理
推荐第一版只切换 `theme id`,保持当前方向不变。
例如:
- 当前是 `simple / landscape`
- 菜单里选中 `paper`
- 最终切到 `paper / landscape`
如果目标主题不支持当前方向,再回退到:
- 该主题可用的默认方向
这样能避免菜单第一版就把交互复杂度拉高到二维。
## 4. 菜单布局
### 4.1 布局风格
菜单布局可以模拟 KUAL但不追求逐像素复刻。
推荐视觉结构:
- 全屏白底
- 顶部两行标题
- `Kindle Dashboard`
- `Theme Menu`
- 中部竖向列表
- 当前选中项前用 `>` 标识
- 底部两行提示文案
示意:
```text
Kindle Dashboard
Theme Menu
Orientation: landscape
Selected: Simple
--------------------------------
> Default (default)
Paper (paper)
Classic (classic)
Simple (simple)
PageUp/PageDown: move
Press both keys: apply
```
### 4.2 为什么不做缩略图页
在当前阶段,不建议直接做缩略图式主题预览页。
原因:
- 需要更多图形绘制和排版能力
- 需要解决选中态、滚动和点击区域映射
- 在 SSH 不稳定时,调试成本显著上升
所以推荐第一版先做:
- 纯文本 KUAL 风格列表
确认流程稳定后,再考虑升级到:
- 缩略图 + 文本说明
## 5. 进程模型
### 5.1 推荐采用独立监听服务
推荐新增一个独立后台服务,例如:
- `dash/src/local/theme-menu-service.sh`
职责:
- 独立监听 `/dev/input/event2`
- 识别组合键
- 维护菜单状态
- 在确认后调用现有主题切换链路
不建议把这套逻辑直接塞进 `dash.sh` 主循环。
原因:
- 主循环核心职责已经是拉图、刷屏、休眠
- 组合键菜单属于输入状态机
- 两者耦合后,调试和排错都会变差
### 5.2 与现有链路的关系
推荐关系如下:
1. `dash.sh` 启动时拉起 `theme-menu-service.sh`
2. `theme-menu-service.sh` 常驻监听 `event2`
3. 识别到组合键后,本机绘制菜单
4. 用户确认后,调用现有:
- `/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]`
5. `switch-theme.sh` 继续负责:
- 同步主题配置
- 拉最新背景
- 刷新屏幕
- 重绘时钟
也就是说,菜单服务只负责:
- 触发
- 选择
- 调用已有切换入口
而不是重新实现一套主题切换。
## 6. 事件状态机
### 6.1 空闲态
空闲态只关注组合键触发。
状态变量:
- `pending_key`
- `pending_time`
- `combo_window_seconds`
逻辑:
1. 收到 `PageUp down`
- 记录 `pending_key=up`
2. 收到 `PageDown down`
- 如果此前 `pending_key=up` 且时间差小于窗口,判定为组合键
3. 反向顺序同理
4. 组合键成立后,打开菜单
### 6.2 菜单态
菜单态下,状态机切成另一套规则:
- `PageUp` release选中项上移
- `PageDown` release选中项下移
- 组合键:确认当前项
推荐选中项循环:
- 第一项再上移 -> 跳到最后一项
- 最后一项再下移 -> 跳到第一项
这样可以减少边界判断和失败反馈。
## 7. 文件落点
推荐新增或修改这些文件。
### 7.1 新增
- `dash/src/local/theme-menu-service.sh`
- 常驻监听器和菜单状态机
### 7.2 修改
- `dash/src/local/theme-json.lua`
- 新增 `list` 子命令
- 输出主题列表给菜单服务消费
- `dash/src/local/env.sh`
- 新增菜单相关开关,例如:
- `THEME_MENU_ENABLED`
- `THEME_MENU_EVENT_DEVICE`
- `THEME_MENU_COMBO_WINDOW_SECONDS`
- `dash/src/dash.sh`
- 在初始化阶段拉起菜单监听服务
- `dash/src/stop.sh`
- 停止 dashboard 时顺手停止菜单服务
- `scripts/sync-layered-clock-to-kindle.sh`
- 把新增服务脚本同步到设备
## 8. 推荐配置
建议新增这些环境变量:
```sh
export THEME_MENU_ENABLED=true
export THEME_MENU_EVENT_DEVICE="/dev/input/event2"
export THEME_MENU_COMBO_WINDOW_SECONDS=0.35
```
说明:
- `THEME_MENU_ENABLED`
- 总开关
- `THEME_MENU_EVENT_DEVICE`
- Voyage 上通常是 `event2`
- 后续换机型时可以只改这个
- `THEME_MENU_COMBO_WINDOW_SECONDS`
- 组合键判定窗口
## 9. 风险与限制
### 9.1 真休眠态不可用
这是本方案最重要的限制。
只要系统真的进入:
- `echo "mem" >/sys/power/state`
那么:
- 监听器就不会继续运行
- 双键菜单也不会再响应
因此第一版推荐配套策略是:
- 调试阶段 `DISABLE_SYSTEM_SUSPEND=true`
- 菜单只保证运行态可用
后续如果要支持“近似待机但仍可双键唤出菜单”,再单独设计:
- sleeping 页面但不进真 suspend
### 9.2 菜单样式是 KUAL 风格,不是 KUAL 组件
本方案里的“KUAL 风格”是指:
- 白底
- 列表
- 简洁标题
- 文本选中态
并不是:
- 真的把 KUAL 的 Java/UI 组件拉起来
原因是当前 dashboard 已经停掉 framework直接复用 KUAL UI 的成本会更高。
### 9.3 第一版不依赖触摸
从评审角度看,第一版最好不要把选择动作建立在触摸上。
原因:
- 触摸设备事件会更复杂
- 点选区域映射也更容易出错
- 无 SSH 时问题更难恢复
所以第一版推荐:
- 只用翻页键完成整个菜单闭环
## 10. 后续验证路径
等 SSH 恢复后,建议按这个顺序验证:
1. 确认 `evtest /dev/input/event2` 仍能看到:
- `PageUp`
- `PageDown`
2. 先同步本地最新 dashboard 代码到 Kindle
- 本轮已在本地加入一个“短按 `power` 提前唤醒后,额外保持 60 秒”的改动
- 相关配置在 `dash/src/local/env.sh`
- `MANUAL_WAKE_KEEP_AWAKE_SECONDS=60`
- `MANUAL_WAKE_EARLY_TOLERANCE_SECONDS=5`
- 相关运行逻辑在 `dash/src/dash.sh`
3. 重启 dashboard先单独验证短按 `power`
- 从休眠态短按 `power`
- 预期不再只亮约 3 秒
- 预期应保持约 60 秒再回到下一轮休眠
4. 让 dashboard 保持运行态,不进真 suspend
5. 手动启动菜单服务
6. 验证:
- 双键能否稳定打开菜单
- 单键移动是否会误触组合键
- 再次双键能否稳定确认
7. 验证主题切换后:
- 背景立即更新
- 时钟位置和方向保持正确
## 11. 结论
在当前仓库和 Kindle Voyage 的约束下,推荐采用下面这条路线:
- 监听 `/dev/input/event2`
- 用短时间窗口识别 `PageUp + PageDown` 组合键
- 在运行态下绘制一个 KUAL 风格的文本主题菜单
-`PageUp / PageDown` 导航
- 再次双键确认
- 最终复用现有 `switch-theme.sh` 完成主题切换
这是当前成本最低、最容易恢复、也最适合在 SSH 不稳定阶段先落地评审的方案。

View File

@@ -1,4 +1,52 @@
# Kindle Dashboard 多主题方案 # Kindle Dashboard 多主题与方向方案
## 0. 当前实现状态2026-03-16
以下能力已经落地,不再只是设计目标:
- `calendar/` 网站已支持 `theme + orientation` 两维预览
- `mode=background` 下不会显示预览菜单,避免污染截图
- `calendar/` 已产出:
- `dist/themes.json`
- `dist/themes/<theme-id>.json`
- `dist/themes/<theme-id>/<orientation>/kindlebg.png`
- 兼容输出仍保留:
- `dist/kindlebg.png`
- `dist/clock-region.json`
- Kindle 已支持:
- 拉取 `themes.json` 和主题级 JSON
- 通过 `/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]` 立即切换
- 优先使用本地已同步的主题背景图
- 优先使用本地已同步的主题索引和主题 JSON
- 本地没有对应背景图时,再回退到远端 `background.url`
- 同步脚本 `scripts/sync-layered-clock-to-kindle.sh` 会先批量导出全部主题背景,再把 `/mnt/us/dashboard/themes/` 同步到设备
### 0.1 横向主题的抓屏评审约定
`fbgrab` 抓到的是 Kindle 的原始 framebuffer 图,尺寸始终是纵向的 `1072 x 1448`
这意味着:
- `portrait` 主题下,`physical.png``raw.png` 相同
- `landscape` + `logo_right` 主题下,日常评审应优先看 `physical.png`
- `raw.png` 只用于排查原始 framebuffer 坐标
- `physical.png` 等于把 raw 图按设备实际摆放方向逆时针旋转 90 度后的结果
仓库里已经补了一个辅助脚本:
```sh
sh scripts/capture-kindle-screen.sh kindle landscape-check
```
它会同时输出两张图:
- `tmp/landscape-check-raw.png`
- `tmp/landscape-check-physical.png`
其中:
- `raw.png` 用于排查 framebuffer 原始坐标
- `physical.png` 用于按 Kindle 实际摆放方向评审横向主题
## 1. 背景 ## 1. 背景
@@ -7,39 +55,48 @@
- `calendar/` 负责渲染网页和导出背景图 - `calendar/` 负责渲染网页和导出背景图
- `dash/` 运行在 Kindle 上,负责拉取背景图并在本地重画时钟 - `dash/` 运行在 Kindle 上,负责拉取背景图并在本地重画时钟
里先明确一个约束,后续方案都基于它展开 次方案先明确两个概念
- `theme`:视觉主题,例如 `default``paper``classic`
- `orientation`:显示方向,例如 `portrait``landscape`
其中方向不是主题名的一部分,而是每个主题都必须包含的两套样式:
- `portrait`
- Kindle 竖向摆放
- 设备外部面板上的 `kindle` logo 在下方
- `landscape`
- Kindle 横向摆放
- 设备外部面板上的 `kindle` logo 在右侧
也就是说,后续系统模型应当是:
- 每个主题都包含纵向和横向两套显示样式
- Kindle 端仍然保持“拉背景图片 + 绘制时钟”的职责 - Kindle 端仍然保持“拉背景图片 + 绘制时钟”的职责
- 不把整页日历渲染放回 Kindle - 时钟的位置、尺寸和绘制参数都由 `calendar/` 提供
- 主题的名称、路径、时钟占位信息都由 `calendar/` 提供
这次多主题方案要同时满足两类需求:
1. 访问 `calendar` 网站时,可以在页面上切换主题预览效果
2. 后续 Kindle 端如果需要切换主题,也只依赖 `calendar` 提供的主题配置,不自己猜路径或布局
## 2. 目标 ## 2. 目标
本方案的目标是: 本方案的目标是:
1. 访问网站时,可以通过顶部可选菜单切换主题样式 1. 访问网站时,可以切换主题和方向进行预览
2. 网站预览效果应尽量与 Kindle 最终显示效果一致 2. 网站预览效果应尽量与 Kindle 最终显示效果一致
3. 当前背景生成流程继续可用,不因主题预览被破坏 3. 当前背景生成流程继续可用,不因预览能力被破坏
4. `calendar/` 提供统一的主题清单 JSON作为主题的单一真相源 4. `calendar/` 提供统一的主题清单 JSON作为主题与方向配置的单一真相源
5. Kindle 只需要读取固定位置的主题清单和主题配置 JSON 5. Kindle 只需要读取固定位置的主题清单和主题配置 JSON
6. Kindle 切换主题后,应立即拉取该主题背景和主题级配置并刷新 6. Kindle 切换主题或方向后,应立即拉取对应背景并刷新
7. 时钟的位置、尺寸和绘制参数由 `calendar` 的主题配置决定Kindle 不自行决定 7. 时钟的位置、尺寸和绘制参数由 `calendar` 的主题配置决定Kindle 不自行决定
非目标: 非目标:
- 不在 Kindle 上重新渲染整张页面 - 不在 Kindle 上重新渲染整张页面
- 不让 Kindle 维护主题路径规则 - 不让 Kindle 维护主题路径规则
-要求第一阶段就把现有导出脚本改成强制批量导出 -让 Kindle 自行推导时钟区域布局
- 不要求第一阶段就做“本地表盘素材包”体系 - 不要求第一阶段就做“本地表盘素材包”体系
## 3. 设计原则 ## 3. 设计原则
### 3.1 `calendar/` 是主题真相源 ### 3.1 `calendar/` 是单一真相源
主题系统的单一真相源放在 `calendar/` 侧。 主题系统的单一真相源放在 `calendar/` 侧。
@@ -47,7 +104,7 @@
- 一个主题清单文件,例如 `themes.json` - 一个主题清单文件,例如 `themes.json`
- 每个主题自己的配置文件,例如 `default.json``paper.json` - 每个主题自己的配置文件,例如 `default.json``paper.json`
- 每个主题自己的背景图 - 每个主题在不同方向下的背景图资源
其中 `themes.json` 至少描述: 其中 `themes.json` 至少描述:
@@ -55,6 +112,7 @@
- 每个主题叫什么名字 - 每个主题叫什么名字
- 每个主题的配置 JSON 在哪里 - 每个主题的配置 JSON 在哪里
- 默认主题是谁 - 默认主题是谁
- 默认方向是谁
这样做的好处是: 这样做的好处是:
@@ -62,46 +120,56 @@
- 新增主题时Kindle 无需改代码里的路径规则 - 新增主题时Kindle 无需改代码里的路径规则
- 网站预览和 Kindle 切换都依赖同一套主题元数据 - 网站预览和 Kindle 切换都依赖同一套主题元数据
这里再明确一下边界。 ### 3.2 `theme` 和 `orientation` 分开建模
远端主题配置只有两类: 不建议把“横向主题”“纵向主题”作为主题名本身。
- `themes.json` 推荐模型是:
- 每个主题自己的 JSON例如 `themes/default.json`
除此之外: - `theme`
- `orientation`
- 背景图属于资源文件,不属于主题配置 例如:
- Kindle 本地缓存属于运行时状态,不属于主题配置
### 3.2 网站预览与导出解耦 - `theme=default` + `orientation=portrait`
- `theme=default` + `orientation=landscape`
- `theme=paper` + `orientation=portrait`
主题预览只是网站访问时的交互能力,不应破坏当前导出主流程。 这样更符合实际含义:
建议明确两条链路: - `theme` 代表视觉风格
- `orientation` 代表设备摆放方向与对应布局
- 预览链路:用户访问 `/?mode=full&theme=<id>`,通过页面菜单切换主题 ### 3.3 网站预览与导出解耦
- 导出链路:现有脚本继续生成默认背景图,保持兼容
主题和方向预览只是网站访问时的交互能力,不应破坏当前导出主流程。
当前已明确三条链路:
- 预览链路:用户访问 `/?mode=full&theme=<id>&orientation=<orientation>`,通过页面菜单切换主题和方向
- 兼容导出链路:`export:background` 继续生成默认背景图,保持兼容
- 批量导出链路:`export:themes` 一次性生成全部主题和方向背景
也就是说: 也就是说:
- 预览能力落地 - 预览能力已经落地
- 当前 `kindlebg.png` 导出链路先不强制变化 - 当前 `kindlebg.png` 导出链路继续保留
- 多主题导出作为后续增量能力接入 - 多主题多方向导出已经接入
### 3.3 Kindle 侧最简化 ### 3.4 Kindle 侧最简化
Kindle 侧只保留最小能力: Kindle 侧只保留最小能力:
- 拉取背景图 - 拉取背景图
- 读取主题 JSON - 读取主题 JSON
- 根据当前方向读取对应 variant
- 按 JSON 提供的时钟参数绘制时钟 - 按 JSON 提供的时钟参数绘制时钟
Kindle 不负责: Kindle 不负责:
- 推导主题路径 - 推导主题路径
- 决定时钟区域坐标 - 决定时钟区域坐标
- 维护主题布局规则 - 维护布局规则
推荐让 Kindle 只认识一个固定入口,例如: 推荐让 Kindle 只认识一个固定入口,例如:
@@ -112,14 +180,14 @@ https://shell.biboer.cn:20001/themes.json
然后从这个 JSON 中知道: 然后从这个 JSON 中知道:
- 当前有哪些主题 - 当前有哪些主题
- 默认主题和默认方向是什么
- 每个主题的配置 JSON 在哪里 - 每个主题的配置 JSON 在哪里
- 切换到该主题时该去拉哪个背景图
### 3.4 当前时钟实现已经足够轻量 ### 3.5 当前时钟实现已经足够轻量
当前 Kindle 侧时钟是本地几何绘制,不依赖外部表盘或指针素材文件。 当前 Kindle 侧时钟是本地几何绘制,不依赖外部表盘或指针素材文件。
这意味着第一阶段主题切换只需要关注: 这意味着第一阶段主题与方向切换只需要关注:
- 背景图 URL - 背景图 URL
- 时钟区域位置和尺寸 - 时钟区域位置和尺寸
@@ -137,24 +205,25 @@ https://shell.biboer.cn:20001/themes.json
{ {
"updatedAt": "2026-03-16T10:00:00+08:00", "updatedAt": "2026-03-16T10:00:00+08:00",
"defaultThemeId": "default", "defaultThemeId": "default",
"defaultOrientation": "portrait",
"themes": [ "themes": [
{ {
"id": "default", "id": "default",
"label": "Default", "label": "Default",
"configUrl": "https://shell.biboer.cn:20001/themes/default.json", "configUrl": "https://shell.biboer.cn:20001/themes/default.json",
"previewQuery": "?mode=full&theme=default" "orientations": ["portrait", "landscape"]
}, },
{ {
"id": "paper", "id": "paper",
"label": "Paper", "label": "Paper",
"configUrl": "https://shell.biboer.cn:20001/themes/paper.json", "configUrl": "https://shell.biboer.cn:20001/themes/paper.json",
"previewQuery": "?mode=full&theme=paper" "orientations": ["portrait", "landscape"]
}, },
{ {
"id": "classic", "id": "classic",
"label": "Classic", "label": "Classic",
"configUrl": "https://shell.biboer.cn:20001/themes/classic.json", "configUrl": "https://shell.biboer.cn:20001/themes/classic.json",
"previewQuery": "?mode=full&theme=classic" "orientations": ["portrait", "landscape"]
} }
] ]
} }
@@ -167,18 +236,22 @@ https://shell.biboer.cn:20001/themes.json
### 4.2 主题配置 `<theme-id>.json` ### 4.2 主题配置 `<theme-id>.json`
每个主题提供一份主题级配置,例如: 每个主题提供一份主题级配置,内部包含两个方向的 variant例如:
```json ```json
{ {
"id": "default", "id": "default",
"label": "Default", "label": "Default",
"variants": {
"portrait": {
"devicePlacement": "logo_bottom",
"background": { "background": {
"url": "https://shell.biboer.cn:20001/themes/default/kindlebg.png", "path": "themes/default/portrait/kindlebg.png",
"url": "https://shell.biboer.cn:20001/themes/default/portrait/kindlebg.png",
"refreshIntervalMinutes": 120 "refreshIntervalMinutes": 120
}, },
"clock": { "clock": {
"x": 262, "x": 347,
"y": 55, "y": 55,
"width": 220, "width": 220,
"height": 220, "height": 220,
@@ -195,16 +268,61 @@ https://shell.biboer.cn:20001/themes.json
"minuteThickness": 5, "minuteThickness": 5,
"centerRadius": 7 "centerRadius": 7
} }
},
"landscape": {
"devicePlacement": "logo_right",
"background": {
"path": "themes/default/landscape/kindlebg.png",
"url": "https://shell.biboer.cn:20001/themes/default/landscape/kindlebg.png",
"refreshIntervalMinutes": 120
},
"clock": {
"x": 795,
"y": 659,
"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,
"rotationDegrees": 90
}
}
}
} }
``` ```
说明: 说明:
- 背景图真实路径由主题配置提供,不在 Kindle 端拼接猜测 - 背景图真实路径由主题配置提供,不在 Kindle 端拼接猜测
- 时钟坐标和大小来自 `calendar` 中该主题的时钟占位,不允许 Kindle 任意改 - `background.path` 是设备本地相对路径,用于优先读取已同步的本地背景
- 后续如果某个主题需要不同的时钟绘制参数,也由该 JSON 一起提供 - 每个方向都有自己的背景图和时钟区域
- `landscape``clock` 已经是 Kindle 设备坐标,不再是网页坐标
- `landscape``rotationDegrees=90` 表示 Kindle 本地绘制时钟时,表盘和指针再顺时针旋转 90 度
- 时钟坐标和大小来自 `calendar` 中该主题该方向下的时钟占位,不允许 Kindle 任意改
- 后续如果某个主题在不同方向下需要不同绘制参数,也由该 JSON 一起提供
### 4.3 Kindle 本地状态 ### 4.3 远端配置边界
远端主题配置只有两类:
- `themes.json`
- 每个主题自己的 JSON例如 `themes/default.json`
除此之外:
- 背景图属于资源文件,不属于主题配置
- Kindle 本地缓存属于运行时状态,不属于主题配置
### 4.4 Kindle 本地状态
Kindle 本地只需要保存少量状态: Kindle 本地只需要保存少量状态:
@@ -212,35 +330,67 @@ Kindle 本地只需要保存少量状态:
/mnt/us/dashboard/local/theme.env /mnt/us/dashboard/local/theme.env
/mnt/us/dashboard/local/state/themes.json /mnt/us/dashboard/local/state/themes.json
/mnt/us/dashboard/local/state/current-theme.json /mnt/us/dashboard/local/state/current-theme.json
/mnt/us/dashboard/local/state/theme-runtime.env
/mnt/us/dashboard/local/state/background-updated-at /mnt/us/dashboard/local/state/background-updated-at
``` ```
其中: 其中:
- `theme.env` 记录当前主题 ID - `theme.env` 记录当前 `THEME_ID``ORIENTATION`
- `themes.json` 是最近一次同步到本地的主题清单缓存 - `themes.json` 是最近一次同步到本地的主题清单缓存
- `current-theme.json` 是当前主题配置缓存 - `current-theme.json` 是当前主题配置缓存
- `theme-runtime.env` 是 Kindle 实际消费的运行时快照,包含背景路径和时钟参数
这几项是 Kindle 本地运行状态,不属于远端主题配置。 例如:
```sh
export THEME_ID=default
export ORIENTATION=portrait
```
### 4.5 Kindle 本地主题资源
当前实现中,主题背景图会随部署一起同步到 Kindle
```text
/mnt/us/dashboard/themes/default/portrait/kindlebg.png
/mnt/us/dashboard/themes/default/landscape/kindlebg.png
/mnt/us/dashboard/themes/paper/portrait/kindlebg.png
/mnt/us/dashboard/themes/paper/landscape/kindlebg.png
/mnt/us/dashboard/themes/classic/portrait/kindlebg.png
/mnt/us/dashboard/themes/classic/landscape/kindlebg.png
```
Kindle 拉背景时的规则是:
1. 先读 `theme-runtime.env` 中的 `BACKGROUND_PATH`
2. 如果本地对应文件存在,直接从 `/mnt/us/dashboard/themes/...` 拷贝
3. 如果本地不存在,再回退到 `BACKGROUND_URL`
主题元数据同步的规则是:
1. 先读本地 `/mnt/us/dashboard/themes.json`
2. 再读本地 `/mnt/us/dashboard/themes/<theme-id>.json`
3. 本地 bundle 不存在时,才回退到远端 URL
## 5. `calendar/` 侧方案 ## 5. `calendar/` 侧方案
### 5.1 网站访问时增加主题预览菜单 ### 5.1 网站访问时增加主题与方向预览菜单
主题预览菜单只出现在网站访问场景,不出现在背景截图结果里。 预览菜单只出现在网站访问场景,不出现在背景截图结果里。
建议行为: 建议行为:
1. 用户访问 `/?mode=full` 时,在页面顶部显示个可选主题菜单 1. 用户访问 `/?mode=full` 时,在页面顶部显示个可选菜单
2. 切换菜单时更新当前页面的 `theme` 参数 2. 一个菜单切换 `theme`
3. 菜单位置放在顶部任意不影响截图的位置即可 3. 一个菜单切换 `orientation`
4.`mode=background` 下不显示菜单,避免影响截图导出 4.`mode=background` 下不显示这些菜单,避免影响截图导出
建议 URL 形态如下: 建议 URL 形态如下:
- `/?mode=full&theme=default` - `/?mode=full&theme=default&orientation=portrait`
- `/?mode=full&theme=paper` - `/?mode=full&theme=default&orientation=landscape`
- `/?mode=full&theme=classic` - `/?mode=full&theme=paper&orientation=portrait`
### 5.2 预览效果与 Kindle 效果对齐 ### 5.2 预览效果与 Kindle 效果对齐
@@ -249,8 +399,8 @@ Kindle 本地只需要保存少量状态:
建议做到: 建议做到:
- 预览和导出使用同一套主题配置 - 预览和导出使用同一套主题配置
- 背景导出与预览使用同一套时钟占位信息 - 预览和导出使用同一套方向布局配置
- Kindle 侧按主题 JSON 中的时钟参数绘制,避免预览和实机错位 - Kindle 侧按主题 JSON 中当前方向的参数绘制,避免预览和实机错位
这样评审主题时,看到的页面效果和 Kindle 最终效果才是一致的。 这样评审主题时,看到的页面效果和 Kindle 最终效果才是一致的。
@@ -267,34 +417,41 @@ calendar/dist/clock-region.json
兼容规则建议如下: 兼容规则建议如下:
- 如果导出脚本没有显式传入 `theme`,默认导出 `default` - 如果导出脚本没有显式传入 `theme`,默认导出 `default`
- 如果导出脚本没有显式传入 `orientation`,默认导出 `portrait`
- 现有 `export:background` 的行为保持不变 - 现有 `export:background` 的行为保持不变
- 先不强制要求所有主题都参与默认导出 - 默认导出继续只产出 `default + portrait`
这样可以保证: 这样可以保证:
- 网站预览菜单先落地 - 网站预览菜单先落地
- 当前生产链路不被打断 - 当前生产链路不被打断
- Kindle 当前单主题使用方式继续可用 - Kindle 当前单主题纵向使用方式继续可用
### 5.4 多主题导出作为附加能力 ### 5.4 多主题多方向导出作为附加能力落地
需要支持 Kindle 端多主题切换时,再增加多主题导出,例如 `export:themes` 会生成如下产物
```text ```text
calendar/dist/themes.json calendar/dist/themes.json
calendar/dist/themes/default.json calendar/dist/themes/default.json
calendar/dist/themes/default/kindlebg.png calendar/dist/themes/default/portrait/kindlebg.png
calendar/dist/themes/default/landscape/kindlebg.png
calendar/dist/themes/paper.json calendar/dist/themes/paper.json
calendar/dist/themes/paper/kindlebg.png calendar/dist/themes/paper/portrait/kindlebg.png
calendar/dist/themes/paper/landscape/kindlebg.png
calendar/dist/themes/classic.json calendar/dist/themes/classic.json
calendar/dist/themes/classic/kindlebg.png calendar/dist/themes/classic/portrait/kindlebg.png
calendar/dist/themes/classic/landscape/kindlebg.png
``` ```
这里的关键点是: 这里的关键点是:
- 原有 `calendar/dist/kindlebg.png` 继续保留 - 原有 `calendar/dist/kindlebg.png` 继续保留
- 多主题目录和 `themes.json` 是新增能力,不是替换能力 - 多主题多方向目录和 `themes.json` 是新增能力,不是替换能力
- 主题路径管理由 `calendar` 决定,不在 Kindle 侧固化为固定模式 - 主题和方向的路径管理由 `calendar` 决定,不在 Kindle 侧固化为固定模式
- 同步到 Kindle 时,会把整个 `calendar/dist/themes/` 目录一起带上
- `landscape` 的网页先按 `1448x1072` 渲染,再顺时针旋转 90 度,写成 `1072x1448` 的 Kindle 设备图
- 因此 `landscape``kindlebg.png` 是设备向产物,不是给桌面直接平铺预览的横图
## 6. Kindle 侧方案 ## 6. Kindle 侧方案
@@ -310,29 +467,33 @@ themes.json 的 URL
- 被动同步Kindle 每天拉取一次 `themes.json` - 被动同步Kindle 每天拉取一次 `themes.json`
- 主动同步:用户进入主题切换流程时,先即时拉取最新 `themes.json` - 主动同步:用户进入主题切换流程时,先即时拉取最新 `themes.json`
- 如果设备上已经有本地同步的 `themes.json` 和主题 JSON本地 bundle 优先
这样能兼顾: 这样能兼顾:
- 平时低频更新 - 平时低频更新
- 切换主题时拿到最新主题清单 - 切换主题时拿到最新主题清单
### 6.2 主题切换流程 ### 6.2 主题与方向切换流程
主题切换时建议执行以下流程: 主题切换时建议执行以下流程:
1. 拉取最新 `themes.json` 1. 拉取最新 `themes.json`
2. 根据用户选择的主题 ID 找到对应 `configUrl` 2. 根据用户选择的主题 ID 找到对应 `configUrl`
3. 拉取该主题对应的 `<theme-id>.json` 3. 拉取该主题对应的 `<theme-id>.json`
4. `THEME_ID` 写入本地 `theme.env` 4. 确定当前要使用的 `orientation`
5. 将主题 JSON 缓存在本地 5. `variants[orientation]` 里取出背景和时钟配置
6. 立即拉取该主题背景图 6. `THEME_ID``ORIENTATION` 写入本地 `theme.env`
7. 立即执行一次全屏刷新 7. 将主题 JSON 缓存在本地
8. 之后继续按当前分钟级时钟刷新逻辑运行 8. 优先读取本地 `themes/<theme>/<orientation>/kindlebg.png`
9. 如果本地没有对应背景,再回退拉取 `background.url`
10. 立即执行一次全屏刷新
11. 之后继续按当前分钟级时钟刷新逻辑运行
这里有个关键要求: 这里有个关键要求:
- 主题切换后,不等待下一个两小时周期 - 主题或方向切换后,不等待下一个两小时周期
- 而是立即拉取一次新主题背景 - 而是立即拉取一次对应的新背景
### 6.3 时钟绘制保持现有模型 ### 6.3 时钟绘制保持现有模型
@@ -342,6 +503,7 @@ themes.json 的 URL
- 拉背景图片 - 拉背景图片
- 读取主题 JSON - 读取主题 JSON
- 读取当前方向的 variant
- 用本地 `lua + fbink` 画时钟 - 用本地 `lua + fbink` 画时钟
不需要在第一阶段增加: 不需要在第一阶段增加:
@@ -352,76 +514,97 @@ themes.json 的 URL
如果未来某个主题确实需要独立表盘素材,再在第二阶段扩展。 如果未来某个主题确实需要独立表盘素材,再在第二阶段扩展。
### 6.4 主题切换入口 ### 6.4 切换入口
入口建议做成“统一主题入口”,而不是在 KUAL 里硬编码每个主题项。 当前已经有一个可直接 SSH 调用的入口:
目标交互是: ```sh
/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]
```
例如:
```sh
ssh kindle '/mnt/us/dashboard/switch-theme.sh default landscape'
ssh kindle '/mnt/us/dashboard/switch-theme.sh paper portrait'
```
如果本地刚改过页面样式,推荐先同步一次:
```sh
sh scripts/sync-layered-clock-to-kindle.sh kindle
```
KUAL 动态主题入口仍然是后续可选项,不是当前必需项。
如果后续要补 KUAL目标交互仍然可以保持
1. 用户点击“Theme” 1. 用户点击“Theme”
2. 设备根据 `themes.json` 获取当前可用主题清单 2. 设备根据 `themes.json` 获取当前可用主题清单
3. 用户点击具体主题 3. 用户选择具体主题
4. 立即切换到该主题 4. 用户选择 `portrait``landscape`
5. 立即切换到该主题该方向
实现上可以接受两种方式:
- KUAL 能直接承载该交互,则由脚本在入口内完成
- 如果 KUAL 不能直接动态展示,则由脚本先读取 `themes.json`,再生成或更新对应菜单
不管具体 UI 形态如何,数据契约都应保持一致: 不管具体 UI 形态如何,数据契约都应保持一致:
- 主题列表来自 `themes.json` - 主题列表来自 `themes.json`
- 切换行为来自所选主题对应的 `<theme-id>.json` - 具体切换行为来自所选主题对应的 `<theme-id>.json`
- 最终生效资源来自该主题该方向下的 variant
## 7. 分阶段落地 ## 7. 分阶段落地与当前进度
### 第一阶段:网站主题预览 ### 第一阶段:网站主题与方向预览(已完成)
目标: 目标:
- 新增 `theme` 参数 - 新增 `theme` 参数
- 增加顶部主题预览菜单 - 新增 `orientation` 参数
- 增加顶部预览菜单
- 默认主题命名为 `default` - 默认主题命名为 `default`
- 新增 `paper``classic` - 新增 `paper``classic`
- 每个主题都支持 `portrait``landscape`
结果: 结果:
- 用户可以在网站上预览三套主题 - 用户可以在网站上预览三套主题的两种方向
- `mode=background` 不显示预览控件
- 现有截图导出流程保持不变 - 现有截图导出流程保持不变
### 第二阶段:主题元数据发布 ### 第二阶段:主题元数据发布(已完成)
目标: 目标:
- `calendar/` 产出 `themes.json` - `calendar/` 产出 `themes.json`
- 每个主题产出自己的 `<theme-id>.json` - 每个主题产出自己的 `<theme-id>.json`
- 每个主题有独立背景图 URL - 每个主题在两个方向下都有独立背景图 URL
结果: 结果:
- 主题列表、主题路径、时钟配置都由 `calendar/` 统一提供 - 主题列表、方向列表、时钟配置都由 `calendar/` 统一提供
- Kindle 侧可以开始按 JSON 理解主题系统 - 主题配置里已同时提供 `background.path``background.url`
### 第三阶段Kindle 端主题切换 ### 第三阶段Kindle 端主题与方向切换(已完成)
目标: 目标:
- Kindle 能拉取 `themes.json` - Kindle 能拉取 `themes.json`
- Kindle 能按选择主题拉取对应的 `<theme-id>.json` - Kindle 能按选择主题拉取对应的 `<theme-id>.json`
- Kindle 能切换 `portrait``landscape`
- 切换后立即拉取新背景并全屏刷新 - 切换后立即拉取新背景并全屏刷新
结果: 结果:
- Kindle 端正式具备多主题切换能力 - Kindle 端具备多主题多方向切换能力
- Kindle 侧仍保持最小职责 - 切换后会立即刷新
- SSH 命令切换已实机验证通过
### 第四阶段:按需扩展主题能力 ### 第四阶段:按需扩展主题能力(未开始)
只有在未来确实出现需求时,再考虑: 只有在未来确实出现需求时,再考虑:
- 为某些主题提供独立表盘素材 - 为某些主题提供独立表盘素材
- 增加主题级本地资源同步 - 增加主题级本地资源同步
- 增加更复杂的主题切换交互 - 增加更复杂的切换交互
这一步不是当前必需项。 这一步不是当前必需项。
@@ -429,7 +612,7 @@ themes.json 的 URL
### 8.1 预览和实机效果偏差 ### 8.1 预览和实机效果偏差
如果预览和导出用的不是同一套主题配置,最终会出现: 如果预览和导出用的不是同一套主题和方向配置,最终会出现:
- 网页看起来正确 - 网页看起来正确
- Kindle 上时钟错位或背景不一致 - Kindle 上时钟错位或背景不一致
@@ -440,42 +623,53 @@ themes.json 的 URL
- 导出 - 导出
- Kindle 时钟绘制 - Kindle 时钟绘制
都依赖同一套主题元数据。 都依赖同一套主题元数据和方向配置
### 8.2 主题切换后的缓存问题 ### 8.2 方向切换后的缓存问题
如果主题切换后没有立即拉取新背景Kindle 可能继续显示旧缓存。 如果主题或方向切换后没有立即拉取新背景Kindle 可能继续显示旧缓存。
因此切换主题时必须同时处理: 因此切换时必须同时处理:
- 更新 `THEME_ID` - 更新 `THEME_ID`
- 更新 `ORIENTATION`
- 拉取新的 `<theme-id>.json` - 拉取新的 `<theme-id>.json`
- 拉取新背景图 - 拉取或读取当前方向对应的新背景图
- 重置背景更新时间戳或直接覆盖缓存背景 - 重置背景更新时间戳或直接覆盖缓存背景
如果设备本地没有同步主题背景,而远端对应 PNG 也还没发布完成Kindle 可能拉到 HTML 或错误页而不是图片。
因此当前推荐做法是:
- 同步到 Kindle 时总是一起同步 `/mnt/us/dashboard/themes/`
- 设备优先使用本地主题背景
- 远端 `background.url` 只作为回退
### 8.3 主题清单缓存过旧 ### 8.3 主题清单缓存过旧
如果 Kindle 长时间只读本地缓存,可能看不到新主题。 如果 Kindle 长时间只读本地缓存,可能看不到新主题或新方向配置
因此建议: 因此建议:
- 日常低频同步一次 `themes.json` - 日常低频同步一次 `themes.json`
- 用户进入主题切换时再主动刷新一次 - 用户进入切换流程时再主动刷新一次
## 9. 推荐结论 ## 9. 当前推荐结论
推荐采用下面这条路线: 推荐采用下面这条路线:
1. 先在 `calendar` 网站上增加顶部主题预览菜单 1. 继续保留默认 `kindlebg.png` 导出链路,默认仍是 `default + portrait`
2. 维持当前默认背景导出链路不变 2. 同时保留 `export:themes` 批量导出,作为多主题切换的资源来源
3.`calendar` 增量提供 `themes.json` 和主题级 `<theme-id>.json` 3.`calendar` 继续维护 `themes.json` 和主题级 `<theme-id>.json`
4. Kindle 只读取固定位置的 `themes.json` 4. 每个主题配置里同时提供 `portrait``landscape` 两套 variant
5. 切换主题时立即拉取该主题配置和背景图,并继续使用现有本地时钟绘制 5. Kindle 继续读取固定位置的 `themes.json`
6. 切换主题或方向时优先使用本地同步的主题背景,并继续使用现有本地时钟绘制
这条路线的优点是: 这条路线的优点是:
- 不打断现有 `kindlebg.png` 生成流程 - 不打断现有 `kindlebg.png` 生成流程
- 主题路径和布局都由 `calendar` 统一管理 - 主题和方向都由 `calendar` 统一管理
- Kindle 侧实现最简化 - Kindle 侧实现最简化
- 预览和实机显示可以基于同一套配置对齐 - 预览和实机显示可以基于同一套配置对齐
- 即使远端主题图片还没发布完整SSH 主题切换也能工作
- 当前时钟实现不依赖表盘和指针素材,第一阶段改造成本低 - 当前时钟实现不依赖表盘和指针素材,第一阶段改造成本低

View File

@@ -9,6 +9,10 @@ LOW_BATTERY_CMD="$DIR/local/low-battery.sh"
CLOCK_RENDER_CMD="$DIR/local/render-clock.sh" CLOCK_RENDER_CMD="$DIR/local/render-clock.sh"
STATE_DIR="$DIR/local/state" STATE_DIR="$DIR/local/state"
BACKGROUND_TIMESTAMP_FILE="$STATE_DIR/background-updated-at" BACKGROUND_TIMESTAMP_FILE="$STATE_DIR/background-updated-at"
THEME_RUNTIME_ENV_FILE="$STATE_DIR/theme-runtime.env"
THEME_SYNC_CMD="$DIR/local/theme-sync.sh"
THEME_MENU_SERVICE_CMD="$DIR/local/theme-menu-service.sh"
THEME_MENU_LOG_FILE="$DIR/logs/theme-menu.log"
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"} REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0} FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
@@ -17,6 +21,8 @@ DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120} BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15} CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
PRE_SLEEP_GRACE_SECONDS=${PRE_SLEEP_GRACE_SECONDS:-10} PRE_SLEEP_GRACE_SECONDS=${PRE_SLEEP_GRACE_SECONDS:-10}
MANUAL_WAKE_KEEP_AWAKE_SECONDS=${MANUAL_WAKE_KEEP_AWAKE_SECONDS:-60}
MANUAL_WAKE_EARLY_TOLERANCE_SECONDS=${MANUAL_WAKE_EARLY_TOLERANCE_SECONDS:-5}
STATUS_MASK_ENABLED=${STATUS_MASK_ENABLED:-true} STATUS_MASK_ENABLED=${STATUS_MASK_ENABLED:-true}
STATUS_MASK_LEFT=${STATUS_MASK_LEFT:-700} STATUS_MASK_LEFT=${STATUS_MASK_LEFT:-700}
STATUS_MASK_TOP=${STATUS_MASK_TOP:-0} STATUS_MASK_TOP=${STATUS_MASK_TOP:-0}
@@ -32,6 +38,27 @@ LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10}
num_refresh=0 num_refresh=0
background_needs_redraw=true background_needs_redraw=true
start_theme_menu_service() {
if [ "${THEME_MENU_ENABLED:-true}" != true ]; then
return
fi
if [ ! -x "$THEME_MENU_SERVICE_CMD" ]; then
return
fi
mkdir -p "$(dirname "$THEME_MENU_LOG_FILE")"
pkill -f "$THEME_MENU_SERVICE_CMD" 2>/dev/null || true
nohup "$THEME_MENU_SERVICE_CMD" >>"$THEME_MENU_LOG_FILE" 2>&1 </dev/null &
}
load_theme_runtime_config() {
if [ -f "$THEME_RUNTIME_ENV_FILE" ]; then
# shellcheck disable=SC1090
. "$THEME_RUNTIME_ENV_FILE"
fi
}
init() { init() {
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
echo "Missing required configuration." echo "Missing required configuration."
@@ -51,6 +78,7 @@ init() {
initctl stop webreader >/dev/null 2>&1 initctl stop webreader >/dev/null 2>&1
echo powersave >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor echo powersave >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
lipc-set-prop com.lab126.powerd preventScreenSaver 1 lipc-set-prop com.lab126.powerd preventScreenSaver 1
start_theme_menu_service
} }
stop_framework() { stop_framework() {
@@ -82,6 +110,8 @@ now_epoch() {
} }
background_refresh_due() { background_refresh_due() {
load_theme_runtime_config
if [ ! -f "$BACKGROUND_PNG" ] || [ ! -f "$BACKGROUND_TIMESTAMP_FILE" ]; then if [ ! -f "$BACKGROUND_PNG" ] || [ ! -f "$BACKGROUND_TIMESTAMP_FILE" ]; then
return 0 return 0
fi fi
@@ -101,6 +131,16 @@ fetch_background() {
echo "Refreshing background" echo "Refreshing background"
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP" "$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
if "$THEME_SYNC_CMD" >/dev/null; then
load_theme_runtime_config
elif [ ! -f "$THEME_RUNTIME_ENV_FILE" ]; then
echo "Theme sync failed and no runtime theme config is available."
return 1
else
echo "Theme sync failed, using cached runtime theme config."
load_theme_runtime_config
fi
"$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG" "$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG"
fetch_status=$? fetch_status=$?
@@ -228,6 +268,39 @@ rtc_sleep() {
fi fi
} }
manual_wake_detected() {
requested_duration=$1
actual_duration=$2
if [ "$DEBUG" = true ] || [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
return 1
fi
if [ "$requested_duration" -le "$MANUAL_WAKE_EARLY_TOLERANCE_SECONDS" ]; then
return 1
fi
[ "$actual_duration" -lt $((requested_duration - MANUAL_WAKE_EARLY_TOLERANCE_SECONDS)) ]
}
hold_after_manual_wake() {
requested_duration=$1
sleep_started_at=$2
sleep_finished_at=$3
actual_duration=$((sleep_finished_at - sleep_started_at))
if ! manual_wake_detected "$requested_duration" "$actual_duration"; then
return
fi
echo "Manual wake detected after ${actual_duration}s, keeping awake for ${MANUAL_WAKE_KEEP_AWAKE_SECONDS}s"
# 短按电源键提前唤醒后,先把 dashboard 内容恢复回来,
# 再给出一段明确的可交互窗口,避免 2~3 秒内再次休眠。
refresh_dashboard || true
sleep "$MANUAL_WAKE_KEEP_AWAKE_SECONDS"
}
main_loop() { main_loop() {
while true; do while true; do
log_battery_stats log_battery_stats
@@ -258,7 +331,10 @@ main_loop() {
echo "Going to $action, next wakeup in ${next_wakeup_secs}s" echo "Going to $action, next wakeup in ${next_wakeup_secs}s"
sleep_started_at=$(now_epoch)
rtc_sleep "$actual_sleep_secs" rtc_sleep "$actual_sleep_secs"
sleep_finished_at=$(now_epoch)
hold_after_manual_wake "$actual_sleep_secs" "$sleep_started_at" "$sleep_finished_at"
done done
} }

View File

@@ -37,6 +37,7 @@ mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它 # 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 然后立刻拉起新的 dashboard避免用户还要再次手动启动。 # 然后立刻拉起新的 dashboard避免用户还要再次手动启动。
pkill -f "$DIR/dash.sh" 2>/dev/null || true pkill -f "$DIR/dash.sh" 2>/dev/null || true
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
sleep 1 sleep 1
"$DIR/start.sh" "$DIR/start.sh"

View File

@@ -37,6 +37,7 @@ mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它 # 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 然后立刻拉起新的 dashboard避免用户还要在短时间内再点一次菜单。 # 然后立刻拉起新的 dashboard避免用户还要在短时间内再点一次菜单。
pkill -f "$DIR/dash.sh" 2>/dev/null || true pkill -f "$DIR/dash.sh" 2>/dev/null || true
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
sleep 1 sleep 1
"$DIR/start.sh" "$DIR/start.sh"

View File

@@ -11,14 +11,44 @@ to_decimal() {
}' }'
} }
clock_values_from_offset() {
epoch_seconds=$(date '+%s')
offset_minutes=$1
# 这里用 Lua 直接按 Unix 时间戳加偏移换算时分,
# 避免 Kindle 上的 BusyBox date 对 Asia/Shanghai 这类时区名支持不完整。
lua - "$epoch_seconds" "$offset_minutes" <<'LUA'
local epoch_seconds = assert(tonumber(arg[1]), "missing epoch seconds")
local offset_minutes = assert(tonumber(arg[2]), "missing offset minutes")
local seconds_per_day = 24 * 60 * 60
local local_seconds = epoch_seconds + offset_minutes * 60
local seconds_of_day = ((local_seconds % seconds_per_day) + seconds_per_day) % seconds_per_day
local hour = math.floor(seconds_of_day / 3600)
local minute = math.floor((seconds_of_day % 3600) / 60)
io.write(string.format("%02d %02d\n", hour, minute))
LUA
}
current_clock_values() { current_clock_values() {
# 时钟渲染要和 dashboard 配置的时区保持一致 # 表盘显示优先走固定 UTC 偏移,避免依赖设备对时区字符串的支持
# 否则即使 next-wakeup TIMEZONE 唤醒,指针仍会按系统默认时区取值。 # 这样 next-wakeup 仍可继续使用 TIMEZONE=Asia/Shanghai 做 cron 计算,
if [ -n "${TIMEZONE:-}" ]; then # 表盘则稳定显示北京时间。
TZ="$TIMEZONE" date '+%H %M' if [ -n "${CLOCK_TIME_OFFSET_MINUTES:-}" ]; then
else clock_values_from_offset "$CLOCK_TIME_OFFSET_MINUTES"
date '+%H %M' return
fi fi
# 如果没有单独配置偏移,再回退到传统的 TZ=date 方案,兼容历史配置。
if [ -n "${TIMEZONE:-}" ]; then
if timezone_clock=$(TZ="$TIMEZONE" date '+%H %M' 2>/dev/null); then
printf '%s\n' "$timezone_clock"
return
fi
fi
# 最后才回退到系统默认时区,至少保证脚本仍能继续工作。
date '+%H %M'
} }
if [ "$#" -ge 2 ]; then if [ "$#" -ge 2 ]; then

View File

@@ -4,10 +4,20 @@
export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1} export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1}
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。 # 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"} export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
# 调度计算依赖 next-wakeup 这个 Rust 程序,它要求使用 IANA 时区名。
# 这里必须保留 Asia/Shanghai才能正确计算下一次唤醒时间。
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"} export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
# Kindle 上的 BusyBox date 对 IANA 时区名支持不稳定,直接拿 TIMEZONE 画表盘会退回 UTC。
# 北京时间全年固定为 UTC+8没有夏令时所以表盘单独走固定偏移避免依赖系统时区解析。
export CLOCK_TIME_OFFSET_MINUTES=${CLOCK_TIME_OFFSET_MINUTES:-480}
export BACKGROUND_URL=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"} export BACKGROUND_URL=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
export THEMES_INDEX_URL=${THEMES_INDEX_URL:-"https://shell.biboer.cn:20001/themes.json"}
export THEMES_INDEX_REFRESH_INTERVAL_MINUTES=${THEMES_INDEX_REFRESH_INTERVAL_MINUTES:-1440}
export THEME_CONFIG_REFRESH_INTERVAL_MINUTES=${THEME_CONFIG_REFRESH_INTERVAL_MINUTES:-1440}
export THEME_ID=${THEME_ID:-"default"}
export ORIENTATION=${ORIENTATION:-"portrait"}
export BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120} export BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
export CLOCK_REGION_X=${CLOCK_REGION_X:-262} export CLOCK_REGION_X=${CLOCK_REGION_X:-347}
export CLOCK_REGION_Y=${CLOCK_REGION_Y:-55} export CLOCK_REGION_Y=${CLOCK_REGION_Y:-55}
export CLOCK_REGION_WIDTH=${CLOCK_REGION_WIDTH:-220} export CLOCK_REGION_WIDTH=${CLOCK_REGION_WIDTH:-220}
export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220} export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220}
@@ -33,6 +43,11 @@ export CLOCK_CENTER_RADIUS=${CLOCK_CENTER_RADIUS:-7}
# 进入 rtc suspend 前预留的可中断窗口,方便在调试时及时停止进程。 # 进入 rtc suspend 前预留的可中断窗口,方便在调试时及时停止进程。
# 这段时间会从真正的休眠时长里扣掉,避免分钟刷新慢一拍。 # 这段时间会从真正的休眠时长里扣掉,避免分钟刷新慢一拍。
export PRE_SLEEP_GRACE_SECONDS=${PRE_SLEEP_GRACE_SECONDS:-10} export PRE_SLEEP_GRACE_SECONDS=${PRE_SLEEP_GRACE_SECONDS:-10}
# 手动短按电源键把 Kindle 提前唤醒后,额外保持前台显示的秒数。
# 这样用户有足够时间看屏、切主题或继续交互,而不会立刻再次休眠。
export MANUAL_WAKE_KEEP_AWAKE_SECONDS=${MANUAL_WAKE_KEEP_AWAKE_SECONDS:-60}
# 如果实际休眠时长比计划值至少少这么多秒,就认为是被用户手动提前唤醒。
export MANUAL_WAKE_EARLY_TOLERANCE_SECONDS=${MANUAL_WAKE_EARLY_TOLERANCE_SECONDS:-5}
# Voyage 顶部状态栏遮罩用于压住系统偶尔重画出来的时间、Wi-Fi、电池图标。 # Voyage 顶部状态栏遮罩用于压住系统偶尔重画出来的时间、Wi-Fi、电池图标。
# 当前坐标只覆盖页面顶部空白带,不会擦到天气卡上边框。 # 当前坐标只覆盖页面顶部空白带,不会擦到天气卡上边框。
@@ -44,6 +59,12 @@ export STATUS_MASK_HEIGHT=${STATUS_MASK_HEIGHT:-24}
export STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3} export STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3}
export STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1} export STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1}
# 左右翻页键同时按下时,呼出主题菜单;
# 菜单本身仍复用当前 dashboard 的运行方向,只切换 theme id。
export THEME_MENU_ENABLED=${THEME_MENU_ENABLED:-true}
export THEME_MENU_EVENT_DEVICE=${THEME_MENU_EVENT_DEVICE:-"/dev/input/event2"}
export THEME_MENU_COMBO_WINDOW_SECONDS=${THEME_MENU_COMBO_WINDOW_SECONDS:-0.35}
# By default, partial screen updates are used to update the screen, # By default, partial screen updates are used to update the screen,
# to prevent the screen from flashing. After a few partial updates, # to prevent the screen from flashing. After a few partial updates,
# the screen will start to look a bit distorted (due to e-ink ghosting). # the screen will start to look a bit distorted (due to e-ink ghosting).

View File

@@ -3,6 +3,30 @@ set -eu
# 拉取低频背景图,调用方负责传入输出路径。 # 拉取低频背景图,调用方负责传入输出路径。
output_path=${1:?"missing output path"} output_path=${1:?"missing output path"}
background_url=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"} DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/env.sh"
THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
"$(dirname "$0")/../xh" -d -q -o "$output_path" get "$background_url" # fetch-dashboard 既会被 dash.sh 调,也会被 switch-theme.sh 单独调。
# 因此这里每次都重新读取一次运行时主题配置,确保拿到当前背景地址。
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
background_path=${BACKGROUND_PATH:-""}
background_url=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
local_background_path=""
if [ -n "$background_path" ]; then
local_background_path="$DIR/../$background_path"
fi
# 主题背景如果已经随本地部署同步到 Kindle优先直接拷贝本地文件
# 这样切换主题时不依赖远端图片资源是否已经发布完成。
if [ -n "$local_background_path" ] && [ -f "$local_background_path" ]; then
cp "$local_background_path" "$output_path"
exit 0
fi
"$DIR/../xh" -d -q -o "$output_path" get "$background_url"

View File

@@ -25,18 +25,25 @@ local WHITE = 255
local BLACK = 0 local BLACK = 0
local cx = (width - 1) / 2 local cx = (width - 1) / 2
local cy = (height - 1) / 2 local cy = (height - 1) / 2
local face_radius = math.floor(math.min(width, height) * number_arg(6, 0.47)) -- 偶数尺寸下如果半径直接顶到边界,右侧和下侧会更容易出现裁切感。
-- 这里把描边厚度的一半让给外圈,保持四边可视留白更均匀。
local face_stroke = number_arg(7, 3) local face_stroke = number_arg(7, 3)
local tick_outer_inset = number_arg(8, 6) local face_radius = math.max(1, math.min(width, height) * number_arg(6, 0.47) - face_stroke / 2)
local major_tick_length = number_arg(9, 14) local major_tick_outer_inset = number_arg(8, 6)
local minor_tick_length = number_arg(10, 7) local minor_tick_outer_inset = number_arg(9, major_tick_outer_inset)
local major_tick_thickness = number_arg(11, 4) local major_tick_length = number_arg(10, 14)
local minor_tick_thickness = number_arg(12, 2) local minor_tick_length = number_arg(11, 7)
local hour_length_ratio = number_arg(13, 0.48) local major_tick_thickness = number_arg(12, 4)
local minute_length_ratio = number_arg(14, 0.72) local minor_tick_thickness = number_arg(13, 2)
local hour_thickness = number_arg(15, 9) local hour_length_ratio = number_arg(14, 0.48)
local minute_thickness = number_arg(16, 5) local hour_back_length_ratio = number_arg(15, 0)
local center_radius = number_arg(17, 7) local minute_length_ratio = number_arg(16, 0.72)
local minute_back_length_ratio = number_arg(17, 0)
local hour_thickness = number_arg(18, 9)
local minute_thickness = number_arg(19, 5)
local center_radius = number_arg(20, 7)
local rotation_degrees = number_arg(21, 0)
local rotation_radians = math.rad(rotation_degrees)
local pixels = {} local pixels = {}
for index = 1, width * height do for index = 1, width * height do
@@ -73,25 +80,6 @@ local function fill_disk(x, y, radius, value)
end end
end end
local function draw_segment(x1, y1, x2, y2, thickness, value)
local dx = x2 - x1
local dy = y2 - y1
local steps = math.max(math.abs(dx), math.abs(dy))
if steps == 0 then
fill_disk(x1, y1, math.max(1, thickness / 2), value)
return
end
local radius = math.max(1, thickness / 2)
for step = 0, steps do
local t = step / steps
local x = x1 + dx * t
local y = y1 + dy * t
fill_disk(x, y, radius, value)
end
end
local function draw_circle(radius, thickness, value) local function draw_circle(radius, thickness, value)
local samples = 720 local samples = 720
for step = 0, samples - 1 do for step = 0, samples - 1 do
@@ -102,34 +90,80 @@ local function draw_circle(radius, thickness, value)
end end
end end
local function draw_ticks() local function fill_bar(origin_x, origin_y, angle, start_length, end_length, thickness, value)
for tick = 0, 59 do local start_pos = math.min(start_length, end_length)
local is_major = tick % 5 == 0 local end_pos = math.max(start_length, end_length)
local angle = (tick / 60) * math.pi * 2 - math.pi / 2 local ux = math.cos(angle)
local outer = face_radius - tick_outer_inset local uy = math.sin(angle)
local inner = outer - (is_major and major_tick_length or minor_tick_length) local vx = -uy
local x1 = cx + math.cos(angle) * inner local vy = ux
local y1 = cy + math.sin(angle) * inner local half_thickness = thickness / 2
local x2 = cx + math.cos(angle) * outer local corners = {
local y2 = cy + math.sin(angle) * outer {
draw_segment(x1, y1, x2, y2, is_major and major_tick_thickness or minor_tick_thickness, BLACK) x = origin_x + ux * start_pos + vx * half_thickness,
y = origin_y + uy * start_pos + vy * half_thickness,
},
{
x = origin_x + ux * start_pos - vx * half_thickness,
y = origin_y + uy * start_pos - vy * half_thickness,
},
{
x = origin_x + ux * end_pos + vx * half_thickness,
y = origin_y + uy * end_pos + vy * half_thickness,
},
{
x = origin_x + ux * end_pos - vx * half_thickness,
y = origin_y + uy * end_pos - vy * half_thickness,
},
}
local min_x = math.floor(math.min(corners[1].x, corners[2].x, corners[3].x, corners[4].x))
local max_x = math.ceil(math.max(corners[1].x, corners[2].x, corners[3].x, corners[4].x))
local min_y = math.floor(math.min(corners[1].y, corners[2].y, corners[3].y, corners[4].y))
local max_y = math.ceil(math.max(corners[1].y, corners[2].y, corners[3].y, corners[4].y))
for py = min_y, max_y do
for px = min_x, max_x do
local sample_x = px + 0.5
local sample_y = py + 0.5
local dx = sample_x - origin_x
local dy = sample_y - origin_y
local longitudinal = dx * ux + dy * uy
local lateral = dx * vx + dy * vy
if longitudinal >= start_pos and longitudinal <= end_pos and math.abs(lateral) <= half_thickness then
set_pixel(px, py, value)
end
end
end end
end end
local function hand_endpoint(angle_deg, length) local function draw_ticks()
local angle = math.rad(angle_deg - 90) for tick = 0, 59 do
return cx + math.cos(angle) * length, cy + math.sin(angle) * length local is_major = tick % 5 == 0
local angle = (tick / 60) * math.pi * 2 - math.pi / 2 + rotation_radians
local outer = face_radius - (is_major and major_tick_outer_inset or minor_tick_outer_inset)
local inner = outer - (is_major and major_tick_length or minor_tick_length)
fill_bar(cx, cy, angle, inner, outer, is_major and major_tick_thickness or minor_tick_thickness, BLACK)
end
end end
local function draw_hands() local function draw_hands()
local hour_angle = (hour_value % 12) * 30 + minute_value * 0.5 local hour_angle = (hour_value % 12) * 30 + minute_value * 0.5
local minute_angle = minute_value * 6 local minute_angle = minute_value * 6
local hour_radians = math.rad(hour_angle + rotation_degrees - 90)
local minute_radians = math.rad(minute_angle + rotation_degrees - 90)
local hour_x, hour_y = hand_endpoint(hour_angle, face_radius * hour_length_ratio) fill_bar(cx, cy, hour_radians, -face_radius * hour_back_length_ratio, face_radius * hour_length_ratio, hour_thickness, BLACK)
local minute_x, minute_y = hand_endpoint(minute_angle, face_radius * minute_length_ratio) fill_bar(
cx,
draw_segment(cx, cy, hour_x, hour_y, hour_thickness, BLACK) cy,
draw_segment(cx, cy, minute_x, minute_y, minute_thickness, BLACK) minute_radians,
-face_radius * minute_back_length_ratio,
face_radius * minute_length_ratio,
minute_thickness,
BLACK
)
fill_disk(cx, cy, center_radius, BLACK) fill_disk(cx, cy, center_radius, BLACK)
end end

View File

@@ -3,11 +3,16 @@ set -eu
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/env.sh" ENV_FILE="$DIR/env.sh"
THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
# 单独执行本脚本时,也需要读取同一份坐标配置。 # 单独执行本脚本时,也需要读取同一份坐标配置。
# 否则会退回到脚本内默认值,导致手工调试与主循环绘制位置不一致。 # 否则会退回到脚本内默认值,导致手工调试与主循环绘制位置不一致。
# shellcheck disable=SC1090 # shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE" [ -f "$ENV_FILE" ] && . "$ENV_FILE"
# 主题切换后,时钟区域坐标和绘制参数会落在运行时 env 里。
# 这里额外覆盖一次,保证分钟级重绘与最近一次主题配置保持一致。
# shellcheck disable=SC1090
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
clock_region_x=${CLOCK_REGION_X:-262} clock_region_x=${CLOCK_REGION_X:-262}
clock_region_y=${CLOCK_REGION_Y:-55} clock_region_y=${CLOCK_REGION_Y:-55}
@@ -16,15 +21,20 @@ clock_region_height=${CLOCK_REGION_HEIGHT:-220}
clock_face_radius_ratio=${CLOCK_FACE_RADIUS_RATIO:-0.47} clock_face_radius_ratio=${CLOCK_FACE_RADIUS_RATIO:-0.47}
clock_face_stroke=${CLOCK_FACE_STROKE:-3} clock_face_stroke=${CLOCK_FACE_STROKE:-3}
clock_tick_outer_inset=${CLOCK_TICK_OUTER_INSET:-6} clock_tick_outer_inset=${CLOCK_TICK_OUTER_INSET:-6}
clock_major_tick_outer_inset=${CLOCK_MAJOR_TICK_OUTER_INSET:-$clock_tick_outer_inset}
clock_minor_tick_outer_inset=${CLOCK_MINOR_TICK_OUTER_INSET:-$clock_tick_outer_inset}
clock_major_tick_length=${CLOCK_MAJOR_TICK_LENGTH:-14} clock_major_tick_length=${CLOCK_MAJOR_TICK_LENGTH:-14}
clock_minor_tick_length=${CLOCK_MINOR_TICK_LENGTH:-7} clock_minor_tick_length=${CLOCK_MINOR_TICK_LENGTH:-7}
clock_major_tick_thickness=${CLOCK_MAJOR_TICK_THICKNESS:-4} clock_major_tick_thickness=${CLOCK_MAJOR_TICK_THICKNESS:-4}
clock_minor_tick_thickness=${CLOCK_MINOR_TICK_THICKNESS:-2} clock_minor_tick_thickness=${CLOCK_MINOR_TICK_THICKNESS:-2}
clock_hour_length_ratio=${CLOCK_HOUR_LENGTH_RATIO:-0.48} clock_hour_length_ratio=${CLOCK_HOUR_LENGTH_RATIO:-0.48}
clock_hour_back_length_ratio=${CLOCK_HOUR_BACK_LENGTH_RATIO:-0}
clock_minute_length_ratio=${CLOCK_MINUTE_LENGTH_RATIO:-0.72} clock_minute_length_ratio=${CLOCK_MINUTE_LENGTH_RATIO:-0.72}
clock_minute_back_length_ratio=${CLOCK_MINUTE_BACK_LENGTH_RATIO:-0}
clock_hour_thickness=${CLOCK_HOUR_THICKNESS:-9} clock_hour_thickness=${CLOCK_HOUR_THICKNESS:-9}
clock_minute_thickness=${CLOCK_MINUTE_THICKNESS:-5} clock_minute_thickness=${CLOCK_MINUTE_THICKNESS:-5}
clock_center_radius=${CLOCK_CENTER_RADIUS:-7} clock_center_radius=${CLOCK_CENTER_RADIUS:-7}
clock_rotation_degrees=${CLOCK_ROTATION_DEGREES:-0}
force_full_refresh=${1:-false} force_full_refresh=${1:-false}
output_path="$DIR/state/clock-render.pgm" output_path="$DIR/state/clock-render.pgm"
@@ -40,16 +50,20 @@ lua "$DIR/render-clock.lua" \
"$minute" \ "$minute" \
"$clock_face_radius_ratio" \ "$clock_face_radius_ratio" \
"$clock_face_stroke" \ "$clock_face_stroke" \
"$clock_tick_outer_inset" \ "$clock_major_tick_outer_inset" \
"$clock_minor_tick_outer_inset" \
"$clock_major_tick_length" \ "$clock_major_tick_length" \
"$clock_minor_tick_length" \ "$clock_minor_tick_length" \
"$clock_major_tick_thickness" \ "$clock_major_tick_thickness" \
"$clock_minor_tick_thickness" \ "$clock_minor_tick_thickness" \
"$clock_hour_length_ratio" \ "$clock_hour_length_ratio" \
"$clock_hour_back_length_ratio" \
"$clock_minute_length_ratio" \ "$clock_minute_length_ratio" \
"$clock_minute_back_length_ratio" \
"$clock_hour_thickness" \ "$clock_hour_thickness" \
"$clock_minute_thickness" \ "$clock_minute_thickness" \
"$clock_center_radius" "$clock_center_radius" \
"$clock_rotation_degrees"
if [ "$force_full_refresh" = true ]; then if [ "$force_full_refresh" = true ]; then
# Kindle Voyage 当前这条链路里fbink 默认会叠加 viewport 修正, # Kindle Voyage 当前这条链路里fbink 默认会叠加 viewport 修正,

View File

@@ -0,0 +1,343 @@
-- 读取 calendar 产出的主题 JSON并把 Kindle 侧真正需要的字段提取出来。
-- 这里实现一个精简 JSON 解析器,避免在设备上额外依赖 jq / python。
local command = assert(arg[1], "missing command")
local function read_file(path)
local file = assert(io.open(path, "rb"))
local content = file:read("*a")
file:close()
return content
end
local function decode(json)
local position = 1
local length = #json
local function skip_whitespace()
while position <= length do
local ch = json:sub(position, position)
if ch ~= " " and ch ~= "\n" and ch ~= "\r" and ch ~= "\t" then
break
end
position = position + 1
end
end
local parse_value
local function parse_string()
position = position + 1
local parts = {}
while position <= length do
local ch = json:sub(position, position)
if ch == '"' then
position = position + 1
return table.concat(parts)
end
if ch == "\\" then
local escape = json:sub(position + 1, position + 1)
local replacements = {
['"'] = '"',
["\\"] = "\\",
["/"] = "/",
["b"] = "\b",
["f"] = "\f",
["n"] = "\n",
["r"] = "\r",
["t"] = "\t",
}
if escape == "u" then
error("unsupported unicode escape")
end
local replacement = replacements[escape]
if not replacement then
error("invalid string escape")
end
parts[#parts + 1] = replacement
position = position + 2
else
parts[#parts + 1] = ch
position = position + 1
end
end
error("unterminated string")
end
local function parse_number()
local start_pos = position
while position <= length do
local ch = json:sub(position, position)
if not ch:match("[%d%+%-%.eE]") then
break
end
position = position + 1
end
local value = tonumber(json:sub(start_pos, position - 1))
if value == nil then
error("invalid number")
end
return value
end
local function parse_array()
position = position + 1
skip_whitespace()
local result = {}
if json:sub(position, position) == "]" then
position = position + 1
return result
end
while true do
result[#result + 1] = parse_value()
skip_whitespace()
local ch = json:sub(position, position)
if ch == "]" then
position = position + 1
return result
end
if ch ~= "," then
error("invalid array separator")
end
position = position + 1
skip_whitespace()
end
end
local function parse_object()
position = position + 1
skip_whitespace()
local result = {}
if json:sub(position, position) == "}" then
position = position + 1
return result
end
while true do
if json:sub(position, position) ~= '"' then
error("object key must be string")
end
local key = parse_string()
skip_whitespace()
if json:sub(position, position) ~= ":" then
error("missing object colon")
end
position = position + 1
result[key] = parse_value()
skip_whitespace()
local ch = json:sub(position, position)
if ch == "}" then
position = position + 1
return result
end
if ch ~= "," then
error("invalid object separator")
end
position = position + 1
skip_whitespace()
end
end
function parse_value()
skip_whitespace()
local ch = json:sub(position, position)
if ch == "{" then
return parse_object()
end
if ch == "[" then
return parse_array()
end
if ch == '"' then
return parse_string()
end
if ch == "-" or ch:match("%d") then
return parse_number()
end
if json:sub(position, position + 3) == "true" then
position = position + 4
return true
end
if json:sub(position, position + 4) == "false" then
position = position + 5
return false
end
if json:sub(position, position + 3) == "null" then
position = position + 4
return nil
end
error("unexpected token")
end
local result = parse_value()
skip_whitespace()
if position <= length then
error("unexpected trailing content")
end
return result
end
local function find_theme(index_data, theme_id)
for _, theme in ipairs(index_data.themes or {}) do
if theme.id == theme_id then
return theme
end
end
return nil
end
local function orientation_exists(theme, orientation)
for _, value in ipairs(theme.orientations or {}) do
if value == orientation then
return true
end
end
return false
end
local function first_orientation(theme, fallback)
if orientation_exists(theme, fallback) then
return fallback
end
return (theme.orientations or {})[1] or fallback
end
local function shell_quote(value)
return "'" .. tostring(value):gsub("'", [['"'"']]) .. "'"
end
local function write_runtime_env(path, theme_id, orientation, variant)
local file = assert(io.open(path, "wb"))
local lines = {
"export THEME_ID=" .. shell_quote(theme_id),
"export ORIENTATION=" .. shell_quote(orientation),
"export THEME_DEVICE_PLACEMENT=" .. shell_quote(variant.devicePlacement or ""),
"export BACKGROUND_PATH=" .. shell_quote((variant.background and variant.background.path) or ""),
"export BACKGROUND_URL=" .. shell_quote(assert(variant.background.url, "missing background url")),
"export BACKGROUND_REFRESH_INTERVAL_MINUTES=" .. tostring(variant.background.refreshIntervalMinutes or 120),
"export CLOCK_REGION_X=" .. tostring(assert(variant.clock.x, "missing clock x")),
"export CLOCK_REGION_Y=" .. tostring(assert(variant.clock.y, "missing clock y")),
"export CLOCK_REGION_WIDTH=" .. tostring(assert(variant.clock.width, "missing clock width")),
"export CLOCK_REGION_HEIGHT=" .. tostring(assert(variant.clock.height, "missing clock height")),
"export CLOCK_FACE_RADIUS_RATIO=" .. tostring(variant.clock.faceRadiusRatio or 0.47),
"export CLOCK_FACE_STROKE=" .. tostring(variant.clock.faceStroke or 3),
"export CLOCK_TICK_OUTER_INSET=" .. tostring(variant.clock.tickOuterInset or 6),
"export CLOCK_MAJOR_TICK_OUTER_INSET=" .. tostring(variant.clock.majorTickOuterInset or variant.clock.tickOuterInset or 6),
"export CLOCK_MINOR_TICK_OUTER_INSET=" .. tostring(variant.clock.minorTickOuterInset or variant.clock.tickOuterInset or 6),
"export CLOCK_MAJOR_TICK_LENGTH=" .. tostring(variant.clock.majorTickLength or 14),
"export CLOCK_MINOR_TICK_LENGTH=" .. tostring(variant.clock.minorTickLength or 7),
"export CLOCK_MAJOR_TICK_THICKNESS=" .. tostring(variant.clock.majorTickThickness or 4),
"export CLOCK_MINOR_TICK_THICKNESS=" .. tostring(variant.clock.minorTickThickness or 2),
"export CLOCK_HOUR_LENGTH_RATIO=" .. tostring(variant.clock.hourLengthRatio or 0.48),
"export CLOCK_HOUR_BACK_LENGTH_RATIO=" .. tostring(variant.clock.hourBackLengthRatio or 0),
"export CLOCK_MINUTE_LENGTH_RATIO=" .. tostring(variant.clock.minuteLengthRatio or 0.72),
"export CLOCK_MINUTE_BACK_LENGTH_RATIO=" .. tostring(variant.clock.minuteBackLengthRatio or 0),
"export CLOCK_HOUR_THICKNESS=" .. tostring(variant.clock.hourThickness or 9),
"export CLOCK_MINUTE_THICKNESS=" .. tostring(variant.clock.minuteThickness or 5),
"export CLOCK_CENTER_RADIUS=" .. tostring(variant.clock.centerRadius or 7),
"export CLOCK_ROTATION_DEGREES=" .. tostring(variant.clock.rotationDegrees or 0),
}
file:write(table.concat(lines, "\n"))
file:write("\n")
file:close()
end
if command == "resolve" then
local index_path = assert(arg[2], "missing themes index path")
local requested_theme_id = arg[3] or ""
local requested_orientation = arg[4] or ""
local index_data = decode(read_file(index_path))
local theme = find_theme(index_data, requested_theme_id)
if not theme then
theme = find_theme(index_data, index_data.defaultThemeId) or assert((index_data.themes or {})[1], "themes index empty")
end
local orientation = requested_orientation
if orientation == "" then
orientation = index_data.defaultOrientation or "portrait"
end
orientation = first_orientation(theme, orientation)
io.write("THEME_ID=", theme.id, "\n")
io.write("ORIENTATION=", orientation, "\n")
io.write("CONFIG_URL=", assert(theme.configUrl, "missing config url"), "\n")
return
end
if command == "write-runtime" then
local theme_path = assert(arg[2], "missing theme config path")
local theme_id = assert(arg[3], "missing theme id")
local requested_orientation = assert(arg[4], "missing orientation")
local output_path = assert(arg[5], "missing output path")
local theme_data = decode(read_file(theme_path))
local variants = assert(theme_data.variants, "missing variants")
local variant = variants[requested_orientation] or variants.portrait or variants.landscape
if not variant then
error("missing usable variant")
end
local resolved_orientation = requested_orientation
if variants[requested_orientation] == nil then
if variants.portrait ~= nil then
resolved_orientation = "portrait"
elseif variants.landscape ~= nil then
resolved_orientation = "landscape"
end
end
write_runtime_env(output_path, theme_id, resolved_orientation, variant)
io.write("THEME_ID=", theme_id, "\n")
io.write("ORIENTATION=", resolved_orientation, "\n")
return
end
if command == "list" then
local index_path = assert(arg[2], "missing themes index path")
local index_data = decode(read_file(index_path))
for _, theme in ipairs(index_data.themes or {}) do
io.write(theme.id or "", "\t")
io.write(theme.label or theme.id or "", "\t")
io.write(table.concat(theme.orientations or {}, ","), "\n")
end
return
end
error("unknown command")

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env sh
set -eu
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/env.sh"
THEME_FILE="$DIR/theme.env"
THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
THEMES_INDEX_PATH="$DIR/../themes.json"
THEME_JSON_LUA="$DIR/theme-json.lua"
SWITCH_THEME_CMD="$DIR/../switch-theme.sh"
STATE_DIR="$DIR/state"
MENU_ITEMS_FILE="$STATE_DIR/theme-menu-items.tsv"
ACTION_FIFO="$STATE_DIR/theme-menu-actions.fifo"
EVENT_DEVICE_DEFAULT="/dev/input/event2"
THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT="0.35"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
EVENT_DEVICE=${THEME_MENU_EVENT_DEVICE:-$EVENT_DEVICE_DEFAULT}
THEME_MENU_COMBO_WINDOW_SECONDS=${THEME_MENU_COMBO_WINDOW_SECONDS:-$THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT}
menu_open=false
selected_index=1
current_theme_id=${THEME_ID:-default}
current_orientation=${ORIENTATION:-portrait}
stream_pid=""
load_runtime() {
# 每次打开菜单前都重新读取当前主题和方向,避免显示过期状态。
# shellcheck disable=SC1090
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
current_theme_id=${THEME_ID:-default}
current_orientation=${ORIENTATION:-portrait}
}
load_menu_items() {
mkdir -p "$STATE_DIR"
lua "$THEME_JSON_LUA" list "$THEMES_INDEX_PATH" >"$MENU_ITEMS_FILE"
}
theme_count() {
awk 'END { print NR + 0 }' "$MENU_ITEMS_FILE"
}
theme_field() {
row=$1
column=$2
awk -F '\t' -v target_row="$row" -v target_column="$column" '
NR == target_row {
print $target_column
exit
}
' "$MENU_ITEMS_FILE"
}
current_theme_index() {
awk -F '\t' -v current_theme="$current_theme_id" '
$1 == current_theme {
print NR
found = 1
exit
}
END {
if (!found) {
print 1
}
}
' "$MENU_ITEMS_FILE"
}
print_line() {
col=$1
row=$2
text=$3
/usr/sbin/eips "$col" "$row" "$text"
}
render_menu() {
total_themes=$(theme_count)
current_label=$(theme_field "$selected_index" 2)
/usr/sbin/eips -c
print_line 3 1 "Kindle Dashboard"
print_line 3 3 "Theme Menu"
print_line 3 5 "Orientation: $current_orientation"
print_line 3 6 "Selected: $current_label"
print_line 3 7 "--------------------------------"
row=9
index=1
while [ "$index" -le "$total_themes" ]; do
theme_label=$(theme_field "$index" 2)
theme_id=$(theme_field "$index" 1)
prefix=" "
if [ "$index" -eq "$selected_index" ]; then
prefix="> "
fi
print_line 3 "$row" "${prefix}${theme_label} (${theme_id})"
row=$((row + 2))
index=$((index + 1))
done
print_line 3 18 "PageUp/PageDown: move"
print_line 3 20 "Press both keys: apply"
}
wrap_index() {
next_index=$1
total_themes=$(theme_count)
if [ "$next_index" -lt 1 ]; then
printf '%s\n' "$total_themes"
return
fi
if [ "$next_index" -gt "$total_themes" ]; then
printf '1\n'
return
fi
printf '%s\n' "$next_index"
}
move_selection() {
delta=$1
selected_index=$(wrap_index $((selected_index + delta)))
render_menu
}
apply_selection() {
selected_theme_id=$(theme_field "$selected_index" 1)
/usr/sbin/eips -c
print_line 3 5 "Applying theme..."
print_line 3 7 "$selected_theme_id / $current_orientation"
"$SWITCH_THEME_CMD" "$selected_theme_id" "$current_orientation"
menu_open=false
}
open_menu() {
load_runtime
load_menu_items
selected_index=$(current_theme_index)
menu_open=true
render_menu
}
handle_action() {
action=$1
case "$action" in
combo)
if [ "$menu_open" = true ]; then
apply_selection
else
open_menu
fi
;;
pageup)
if [ "$menu_open" = true ]; then
move_selection -1
fi
;;
pagedown)
if [ "$menu_open" = true ]; then
move_selection 1
fi
;;
esac
}
action_stream() {
evtest --grab "$EVENT_DEVICE" 2>/dev/null | awk -v combo_window="$THEME_MENU_COMBO_WINDOW_SECONDS" '
function extract_time(line, match_count, time_value) {
match_count = match(line, /time [0-9]+\.[0-9]+/)
if (match_count == 0) {
return 0
}
time_value = substr(line, RSTART + 5, RLENGTH - 5)
return time_value + 0
}
function emit(name) {
print name
fflush()
}
{
current_time = extract_time($0)
if ($0 ~ /code 104 \(PageUp\), value 1/) {
if (pending_key == "down" && current_time - pending_time <= combo_window) {
pending_key = ""
emit("combo")
next
}
pending_key = "up"
pending_time = current_time
next
}
if ($0 ~ /code 109 \(PageDown\), value 1/) {
if (pending_key == "up" && current_time - pending_time <= combo_window) {
pending_key = ""
emit("combo")
next
}
pending_key = "down"
pending_time = current_time
next
}
if ($0 ~ /code 104 \(PageUp\), value 0/) {
if (pending_key == "up") {
pending_key = ""
emit("pageup")
}
next
}
if ($0 ~ /code 109 \(PageDown\), value 0/) {
if (pending_key == "down") {
pending_key = ""
emit("pagedown")
}
}
}
'
}
cleanup() {
if [ -n "$stream_pid" ]; then
kill "$stream_pid" 2>/dev/null || true
fi
rm -f "$ACTION_FIFO"
}
trap cleanup EXIT INT TERM
mkdir -p "$STATE_DIR"
while true; do
rm -f "$ACTION_FIFO"
mkfifo "$ACTION_FIFO"
action_stream >"$ACTION_FIFO" &
stream_pid=$!
while IFS= read -r action; do
handle_action "$action"
done <"$ACTION_FIFO"
wait "$stream_pid" 2>/dev/null || true
stream_pid=""
rm -f "$ACTION_FIFO"
sleep 1
done

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env sh
set -eu
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/env.sh"
THEME_FILE="$DIR/theme.env"
STATE_DIR="$DIR/state"
LOCAL_THEMES_INDEX="$DIR/../themes.json"
LOCAL_THEMES_DIR="$DIR/../themes"
THEMES_INDEX_CACHE="$STATE_DIR/themes.json"
THEMES_INDEX_TIMESTAMP_FILE="$STATE_DIR/themes-updated-at"
CURRENT_THEME_CACHE="$STATE_DIR/current-theme.json"
CURRENT_THEME_ID_FILE="$STATE_DIR/current-theme-id"
CURRENT_THEME_TIMESTAMP_FILE="$STATE_DIR/current-theme-updated-at"
THEME_RUNTIME_ENV_FILE="$STATE_DIR/theme-runtime.env"
THEME_JSON_LUA="$DIR/theme-json.lua"
FETCH_CMD="$DIR/../xh"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
mkdir -p "$STATE_DIR"
force_index=false
force_theme=false
requested_theme_id=${THEME_ID:-default}
requested_orientation=${ORIENTATION:-portrait}
while [ "$#" -gt 0 ]; do
case "$1" in
--force-index)
force_index=true
;;
--force-theme)
force_theme=true
;;
--theme)
shift
requested_theme_id=${1:?"missing theme id"}
;;
--orientation)
shift
requested_orientation=${1:?"missing orientation"}
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
shift
done
THEMES_INDEX_URL=${THEMES_INDEX_URL:-"https://shell.biboer.cn:20001/themes.json"}
THEMES_INDEX_REFRESH_INTERVAL_MINUTES=${THEMES_INDEX_REFRESH_INTERVAL_MINUTES:-1440}
THEME_CONFIG_REFRESH_INTERVAL_MINUTES=${THEME_CONFIG_REFRESH_INTERVAL_MINUTES:-1440}
now_epoch() {
date '+%s'
}
write_timestamp() {
date '+%s' >"$1"
}
refresh_due() {
timestamp_file=$1
interval_minutes=$2
if [ ! -f "$timestamp_file" ]; then
return 0
fi
current_epoch=$(now_epoch)
last_epoch=$(cat "$timestamp_file" 2>/dev/null || echo 0)
[ $((current_epoch - last_epoch)) -ge $((interval_minutes * 60)) ]
}
fetch_to_path() {
url=$1
output_path=$2
tmp_path="${output_path}.tmp.$$"
rm -f "$tmp_path"
if ! "$FETCH_CMD" -d -q -o "$tmp_path" get "$url"; then
rm -f "$tmp_path"
return 1
fi
mv "$tmp_path" "$output_path"
return 0
}
parse_kv_file() {
file_path=$1
while IFS='=' read -r key value; do
case "$key" in
THEME_ID)
resolved_theme_id=$value
;;
ORIENTATION)
resolved_orientation=$value
;;
CONFIG_URL)
resolved_config_url=$value
;;
esac
done <"$file_path"
}
sync_themes_index() {
if [ -f "$LOCAL_THEMES_INDEX" ]; then
cp "$LOCAL_THEMES_INDEX" "$THEMES_INDEX_CACHE"
write_timestamp "$THEMES_INDEX_TIMESTAMP_FILE"
echo "Themes index refreshed from local bundle"
return 0
fi
# 主题清单是全局入口,平时按天同步一次即可。
# 真正切换主题时会走 --force-index确保马上拿到最新列表。
if [ "$force_index" = true ] || [ ! -f "$THEMES_INDEX_CACHE" ] || refresh_due "$THEMES_INDEX_TIMESTAMP_FILE" "$THEMES_INDEX_REFRESH_INTERVAL_MINUTES"; then
if fetch_to_path "$THEMES_INDEX_URL" "$THEMES_INDEX_CACHE"; then
write_timestamp "$THEMES_INDEX_TIMESTAMP_FILE"
echo "Themes index refreshed"
elif [ ! -f "$THEMES_INDEX_CACHE" ]; then
echo "Themes index fetch failed and no cache is available." >&2
return 1
else
echo "Themes index fetch failed, using cached copy."
fi
fi
return 0
}
resolve_selected_theme() {
# 这里统一做一次主题和方向的兜底解析:
# 如果本地请求的 theme / orientation 已失效,就按 themes.json 的默认值回退。
resolve_output_file="$STATE_DIR/theme-resolve.$$"
lua "$THEME_JSON_LUA" resolve "$THEMES_INDEX_CACHE" "$requested_theme_id" "$requested_orientation" >"$resolve_output_file"
resolved_theme_id=""
resolved_orientation=""
resolved_config_url=""
parse_kv_file "$resolve_output_file"
rm -f "$resolve_output_file"
if [ -z "$resolved_theme_id" ] || [ -z "$resolved_orientation" ] || [ -z "$resolved_config_url" ]; then
echo "Unable to resolve theme selection." >&2
return 1
fi
return 0
}
sync_theme_config() {
local_theme_config="$LOCAL_THEMES_DIR/${resolved_theme_id}.json"
if [ -f "$local_theme_config" ]; then
cp "$local_theme_config" "$CURRENT_THEME_CACHE"
printf '%s\n' "$resolved_theme_id" >"$CURRENT_THEME_ID_FILE"
write_timestamp "$CURRENT_THEME_TIMESTAMP_FILE"
echo "Theme config refreshed from local bundle: $resolved_theme_id"
return 0
fi
# 主题配置按 theme 维度缓存;
# orientation 只是同一个主题 JSON 里的 variant切换方向不需要重新拉整份配置。
needs_theme_fetch=$force_theme
cached_theme_id=$(cat "$CURRENT_THEME_ID_FILE" 2>/dev/null || echo "")
if [ ! -f "$CURRENT_THEME_CACHE" ] || [ ! -f "$CURRENT_THEME_ID_FILE" ]; then
needs_theme_fetch=true
elif [ "$cached_theme_id" != "$resolved_theme_id" ]; then
needs_theme_fetch=true
elif refresh_due "$CURRENT_THEME_TIMESTAMP_FILE" "$THEME_CONFIG_REFRESH_INTERVAL_MINUTES"; then
needs_theme_fetch=true
fi
if [ "$needs_theme_fetch" = true ]; then
if fetch_to_path "$resolved_config_url" "$CURRENT_THEME_CACHE"; then
printf '%s\n' "$resolved_theme_id" >"$CURRENT_THEME_ID_FILE"
write_timestamp "$CURRENT_THEME_TIMESTAMP_FILE"
echo "Theme config refreshed: $resolved_theme_id"
elif [ ! -f "$CURRENT_THEME_CACHE" ] || [ "$cached_theme_id" != "$resolved_theme_id" ]; then
echo "Theme config fetch failed and no matching cache is available." >&2
return 1
else
echo "Theme config fetch failed, using cached copy."
fi
fi
return 0
}
write_runtime_env() {
# runtime env 是 Kindle 真正消费的运行时配置快照。
# fetch-dashboard / render-clock / dash.sh 都只读这一份,避免各自重复解析 JSON。
runtime_output_file="$STATE_DIR/theme-runtime-result.$$"
lua "$THEME_JSON_LUA" write-runtime \
"$CURRENT_THEME_CACHE" \
"$resolved_theme_id" \
"$resolved_orientation" \
"$THEME_RUNTIME_ENV_FILE" >"$runtime_output_file"
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 <"$runtime_output_file"
rm -f "$runtime_output_file"
if [ -z "$resolved_theme_id" ] || [ -z "$resolved_orientation" ]; then
echo "Unable to write runtime theme env." >&2
return 1
fi
printf 'THEME_ID=%s\n' "$resolved_theme_id"
printf 'ORIENTATION=%s\n' "$resolved_orientation"
return 0
}
sync_themes_index
resolve_selected_theme
sync_theme_config
write_runtime_env

View File

@@ -4,12 +4,15 @@ DEBUG=${DEBUG:-false}
DIR="$(dirname "$0")" DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh" ENV_FILE="$DIR/local/env.sh"
THEME_FILE="$DIR/local/theme.env"
LOG_FILE="$DIR/logs/dash.log" LOG_FILE="$DIR/logs/dash.log"
mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$LOG_FILE")"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE" [ -f "$ENV_FILE" ] && . "$ENV_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
if [ "$DEBUG" = true ]; then if [ "$DEBUG" = true ]; then
"$DIR/dash.sh" "$DIR/dash.sh"

View File

@@ -32,6 +32,7 @@ wait_for_cvm() {
pkill -f dash.sh 2>/dev/null || true pkill -f dash.sh 2>/dev/null || true
pkill -f start.sh 2>/dev/null || true pkill -f start.sh 2>/dev/null || true
pkill -f theme-menu-service.sh 2>/dev/null || true
lipc-set-prop com.lab126.powerd preventScreenSaver 0 2>/dev/null || true lipc-set-prop com.lab126.powerd preventScreenSaver 0 2>/dev/null || true

46
dash/src/switch-theme.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env sh
set -eu
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/local/env.sh"
THEME_FILE="$DIR/local/theme.env"
THEME_RUNTIME_ENV_FILE="$DIR/local/state/theme-runtime.env"
BACKGROUND_TIMESTAMP_FILE="$DIR/local/state/background-updated-at"
BACKGROUND_PNG="$DIR/kindlebg.png"
WAIT_FOR_WIFI_CMD="$DIR/wait-for-wifi.sh"
THEME_SYNC_CMD="$DIR/local/theme-sync.sh"
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
CLOCK_RENDER_CMD="$DIR/local/render-clock.sh"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
requested_theme_id=${1:?"usage: switch-theme.sh <theme-id> [orientation]"}
requested_orientation=${2:-${ORIENTATION:-portrait}}
mkdir -p "$DIR/local/state"
# 切换主题时必须立刻联网拉到最新配置和背景,
# 否则用户会看到 theme.env 已更新,但屏幕内容仍停留在旧主题。
echo "Switching theme to $requested_theme_id / $requested_orientation"
"$WAIT_FOR_WIFI_CMD" "$WIFI_TEST_IP"
"$THEME_SYNC_CMD" --force-index --force-theme --theme "$requested_theme_id" --orientation "$requested_orientation" >/dev/null
# shellcheck disable=SC1090
. "$THEME_RUNTIME_ENV_FILE"
"$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG"
date '+%s' >"$BACKGROUND_TIMESTAMP_FILE"
# 只有在主题配置和背景都成功拉取后,才把当前选择持久化到 theme.env。
cat >"$THEME_FILE" <<EOF
export THEME_ID='${THEME_ID}'
export ORIENTATION='${ORIENTATION}'
EOF
/usr/sbin/eips -f -g "$BACKGROUND_PNG"
"$CLOCK_RENDER_CMD" true
echo "Theme switched to ${THEME_ID} / ${ORIENTATION}"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
HOST_TARGET=${1:-kindle}
OUTPUT_BASENAME=${2:-current-screen}
OUTPUT_DIR=${3:-"$ROOT_DIR/tmp"}
RUNTIME_ENV_PATH=/mnt/us/dashboard/local/state/theme-runtime.env
mkdir -p "$OUTPUT_DIR"
raw_path="$OUTPUT_DIR/${OUTPUT_BASENAME}-raw.png"
physical_path="$OUTPUT_DIR/${OUTPUT_BASENAME}-physical.png"
tmp_download_path="$OUTPUT_DIR/${OUTPUT_BASENAME}-download.tmp"
read_remote_value() {
key=$1
ssh "$HOST_TARGET" "awk -F= '/^export ${key}=/{gsub(\"\\047\", \"\", \$2); print \$2; exit}' \"$RUNTIME_ENV_PATH\" 2>/dev/null || true"
}
orientation=$(read_remote_value ORIENTATION)
device_placement=$(read_remote_value THEME_DEVICE_PLACEMENT)
ssh "$HOST_TARGET" '
set -eu
capture_path="/tmp/kindle-dashboard-capture-$$.png"
fbgrab "$capture_path" >/dev/null 2>&1
cat "$capture_path"
rm -f "$capture_path"
' >"$tmp_download_path"
python3 - "$tmp_download_path" "$raw_path" <<'PY'
from pathlib import Path
import sys
download_path = Path(sys.argv[1])
raw_path = Path(sys.argv[2])
data = download_path.read_bytes()
magic = b"\x89PNG\r\n\x1a\n"
index = data.find(magic)
if index == -1:
raise SystemExit("抓屏结果里没有找到 PNG 头")
raw_path.write_bytes(data[index:])
download_path.unlink(missing_ok=True)
PY
case "$orientation:$device_placement" in
landscape:logo_right)
cp "$raw_path" "$physical_path"
# 原始 fbgrab 输出始终是纵向 framebuffer。
# Logo 在右侧时,日常评审应优先看 physical 图;
# 它等于把 raw 图按设备实际摆放方向逆时针旋转 90 度。
sips -r 270 "$physical_path" >/dev/null
;;
*)
cp "$raw_path" "$physical_path"
;;
esac
printf 'raw=%s\n' "$raw_path"
printf 'physical=%s\n' "$physical_path"
printf 'orientation=%s\n' "${orientation:-unknown}"
printf 'devicePlacement=%s\n' "${device_placement:-unknown}"

View File

@@ -2,9 +2,68 @@
set -eu set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
KINDLE_TARGET=${1:-kindle} KINDLE_TARGET="kindle"
THEME_FILTER=""
ORIENTATION_FILTER=""
CLOCK_REGION_JSON="$ROOT_DIR/calendar/dist/clock-region.json" CLOCK_REGION_JSON="$ROOT_DIR/calendar/dist/clock-region.json"
print_usage() {
cat <<'EOF'
用法:
sh scripts/sync-layered-clock-to-kindle.sh [host] [选项]
选项:
--theme <theme-id> 只同步指定主题
--orientation <value> 只同步指定方向;必须和 --theme 一起使用
-h, --help 查看帮助
示例:
sh scripts/sync-layered-clock-to-kindle.sh kindle
sh scripts/sync-layered-clock-to-kindle.sh kindle --theme simple
sh scripts/sync-layered-clock-to-kindle.sh kindle --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
;;
*)
KINDLE_TARGET=$1
;;
esac
shift
done
if [ -n "$ORIENTATION_FILTER" ] && [ -z "$THEME_FILTER" ]; then
echo "--orientation 必须和 --theme 一起使用。" >&2
exit 1
fi
cd "$ROOT_DIR/calendar"
if [ -n "$THEME_FILTER" ]; then
if [ -n "$ORIENTATION_FILTER" ]; then
npm run export:themes -- --theme "$THEME_FILTER" --orientation "$ORIENTATION_FILTER" >/dev/null
CLOCK_REGION_JSON="$ROOT_DIR/calendar/dist/themes/$THEME_FILTER/$ORIENTATION_FILTER/kindlebg.clock-region.json"
else
npm run export:themes -- --theme "$THEME_FILTER" >/dev/null
CLOCK_REGION_JSON=""
fi
else
npm run export:themes >/dev/null
fi
cd "$ROOT_DIR"
clock_region_value() { clock_region_value() {
key=$1 key=$1
python3 - "$CLOCK_REGION_JSON" "$key" <<'PY' python3 - "$CLOCK_REGION_JSON" "$key" <<'PY'
@@ -25,30 +84,76 @@ print(data[key])
PY PY
} }
sync_dashboard_runtime() {
rsync -av --no-o --no-g \
"$ROOT_DIR/dash/src/start.sh" \
"$ROOT_DIR/dash/src/dash.sh" \
"$ROOT_DIR/dash/src/switch-theme.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/
rsync -av --no-o --no-g \
"$ROOT_DIR/dash/src/local/env.sh" \
"$ROOT_DIR/dash/src/local/fetch-dashboard.sh" \
"$ROOT_DIR/dash/src/local/clock-index.sh" \
"$ROOT_DIR/dash/src/local/render-clock.lua" \
"$ROOT_DIR/dash/src/local/render-clock.sh" \
"$ROOT_DIR/dash/src/local/theme-menu-service.sh" \
"$ROOT_DIR/dash/src/local/theme-json.lua" \
"$ROOT_DIR/dash/src/local/theme-sync.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/local/
ssh "$KINDLE_TARGET" "chmod +x /mnt/us/dashboard/start.sh /mnt/us/dashboard/dash.sh /mnt/us/dashboard/switch-theme.sh /mnt/us/dashboard/local/fetch-dashboard.sh /mnt/us/dashboard/local/clock-index.sh /mnt/us/dashboard/local/render-clock.sh /mnt/us/dashboard/local/theme-menu-service.sh /mnt/us/dashboard/local/theme-sync.sh"
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/local/state"
ssh "$KINDLE_TARGET" "date '+%s' >/mnt/us/dashboard/local/state/background-updated-at"
}
sync_theme_bundle() {
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/themes.json" \
"$KINDLE_TARGET":/mnt/us/dashboard/
if [ -z "$THEME_FILTER" ]; then
if [ -f "$ROOT_DIR/calendar/dist/kindlebg.png" ]; then
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/kindlebg.png" \
"$KINDLE_TARGET":/mnt/us/dashboard/
fi
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/themes/" \
"$KINDLE_TARGET":/mnt/us/dashboard/themes/
return
fi
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/themes/$THEME_FILTER.json" \
"$KINDLE_TARGET":/mnt/us/dashboard/themes/
if [ -n "$ORIENTATION_FILTER" ]; then
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/themes/$THEME_FILTER/$ORIENTATION_FILTER"
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/themes/$THEME_FILTER/$ORIENTATION_FILTER/" \
"$KINDLE_TARGET":/mnt/us/dashboard/themes/$THEME_FILTER/$ORIENTATION_FILTER/
return
fi
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/themes/$THEME_FILTER"
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/themes/$THEME_FILTER/" \
"$KINDLE_TARGET":/mnt/us/dashboard/themes/$THEME_FILTER/
}
update_default_clock_region_env() {
if [ -z "$CLOCK_REGION_JSON" ] || [ ! -f "$CLOCK_REGION_JSON" ]; then
echo "Skipped CLOCK_REGION_* env update: no single background region selected"
return
fi
clock_x=$(clock_region_value x) clock_x=$(clock_region_value x)
clock_y=$(clock_region_value y) clock_y=$(clock_region_value y)
clock_width=$(clock_region_value width) clock_width=$(clock_region_value width)
clock_height=$(clock_region_value height) clock_height=$(clock_region_value height)
rsync -av --no-o --no-g \
"$ROOT_DIR/dash/src/dash.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/
rsync -av --no-o --no-g \
"$ROOT_DIR/dash/src/local/fetch-dashboard.sh" \
"$ROOT_DIR/dash/src/local/clock-index.sh" \
"$ROOT_DIR/dash/src/local/render-clock.lua" \
"$ROOT_DIR/dash/src/local/render-clock.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/local/
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/kindlebg.png" \
"$KINDLE_TARGET":/mnt/us/dashboard/
ssh "$KINDLE_TARGET" "chmod +x /mnt/us/dashboard/dash.sh /mnt/us/dashboard/local/fetch-dashboard.sh /mnt/us/dashboard/local/clock-index.sh /mnt/us/dashboard/local/render-clock.sh"
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/local/state"
ssh "$KINDLE_TARGET" "date '+%s' >/mnt/us/dashboard/local/state/background-updated-at"
ssh "$KINDLE_TARGET" "tmp=\$(mktemp) && awk \ ssh "$KINDLE_TARGET" "tmp=\$(mktemp) && awk \
-v x='$clock_x' \ -v x='$clock_x' \
-v y='$clock_y' \ -v y='$clock_y' \
@@ -66,5 +171,18 @@ ssh "$KINDLE_TARGET" "tmp=\$(mktemp) && awk \
if (!seen_w) print \"export CLOCK_REGION_WIDTH=\" w; \ if (!seen_w) print \"export CLOCK_REGION_WIDTH=\" w; \
if (!seen_h) print \"export CLOCK_REGION_HEIGHT=\" h; \ if (!seen_h) print \"export CLOCK_REGION_HEIGHT=\" h; \
}' /mnt/us/dashboard/local/env.sh >\"\$tmp\" && cat \"\$tmp\" >/mnt/us/dashboard/local/env.sh && rm -f \"\$tmp\"" }' /mnt/us/dashboard/local/env.sh >\"\$tmp\" && cat \"\$tmp\" >/mnt/us/dashboard/local/env.sh && rm -f \"\$tmp\""
}
sync_dashboard_runtime
sync_theme_bundle
update_default_clock_region_env
if [ -n "$THEME_FILTER" ]; then
if [ -n "$ORIENTATION_FILTER" ]; then
echo "Layered clock runtime synced to $KINDLE_TARGET (theme=$THEME_FILTER orientation=$ORIENTATION_FILTER)"
else
echo "Layered clock runtime synced to $KINDLE_TARGET (theme=$THEME_FILTER)"
fi
else
echo "Layered clock runtime synced to $KINDLE_TARGET" echo "Layered clock runtime synced to $KINDLE_TARGET"
fi

367
snapshot.sh Executable file
View File

@@ -0,0 +1,367 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
THEMES_JSON="$ROOT_DIR/calendar/config/themes.json"
SYNC_SCRIPT="$ROOT_DIR/scripts/sync-layered-clock-to-kindle.sh"
CAPTURE_SCRIPT="$ROOT_DIR/scripts/capture-kindle-screen.sh"
HOST_TARGET="kindle"
THEME_ID=""
ORIENTATION=""
OUTPUT_DIR="$ROOT_DIR/tmp"
OUTPUT_BASENAME=""
OUTPUT_BASENAME_EXPLICIT=false
LIST_ONLY=false
DO_SYNC=true
DO_SWITCH=true
DO_CAPTURE=true
DO_DATE=false
SYNC_THEME_ID=""
SYNC_ORIENTATION=""
SELECTED_THEME_ID=""
SELECTED_ORIENTATIONS=""
RUN_TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
print_usage() {
cat <<'EOF'
用法:
sh snapshot.sh [选项]
默认流程:
1. 把本地最新主题包同步到 Kindle
2. 切换到指定主题和方向
3. 抓取当前 Kindle 实际屏幕到本地
选项:
-t, --theme <theme-id> 指定主题名;默认取 themes.json 的 defaultThemeId
-o, --orientation <value> 指定方向;省略时,指定主题会按全部支持方向处理
-k, --kindle <host> Kindle SSH 主机名,默认 kindle
-d, --output-dir <dir> 本地截图输出目录,默认 ./tmp
-b, --basename <name> 截图文件名前缀,默认 <theme>-<orientation>-<时间戳>
--sync-only 只同步,不切换,不抓图
--switch-only 只切换,不同步,不抓图
--capture-only 只抓图,不同步,不切换
--date 只获取当前 Kindle 系统日期时间
--no-sync 跳过同步
--no-switch 跳过切换
--no-capture 跳过抓图
--list 列出本地现有主题和可用方向
-h, --help 查看帮助
示例:
sh snapshot.sh --list
sh snapshot.sh -t simple
sh snapshot.sh -t simple -o portrait
sh snapshot.sh -t simple -o landscape -b simple-check
sh snapshot.sh --sync-only
sh snapshot.sh --sync-only -t simple
sh snapshot.sh --sync-only -t simple -o portrait
sh snapshot.sh --switch-only -t simple -o portrait
sh snapshot.sh --capture-only -b current-screen
sh snapshot.sh --date
EOF
}
list_themes() {
python3 - "$THEMES_JSON" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
data = json.loads(path.read_text())
print(f"defaultThemeId: {data.get('defaultThemeId', 'unknown')}")
print(f"defaultOrientation: {data.get('defaultOrientation', 'unknown')}")
print("themes:")
for theme in data.get("themes", []):
orientations = ", ".join(theme.get("variants", {}).keys()) or "none"
print(f" - {theme['id']}: {orientations}")
PY
}
resolve_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}")
theme = themes[theme_id]
orientations = list(theme.get("variants", {}).keys())
if not orientations:
raise SystemExit(f"主题 {theme_id} 没有任何方向配置")
print(f"THEME_ID={theme_id}")
if requested_theme and not requested_orientation:
print(f"ORIENTATIONS={' '.join(orientations)}")
else:
orientation = requested_orientation
if not orientation:
default_orientation = data.get("defaultOrientation", "")
orientation = default_orientation if default_orientation in orientations else orientations[0]
if orientation not in orientations:
raise SystemExit(
f"主题 {theme_id} 不支持方向 {orientation},可用方向: {', '.join(orientations)}"
)
print(f"ORIENTATIONS={orientation}")
PY
}
load_selection() {
resolved_output="$(resolve_selection)"
SELECTED_THEME_ID=""
SELECTED_ORIENTATIONS=""
while IFS='=' read -r key value; do
case "$key" in
THEME_ID)
SELECTED_THEME_ID=$value
;;
ORIENTATIONS)
SELECTED_ORIENTATIONS=$value
;;
esac
done <<EOF
$resolved_output
EOF
if [ -z "$SELECTED_THEME_ID" ] || [ -z "$SELECTED_ORIENTATIONS" ]; then
echo "无法解析主题和方向。" >&2
exit 1
fi
}
validate_sync_filter() {
if [ -z "$THEME_ID" ]; then
SYNC_THEME_ID=""
SYNC_ORIENTATION=""
return
fi
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", [])}
if requested_theme not in themes:
raise SystemExit(f"未知主题: {requested_theme}")
if requested_orientation:
orientations = list(themes[requested_theme].get("variants", {}).keys())
if requested_orientation not in orientations:
raise SystemExit(
f"主题 {requested_theme} 不支持方向 {requested_orientation},可用方向: {', '.join(orientations)}"
)
PY
SYNC_THEME_ID=$THEME_ID
SYNC_ORIENTATION=$ORIENTATION
}
log_step() {
printf '\n[%s] %s\n' "$1" "$2"
}
capture_basename_for_orientation() {
current_orientation=$1
if [ "$OUTPUT_BASENAME_EXPLICIT" = true ]; then
if [ "$DO_SWITCH" = true ] && [ "$SELECTED_ORIENTATIONS" != "$current_orientation" ]; then
printf '%s-%s\n' "$OUTPUT_BASENAME" "$current_orientation"
else
printf '%s\n' "$OUTPUT_BASENAME"
fi
return
fi
if [ "$DO_SWITCH" = true ]; then
printf '%s-%s-%s\n' "$SELECTED_THEME_ID" "$current_orientation" "$RUN_TIMESTAMP"
return
fi
printf 'current-screen-%s\n' "$RUN_TIMESTAMP"
}
while [ "$#" -gt 0 ]; do
case "$1" in
-t|--theme)
shift
THEME_ID=${1:?"missing theme id"}
;;
-o|--orientation)
shift
ORIENTATION=${1:?"missing orientation"}
;;
-k|--kindle)
shift
HOST_TARGET=${1:?"missing kindle host"}
;;
-d|--output-dir)
shift
OUTPUT_DIR=${1:?"missing output dir"}
;;
-b|--basename)
shift
OUTPUT_BASENAME=${1:?"missing basename"}
OUTPUT_BASENAME_EXPLICIT=true
;;
--sync-only)
DO_SYNC=true
DO_SWITCH=false
DO_CAPTURE=false
;;
--switch-only)
DO_SYNC=false
DO_SWITCH=true
DO_CAPTURE=false
;;
--capture-only)
DO_SYNC=false
DO_SWITCH=false
DO_CAPTURE=true
;;
--date)
DO_SYNC=false
DO_SWITCH=false
DO_CAPTURE=false
DO_DATE=true
;;
--no-sync)
DO_SYNC=false
;;
--no-switch)
DO_SWITCH=false
;;
--no-capture)
DO_CAPTURE=false
;;
--list)
LIST_ONLY=true
;;
-h|--help)
print_usage
exit 0
;;
*)
echo "未知参数: $1" >&2
echo >&2
print_usage >&2
exit 1
;;
esac
shift
done
if [ "$LIST_ONLY" = true ]; then
list_themes
exit 0
fi
if [ "$DO_SYNC" = false ] && [ "$DO_SWITCH" = false ] && [ "$DO_CAPTURE" = false ] && [ "$DO_DATE" = false ]; then
echo "没有可执行动作;请至少保留同步、切换、抓图、日期中的一个。" >&2
exit 1
fi
if [ "$DO_SYNC" = true ]; then
validate_sync_filter
fi
if [ "$DO_SWITCH" = true ]; then
load_selection
fi
step_total=0
[ "$DO_SYNC" = true ] && step_total=$((step_total + 1))
[ "$DO_DATE" = true ] && step_total=$((step_total + 1))
selection_count=1
if [ "$DO_SWITCH" = true ]; then
set -- $SELECTED_ORIENTATIONS
selection_count=$#
step_total=$((step_total + selection_count))
fi
if [ "$DO_CAPTURE" = true ]; then
if [ "$DO_SWITCH" = true ]; then
step_total=$((step_total + selection_count))
else
step_total=$((step_total + 1))
fi
fi
step_index=0
if [ "$DO_SYNC" = true ]; then
step_index=$((step_index + 1))
if [ -n "$SYNC_THEME_ID" ] && [ -n "$SYNC_ORIENTATION" ]; then
log_step "${step_index}/${step_total}" "同步主题包到 Kindle ($HOST_TARGET): ${SYNC_THEME_ID} / ${SYNC_ORIENTATION}"
sh "$SYNC_SCRIPT" "$HOST_TARGET" --theme "$SYNC_THEME_ID" --orientation "$SYNC_ORIENTATION"
elif [ -n "$SYNC_THEME_ID" ]; then
log_step "${step_index}/${step_total}" "同步主题包到 Kindle ($HOST_TARGET): ${SYNC_THEME_ID}"
sh "$SYNC_SCRIPT" "$HOST_TARGET" --theme "$SYNC_THEME_ID"
else
log_step "${step_index}/${step_total}" "同步本地最新主题包到 Kindle ($HOST_TARGET)"
sh "$SYNC_SCRIPT" "$HOST_TARGET"
fi
fi
if [ "$DO_SWITCH" = true ]; then
mkdir -p "$OUTPUT_DIR"
for current_orientation in $SELECTED_ORIENTATIONS; do
step_index=$((step_index + 1))
log_step "${step_index}/${step_total}" "切换 Kindle 主题到 ${SELECTED_THEME_ID} / ${current_orientation}"
ssh "$HOST_TARGET" "/mnt/us/dashboard/switch-theme.sh '$SELECTED_THEME_ID' '$current_orientation'"
if [ "$DO_CAPTURE" = true ]; then
current_basename="$(capture_basename_for_orientation "$current_orientation")"
step_index=$((step_index + 1))
log_step "${step_index}/${step_total}" "抓取当前 Kindle 实际屏幕到本地 (${current_orientation})"
sh "$CAPTURE_SCRIPT" "$HOST_TARGET" "$current_basename" "$OUTPUT_DIR"
fi
done
elif [ "$DO_CAPTURE" = true ]; then
step_index=$((step_index + 1))
log_step "${step_index}/${step_total}" "抓取当前 Kindle 实际屏幕到本地"
mkdir -p "$OUTPUT_DIR"
current_basename="$(capture_basename_for_orientation "")"
sh "$CAPTURE_SCRIPT" "$HOST_TARGET" "$current_basename" "$OUTPUT_DIR"
fi
if [ "$DO_DATE" = true ]; then
step_index=$((step_index + 1))
log_step "${step_index}/${step_total}" "获取当前 Kindle 系统日期时间"
ssh "$HOST_TARGET" "date '+%Y-%m-%d %H:%M:%S %Z'"
fi
if [ "$DO_SWITCH" = true ]; then
printf '\n完成theme=%s orientations=%s host=%s\n' "$SELECTED_THEME_ID" "$SELECTED_ORIENTATIONS" "$HOST_TARGET"
else
printf '\n完成host=%s\n' "$HOST_TARGET"
fi
if [ "$DO_CAPTURE" = true ]; then
printf '截图输出目录:%s\n' "$OUTPUT_DIR"
fi