<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>I am LAZY bones?</title>
	<atom:link href="https://luy.li/feed/" rel="self" type="application/rss+xml" />
	<link>https://luy.li</link>
	<description>AN ancient AND boring SITE</description>
	<lastBuildDate>Fri, 31 Jul 2026 10:13:24 +0000</lastBuildDate>
	<language>zh-Hans</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	
	<item>
		<title>记一例 nginx 故障分析</title>
		<link>https://luy.li/2026/07/28/nginx-incident/</link>
					<comments>https://luy.li/2026/07/28/nginx-incident/#comments</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Tue, 28 Jul 2026 04:55:09 +0000</pubDate>
				<category><![CDATA[故障分析]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2533</guid>

					<description><![CDATA[<p>早上起来习惯性瞄一眼 home lab 的日志，看到我那个 WebSocket 服务凌晨被重启过： [cray [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/07/28/nginx-incident/">记一例 nginx 故障分析</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>早上起来习惯性瞄一眼 home lab 的日志，看到我那个 WebSocket 服务凌晨被重启过：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">[2026-07-28 06:48:02] || Server: Loaded 7 pinned rooms
[2026-07-28 06:48:02] || Server: Starting server at wss://0.0.0.0:8765</pre><p></p>
<p>重启就重启吧，本来没当回事。结果顺手打开本博客——打不开。又试了下这台机器上跑的其他站，也全打不开。好家伙，整台机器的 nginx 全躺了，而且看时间已经躺了三个多小时。</p>
<h2>先看现场</h2>
<p>第一件事是确认机器有没有重启过。<code>uptime</code> 一看，没有，机器好好的。那就是 nginx 单独出事了。<code>systemctl restart nginx</code> 下去，唰一下全恢复了。</p>
<p>服务是回来了，但这更让人不安：能 restart 成功，说明磁盘上的配置本来就是好的，那它三个小时前到底为什么起不来？翻 unit 日志：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">$ sudo journalctl -u nginx.service --since &quot;6 hour ago&quot; --no-pager
Jul 28 06:48:02 s systemd[1]: Stopping nginx.service - A high performance web server and a reverse proxy server...
Jul 28 06:48:02 s systemd[1]: nginx.service: Deactivated successfully.
Jul 28 06:48:02 s systemd[1]: Stopped nginx.service - A high performance web server and a reverse proxy server.
Jul 28 06:48:02 s systemd[1]: nginx.service: Consumed 19min 12.963s CPU time, 117.6M memory peak, 0B memory swap peak.
Jul 28 06:48:02 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server...
Jul 28 06:48:02 s nginx[2766192]: 2026/07/28 06:48:02 [emerg] 2766192#2766192: host not found in upstream &quot;bones7456.github.io&quot; in /etc/nginx/sites-enabled/luy:75
Jul 28 10:00:10 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server...
Jul 28 10:00:10 s systemd[1]: Started nginx.service - A high performance web server and a reverse proxy server.</pre><p></p>
<p><code>host not found in upstream</code>。我博客有几个路径(比如<a href="https://luy.li/2026/06/06/twgx/" title="滕王阁序">这个</a>)是反代到 GitHub Pages 的，nginx 起来的时候要解析 <code>bones7456.github.io</code>，那一刻没解析出来，直接 emerg 拒绝启动。</p>
<p>再一看，06:48 这个时间点太像 Debian 系的 cron.daily 窗口了（<code>/etc/crontab</code> 里是 6:25，加上 anacron 的随机延迟，另外 cron.weekly 是 6:47，更像），我当时第一反应是 certbot 续期把 nginx 重启崩了。结果把那五分钟的全量日志拉出来一看，跟 certbot 半毛钱关系没有。所以还是那句话：别猜，看日志。</p>
<h2>1 秒的竞速</h2>
<p>把 <code>journalctl --since "06:45" --until "06:50"</code> 全量捞出来，真凶一目了然：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">06:47:50  Starting apt-daily-upgrade.service       &lt;== unattended-upgrades 开跑
06:47:57  systemd[1]: Reexecuting requested from client PID ... (unit apt-daily-upgrade.service)
          systemd 255.4-1ubuntu8.16 running in system mode  &lt;== systemd 自己被升级了

06:48:02  ★ 满机器的服务被批量重启（needrestart 干的）
          Stopping named...  &lt;== named 被停掉，不再监听 127.0.0.1#53
          Stopping nginx...  &lt;== nginx 被停掉
          Starting nginx...  (PID 2766192) &lt;== nginx 再次启动
             └─ [emerg] host not found in upstream &quot;bones7456.github.io&quot; &lt;== nginx 报错失败了
                nginx.service: Control process exited, code=exited, status=1/FAILURE

06:48:03  Starting named...  (PID 2766508)
          named: listening on IPv4 interface lo, 127.0.0.1#53   &lt;== named 启动，DNS 回来了</pre><p></p>
<p>看明白了：systemd 被 apt 自动升级，触发 needrestart 把机器上几乎所有服务全重启一遍。我这台机器上跑着 BIND 做内网 DNS，于是 named 和 nginx 一起被拖下水。</p>
<p><strong>nginx 在 06:48:02 查 DNS，named 在 06:48:03 才恢复监听。就差 1 秒，nginx 输了这场竞速，代价是三个多小时的全站宕机。</strong></p>
<p>最扎心的是同一批重启里的对比：php-fpm、redis、mariadb，还有我自己那几个 Flask 服务，全都 <code>Started</code> 成功了。唯独 nginx 死了。为什么？因为<strong>只有 nginx 在启动阶段需要解析外部域名</strong>，别的服务都不需要。</p>
<h2>为什么 After=nss-lookup.target 没救下它</h2>
<p>这才是这次最值得说的地方。翻开 Ubuntu 自带的 nginx unit：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">[Unit]
Description=A high performance web server and a reverse proxy server
After=network-online.target remote-fs.target nss-lookup.target
Wants=network-online.target</pre><p></p>
<p><code>nss-lookup.target</code> 是 systemd 专门用来表示&#8221;域名解析已就绪&#8221;的标准锚点，DNS 服务一般会用 <code>Before=nss-lookup.target</code> 把自己挂上去。也就是说，nginx <strong>早就声明过&#8221;我要等 DNS 就绪再启动&#8221;</strong>——然而它还是死在了 DNS 上。</p>
<p>原因在于<code>After=</code> 的语义被普遍<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html">误解</a>了：</p>
<p>它只在<strong>同一个 systemd job transaction 内部</strong>编排启动先后，并不检查目标服务的实时健康状态。</p>
<p>needrestart 是逐个执行 <code>systemctl restart &lt;unit&gt;</code> 的，nginx 和 named 属于两个互相独立的 transaction，彼此之间没有任何排序约束。更要命的是，<code>nss-lookup.target</code> 是个 passive target，named 停止时它<strong>并不会跟着 deactivate</strong>，全程保持 active——于是 nginx 一查&#8221;依赖满足了吗&#8221;，满足，启动，然后一头撞死。</p>
<p>所以结论挺反直觉的：<strong>在 restart 场景下，<code>After=</code> 基本等于安慰剂</strong>。指望靠加一行 <code>After=named.service</code> 来防这类问题，是防不住的。</p>
<h2>两道防线</h2>
<p>既然顺序编排靠不住，那就换思路：一道事后自愈，一道事前免疫。</p>
<h3>防线一：让它自己重试</h3>
<p>Ubuntu 的 nginx.service <strong>默认没有 <code>Restart=</code></strong>，意味着启动失败一次就永久 failed，没有任何重试。而这次 named 只用了 1 秒就恢复——只要能重试一次，整件事根本不会发生。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">sudo mkdir -p /etc/systemd/system/nginx.service.d
sudo tee /etc/systemd/system/nginx.service.d/override.conf &lt;&lt;EOF
[Unit]
StartLimitIntervalSec=600
StartLimitBurst=20

[Service]
Restart=on-failure
RestartSec=10
EOF
sudo systemctl daemon-reload</pre><p></p>
<p><code>StartLimit</code> 那两行不是可选的：systemd 全局默认是 10 秒内最多 5 次，配上 <code>RestartSec=10</code> 会立刻撞上限流然后彻底放弃。改成 10 分钟内 20 次，才扛得住像样的 DNS 故障。</p>
<p>这里补一个 drop-in 的合并规则，很多人会搞混：</p>
<p>1. 标量指令（<code>Restart=</code>、<code>RestartSec=</code>、<code>Type=</code>、<code>PIDFile=</code>…）是<strong>覆盖</strong>。<br />
2. 列表指令（<code>After=</code>、<code>Wants=</code>、<code>Environment=</code>、<code>ExecStartPre=</code>…）是<strong>追加</strong>，不是覆盖。<del datetime="2026-07-31T10:04:14+00:00">想清空得先写一行空赋值 <code>After=</code> 再写新值</del> <strong>Update：</strong><code>After=</code>还不能被清空，详见下方评论。<br />
3. <code>ExecStart=</code> 是重灾区：<code>Type=forking</code> 下只允许一条，直接在 drop-in 里写会报错，必须先 <code>ExecStart=</code> 清空再写。</p>
<p>想看合并后到底生效了什么，别看文件，看这个：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">systemctl cat nginx                      # 看拼了哪些文件
systemctl show nginx -p After -p Restart -p RestartUSec   # 看最终生效值，配置项是 RestartSec 对应的生效值是 RestartSec</pre><p></p>
<p>还有个细节值得确认：这次失败的其实是 <code>ExecStartPre</code> 里的 <code>nginx -t -q</code>（日志里那句 <code>Control process exited, code=exited, status=1/FAILURE</code>，result 是 <code>exit-code</code>）。<code>Restart=on-failure</code> 是覆盖这种情况的，不用担心它管不到。</p>
<h3>防线二：别让 nginx 在启动期解析域名</h3>
<p>自愈只是兜底，根上的毛病是：<strong>一个 location 的 upstream 解析不了，整个 nginx 拒绝启动，机器上所有站点陪葬</strong>。nginx 的配置校验是 all-or-nothing 的，一个小站点的临时故障被放大成了全局故障。</p>
<p>解法是给 <code>proxy_pass</code> 用变量——只要 <code>proxy_pass</code> 里含变量，nginx 就不再在启动期做一次性解析，改成运行时按需查 <a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#resolver"><code>resolver</code></a>。这样 DNS 挂了 nginx 照样能起来，最坏只是那一个 location 返回 502。</p>
<p>但这里有个大坑，下面单独说。</p>
<h2>变量化 proxy_pass 的那个坑</h2>
<p>我原来的配置是这样的，注意 <code>proxy_pass</code> 后面<strong>带了路径</strong>：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">location ^~ /data/shi/ {
    proxy_pass https://bones7456.github.io/china-dynasty-timeline/;
    proxy_ssl_server_name on;
    proxy_set_header Host bones7456.github.io;
    ...
}</pre><p></p>
<p>如果你天真地把域名换成变量，写成 <code>proxy_pass https://$gh_pages/china-dynasty-timeline/;</code>，就掉坑里了。<a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass">nginx 的规则</a>是：</p>
<p>1. <code>proxy_pass</code> <strong>不含变量</strong>且带 URI → nginx 做<strong>前缀替换</strong>，把 location 匹配掉的 <code>/data/shi/</code> 换成 <code>/china-dynasty-timeline/</code>。<br />
2. <code>proxy_pass</code> <strong>含变量</strong> → 前缀替换机制<strong>彻底失效</strong>，URI 被固定成你写的那个。</p>
<p>后果是这样的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">请求 /data/shi/              原配置 &rarr; /china-dynasty-timeline/               ✓
                             变量版 &rarr; /china-dynasty-timeline/               ✓
请求 /data/shi/assets/app.js  原配置 &rarr; /china-dynasty-timeline/assets/app.js  ✓
                             变量版 &rarr; /china-dynasty-timeline/               ✗</pre><p></p>
<p>首页看着还挺正常，<strong>所有子资源全部错位</strong>——CSS、JS、图片全挂。这种&#8221;打开一看好像没事&#8221;的故障最恶心。</p>
<p>正解是用 <code>rewrite ... break</code> 手动接管路径映射，配一个<strong>不带 URI</strong> 的 <code>proxy_pass</code>。nginx 文档明确写了：<code>proxy_pass</code> 不带 URI 时，若 URI 已被 rewrite 改写，传递的就是改写后的 URI——正是我们要的。</p>
<p>先在 server 块里放解析器和变量：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">resolver 127.0.0.1 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;
set $gh_pages &quot;bones7456.github.io&quot;;</pre><p></p>
<p>然后 location 改成：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">location ^~ /data/shi/ {
    rewrite ^/data/shi/(.*)$ /china-dynasty-timeline/$1 break;
    proxy_pass https://$gh_pages;

    proxy_ssl_server_name on;
    proxy_ssl_name bones7456.github.io;
    proxy_set_header Host bones7456.github.io;

    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # 避免 GitHub Pages 返回的跳转暴露 github.io 域名
    proxy_redirect https://bones7456.github.io/china-dynasty-timeline/ /data/shi/;
    proxy_redirect https://bones7456.github.io/ /data/shi/;
}</pre><p></p>
<p>实际只动了三处：加 <code>rewrite ... break</code>、<code>proxy_pass</code> 去掉路径换成变量、显式加 <code>proxy_ssl_name</code>（它默认取 <code>$proxy_host</code>，变量化后写死更稳）。<code>proxy_redirect</code> 是字面量匹配，不受影响。查询串也会自动带过去，<code>/data/shi/a?b=1</code> 照常工作。</p>
<h2>几个坑</h2>
<p>1. <code>resolver</code> 里写多个地址是<strong>轮询</strong>，不是主备。我这里反代的是公网域名，两个 DNS 都能解析，正好互为冗余；但如果你要反代本机 named 里那些内网 zone，就绝不能这么写——轮询到 <code>1.1.1.1</code> 直接解析失败。那种 location 得单独配 <code>resolver 127.0.0.1;</code>。<br />
2. <code>ipv6=off</code> 是刻意加的。变量化之后每次请求都要重新解析，家宽 IPv6 到 GitHub 的连通性又不一定稳，少一个变数是一个。确认 IPv6 走得通再打开。<br />
3. 别指望 <code>After=</code> 能防住 restart 场景，前面说透了。它在正常开机时有用，成本为零可以留着，但不能当防线。<br />
4. <code>nginx -t</code> 通过不代表这次改对了。它只校验语法，<code>rewrite</code> 的路径映射对不对，它一个字都不会告诉你。</p>
<h2>怎么验证真的修好了</h2>
<p>改完一定要实测，光看配置&#8221;觉得对&#8221;没用。先验路径映射——<strong>必须测子路径，别只测首页</strong>：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">curl -sI https://luy.li/data/shi/ | head -3
# 从页面里抓个真实静态资源来测，期望 200 而不是 404
curl -s https://luy.li/data/shi/ | grep -oE '(src|href)=&quot;[^&quot;]+\.(js|css)&quot;' | head -3</pre><p></p>
<h2>说点本质的</h2>
<p>复盘下来，这次事故里有三个独立的问题，任意修掉一个都不会出事：nginx 没有真正可靠的 DNS 就绪依赖、配置在启动期硬依赖 DNS、失败之后没有任何重试。三个凑齐了，一次 1 秒的 DNS 抖动才放大成 3 小时的全站宕机。</p>
<p>而我最想留下的一条经验是：<strong>别把系统的健壮性寄托在&#8221;顺序&#8221;上</strong>。<code>After=</code>、<code>Before=</code>、<code>network-online.target</code> 这些东西给人一种&#8221;我已经处理好依赖了&#8221;的错觉，但它们描述的是编排意图，不是运行时的真实状态。真正靠得住的只有两种东西——<strong>失败了能自己重试</strong>（自愈），和<strong>压根不依赖那个东西</strong>（免疫）。分布式系统里讲了很多年的道理，放在单机 systemd 上一样成立。</p>
<p>最后再补一句题外话：这次是我早上&#8221;顺手看了眼日志&#8221;才发现的，不然还得挂更久。所以监控该上还是得上，healthchecks.io、UptimeRobot 之类的免费额度足够个人站用了。再优雅的配置优化，也不如有人（或者有个机器人）在你睡觉的时候盯着。</p>
<p>全文完。</p><p>The post <a href="https://luy.li/2026/07/28/nginx-incident/">记一例 nginx 故障分析</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/07/28/nginx-incident/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title>Notchy 1.3.7</title>
		<link>https://luy.li/2026/07/26/notchy-1-3-7/</link>
					<comments>https://luy.li/2026/07/26/notchy-1-3-7/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sun, 26 Jul 2026 01:45:48 +0000</pubDate>
				<category><![CDATA[精华]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2530</guid>

					<description><![CDATA[<p>上次写 Notchy 的时候（从接手到日用：我把 Notchy 改成了什么样），我自以为已经比较完善了，自己想 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/07/26/notchy-1-3-7/">Notchy 1.3.7</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>上次写 Notchy 的时候（<a href="https://luy.li/2026/06/04/notchy_now/">从接手到日用：我把 Notchy 改成了什么样</a>），我自以为已经比较完善了，自己想要的功能都有了。但后面自己深度使用以后，包括也有网友反馈，发现还是有不少细节需要打磨。</p>
<p>于是一晃一个半月过去，翻了下 git log，从 1.2.7 到 1.3.7，中间又是 23 个 commit、小一千五百行 Swift，版本号跳了整整十个小版本。。。</p>
<p>趁着 1.3.7 刚发出去，把这段时间攒的东西整理一下：</p>
<h2>标签页：从&#8221;能用&#8221;到&#8221;顺手&#8221;</h2>
<p>上次提到的 Shadow Tab（右键一个 Xcode 或 Pinned 标签页，开一个 cd 到同目录但不启动 agent 的纯 shell）用顺手之后，发现每次都要right-click 选菜单有点烦，于是加了个 Cmd+Shift+T，直接对当前标签页开一个分身。Pin、unpin、开 Shadow Tab 的时候，标签页角上也会弹一个小图标提示一下，不然经常按完不确定到底生效没有。</p>
<p>关掉当前标签页这件事之前也有点反人类：永远跳到&#8221;下一个&#8221;，跟浏览器的习惯不一样。现在改成关掉后自动回到关之前那个活跃的标签页，一路往回找，符合直觉多了。</p>
<p>标签页多了之后，顺序调整和快速定位就成了刚需。这次加了两个东西：</p>
<p>一是拖拽排序——按住标签页左右拖，松手自动补位，Cmd+1…9 的跳转序号也跟着重新编号，不用再手动数第几个。</p>
<p>二是 Cmd+K 快速切换器：弹出一个按最近使用顺序排列、支持模糊搜索的会话列表，方向键选、回车跳、Esc 取消。做这个的时候踩了个 AppKit 的老坑——SwiftUI 的浮层（QuickSwitcherOverlay）从视图树里移除之后，并不会自动把键盘焦点还给原来的终端，得在 Esc 或选中之后手动调用 makeFirstResponder 把焦点抢回来，否则切换器关了，键盘输入却发不出去，你会以为是终端卡住了。</p>
<h2>终端里能像正经终端一样用了</h2>
<p>早些版本的终端体验偏&#8221;能跑就行&#8221;，这轮补了不少 iTerm2 用户会想念的东西。</p>
<p>右键菜单是最直观的一个：复制/粘贴/全选、查词典、拿选中内容或光标下的词去搜网页、打开光标下的 URL、在 Finder 里显示当前工作目录（或复制路径）、清屏。标签页右键菜单也顺带加了&#8221;创建检查点&#8221;。</p>
<p>深压（force click）一个单词会弹出系统词典释义，跟 Safari 里的 Look Up 一模一样——原理是接进了 trackpad 的 deep-click 二段位移，从 SwiftTerm 的缓冲区里解出光标下的词，再丢给 AppKit 的 showDefinition(for:at:)。</p>
<p>Cmd+点击打开文件/链接这块改动最细，也最容易在细节上翻车。之前直接点会遇到带空格的路径识别不出来、相对路径解析错目录之类的问题。现在带引号的路径（比如截图文件名里常见的空格）能整体识别成一个链接，相对路径按 shell 当前实际所在目录解析（用 proc_pidinfo 拿真实 cwd，不是猜的），像 agent 输出里常见的 <code>Sources/File.swift:12</code> 这种&#8221;文件:行号&#8221;引用，点一下直接在 Xcode 里跳到对应行。</p>
<p>另外一个不算起眼但天天受益的：流式输出的时候终端选区不再被冲掉了。原来 SwiftTerm 只要收到新数据就会重置鼠标上报状态，顺带清掉你刚选中的文本；现在只有 vim、htop 这类真正需要鼠标事件的 TUI 才会保留上报，普通 agent 输出流不会再打断你复制粘贴。</p>
<h2>输入法这次是另一个坑</h2>
<p>上次那篇讲的输入法问题，是 SwiftTerm 的 NSTextInputClient 吞掉预编辑文本（打拼音看不到候选字下面自己打了啥）；这次修的是另一层——输入法来源（源）在标签页之间的记忆。</p>
<p>现在每个标签页会记住自己上次用的输入法：一个跑着 CLAUDE.md 项目、习惯打中文的标签页，切走切回来还是中文；旁边一个 Shadow Tab 默认停在英文，互不干扰。且只有 Notchy 面板真正拿到焦点时才会去改系统输入法，不会误动其他 App 正在用的输入法。</p>
<p>这个功能刚上的时候有个隐蔽 bug：macOS 的 panelDidResignKey 有时候一次失焦会触发两次，第二次触发时外部输入法已经被恢复了，结果把&#8221;外面那个 App 的中文输入法&#8221;错记成了这个标签页的输入法，污染了原本该是英文的 Shadow Tab。加了个 idempotent 保护（guard isPanelKey）才算收住。</p>
<p>同一批还加了 Quick Input：自己绑快捷键到预设命令，按一下自动敲进当前聚焦的终端（内置了 Cmd+G → git status），要不要自动回车可以按行配置，在 Settings → Quick Input 里随便增删，也可以整体关掉。</p>
<h2>边边角角的细节</h2>
<p>一些不太会单独写文章但用起来很舒服的小改动：</p>
<p>Xcode 工程在磁盘上被挪了地方之后，对应标签页现在会自动刷新到新路径——之前是按项目名字匹配的，工程一挪，标签页就一直 cd 到一个不存在的旧目录。</p>
<p>拖动窗口或者用 Cmd +/-/0 缩放字体的时候，HUD 提示会顺带把终端的字符行列数也标出来：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">720 &times; 400  (120&times;40)</pre><p></p>
<p>外接显示器没接的时候，相关开关会自动置灰并给提示，接上拔下都会跟着实时更新，不用再对着一个点了没反应的开关发呆。</p>
<p>还有个不起眼但对 agent 体验有直接影响的：内嵌终端之前没有正确设置 TERM_PROGRAM，claude 这类会识别宿主终端类型的 CLI 只能退回去看原始的 $TERM 值，现在能正确认出自己是跑在 Notchy 里。</p>
<h2>顺手修的一堆 bug</h2>
<p>这段时间里还有几个纯粹的显示 bug，挑重点提一句：设置窗口以前是 floating 层级，会盖住其他 App 窗口，改成 normal 就好了；改字体大小之后终端字符被滚动条挡住的问题修了；终端往上滚看历史的时候，敲的字没有正确回显到屏幕上的问题也修了——这几个都是 SwiftTerm 底层渲染逻辑的坑，改动细节没什么好展开的，能用就完事了。</p>
<p>—</p>
<p>十个版本攒下来，Notchy 离&#8221;能用&#8221;更远了一步，离&#8221;顺手&#8221;近了一大截。想试试的话去 <a href="https://github.com/bones7456/notchy">GitHub</a> 下最新的 DMG 或 ZIP。</p><p>The post <a href="https://luy.li/2026/07/26/notchy-1-3-7/">Notchy 1.3.7</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/07/26/notchy-1-3-7/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>代码库放 iCloud 文件夹会怎样？</title>
		<link>https://luy.li/2026/07/17/code_shoud_not_in_icloud/</link>
					<comments>https://luy.li/2026/07/17/code_shoud_not_in_icloud/#comments</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Fri, 17 Jul 2026 15:15:31 +0000</pubDate>
				<category><![CDATA[经验技巧]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2522</guid>

					<description><![CDATA[<p>之前我有个习惯，会把代码（整个repo）放到 iCloud 管理的 ~/Documents 下。一直觉得挺方便 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/07/17/code_shoud_not_in_icloud/">代码库放 iCloud 文件夹会怎样？</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>之前我有个习惯，会把代码（整个repo）放到 iCloud 管理的 <code>~/Documents</code> 下。一直觉得挺方便的，因为即使没有 commit+push，我到另一台电脑上也能接着干活，登了同一个iCloud账号的目录会自动同步。</p>
<p>但最近遇到一个特别邪门的问题。一个用 uv 管理的 Python 项目，editable 方式安装，前一个小时还好好的，突然就：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">$ .venv/bin/mycli --help
Traceback (most recent call last):
  File &quot;.venv/bin/mycli&quot;, line 4, in &lt;module&gt;
    from mycli.cli import main
ModuleNotFoundError: No module named 'mycli.cli'</pre><p></p>
<p>邪门在哪呢？同一个 venv、同一个解释器，pytest 跑起来一千六百多个用例全绿。测试说包在，入口脚本说包没了。去 site-packages 里看，editable 安装落下的 .pth 文件好好躺在那，内容就是一行指向源码目录的路径，权限 644，路径也真实存在。文件在、内容对、权限对，但它就是不生效。<br />
<img fetchpriority="high" decoding="async" src="https://luy.li/wp-content/uploads/2026/07/icloud_venv_blog_cover.png" alt="" width="1200" height="630" class="alignnone size-full wp-image-2524" /></p>
<h2>排查：谁把我的 .pth 吞了</h2>
<p>先补一句背景：editable 安装的机制，是往 site-packages 里放一个 .pth 文件，Python 启动时 <a href="https://docs.python.org/zh-cn/3/library/site.html">site 模块</a>会读它，把里面的路径追加进 sys.path。这个文件失效，包就从解释器眼里消失。</p>
<p>做了三个对照实验，结果非常有意思：</p>
<p>1. 自己写一个探针 .pth（内容随便指个存在的目录）放进同一个目录——生效；<br />
2. 把出问题的 .pth 原样 cp 成另一个文件名——生效；<br />
3. 出问题的那个文件本身——死活不生效。</p>
<p>同目录、同内容、同权限，副本能用原件不能用，那唯一的区别只剩下文件的元数据了。ls 加个 -O 把 BSD 文件标志打出来：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">$ ls -lO .venv/lib/python3.12/site-packages/*.pth
-rw-r--r--@ 1 me  staff  hidden  56 Jul 16 17:42 _editable_impl_mycli.pth
-rw-r--r--@ 1 me  staff  hidden  18 Jul 16 17:45 _virtualenv.pth</pre><p></p>
<p>flags 一栏赫然写着 hidden——macOS 的 UF_HIDDEN 文件标志。cp 不会复制 BSD flags，所以副本是干净的，这就解释了实验 2。而 CPython 从 3.11 起，<a href="https://github.com/python/cpython/blob/main/Lib/site.py">site.py</a> 处理 .pth 时加了一条安全加固（防止恶意软件用隐藏 .pth 做隐蔽注入）：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">if ((getattr(st, 'st_flags', 0) &amp; stat.UF_HIDDEN) or
    (getattr(st, 'st_file_attributes', 0) &amp; stat.FILE_ATTRIBUTE_HIDDEN)):
    _trace(f&quot;Skipping hidden .pth file: {fullname!r}&quot;)
    return</pre><p></p>
<p>带隐藏标志的 .pth，静默跳过，一个字的错都不报。两个各自都算合理的行为叠在一起，效果就是：文件在，import 没了。</p>
<p>那谁给文件打的 hidden？chflags nohidden 清掉，几秒钟之后再看，又变回 hidden 了——有个进程在实时跟我对抗。答案到这基本明了：这个仓库在 ~/Documents 底下，而这台 Mac 开着 iCloud 的<a href="https://support.apple.com/zh-cn/HT206985">&#8220;桌面与文稿&#8221;同步</a>。把仓库挪出 Documents、重建 venv，flags 干干净净，问题再没复发。</p>
<h2>iCloud Drive 到底在做什么</h2>
<p>开了&#8221;桌面与文稿&#8221;同步之后，~/Documents 就不再是普通目录，而是由 fileproviderd 守护进程托管的同步空间。它主要干三类事：</p>
<p>1. 监听并上传每一次文件变更——它假设文件是&#8221;偶尔被人编辑的文档&#8221;；<br />
2. 冲突消解——当它认为同一个文件出现两个竞争版本，不丢弃任何一边，而是生成&#8221;xxx 2&#8243;&#8221;xxx 3&#8243;这样的冲突副本；<br />
3. 元数据管理——给托管文件打 xattr 和文件标志（上面那个 hidden 就是它干的），开了&#8221;优化 Mac 存储&#8221;还会把冷文件驱逐成无数据的占位符，读取时才按需下载。</p>
<p>对文档来说这些设计都挺好。但代码库不是文档。</p>
<h2>代码库为什么全中</h2>
<p>第一，写入模式冲突。开发工具链的写入是高频、批量、依赖原子性的：包管理器一次重写上万个小文件，Python 用&#8221;临时文件 + rename&#8221;做原子写，git 靠锁文件和 rename 更新引用。同步进程和这些写入异步竞争，竞争输了就落冲突副本。我这次在 site-packages 里就看到了 &#8220;_editable_impl_mycli 2.pth&#8221;、&#8221;3.pth&#8221;、&#8221;4.pth&#8221; 一窝副本，主 .pth 的内容更是被拼接成了四份路径首尾相连的乱码：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">/Users/me/Documents/dev/mycli/src/Users/me/Documents/dev/mycli/src/Users/me/...</pre><p></p>
<p>第二，元数据篡改。就是前面 UF_HIDDEN 那一段，而且它是主动维护的，你清掉它还会打回来。</p>
<p>第三，驱逐。.git/objects 里的 packfile、venv 里的二进制，都可能被&#8221;优化存储&#8221;驱逐成占位符——在线时表现为构建随机卡顿，离线时表现为仓库&#8221;损坏&#8221;。</p>
<p>第四，git 自己的风险。.git 里的 index、refs 一旦出现冲突副本，仓库状态可能真损坏。git 本身就是分布式同步工具，外面再套一层文件级同步，等于双重同步，语义必然打架。</p>
<h2>这次踩到的坑</h2>
<p>1. chflags nohidden 修不好——几秒内被 fileproviderd 顶回来，别在这条路上浪费时间；<br />
2. 症状自相矛盾极具误导性：pytest 全绿（它靠 rootdir 机制自己把源码目录塞进了路径），入口脚本却挂了，两个工具对&#8221;包是否存在&#8221;给出相反答案，直觉上会先怀疑一万个别的东西，最后才怀疑文件系统在说谎；<br />
3. 损坏是异步、随机发生的：同一个 venv 一小时内坏了两次，中间检查全都正常——因为坏不坏取决于同步进程什么时候追上来；<br />
4. 诊断口诀：文件明明在但 import 不到，先 ls -lO 看 flags 列。</p>
<h2>结论</h2>
<p>iCloud（以及 Dropbox、OneDrive 这些文件级同步盘，机制细节不同但三板斧一样）适合放终态文档：文稿、图片、表格。任何带衍生状态的目录——代码库、venv、node_modules、.git、构建缓存——都不该放进去。代码的跨机同步交给 git，这本来就是它的职责，而且语义正确。仓库放个非托管路径（比如 ~/dev）就好；实在有目录必须留在 iCloud 里又想排除，macOS 没有官方排除项，只有给目录名加 .nosync 后缀这个 hack。</p>
<p>一个按文档假设设计的同步器，遇上一堆违反它全部假设的文件，双方都没有 bug，组合在一起就是灾难。就此，完毕。</p><p>The post <a href="https://luy.li/2026/07/17/code_shoud_not_in_icloud/">代码库放 iCloud 文件夹会怎样？</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/07/17/code_shoud_not_in_icloud/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>每日一辨 DailyDiff</title>
		<link>https://luy.li/2026/07/11/dailydiff/</link>
					<comments>https://luy.li/2026/07/11/dailydiff/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sat, 11 Jul 2026 02:52:24 +0000</pubDate>
				<category><![CDATA[AI]]></category>
		<category><![CDATA[精华]]></category>
		<category><![CDATA[学习打卡]]></category>
		<category><![CDATA[独立开发]]></category>
		<category><![CDATA[英语app推荐]]></category>
		<category><![CDATA[英语单词]]></category>
		<category><![CDATA[英语学习]]></category>
		<category><![CDATA[近义词辨析]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2519</guid>

					<description><![CDATA[<p>最近上架了一个新 App：DailyDiff（每日一辨）。做它的初衷很简单：一是我自己想把英语再往上提一提，二 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/07/11/dailydiff/">每日一辨 DailyDiff</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>最近上架了一个新 App：<a href="https://dailydiff.vip/?src=luyli">DailyDiff（每日一辨）</a>。做它的初衷很简单：一是我自己想把英语再往上提一提，二是儿子也在学英语，我想给他（也给自己）找一个每天花几分钟、细水长流的练法。</p>
<p>市面上背单词的软件很多，但它们解决的基本都是&#8221;认识&#8221;：看见 soldier 知道是战士，看见 warrior 也知道是战士。可这两个词的区别是什么、什么场合该用哪个——这种&#8221;掌握&#8221;层面的功夫，几乎没有软件管。而&#8221;认识一个单词&#8221;和&#8221;掌握一个单词&#8221;之间恰恰隔着一条鸿沟，它决定了你的英语是只能读，还是真的能用。所以我做了一个专门练这个的 App。</p>
<h2>每天一道题，它长什么样</h2>
<p>玩法抄了 Wordle 的作业：每天全球所有人拿到同一道题——一对容易混淆的英语近义词，各配一张黑白线稿插图。你用英语写出这两个词在含义、语气、用法上的区别，一两句话就行，然后 AI 给你批改。</p>
<p>批改是这个 App 的核心。不是给个分就完事，而是从五个维度（准确性、覆盖度、语言、清晰度、洞察）各打一个 0–100 的分，每个维度都附一句针对你这次答案的点评——指出你答到位的地方、漏掉的要点，像一对一外教改作业。五个分数汇总成总分和等级，从 D 到 SSS。SSS 很难拿：不光要总分 95 以上，还要求最低的那个维度也不低于 90，纯靠某一项拉平均分是蒙不到的。</p>
<p>批改完揭晓标准答案，对照着学。之后就是熟悉的套路：每日连击打卡、日历一格格点亮，还能导出一张成绩卡分享——卡上只有词对、插图、等级和雷达图，刻意不含答案，朋友看到照样能去玩。</p>
<p>题库目前 200 道，四百张插图全是 AI 生成的黑白线稿，风格统一得像一个插画师画的。</p>
<h2>技术上几个有意思的决定</h2>
<p>作为技术博客，还是得聊聊后端。整个服务端就是一个 <a href="https://workers.cloudflare.com/">Cloudflare Worker</a>，配 D1（题库和用户数据）和 R2（插图），没有一台服务器要运维，账单基本可以忽略——独立开发选这套栈真的省心。</p>
<p>我之前的服务，都是部署在自己的home lab上的，这也是一次全套采用cloudflare的技术方案，发觉这个赛博菩萨果然名不虚传，免费额度够用不说，wrangler的开发体验还非常棒！</p>
<p>几个细节自认为做得还算讲究：</p>
<p>1. 评分标准（rubric）永远不下发到客户端。客户端只上传题目 ID 和你写的答案，评分要点由服务端按 ID 查表后喂给模型。否则抓个包就能看到得分点，这游戏就没法玩了。</p>
<p>2. 总分和等级不信任模型自己报的数。LLM 的算术是出了名的不可靠，让它算五个维度的加权和，隔三差五给你算错一个。所以模型只负责逐维度打分和写点评，加总、定级在代码里重算。</p>
<p>3. 匿名用户不用注册就能玩，每天免费批改一次。防滥用靠的不是强制登录，而是 Apple 的 App Attest——让苹果证明请求来自真机上的正版 App，机器人和脚本过不来。这套东西的原理和服务端验证的坑，我之前单独写过一篇<a href="https://luy.li/2026/06/14/attest/">《Apple App Attest 简介》</a>，感兴趣可以看看。</p>
<p>4. 批改额度是&#8221;先预扣、失败退还&#8221;。最早的实现是先只读检查额度、批改成功了再扣，看起来很稳妥——但批改一次要跑上一两分钟，这个窗口里并发发请求，每个请求检查时都显示&#8221;还有额度&#8221;，就都放行了，白烧 token。改成预扣制之后，靠 D1 单写者的串行化保证并发下最多放行额度上限那么多个请求，批改失败再把额度退回去。</p>
<h2>免费与收费</h2>
<p>说说钱的事，明码标价：每天的题目永远免费，匿名一天能批改 1 次，登录后 2 次。Pro 订阅（月付或年付）解锁 200 道历史题库、研读模式（先看标准答案再作答）和每天 15 次批改。订阅收入拿去付 AI 推理的账单——每一次批改都是真金白银的 API 调用，所以免费额度给得抠门，请理解哈哈。</p>
<h2>来玩</h2>
<p>App Store 搜「DailyDiff」或「每日一辨」，或者直接点<a href="https://apps.apple.com/app/apple-store/id6780440998?pt=126682695&#038;ct=luyli&#038;mt=8">这个链接</a>。中英文界面都有，今天的题不用注册就能做。</p>
<p>如果你试了之后觉得 AI 批改哪次明显不靠谱，或者有任何建议，欢迎邮件 support@dailydiff.vip 或者直接在下面留言——独立开发，每一条反馈我都会看。</p>
<p>顺便说一句，今天的题你打算拿几分？我至今没拿到过自己 App 里的 SSS。</p>
<p>就此，完毕。</p><p>The post <a href="https://luy.li/2026/07/11/dailydiff/">每日一辨 DailyDiff</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/07/11/dailydiff/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>fastlane——App Store Connect CLI（非官方）</title>
		<link>https://luy.li/2026/07/04/fastlane/</link>
					<comments>https://luy.li/2026/07/04/fastlane/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sat, 04 Jul 2026 01:34:44 +0000</pubDate>
				<category><![CDATA[AI]]></category>
		<category><![CDATA[CLI软件]]></category>
		<category><![CDATA[经验技巧]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2516</guid>

					<description><![CDATA[<p>我的打鼾监测 App NightSnore 支持 7 种语言（简中、英、日、韩、德、法、阿拉伯语）。这带来一个 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/07/04/fastlane/">fastlane——App Store Connect CLI（非官方）</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>我的打鼾监测 App <a href="https://nightsnore.com">NightSnore</a> 支持 7 种语言（简中、英、日、韩、德、法、阿拉伯语）。这带来一个每次发版都要经历的痛苦环节：在 App Store Connect 后台，把 What&#8217;s New（新功能介绍）逐个语言粘贴进去——切语言、粘贴、保存，再切下一个，七遍。要是 Promotional Text（推广文本）也更新了，那就是十四遍。发布过APP的朋友，肯定对此就深有体会了。</p>
<p>这次发 2.3.0 的时候我终于忍不住了：这玩意儿就没有 CLI 能自动化吗？</p>
<p>还真有，而且就是 fastlane。有意思的是，fastlane 我其实早就装了——之前一直拿它给 App Store 截图加设备边框（frameit），我一直以为它就是个截图美化工具，哈哈。这次才发现，截图加框只是它十八般武艺里最不起眼的一样。</p>
<h2>fastlane 到底是什么</h2>
<p><a href="https://fastlane.tools">fastlane</a> 的定位是&#8221;把 iOS/Android 发布流程的每个环节都变成可脚本化的命令&#8221;。它其实是一整套工具的集合，每个工具管一段：</p>
<p>1. deliver：上传元数据（What&#8217;s New、描述、关键词、截图）到 App Store Connect，本文主角。<br />
2. snapshot：跑 UI 测试自动截图，能覆盖每种语言 × 每种设备尺寸。<br />
3. frameit：给截图加设备边框，我之前唯一用过的那个。<br />
4. gym / pilot：打包上传、TestFlight 分发和测试员管理。<br />
5. match / cert / sigh：证书和描述文件的团队共享管理。<br />
6. precheck：上传前扫描文案里的审核高危词。</p>
<p>单人开发、Xcode 自动签名的话，match 这类团队工具基本用不上；但 deliver 对多语言 App 来说是刚需级的效率工具。</p>
<h2>deliver：把 ASC 表单变成本地文件</h2>
<p><a href="https://docs.fastlane.tools/actions/deliver/">deliver</a> 的思路很直接：ASC 后台的每个表单字段，对应本地一个文本文件，目录按语言组织：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">fastlane/metadata/
├── zh-Hans/
│   ├── release_notes.txt      &larr; What's New
│   └── promotional_text.txt   &larr; 推广文本
├── en-US/
├── ja/
├── ko/
├── de-DE/
├── fr-FR/
└── ar-SA/</pre><p></p>
<p>一个很贴心的设计是：目录里有什么文件，它就只上传什么。我只放了 release_notes.txt 和 promotional_text.txt，那么描述、关键词、截图这些都不会被碰。配置文件 Deliverfile 里再把二进制和截图明确跳过：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">app_identifier &quot;Senob.NightSnore&quot;

skip_binary_upload true
skip_screenshots true
force true                        # 跳过上传前的 HTML 预览确认
run_precheck_before_submit false
submit_for_review false           # 只填表单，提交审核仍手动</pre><p></p>
<h2>搭一条 What&#8217;s New 流水线</h2>
<p>我的 App Store 文案一直维护在仓库的 AppStore/*.md 里（七个语言各一个文件），每次发版往里追加一段&#8221;## What&#8217;s New (X.Y.Z)&#8221;。这个 Markdown 就是唯一数据源，所以流水线只需要一个提取脚本：从 md 里抠出指定版本的段落，写到 deliver 要的 metadata 目录去。再包一个 fastlane lane 串起来：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">lane :whatsnew do |options|
  version = options[:version] || get_version_number(
    xcodeproj: &quot;NightSnore.xcodeproj&quot;,
    target: &quot;NightSnore&quot;
  )

  # 从 AppStore/*.md 生成 fastlane/metadata/&lt;locale&gt;/release_notes.txt
  sh(&quot;python3&quot;, &quot;../utils/whatsnew_sync.py&quot;, version)

  deliver(
    api_key_path: &quot;fastlane/api_key.json&quot;,
    app_version: version
  )
end</pre><p></p>
<p>提取脚本里顺手做了两层校验：七个语言缺任何一个对应版本的段落就直接报错（强制多语言同步，防漏），超过 ASC 的字符上限（What&#8217;s New 4000 字符、Promotional Text 170 字符）也直接拦下。以后发版就是一条命令：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">$ fastlane whatsnew version:2.3.0
[18:02:20]: ▸ 同步版本 2.3.0 的 What's New + Promotional Text &rarr; fastlane/metadata/
[18:02:20]: ▸ zh-Hans  notes  240 字符 / promo  57 字符
[18:02:20]: ▸ en-US    notes  659 字符 / promo 158 字符
...
[18:02:29]: Uploading metadata to App Store Connect for localized version 'ja'
[18:02:29]: Uploading metadata to App Store Connect for localized version 'ar-SA'
[18:02:31]: <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 2.3.0 七语言 What's New 已同步到 ASC
[18:02:31]: fastlane.tools finished successfully <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f389.png" alt="🎉" class="wp-smiley" style="height: 1em; max-height: 1em;" /></pre><p></p>
<p>从跑命令到 ASC 七个语言全部填好，9 秒。之前手动粘贴至少十分钟，还得祈祷别粘串了语言。</p>
<h2>API Key，和一个专门坑你的格式问题</h2>
<p>deliver 走的是 App Store Connect API，需要一个 API 密钥：ASC 后台&#8221;用户和访问 → 集成 → App Store Connect API&#8221;里创建一个团队密钥，角色选 App Manager，会得到一个 .p8 私钥文件（只能下载一次）加 Key ID 和 Issuer ID。</p>
<p>然后我就结结实实踩了个坑。deliver 支持用一个 JSON 文件传密钥，我很自然地写成了指向 .p8 文件路径的形式，结果：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">Spaceship::ConnectAPI::Token.from_json_file':
[!] App Store Connect API key JSON is missing field(s): key (RuntimeError)</pre><p></p>
<p>查了才知道：fastlane 的 Fastfile 里有个 app_store_connect_api_key 这个 action，它支持 key_filepath 参数指向 .p8 文件；但 deliver 的 api_key_path 参数指向的 JSON 文件，只认内联的 key 字段——你得把 .p8 的 PEM 内容整个塞进 JSON 字符串里（换行转成 \n）。同一个工具链里两种密钥写法长得几乎一样但互不兼容，这不纯纯挖坑嘛。正确格式长这样：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">{
  &quot;key_id&quot;: &quot;ABCD123456&quot;,
  &quot;issuer_id&quot;: &quot;12345678-abcd-....&quot;,
  &quot;key&quot;: &quot;-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----&quot;,
  &quot;in_house&quot;: false
}</pre><p></p>
<p>这个文件含私钥，务必进 .gitignore，待遇跟你的其他 secrets 一样。</p>
<h2>踩坑与注意事项</h2>
<p>1. deliver 只能写&#8221;可编辑状态&#8221;的版本（准备提交、被拒等），已在审核中或已上架的版本改不了。所以要在上传 build 之后、点提交审核之前跑它；版本还没建也没关系，deliver 会自动创建。</p>
<p>2. locale 代码不都带地区后缀：日语是 ja 不是 ja-JP，韩语是 ko，但英语是 en-US、德语是 de-DE。写映射表的时候留意。</p>
<p>3. metadata 目录里有什么就传什么，这既是特性也是风险：目录里残留一个过期的 description.txt，就会把线上描述覆盖掉。我的做法是 metadata 目录整个 gitignore，每次由脚本从 md 重新生成，保证它永远是纯派生产物。</p>
<h2>用 skill 操作，vibe coding 更顺畅</h2>
<p>还有一层我觉得比工具本身更有意思：这整条流水线，从功能开发、写七语言文案，到搭 fastlane、踩坑、修好，都是在 Claude Code 里完成的。搭好之后我顺手让它把整个发版流程沉淀成一个项目 skill——仓库里的一个 .claude/skills/release/SKILL.md 文件，把九个步骤写成清单：bump 版本号 → 编译验证 → 七语言 What&#8217;s New / Promotional Text → commit → xcodebuild 归档上传 → 打 tag → fastlane 同步 ASC 表单，连&#8221;deliver 的 JSON 只认内联 key&#8221;这种坑位说明都写在里面。</p>
<p>下次发版，我只需要说一句 /release 2.4.0，AI 就照着清单把整条链路跑完，唯一剩下的手动操作是去 ASC 点提交审核。skill 跟着仓库走，clone 下来就有，等于把发版的&#8221;部落知识&#8221;固化成了可执行的文档。对 vibe coding 来说这很关键：写代码交给 AI 大家都会了，但发布环节往往还是人肉在各个后台之间点来点去——把这段也纳入对话式工作流，从开发到上架才算真正闭环。</p>
<h2>下一个目标</h2>
<p>尝到甜头之后我看了一圈 fastlane 工具箱，对我这种多语言独立开发场景，下一个最值得上的是 <a href="https://docs.fastlane.tools/actions/snapshot/">snapshot</a>：七个语言的商店截图现在还是手动截的，每次界面大改就是一下午。snapshot 跑 UI 测试自动出全语言截图，再接上我已经会用的 frameit 加框、deliver 上传，理论上截图这条线也能变成一条命令。挖个坑，做完再写。</p>
<p>发布流程自动化这件事，本质上是把&#8221;每次发版都要凭记忆和手感重复一遍的操作&#8221;变成&#8221;写一次、以后白嫖&#8221;的脚本。对独立开发者来说，省的那十几分钟是小事，真正值钱的是不再需要担心&#8221;这次是不是又漏了哪个语言&#8221;——机器不会漏。</p>
<p>就此，完毕。</p><p>The post <a href="https://luy.li/2026/07/04/fastlane/">fastlane——App Store Connect CLI（非官方）</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/07/04/fastlane/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
