简介:面向偏好复古机械翻页钟与桌面个性化的 Mac 用户,这份屏保资源把时间以翻页卡片形式呈现,在计算机闲置时承担屏幕保护作用,也为桌面增添克制的动态装饰,让时间展示更具仪式感。压缩包共 6 个文件,约 16KB,涵盖 HTML、JS、CSS、plist、nib 及主屏保文件等类型,分别对应页面样式、交互脚本、属性配置与界面资源,体量轻巧却保留了较完整的 .saver 包结构线索。已有 299 人学习或下载,可作为 Mac 屏保爱好者、桌面美化用户及轻量前端练习者的参考样本。对于想了解屏保封装方式、配置项组织、界面资源搭配与翻页动画构成的学习者,可从中观察各类文件如何协作,理解时间显示风格与桌面氛围的定制空间,也便于按个人审美评估这类轻量屏保的改造余地。
1. Epoch Flip Clock.saver.zip:把 10 位 Unix 时间戳做成翻页动画的屏保包
排查线上问题时,我经常遇到同一个错位:日志里全是1767xxxxxx这样的 10 位秒级时间戳,而屏幕角落显示的是“下午 3:42”。两者要在脑子里换算一遍,一晚上能换算几十次。Epoch Flip Clock.saver.zip 就是针对这个场景做的:它是一个 macOS 屏保包(.saver),把当前 Unix 时间戳当成主视觉,用翻页钟的方式一秒一跳地摆在屏幕上,读数和日志同一套口径。它适合需要长时间盯任务、盯日志、盯定时器的开发者,也适合把副屏或闲置机器改成一块“时间戳看板”的人。这个包本身很小,装起来只有一步,真正需要花时间的是让它在你的系统版本上正确加载、按你要的口径显示、并且不要在主屏之外的地方拖慢机器。
2. 解压后先不要双击:.saver 包的骨架与加载链路
拿到一个来源不明的屏保包,双击是最差的第一步。双击会直接把包塞进用户目录并触发一次加载,出问题之后你很难判断是包本身坏了、路径不对,还是被系统的隔离机制拦了。稳妥的做法是先解压到临时目录,把骨架和元信息读一遍,确认它确实是一个合法的屏保包,再决定装不装、装到哪一层。这一章讲的就是这三件事:目录长什么样、哪几个键决定它能不能被加载、系统到底是用哪个进程在跑它。
2.1 三分钟看清目录骨架
.saver本质上是一个 bundle(目录),不是单个文件。压缩包解出来的顶层目录名通常就是包名,里面固定有Contents。先把清单打出来,不要急着往~/Library里放:
# 先看压缩包里到底有什么,不要直接双击 unzip -l "Epoch Flip Clock.saver.zip" # 解到临时目录,避免污染下载目录 mkdir -p ~/tmp/saver-inspect && cd ~/tmp/saver-inspect unzip -q ~/Downloads/"Epoch Flip Clock.saver.zip" # 只看三层,足够判断结构是否完整 find . -maxdepth 3 -print | sed 's|^\./||' | sortunzip -l那一步看的是文件名和压缩比,如果里面只有一堆散落的源文件、没有Contents/Info.plist,那它就不是成品包,而是源码,需要自己编译,走的是完全不同的路径。find -maxdepth 3那一步看的是层级,正常的成品包结构大致如下:
| 路径 | 作用 | 缺失后的表现 |
|---|---|---|
Contents/Info.plist | 声明包类型、入口类、版本号 | 系统完全不认这个目录,列表里看不到 |
Contents/MacOS/<可执行文件> | 编译产物,含屏保视图类 | 列表里能看到,但预览是一片黑 |
Contents/Resources/ | 字体、翻页贴图、背景素材 | 能跑,但字体回退、动画缺帧 |
Contents/_CodeSignature/ | 签名信息 | 首次加载被系统拦下 |
有一类包会把可执行文件放在Contents/MacOS/之外,或者干脆只有一个Contents/Resources加一个动态库。这不算错,但只要Info.plist里的入口类对不上实际产物,加载就会失败。所以第二步永远是读 plist,而不是看目录好不好看。
2.2 Info.plist 里决定能否正常加载的四个键
Info.plist是二进制或 XML 格式,直接cat可能是一堆乱码,用系统自带的工具读:
# 打成可读的键值对,先整体扫一眼 plutil -p "Epoch Flip Clock.saver/Contents/Info.plist" # 只关心决定「能不能被加载」的几个键,缺哪个一目了然 PLIST="Epoch Flip Clock.saver/Contents/Info.plist" for k in CFBundlePackageType CFBundleIdentifier NSPrincipalClass CFBundleVersion; do printf '%-22s ' "$k" /usr/libexec/PlistBuddy -c "Print :$k" "$PLIST" 2>/dev/null || echo "(缺失)" doneCFBundlePackageType必须是SCRN(Screen Saver 的包类型标识)。这个值不对,系统连枚举都不会枚举到你,后面所有排查都是白费。NSPrincipalClass是入口类名,系统会用这个字符串去实例化屏保视图;如果包是用 Swift 写的,这个值通常是“模块名.类名”的形式,比如EpochFlipClock.EpochFlipClockView,只写类名不写模块名,在部分系统版本上会实例化失败,表现为预览黑屏但列表里有条目。CFBundleIdentifier决定偏好写到哪个域,也是后面用defaults读配置的依据;CFBundleVersion只影响系统判断包是否更新,但如果两个版本号相同、路径相同,系统可能继续用缓存里的旧版,这点在第 5 章会单独讲。
提示:
plutil -p的输出里如果看到CFBundleExecutable指向的文件在MacOS/下不存在,说明这个包被打包漏了,不必再往下折腾,直接换一份。
2.3 从系统设置到 legacyScreenSaver 的加载链路
理解链路能省掉一半的排查时间。你在系统设置里选中一个第三方.saver之后,真正干活的是一个常驻的屏保进程(近年系统上叫legacyScreenSaver),它负责在空闲触发时创建一个窗口,把NSPrincipalClass指向的视图实例化出来,以帧驱动的方式调animateOneFrame。预览缩略图也是同一个进程生成的,只是视图尺寸很小。
这条链路带来两个直接影响。第一,改完包里的任何东西,包括替换可执行文件、改 plist、换资源图,都已经加载过的进程不会自动重新读,必须让它重启一次:
# 让系统重新枚举并加载屏保,等价于「重启这个子系统」 killall legacyScreenSaver 2>/dev/null || true # 确认进程已经不在了(下次进预览会重新拉起) pgrep -fl legacyScreenSaver || echo "已退出,下次预览会重新加载"第二,预览和实际全屏跑的是同一份代码,但尺寸不同。有些包在预览尺寸下画得很好,全屏就崩,原因通常是字号或贴图按固定像素写死,没有按bounds做比例换算。遇到“预览正常、全屏异常”的情况,不要怀疑安装,直接往视图布局代码里找。
3. 装进去并出现在屏保列表里:两条路径与四条验证命令
装.saver只有两个候选位置,区别不在“能不能用”,而在“给谁用”和“权限要求”。选错位置是新手最常见的翻车点之一:包明明复制进去了,系统设置里却怎么都刷不出来,最后发现是路径里少了一个空格或者少了复数 s。
3.1 用户级与全局级:两条路径的取舍
用户级路径是~/Library/Screen Savers/,只对当前账户生效,不需要管理员权限,改坏了删掉即可,我个人一律用这个。全局级路径是/Library/Screen Savers/,对所有账户生效,写入需要sudo,适合公用机器或者给一台展示机做统一配置。
两个路径都带空格,并且Savers是复数。命令行里必须加引号,否则会被拆成两个参数,cp会把包复制到~/Library/Screen这个不存在或者语义奇怪的位置,然后你会得到一个“装好了但列表里没有”的假象。另外,较新的系统版本里屏保入口和壁纸设置合并到了同一个面板,界面位置变了但目录没变,找不到入口时不要怀疑路径。
| 位置 | 生效范围 | 权限 | 适用场景 |
|---|---|---|---|
~/Library/Screen Savers/ | 当前账户 | 普通用户 | 个人开发机、临时试用 |
/Library/Screen Savers/ | 所有账户 | 需要管理员 | 公用机、展示机、统一配置 |
3.2 一次完整的命令行安装与回读
下面这段可以整段抄,只要把开头的包名改成你解压出来的实际目录名。关键是三步:先删旧目录再复制(避免cp -R在目标已存在时变成嵌套)、清隔离属性、重启屏保进程。
SAVER="Epoch Flip Clock.saver" SRC="$HOME/tmp/saver-inspect/$SAVER" # 2.1 解压出来的位置 DEST="$HOME/Library/Screen Savers" mkdir -p "$DEST" # 先删再拷:cp -R 遇到已存在的同名目录会嵌套成 xxx.saver/xxx.saver rm -rf "$DEST/$SAVER" cp -R "$SRC" "$DEST/" # 从网络下载的包会带隔离属性,不清掉首次加载会被拦 xattr -dr com.apple.quarantine "$DEST/$SAVER" # 让系统重新枚举屏保 killall legacyScreenSaver 2>/dev/null || true # 回读:确认目录在、文件全、隔离属性已清 ls -la "$DEST" xattr -l "$DEST/$SAVER" || echo "无扩展属性,干净"rm -rf那一行的注释不是多余的。cp -R src dest在dest不存在时是把src复制成dest,在dest已存在时是把src复制到dest里。这个语义差异导致很多人反复安装之后,磁盘上躺着Epoch Flip Clock.saver/Epoch Flip Clock.saver/Epoch Flip Clock.saver,而系统读的是最外层那个空壳,自然什么都不显示。
装完还要确认系统认了这个包。下面几条命令分别回答“系统当前选的是哪个模块”“这个包自己有没有写过偏好”“签名是什么状态”:
# 1) 系统当前的屏保配置:moduleName 能看出选中的是哪个包 defaults -currentHost read com.apple.screensaver # 2) 这个包暴露了哪些可配置项(没写过就说明还在用包内默认值) BID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \ "$DEST/$SAVER/Contents/Info.plist") echo "bundle id: $BID" defaults read "$BID" 2>/dev/null || echo "尚未写入偏好,使用默认值" # 3) 签名状态:没有签名也能跑,但首次加载会被拦一次 codesign -dv --verbose=4 "$DEST/$SAVER" 2>&1 | head -n 12defaults -currentHost read com.apple.screensaver的输出里,moduleName是当前选中的屏保标识,idleTime是空闲触发秒数。改idleTime可以用defaults -currentHost write com.apple.screensaver idleTime -int 120,但改完同样要killall legacyScreenSaver才生效。第二条命令里的$BID是从包里现读的,不要凭印象手写,包一换标识就变了。第三条codesign如果输出code object is not signed at all,说明是本地编译的未签名产物,能用,但每次全新安装第一次加载都要在系统弹窗里手动放行一次。
3.3 卸载、回滚与“装了两个版本”
卸载就是删目录,但要删对地方:
# 用户级卸载 rm -rf "$HOME/Library/Screen Savers/Epoch Flip Clock.saver" # 如果当初装到了全局级,也要清一遍 sudo rm -rf "/Library/Screen Savers/Epoch Flip Clock.saver" # 顺手清掉这个包写过的偏好,避免下次装回来还是旧配置 defaults delete "$BID" 2>/dev/null || true killall legacyScreenSaver 2>/dev/null || true经常出现的情况是“我明明换了新版本,界面还是老的”。大概率是两个路径各有一份,系统加载的是另一份。排查方法很直接:两个路径都ls一遍,看修改时间,把旧的那份删掉。偏好残留是第二种可能,包的可执行文件换了但defaults域里还是旧值,涉及显示口径这种配置项时会表现得像“没更新”。
4. 调成你要的样子:显示口径、翻页对齐与多屏表现
这个包的核心是一个翻页钟视图,决定观感的其实只有三件事:显示哪一套时间口径、翻页动作有没有咬住整秒、以及多屏和低功耗状态下它会不会变成负担。这三件事里,第一件影响你读数的准确性,第二件影响“像不像一个钟”,第三件影响你会不会在第三天就把它关掉。
4.1 三种口径:秒、毫秒、带偏移的 UTC
Unix 时间戳常见三种宽度:10 位(秒)、13 位(毫秒)、16 位(微秒)。屏保上显示几位,取决于包里的格式化逻辑,也取决于它有没有开放配置项。第 3.2 节那条defaults read "$BID"如果输出了内容,里面通常会有一个类似显示模式、位数的键,改完重启进程即可生效;如果输出是“尚未写入偏好”,说明作者只留了包内默认值,要改就得动源码。
口径选择上我的建议很明确:日常用 10 位秒级。理由不是省事,而是可对照——日志、date +%s、数据库里存的时间戳,绝大多数都是秒级,13 位在肉眼对照时反而要多做一次截断。只有在做高频采样、需要区分同一秒内的事件顺序时,才值得切到毫秒。
还有一个容易忽略的点:epoch 本身是 UTC 口径,不含时区。所以翻页钟旁边如果还画了一个本地时间,两者在跨时区协作、看海外机器日志的时候会显得“对不上”,那不是包的问题,是两套口径同时在屏幕上。要么只留 epoch,要么在心里明确左边是绝对时间、右边是本地时间。
// 固定位数输出,翻页时才不会因为位数变化整体左移 private func renderEpoch() { let t = Date().timeIntervalSince1970 // 10 位秒级:和 date +%s、日志时间戳同一口径 let seconds = Int64(t) // 13 位毫秒级:高位补零,宽度恒定 let millis = Int64((t * 1000).rounded()) epochLabel.stringValue = String(format: "%lld", seconds) // 切毫秒模式时换成下面这行 // epochLabel.stringValue = String(format: "%013lld", millis) }String(format: "%013lld", millis)里的013是关键:0表示高位补零,13是总宽度。不补零的话,毫秒数在低位变化时字符宽度不变,但一旦跨过某个数量级(比如从 999 跳到 1000),整块数字会左右抖动,翻页动画看起来就像“卡了一下”。位数固定之后,每一帧的字符网格完全一致,翻页才是干净的。
4.2 让翻页动作咬住整秒边界
这是这类屏保最容易翻车的地方,也是最能体现“是不是正经做过”的细节。直觉写法是Timer.scheduledTimer(withTimeInterval: 1, repeats: true),它从视图启动的那一刻开始计时。问题是启动时刻通常是“某个随机的毫秒”,比如 12:00:00.37,于是这个钟此后每一跳都发生在.37秒,屏幕上数字变化和系统时间变化永远差着小半秒。跑上几个小时,加上系统调度抖动和休眠唤醒,偏差会累积到肉眼可见。
正确做法是把第一次触发对齐到下一个整秒,并且周期性做一次重对齐:
private var ticker: Timer? private func startAlignedTicker() { let now = Date() // 向上取整到下一个整秒,再让 20ms,避开调度抖动带来的「刚好早一点」 let next = Date(timeIntervalSince1970: ceil(now.timeIntervalSince1970) + 0.02) let t = Timer(fire: next, interval: 1, repeats: true) { [weak self] _ in self?.renderEpoch() self?.reAlignIfNeeded() // 长时间运行后补偿累计漂移 } t.tolerance = 0.01 // 允许 10ms 误差,换取更低的唤醒开销 RunLoop.main.add(t, forMode: .common) // 关键:不进 common mode 会停 ticker = t } private func reAlignIfNeeded() { // 小数部分超过 0.5 说明这一跳已经明显晚了,重建定时器 let frac = Date().timeIntervalSince1970.truncatingRemainder(dividingBy: 1) guard frac > 0.5 else { return } ticker?.invalidate() startAlignedTicker() }三个参数值得单独说。Date(timeIntervalSince1970:)配合ceil是拿“绝对时间轴上的整秒”,比Date().addingTimeInterval(1)靠谱得多。tolerance = 0.01是给系统留的合并唤醒空间,屏保这种场景 10ms 误差完全看不出来,但功耗差别在笔记本上能感知。forMode: .common是最容易漏的一行:默认的.defaultmode 在用户拖动、滚动、菜单弹出时会被挂起,屏保场景下表现就是“翻页偶尔停一下再连跳两格”。
注意:
reAlignIfNeeded里的 0.5 是个经验阈值,不是定理。如果你的机器休眠唤醒频繁,可以把阈值调到 0.2,让重对齐更积极,代价是偶尔会看到一次“同一秒内翻两次”,取舍看你更在意稳定还是更在意观感。
4.3 多显示器、预览缩略图与资源占用
多屏环境下,第三方屏保的常见表现是只在主屏跑,副屏走系统默认的黑屏或者纯色。这不是这个包的问题,而是屏保框架的行为:它只在主屏创建视图实例。想两个屏都显示 timestamp,我的做法是把副屏设成不进入屏保、直接用一张静态图或者另一个轻量工具,反而比强行让两个屏同步翻页更稳。
资源占用方面,一秒一次的文本重绘本身开销很小,真正吃电的是翻页动画。如果包里的翻页是逐帧插值的(每帧重绘半翻状态),在低电量模式下会明显感觉到机器发热。这种情况下有两个选择:把动画时长压短,或者干脆改成整秒无过渡直接切。屏保不是 UI 演示,能看清数字比动画顺滑重要得多。
预览缩略图和全屏用的是同一份代码,但bounds完全不同。如果你的机器上预览正常、全屏数字被裁切,问题在布局代码里把字号写成了固定值,改成按bounds.height的比例算就行。这个改动很小,但需要重新编译,走的是下一章最后一节提到的构建流程。
5. 避坑与排查:五条实际踩过的坑
下面五条都是“装完了但不对劲”的典型。每一条按“现象、原因、解决”写,遇到对应症状可以直奔那一条,不用从头读。
5.1 首次加载提示“来自身份不明的开发者”
现象:装好之后点预览,弹窗拦下,只有“移到废纸篓”和“取消”两个选项,没有“打开”。
原因:从网络下载的包会带上com.apple.quarantine扩展属性,系统对带这个属性且没有有效签名的 bundle 一律拦截。
解决:清掉隔离属性再重启屏保进程。命令就是 3.2 节里那两行xattr -dr com.apple.quarantine加killall legacyScreenSaver。清完之后如果还拦,用xattr -l确认属性真的没了——有时候属性在包的子文件上,-r参数漏掉就会只清了外层。另外提醒一句,这个操作等于你自己给这个包背书,来源不明的包不要这么干。
5.2 装完在系统设置里刷不出来
现象:ls能看到文件确实在~/Library/Screen Savers/下,但屏保列表里怎么刷新都没有新条目。
原因:按发生频率排,第一是路径写错——少个空格、少个复数 s、或者装到了~/Library/ScreenSavers(没空格),系统只认带空格的那个;第二是CFBundlePackageType不是SCRN;第三是解压出了嵌套目录,最外层是个空壳。
解决:先用绝对路径确认位置,ls -d "$HOME/Library/Screen Savers/"*.saver。位置对了就读 plist 里的CFBundlePackageType,值不对说明包本身有问题。再不行就find "$HOME/Library/Screen Savers" -maxdepth 2 -name Info.plist,看看是不是多套了一层。
5.3 换了新版本,界面上还是旧的
现象:包已经替换成新版,预览出来的还是旧样式,甚至数字位数都没变。
原因:两个可能。一是用户级和全局级各有一份,系统优先加载了另一份;二是可执行文件换了但偏好域里存着旧配置,覆盖了包内默认值。
解决:两个路径都列一遍看修改时间,把旧的那份删掉;然后defaults read "$BID"看有没有残留,有就defaults delete清掉再重启进程。养成“先删再拷”的习惯能规避掉一大半同类问题。
5.4 翻页慢半拍,或者偶尔连跳两格
现象:屏保上的数字变化和系统时间对不齐,有时明显慢一点点,有时一次跳两格。
原因:定时器没有对齐整秒边界,从启动瞬间的随机毫秒开始计时,误差稳定存在;连跳两格通常是定时器落在了默认 runloop mode,被其他交互挂起后补跑。
解决:按 4.2 节改成对齐整秒加.commonmode,并加一个周期性重对齐。改完可以拿终端里的while :; do printf '\r%s' "$(date +%s)"; sleep 1; done并排对照几十秒,看两边数字的切换是不是同一瞬间。
5.5 副屏黑屏,或者机器发热明显
现象:主屏翻页正常,副屏一直是黑的;或者屏保跑十几分钟风扇转速明显上升。
原因:前者是屏保框架只给主屏建视图实例,属于预期行为;后者多半是逐帧插值的翻页动画在一秒内做了几十次重绘。
解决:副屏不要强求同步,走静态方案更省心。发热问题把动画时长压短或改成无过渡切换,重新编译后观察top里屏保进程的 CPU 占用,正常应该稳定在个位数百分比,长期两位数就该优化了。
6. 当成校时提醒:两个对照脚本和一条验收习惯
屏保上的秒数不只是好看,它可以变成一个低成本的校时参照物。我常用的做法是开一个终端窗口贴在副屏,每秒打印一次系统时间戳,和屏保上的数字并排看:
# 每秒刷一次,输出和屏保同口径的 10 位时间戳 while :; do printf '\r%s' "$(date +%s)"; sleep 1; done如果两边在整秒切换上明显错开,先怀疑屏保的定时器对齐(第 4.2 节),再怀疑系统时间本身。想把日志时间和屏幕上读到的时间对一遍,用下面这段:
LOG="$HOME/logs/app.log" # 从日志里抓最后一条 10 位时间戳(16 开头到 19 开头基本覆盖近几年) last=$(grep -oE '\b1[6-9][0-9]{8}\b' "$LOG" | tail -n 1) now=$(date +%s) echo "日志最后一条: $last" echo "系统当前: $now" echo "差值: $((now - last)) 秒"grep -oE那行的正则刻意写窄了:只匹配 10 位、以 16 到 19 开头,能滤掉请求 ID、毫秒时间戳和端口号之类的干扰。前面几章改完包之后,我一般会走一遍最小验收清单,避免“改了 A 结果 B 坏了”:
| 检查项 | 命令或动作 | 通过标准 |
|---|---|---|
| 包在正确路径 | ls -d "$HOME/Library/Screen Savers/"*.saver | 只出现一个目标目录 |
| 隔离属性已清 | xattr -l <包路径> | 无com.apple.quarantine |
| 入口类可实例化 | 进系统设置点预览 | 有内容,不是黑屏 |
| 整秒对齐 | 与终端date +%s并排看 30 秒 | 切换在同一瞬间 |
| 无旧配置残留 | defaults read "$BID" | 值符合预期或为空 |
这套东西花不了五分钟,但能挡掉绝大多数“以为成功了其实没成功”的情况。我的教训很具体:有次替换包之后直接开会投屏演示,屏保触发时数字被裁掉一截,因为新版本把字号写死成了固定像素,而预览窗口和投影分辨率不一样。从那以后我每改一版.saver,都先把xattr、killall、defaults这三条走一遍,再进预览看整秒对齐,最后才去接外屏。如果你只是想要一块能一眼读到时间戳的副屏,装上这个包、把口径定在 10 位秒级就够用了;想改布局或位数,就按第 2、4 章的路径动源码重新编译。希望帮到你。
本文还有配套的精品资源,点击获取