From 4b280073d49540216d6f7d8af036a971695afb93 Mon Sep 17 00:00:00 2001 From: "douboer@gmail.com" Date: Sun, 15 Mar 2026 15:58:14 +0800 Subject: [PATCH] update at 2026-03-15 15:58:14 --- calendar/package.json | 4 +- calendar/scripts/export-kindle-background.sh | 23 + .../scripts/export-kindle-background.swift | 186 ++++++++ .../scripts/generate-dashboard-manifest.mjs | 50 +++ calendar/src/App.vue | 37 +- calendar/src/components/AnalogClock.vue | 126 ++++++ calendar/src/components/CalendarCard.vue | 312 +++++++++++-- calendar/src/components/QuoteCard.vue | 54 ++- calendar/src/components/WeatherCard.vue | 232 +++++++++- calendar/src/components/WeatherGlyph.vue | 143 +----- calendar/src/env.d.ts | 10 + calendar/src/lib/clock.ts | 51 +++ calendar/src/lib/dashboard-mode.ts | 14 + calendar/src/lib/icon-assets.ts | 39 ++ calendar/src/style.css | 417 ++---------------- dash/README.md | 8 +- dash/src/dash.sh | 75 +++- dash/src/local/clock-index.sh | 24 + dash/src/local/env.sh | 7 + dash/src/local/fetch-dashboard.sh | 10 +- dash/src/local/render-clock.sh | 32 ++ scripts/generate-kindle-clock-assets.swift | 166 +++++++ scripts/sync-layered-clock-assets.sh | 14 + scripts/sync-layered-clock-to-kindle.sh | 71 +++ 24 files changed, 1509 insertions(+), 596 deletions(-) create mode 100644 calendar/scripts/export-kindle-background.sh create mode 100644 calendar/scripts/export-kindle-background.swift create mode 100644 calendar/scripts/generate-dashboard-manifest.mjs create mode 100644 calendar/src/components/AnalogClock.vue create mode 100644 calendar/src/lib/clock.ts create mode 100644 calendar/src/lib/dashboard-mode.ts create mode 100644 calendar/src/lib/icon-assets.ts create mode 100644 dash/src/local/clock-index.sh create mode 100644 dash/src/local/render-clock.sh create mode 100644 scripts/generate-kindle-clock-assets.swift create mode 100644 scripts/sync-layered-clock-assets.sh create mode 100644 scripts/sync-layered-clock-to-kindle.sh diff --git a/calendar/package.json b/calendar/package.json index 56bce6b..7553407 100644 --- a/calendar/package.json +++ b/calendar/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc --noEmit && vite build", + "build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs", + "export:background": "sh scripts/export-kindle-background.sh", + "manifest": "node scripts/generate-dashboard-manifest.mjs", "typecheck": "vue-tsc --noEmit", "preview": "vite preview" }, diff --git a/calendar/scripts/export-kindle-background.sh b/calendar/scripts/export-kindle-background.sh new file mode 100644 index 0000000..4d1038f --- /dev/null +++ b/calendar/scripts/export-kindle-background.sh @@ -0,0 +1,23 @@ +#!/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} +URL=${1:-"http://127.0.0.1:$PORT/?mode=background"} +OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"} +OUT_REGION=${3:-"$DIST_DIR/clock-region.json"} + +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 +/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION" +node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null + +cat "$OUT_REGION" diff --git a/calendar/scripts/export-kindle-background.swift b/calendar/scripts/export-kindle-background.swift new file mode 100644 index 0000000..ad0e1a7 --- /dev/null +++ b/calendar/scripts/export-kindle-background.swift @@ -0,0 +1,186 @@ +#!/usr/bin/env swift + +import AppKit +import Foundation +import WebKit + +enum ExportError: Error, CustomStringConvertible { + case invalidArguments + case snapshotFailed + case pngEncodingFailed(String) + case clockRegionMissing + + var description: String { + switch self { + case .invalidArguments: + return "用法:export-kindle-background.swift " + case .snapshotFailed: + return "网页截图失败" + case let .pngEncodingFailed(path): + return "PNG 编码失败:\(path)" + case .clockRegionMissing: + return "页面里没有找到 data-clock-region 标记" + } + } +} + +final class SnapshotExporter: NSObject, WKNavigationDelegate { + private let url: URL + private let pngOutputURL: URL + private let regionOutputURL: URL + private let completion: (Result) -> Void + private let targetSize = CGSize(width: 1024, height: 600) + + private lazy var window: NSWindow = { + let window = NSWindow( + contentRect: CGRect(origin: .zero, size: targetSize), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.backgroundColor = .white + return window + }() + + private lazy var webView: WKWebView = { + let config = WKWebViewConfiguration() + let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config) + view.navigationDelegate = self + return view + }() + + init(url: URL, pngOutputURL: URL, regionOutputURL: URL, completion: @escaping (Result) -> Void) { + self.url = url + self.pngOutputURL = pngOutputURL + self.regionOutputURL = regionOutputURL + self.completion = completion + super.init() + } + + func start() { + window.contentView = webView + window.orderBack(nil) + webView.load(URLRequest(url: url)) + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + // 等页面数据与图片稳定下来,避免只截到骨架。 + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + self.capture() + } + } + + private func capture() { + let regionScript = """ + (() => { + const node = document.querySelector('[data-clock-region="true"]'); + if (!node) return null; + const rect = node.getBoundingClientRect(); + return { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height) + }; + })(); + """ + + webView.evaluateJavaScript(regionScript) { value, error in + if let error { + self.finish(.failure(error)) + return + } + + guard let region = value as? [String: NSNumber] else { + self.finish(.failure(ExportError.clockRegionMissing)) + return + } + + let snapshotConfig = WKSnapshotConfiguration() + snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize) + snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width)) + + self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in + if let snapshotError { + self.finish(.failure(snapshotError)) + return + } + + guard let image else { + self.finish(.failure(ExportError.snapshotFailed)) + return + } + + do { + try self.savePNG(image: image, to: self.pngOutputURL) + try self.saveRegion(region: region) + self.finish(.success(())) + } catch { + self.finish(.failure(error)) + } + } + } + } + + private func savePNG(image: NSImage, to url: URL) throws { + let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height)) + normalizedImage.lockFocus() + image.draw(in: NSRect(origin: .zero, size: targetSize)) + normalizedImage.unlockFocus() + + guard + let tiffRepresentation = normalizedImage.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffRepresentation), + let pngData = bitmap.representation(using: .png, properties: [:]) + else { + throw ExportError.pngEncodingFailed(url.path) + } + + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try pngData.write(to: url) + } + + private func saveRegion(region: [String: NSNumber]) throws { + let jsonObject: [String: Int] = [ + "x": region["x"]?.intValue ?? 0, + "y": region["y"]?.intValue ?? 0, + "width": region["width"]?.intValue ?? 0, + "height": region["height"]?.intValue ?? 0, + ] + let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys]) + try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: regionOutputURL) + } + + private func finish(_ result: Result) { + completion(result) + NSApplication.shared.terminate(nil) + } +} + +guard CommandLine.arguments.count >= 4 else { + fputs("\(ExportError.invalidArguments)\n", stderr) + exit(1) +} + +let url = URL(string: CommandLine.arguments[1])! +let pngOutputURL = URL(fileURLWithPath: CommandLine.arguments[2]) +let regionOutputURL = URL(fileURLWithPath: CommandLine.arguments[3]) + +let app = NSApplication.shared +app.setActivationPolicy(.prohibited) + +let exporter = SnapshotExporter(url: url, pngOutputURL: pngOutputURL, regionOutputURL: regionOutputURL) { result in + switch result { + case .success: + print("Exported \(pngOutputURL.path)") + print("Region saved to \(regionOutputURL.path)") + case let .failure(error): + fputs("\(error)\n", stderr) + exit(1) + } +} + +exporter.start() +app.run() diff --git a/calendar/scripts/generate-dashboard-manifest.mjs b/calendar/scripts/generate-dashboard-manifest.mjs new file mode 100644 index 0000000..9b7f5c1 --- /dev/null +++ b/calendar/scripts/generate-dashboard-manifest.mjs @@ -0,0 +1,50 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.resolve(currentDir, '../dist'); +const manifestPath = path.join(distDir, 'dashboard-manifest.json'); +const clockRegionPath = path.join(distDir, 'clock-region.json'); + +const defaultClockRegion = { + x: 313, + y: 0, + width: 220, + height: 220, +}; + +const clockRegion = fs.existsSync(clockRegionPath) + ? { + ...defaultClockRegion, + ...JSON.parse(fs.readFileSync(clockRegionPath, 'utf8')), + } + : defaultClockRegion; + +const manifest = { + background: { + path: 'kindlebg.png', + url: 'https://shell.biboer.cn:20001/kindlebg.png', + updatedAt: new Date().toISOString(), + refreshIntervalMinutes: 120, + }, + clockRegion, + clockFace: { + path: 'assets/clock-face.png', + managedOnKindle: true, + designWidth: 220, + designHeight: 220, + }, + clockHands: { + hourPattern: 'assets/hour-hand/%03d.png', + minutePattern: 'assets/minute-hand/%02d.png', + refreshIntervalMinutes: 1, + networkRequired: false, + anchorMode: 'baked-into-patch', + scaleWithClockFace: false, + }, +}; + +fs.mkdirSync(distDir, { recursive: true }); +fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +console.log(`Wrote ${manifestPath}`); diff --git a/calendar/src/App.vue b/calendar/src/App.vue index bd728c4..ea8167c 100644 --- a/calendar/src/App.vue +++ b/calendar/src/App.vue @@ -1,14 +1,17 @@ + + diff --git a/calendar/src/components/WeatherGlyph.vue b/calendar/src/components/WeatherGlyph.vue index b51cee2..d000dc6 100644 --- a/calendar/src/components/WeatherGlyph.vue +++ b/calendar/src/components/WeatherGlyph.vue @@ -1,154 +1,35 @@ diff --git a/calendar/src/env.d.ts b/calendar/src/env.d.ts index 1b62241..6c0bcdd 100644 --- a/calendar/src/env.d.ts +++ b/calendar/src/env.d.ts @@ -9,3 +9,13 @@ declare module '*.md?raw' { const content: string; export default content; } + +declare module '*.svg' { + const content: string; + export default content; +} + +declare module '*.png' { + const content: string; + export default content; +} diff --git a/calendar/src/lib/clock.ts b/calendar/src/lib/clock.ts new file mode 100644 index 0000000..480b9e0 --- /dev/null +++ b/calendar/src/lib/clock.ts @@ -0,0 +1,51 @@ +import clockFaceAsset from '../../../assets/clock-face.png'; +import hourHandAsset from '../../../assets/hour-hand.png'; +import minuteHandAsset from '../../../assets/minite-hand.png'; + +export interface ClockHandSpec { + src: string; + sourceWidth: number; + sourceHeight: number; + pivotX: number; + pivotY: number; +} + +export interface ClockState { + hourAngle: number; + minuteAngle: number; + digitalLabel: string; +} + +// 这组尺寸直接来自仓库里的 PNG 素材,Kindle 侧也复用同一套基准。 +export const CLOCK_FACE_SOURCE_SIZE = 431; +export const CLOCK_RENDER_SIZE = 220; + +// 锚点按 Figma annotation 的中心点拟合到现有 PNG,保证网页预览与 Kindle 贴图一致。 +export const HOUR_HAND_SPEC: ClockHandSpec = { + src: hourHandAsset, + sourceWidth: 32, + sourceHeight: 205, + pivotX: 13, + pivotY: 138, +}; + +export const MINUTE_HAND_SPEC: ClockHandSpec = { + src: minuteHandAsset, + sourceWidth: 32, + sourceHeight: 288, + pivotX: 15, + pivotY: 203, +}; + +export const CLOCK_FACE_ASSET = clockFaceAsset; + +export function buildClockState(date: Date): ClockState { + const hour = date.getHours() % 12; + const minute = date.getMinutes(); + + return { + hourAngle: hour * 30 + minute * 0.5, + minuteAngle: minute * 6, + digitalLabel: `${String(date.getHours()).padStart(2, '0')}:${String(minute).padStart(2, '0')}`, + }; +} diff --git a/calendar/src/lib/dashboard-mode.ts b/calendar/src/lib/dashboard-mode.ts new file mode 100644 index 0000000..9a037bc --- /dev/null +++ b/calendar/src/lib/dashboard-mode.ts @@ -0,0 +1,14 @@ +export type DashboardMode = 'full' | 'background' | 'clock-face'; + +const VALID_MODES = new Set(['full', 'background', 'clock-face']); + +export function resolveDashboardMode(search: string): DashboardMode { + const params = new URLSearchParams(search); + const mode = params.get('mode'); + + if (mode && VALID_MODES.has(mode as DashboardMode)) { + return mode as DashboardMode; + } + + return 'full'; +} diff --git a/calendar/src/lib/icon-assets.ts b/calendar/src/lib/icon-assets.ts new file mode 100644 index 0000000..0555aab --- /dev/null +++ b/calendar/src/lib/icon-assets.ts @@ -0,0 +1,39 @@ +import bookIcon from '../../../assets/书摘.svg'; +import cloudyIcon from '../../../assets/多云.svg'; +import heavyRainIcon from '../../../assets/大雨.svg'; +import snowIcon from '../../../assets/大雪.svg'; +import lightRainIcon from '../../../assets/小雨.svg'; +import nightIcon from '../../../assets/晚上.svg'; +import clearIcon from '../../../assets/晴天.svg'; +import sleetIcon from '../../../assets/雨夹雪.svg'; + +export type WeatherIconKind = + | 'clear' + | 'partly' + | 'cloudy' + | 'fog' + | 'rain' + | 'heavy-rain' + | 'snow' + | 'storm' + | 'sleet' + | 'night'; + +const WEATHER_ICON_MAP: Record = { + clear: clearIcon, + partly: cloudyIcon, + cloudy: cloudyIcon, + fog: cloudyIcon, + rain: lightRainIcon, + 'heavy-rain': heavyRainIcon, + snow: snowIcon, + storm: heavyRainIcon, + sleet: sleetIcon, + night: nightIcon, +}; + +export function weatherIconForKind(kind: WeatherIconKind) { + return WEATHER_ICON_MAP[kind]; +} + +export const QUOTE_ICON_ASSET = bookIcon; diff --git a/calendar/src/style.css b/calendar/src/style.css index 629f6fa..77b0121 100644 --- a/calendar/src/style.css +++ b/calendar/src/style.css @@ -4,11 +4,11 @@ 'PingFang SC', 'Noto Sans SC', sans-serif; - color: #0f172a; + color: #111111; background: - radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.88), transparent 30%), - radial-gradient(circle at 82% 18%, rgba(241, 238, 232, 0.92), transparent 24%), - linear-gradient(160deg, #f4f1eb 0%, #ebe5dc 52%, #e4ddd4 100%); + radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.9), transparent 26%), + radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.74), transparent 22%), + linear-gradient(150deg, #fbe9e8 0%, #f2d7d7 48%, #eed6d5 100%); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -30,419 +30,74 @@ body { min-height: 100vh; } +img { + display: block; +} + .page-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; - padding: 1.5rem; + padding: 0.75rem; +} + +.page-shell--clock-face { + background: #f5f2ef; } .dashboard-frame { - width: min(100%, 1448px); - aspect-ratio: 1448 / 1072; - padding: clamp(1.2rem, 1vw + 0.9rem, 2rem); - border-radius: 2rem; + width: 100%; + aspect-ratio: 1024 / 600; + padding: 1rem; + border-radius: 1.9rem; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)), - rgba(255, 255, 255, 0.14); + linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)), + rgba(255, 255, 255, 0.16); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.55), - 0 38px 72px rgba(64, 52, 38, 0.14); + inset 0 1px 0 rgba(255, 255, 255, 0.62), + 0 24px 46px rgba(99, 64, 66, 0.18); backdrop-filter: blur(14px); } .dashboard-grid { display: grid; - grid-template-columns: 1.08fr 1fr; - grid-template-rows: auto auto; - gap: clamp(1rem, 1vw + 0.75rem, 2rem); + grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr); + grid-template-rows: minmax(0, 1fr) 72px; + gap: 1rem; height: 100%; align-content: start; align-items: stretch; } .card { - border-radius: 1.6rem; + min-height: 0; + border-radius: 1.75rem; background: rgba(255, 255, 255, 0.94); box-shadow: - 0 20px 36px rgba(72, 58, 44, 0.12), - inset 0 1px 0 rgba(255, 255, 255, 0.65); - border: 1px solid rgba(70, 58, 46, 0.08); + 0 16px 34px rgba(92, 67, 60, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.72); + border: 1px solid rgba(117, 80, 76, 0.08); } -.calendar-card, -.weather-card { - padding: clamp(1.4rem, 1.2vw + 1rem, 2rem); - min-height: 0; - height: 100%; -} - -.calendar-card { - display: grid; - grid-template-rows: auto 1fr; - gap: 0.8rem; -} - -.calendar-card__header { - display: flex; - align-items: center; - gap: 1.6rem; -} - -.calendar-card__day { - font-family: - 'Iowan Old Style', - 'Baskerville', - serif; - font-size: clamp(4.4rem, 5vw, 7rem); - line-height: 0.95; - letter-spacing: -0.08em; - color: #101010; -} - -.calendar-card__meta { - display: grid; - gap: 0.65rem; -} - -.calendar-card__date-line, -.calendar-card__lunar { - display: flex; - gap: 1rem; - align-items: baseline; - margin: 0; - font-size: clamp(1rem, 0.7vw + 0.7rem, 1.55rem); -} - -.calendar-card__weekday { - color: #556274; -} - -.calendar-card__lunar { - color: #5f4b32; - flex-wrap: wrap; -} - -.calendar-card__badges { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; -} - -.calendar-card__badges--inline { - display: inline-flex; - align-items: center; - margin-left: 0.1rem; -} - -.calendar-card__badge { - display: inline-flex; - align-items: center; - min-height: 1.6rem; - padding: 0.2rem 0.55rem; - border-radius: 999px; - border: 1px solid currentColor; - font-size: 0.76rem; - line-height: 1; -} - -.calendar-card__badge--holiday { - color: #2f2b26; - background: #ece5db; -} - -.calendar-card__badge--workday { - color: #68593f; - background: #f2eadb; -} - -.calendar-card__badge--festival { - color: #4f4436; - background: #f5f0e8; -} - -.calendar-card__badge--term { - color: #3a4a4a; - background: #e9efef; -} - -.calendar-card__grid { - display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - grid-template-rows: auto; - grid-auto-rows: minmax(0, 1fr); - gap: 0.35rem 0.3rem; - align-content: start; - height: 100%; -} - -.calendar-card__week-label { - text-align: center; - color: #798395; - font-size: 0.92rem; - padding-bottom: 0.45rem; -} - -.calendar-card__cell { - min-height: 0; +.clock-face-stage { display: grid; place-items: center; - align-content: center; - padding: 0.2rem 0; - border-radius: 0.8rem; - color: #304153; - transition: background-color 150ms ease; + width: min(100%, 320px); + aspect-ratio: 1; } -.calendar-card__cell--muted { - color: #bac4d2; -} - -.calendar-card__cell--today { - background: linear-gradient(180deg, #303640 0%, #1f252d 100%); - color: #ffffff; - box-shadow: 0 14px 24px rgba(31, 37, 45, 0.24); -} - -.calendar-card__cell--holiday .calendar-card__solar, -.calendar-card__cell--holiday .calendar-card__lunar-day { - color: #6a4a1c; -} - -.calendar-card__solar { - font-size: 1.3rem; - line-height: 1.1; -} - -.calendar-card__lunar-day { - font-size: 0.68rem; - line-height: 1.2; - opacity: 0.82; -} - -.weather-card { - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 0.8rem; -} - -.weather-card__heading { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 1rem; -} - -.weather-card__title { - margin: 0; - font-size: clamp(1.5rem, 1vw + 1rem, 2rem); - font-weight: 700; -} - -.weather-card__subtitle { - margin: 0.35rem 0 0; - color: #778396; - font-size: 0.95rem; -} - -.weather-card__hero { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - padding: 1.05rem 1.2rem; - border-radius: 1.2rem; - background: linear-gradient(180deg, #ece8e1 0%, #e2ddd4 100%); -} - -.weather-card__hero--placeholder { - justify-content: center; - color: #64748b; -} - -.weather-card__hero-main { - display: flex; - align-items: center; - gap: 1rem; -} - -.weather-card__temperature { - font-size: clamp(2.2rem, 1.2vw + 1.6rem, 3.2rem); - line-height: 1; - font-family: - 'Iowan Old Style', - 'Baskerville', - serif; -} - -.weather-card__condition { - margin-top: 0.35rem; - font-size: 1.15rem; - color: #243041; -} - -.weather-card__facts { - display: grid; - gap: 0.45rem; - color: #475569; -} - -.weather-card__fact { - display: flex; - align-items: center; - gap: 0.45rem; - font-size: 0.98rem; -} - -.weather-card__forecast { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 0.65rem; - align-items: stretch; -} - -.forecast-pill { - display: grid; - justify-items: center; - align-content: center; - gap: 0.45rem; - padding: 0.8rem 0.55rem; - border-radius: 1rem; - background: #f7f4ef; - border: 1px solid rgba(82, 70, 56, 0.08); - height: 100%; -} - -.forecast-pill__label { - margin: 0; - color: #556274; - font-size: 0.9rem; - line-height: 1.1; -} - -.forecast-pill__temp { - margin: 0; - font-size: 1rem; - font-weight: 700; -} - -.forecast-pill__temp span { - margin-left: 0.15rem; - color: #7d8798; - font-weight: 500; -} - -.weather-card__metrics { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0.65rem; -} - -.metric-pill { - display: grid; - align-content: center; - gap: 0.45rem; - padding: 0.85rem 0.9rem 0.9rem; - border-radius: 1rem; -} - -.metric-pill__label { - display: flex; - align-items: center; - gap: 0.45rem; - font-size: 0.88rem; -} - -.metric-pill__value { - margin: 0; - font-size: 1.25rem; - font-weight: 700; -} - -.metric--sunrise { - background: linear-gradient(150deg, #f3eee5, #ebe3d6); - color: #5c4631; -} - -.metric--sunset { - background: linear-gradient(150deg, #efebe6, #e5dfd6); - color: #4f463d; -} - -.metric--air { - background: linear-gradient(150deg, #eef0eb, #e3e7de); - color: #354338; -} - -.metric--visibility { - background: linear-gradient(150deg, #eef1f0, #e2e5e4); - color: #3b4a4d; -} - -.quote-card { - grid-column: 1 / -1; - display: grid; - gap: 1.1rem; - padding: clamp(1.5rem, 1.2vw + 1rem, 2rem); - background: linear-gradient(168deg, rgba(249, 245, 236, 0.98), rgba(244, 239, 229, 0.94)); -} - -.quote-card__content { - margin: 0; - color: #1e293b; - line-height: 1.65; - font-family: - 'Iowan Old Style', - 'Baskerville', - 'Noto Serif SC', - serif; -} - -@media (max-width: 1080px) { - .page-shell { - padding: 0.9rem; - } - +@media (max-width: 860px) { .dashboard-frame { aspect-ratio: auto; - width: min(100%, 1448px); } .dashboard-grid { grid-template-columns: 1fr; - grid-template-rows: auto; + grid-template-rows: none; } - .quote-card { - grid-column: auto; - } - - .weather-card__forecast, - .weather-card__metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 720px) { - .calendar-card__header, - .weather-card__heading, - .weather-card__hero { - grid-template-columns: 1fr; - display: grid; - } - - .calendar-card__header { - gap: 1rem; - } - - .weather-card__forecast, - .weather-card__metrics { - grid-template-columns: 1fr 1fr; - } - - .quote-card__content { - font-size: 1.45rem !important; + .page-shell { + align-items: stretch; } } diff --git a/dash/README.md b/dash/README.md index f4cb8fe..02c6d74 100644 --- a/dash/README.md +++ b/dash/README.md @@ -8,7 +8,9 @@ Turns out old Kindle devices make great, energy efficient dashboards :-) This repo only contains the code that runs on the Kindle. It periodically fetches an image to be displayed on the screen and suspends the device to RAM (which is very power efficient) until the next screen update. -This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetchd via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image. +This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetched via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image. + +In the current Voyage layered-clock setup, the Kindle only fetches a low-frequency `kindlebg.png` background. The clock face and hand patches are synced as local assets and re-drawn on-device once per minute without network access. In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard that I render into a PNG screenshot on a server. See [here](https://github.com/pascalw/kindle-dash/blob/main/docs/tipstricks.md#producing-dashboard-images-from-a-webpage) for information on how these PNGs should be produced, including some sample code. @@ -55,8 +57,9 @@ If you're connected over SSH and only want a one-off foreground session, you can ## How this works -* This code periodically downloads a dashboard image from an HTTP(s) endpoint. +* This code periodically downloads a dashboard background image from an HTTP(s) endpoint. * The interval can be configured in `dist/local/env.sh` using a cron expression. +* When layered clock assets are present, the Kindle re-renders the clock patch locally every minute. * During the update intervals the device is suspended to RAM to save power. ## Notes @@ -64,6 +67,7 @@ If you're connected over SSH and only want a one-off foreground session, you can * The releases contain a pre-compiled binary of the [ht](https://github.com/ducaale/ht) command-line HTTP client. This fully supports modern HTTPS crypto, wheras the built-in `curl` and `wget` commands don't (because they rely on a very old `openssl` library). * For a detailed Kindle Voyage 5.13.6 jailbreak and deployment walkthrough, see [docs/kindle-voyage-5.13.6-watchthis-zh.md](./docs/kindle-voyage-5.13.6-watchthis-zh.md). * For a detailed same-device dashboard/SSH troubleshooting playbook based on the 2026-03-15 session, see [docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](./docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md). +* For the layered clock split and runtime model, see [docs/layered-clock-plan.zh.md](./docs/layered-clock-plan.zh.md). ## Credits diff --git a/dash/src/dash.sh b/dash/src/dash.sh index cc30680..3546813 100755 --- a/dash/src/dash.sh +++ b/dash/src/dash.sh @@ -3,14 +3,19 @@ DEBUG=${DEBUG:-false} [ "$DEBUG" = true ] && set -x DIR="$(dirname "$0")" -DASH_PNG="$DIR/dash.png" +BACKGROUND_PNG="$DIR/kindlebg.png" FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh" LOW_BATTERY_CMD="$DIR/local/low-battery.sh" +CLOCK_RENDER_CMD="$DIR/local/render-clock.sh" +STATE_DIR="$DIR/local/state" +BACKGROUND_TIMESTAMP_FILE="$STATE_DIR/background-updated-at" REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"} FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0} SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600} DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false} +BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120} +CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15} RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false} @@ -27,6 +32,7 @@ init() { fi echo "Starting dashboard with $REFRESH_SCHEDULE refresh..." + mkdir -p "$STATE_DIR" if [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then echo "System suspend disabled, using normal sleep between refreshes." @@ -50,27 +56,72 @@ prepare_sleep() { num_refresh=$FULL_DISPLAY_REFRESH_RATE } -refresh_dashboard() { - echo "Refreshing dashboard" +now_epoch() { + date '+%s' +} + +background_refresh_due() { + if [ ! -f "$BACKGROUND_PNG" ] || [ ! -f "$BACKGROUND_TIMESTAMP_FILE" ]; then + return 0 + fi + + current_epoch=$(now_epoch) + last_background_epoch=$(cat "$BACKGROUND_TIMESTAMP_FILE") + refresh_interval_seconds=$((BACKGROUND_REFRESH_INTERVAL_MINUTES * 60)) + + [ $((current_epoch - last_background_epoch)) -ge "$refresh_interval_seconds" ] +} + +store_background_timestamp() { + now_epoch >"$BACKGROUND_TIMESTAMP_FILE" +} + +fetch_background() { + echo "Refreshing background" "$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP" - "$FETCH_DASHBOARD_CMD" "$DASH_PNG" + "$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG" fetch_status=$? if [ "$fetch_status" -ne 0 ]; then - echo "Not updating screen, fetch-dashboard returned $fetch_status" + echo "Background fetch failed with $fetch_status" return 1 fi - if [ "$num_refresh" -eq "$FULL_DISPLAY_REFRESH_RATE" ]; then - num_refresh=0 + store_background_timestamp + return 0 +} - # trigger a full refresh once in every 4 refreshes, to keep the screen clean - echo "Full screen refresh" - /usr/sbin/eips -f -g "$DASH_PNG" +clock_force_full_refresh() { + eval "$("$DIR/local/clock-index.sh")" + [ $((minute % CLOCK_FULL_REFRESH_INTERVAL_MINUTES)) -eq 0 ] +} + +refresh_dashboard() { + background_refreshed=false + + if background_refresh_due; then + if fetch_background; then + background_refreshed=true + echo "Full screen refresh" + /usr/sbin/eips -f -g "$BACKGROUND_PNG" + elif [ ! -f "$BACKGROUND_PNG" ]; then + echo "No cached background available." + return 1 + fi + fi + + if [ "$background_refreshed" = false ] && [ ! -f "$BACKGROUND_PNG" ]; then + echo "No cached background available." + return 1 + fi + + if [ "$background_refreshed" = true ] || clock_force_full_refresh; then + echo "Clock patch full refresh" + "$CLOCK_RENDER_CMD" true else - echo "Partial screen refresh" - /usr/sbin/eips -g "$DASH_PNG" + echo "Clock patch partial refresh" + "$CLOCK_RENDER_CMD" false fi num_refresh=$((num_refresh + 1)) diff --git a/dash/src/local/clock-index.sh b/dash/src/local/clock-index.sh new file mode 100644 index 0000000..db60c24 --- /dev/null +++ b/dash/src/local/clock-index.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -eu + +to_decimal() { + printf '%s' "$1" | awk '{ + sub(/^0+/, "", $0) + if ($0 == "") { + $0 = 0 + } + print $0 + }' +} + +hour_value=${1:-$(date '+%H')} +minute_value=${2:-$(date '+%M')} + +hour_decimal=$(to_decimal "$hour_value") +minute_decimal=$(to_decimal "$minute_value") +hour_index=$(( (hour_decimal % 12) * 60 + minute_decimal )) + +printf 'hour=%s\n' "$hour_decimal" +printf 'minute=%s\n' "$minute_decimal" +printf 'hour_index=%03d\n' "$hour_index" +printf 'minute_index=%02d\n' "$minute_decimal" diff --git a/dash/src/local/env.sh b/dash/src/local/env.sh index 2d5cc7f..0a5d1d6 100644 --- a/dash/src/local/env.sh +++ b/dash/src/local/env.sh @@ -5,6 +5,13 @@ export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1} # 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。 export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"} export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"} +export BACKGROUND_URL=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"} +export BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120} +export CLOCK_REGION_X=${CLOCK_REGION_X:-262} +export CLOCK_REGION_Y=${CLOCK_REGION_Y:-55} +export CLOCK_REGION_WIDTH=${CLOCK_REGION_WIDTH:-220} +export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220} +export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15} # By default, partial screen updates are used to update the screen, # to prevent the screen from flashing. After a few partial updates, diff --git a/dash/src/local/fetch-dashboard.sh b/dash/src/local/fetch-dashboard.sh index 0733233..d0f71fc 100755 --- a/dash/src/local/fetch-dashboard.sh +++ b/dash/src/local/fetch-dashboard.sh @@ -1,4 +1,8 @@ #!/usr/bin/env sh -# Fetch a new dashboard image, make sure to output it to "$1". -# For example: -"$(dirname "$0")/../xh" -d -q -o "$1" get https://raw.githubusercontent.com/pascalw/kindle-dash/master/example/example.png +set -eu + +# 拉取低频背景图,调用方负责传入输出路径。 +output_path=${1:?"missing output path"} +background_url=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"} + +"$(dirname "$0")/../xh" -d -q -o "$output_path" get "$background_url" diff --git a/dash/src/local/render-clock.sh b/dash/src/local/render-clock.sh new file mode 100644 index 0000000..9dcf870 --- /dev/null +++ b/dash/src/local/render-clock.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env sh +set -eu + +DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +clock_face_path=${CLOCK_FACE_PATH:-"$DIR/../assets/clock-face.png"} +hour_assets_dir=${CLOCK_HOUR_ASSETS_DIR:-"$DIR/../assets/hour-hand"} +minute_assets_dir=${CLOCK_MINUTE_ASSETS_DIR:-"$DIR/../assets/minute-hand"} +clock_region_x=${CLOCK_REGION_X:-313} +clock_region_y=${CLOCK_REGION_Y:-0} +force_full_refresh=${1:-false} + +eval "$("$DIR/clock-index.sh")" + +hour_patch="$hour_assets_dir/$hour_index.png" +minute_patch="$minute_assets_dir/$minute_index.png" + +if [ ! -f "$clock_face_path" ] || [ ! -f "$hour_patch" ] || [ ! -f "$minute_patch" ]; then + echo "Clock assets missing." + echo "Face: $clock_face_path" + echo "Hour: $hour_patch" + echo "Minute: $minute_patch" + exit 1 +fi + +if [ "$force_full_refresh" = true ]; then + /usr/sbin/eips -f -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y" +else + /usr/sbin/eips -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y" +fi + +/usr/sbin/eips -g "$hour_patch" -x "$clock_region_x" -y "$clock_region_y" +/usr/sbin/eips -g "$minute_patch" -x "$clock_region_x" -y "$clock_region_y" diff --git a/scripts/generate-kindle-clock-assets.swift b/scripts/generate-kindle-clock-assets.swift new file mode 100644 index 0000000..17ed467 --- /dev/null +++ b/scripts/generate-kindle-clock-assets.swift @@ -0,0 +1,166 @@ +#!/usr/bin/env swift + +import AppKit +import Foundation + +struct HandConfig { + let sourcePath: String + let outputDirectoryName: String + let frameCount: Int + let sourceWidth: CGFloat + let sourceHeight: CGFloat + let pivotX: CGFloat + let pivotY: CGFloat + let digits: Int + let angleStep: CGFloat +} + +enum AssetError: Error, CustomStringConvertible { + case invalidImage(String) + case pngEncodingFailed(String) + + var description: String { + switch self { + case let .invalidImage(path): + return "无法读取图片:\(path)" + case let .pngEncodingFailed(path): + return "无法编码 PNG:\(path)" + } + } +} + +let fileManager = FileManager.default +let workingDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) +let repoRoot = CommandLine.arguments.count > 1 + ? URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + : workingDirectory +let outputRoot = CommandLine.arguments.count > 2 + ? URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) + : repoRoot.appendingPathComponent("assets/kindle-clock", isDirectory: true) + +let faceSourceURL = repoRoot.appendingPathComponent("assets/clock-face.png") +let targetSize = NSSize(width: 220, height: 220) +let faceSourceSize = NSSize(width: 431, height: 431) +let scale = targetSize.width / faceSourceSize.width + +let handConfigs = [ + HandConfig( + sourcePath: "assets/hour-hand.png", + outputDirectoryName: "hour-hand", + frameCount: 720, + sourceWidth: 32, + sourceHeight: 205, + pivotX: 13, + pivotY: 138, + digits: 3, + angleStep: 0.5 + ), + HandConfig( + sourcePath: "assets/minite-hand.png", + outputDirectoryName: "minute-hand", + frameCount: 60, + sourceWidth: 32, + sourceHeight: 288, + pivotX: 15, + pivotY: 203, + digits: 2, + angleStep: 6 + ), +] + +func loadImage(at url: URL) throws -> NSImage { + guard let image = NSImage(contentsOf: url) else { + throw AssetError.invalidImage(url.path) + } + + return image +} + +func savePNG(_ image: NSImage, to url: URL) throws { + guard + let tiffRepresentation = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffRepresentation), + let pngData = bitmap.representation(using: .png, properties: [:]) + else { + throw AssetError.pngEncodingFailed(url.path) + } + + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try pngData.write(to: url) +} + +func renderImage(size: NSSize, draw: () -> Void) -> NSImage { + let image = NSImage(size: size) + image.lockFocusFlipped(true) + NSColor.clear.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + draw() + image.unlockFocus() + return image +} + +func renderFace() throws { + let faceImage = try loadImage(at: faceSourceURL) + let renderedFace = renderImage(size: targetSize) { + faceImage.draw( + in: NSRect(origin: .zero, size: targetSize), + from: NSRect(origin: .zero, size: faceImage.size), + operation: .sourceOver, + fraction: 1 + ) + } + + try savePNG(renderedFace, to: outputRoot.appendingPathComponent("clock-face.png")) +} + +func renderHandFrames(config: HandConfig) throws { + let sourceURL = repoRoot.appendingPathComponent(config.sourcePath) + let handImage = try loadImage(at: sourceURL) + let outputDirectory = outputRoot.appendingPathComponent(config.outputDirectoryName, isDirectory: true) + let scaledWidth = config.sourceWidth * scale + let scaledHeight = config.sourceHeight * scale + let scaledPivotX = config.pivotX * scale + let scaledPivotY = config.pivotY * scale + + try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + + for frameIndex in 0../mnt/us/dashboard/local/state/background-updated-at" +ssh "$KINDLE_TARGET" "tmp=\$(mktemp) && awk \ + -v x='$clock_x' \ + -v y='$clock_y' \ + -v w='$clock_width' \ + -v h='$clock_height' \ + 'BEGIN { seen_x=0; seen_y=0; seen_w=0; seen_h=0 } \ + /^export CLOCK_REGION_X=/ { print \"export CLOCK_REGION_X=\" x; seen_x=1; next } \ + /^export CLOCK_REGION_Y=/ { print \"export CLOCK_REGION_Y=\" y; seen_y=1; next } \ + /^export CLOCK_REGION_WIDTH=/ { print \"export CLOCK_REGION_WIDTH=\" w; seen_w=1; next } \ + /^export CLOCK_REGION_HEIGHT=/ { print \"export CLOCK_REGION_HEIGHT=\" h; seen_h=1; next } \ + { print } \ + END { \ + if (!seen_x) print \"export CLOCK_REGION_X=\" x; \ + if (!seen_y) print \"export CLOCK_REGION_Y=\" y; \ + if (!seen_w) print \"export CLOCK_REGION_WIDTH=\" w; \ + 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\"" + +echo "Layered clock runtime synced to $KINDLE_TARGET"