从接手到日用:我把 Notchy 改成了什么样
还记得上次那篇吗?当时我接手 Notchy 的时候,基本就是原作者 Adam Lyttle 的初始版本——点子非常好,但功能比较基础,bug 也不少。我本来只是想”修修 bug,打个包”就完事了。
结果一改就停不下来了。
55 个 commit、4600 多行 Swift 之后(当然大部分都是 vibing 的),Notchy 已经从一个”能用”的 demo 变成了我日常干活的主力终端。是的,之前我还是混着状态,现在 iTerm2 已经从 Dock 上消失了。
这篇就来聊聊,到底改了些啥,才让我有底气做出这个切换。
Terminal UX:从”能打字”到”能干活”
原版的终端体验非常朴素——打开一个黑框,能输入命令,仅此而已。要把它当日用终端,差的东西太多了。
动画和视觉:面板从菜单栏后面滑出来(slide-down),背景是 NSVisualEffectView 的毛玻璃效果。看起来比较像一个系统原生组件,而不是一个第三方窗口硬贴在那里。
快捷键:这是最影响手感的部分。
- 全局热键
Ctrl+`呼出/收起面板,任何应用中随时可用 Cmd+1..9切 tab,Cmd+W关 tab,Ctrl+Tab和Ctrl+Shift+Tab循环切换Cmd++/Cmd+-缩放字体(全局生效,持久化),Cmd+0重置Shift+Enter发送换行而不是提交(通过 kitty CSI u 协议实现),这对 Claude Code 的多行输入至关重要Cmd+Backspace清行(发 Ctrl-U)- Copy-on-selection,选中即复制,iTerm2 用户的肌肉记忆
滚动:这块踩了不少坑。原版在 TUI 应用(比如 Claude Code 自己的界面)里滚动完全不工作。修了 alternate screen buffer 的滚轮转发,修了自动跟随输出的逻辑(在底部时跟随新输出,在回看历史时保持位置不动),还修了退出 vim/less 之后视口跳到顶部的 bug——这个 bug 的原因是 alt buffer 的 yDisp 始终是 0,退出时被误判为”用户在回看滚动历史”。Scrollback buffer 大小也做成了可配置的(默认 1000 行,最大 50000)。
字体:支持 Nerd Font,Powerline 图标正常显示。
从 Claude 专属到多 Agent 支持
原版 Notchy 是纯粹为 Claude Code 设计的——检测到 CLAUDE.md 就自动启动 claude,写死的,没有别的选项。
但现实是,越来越多人在用不同的 AI coding agent。OpenAI 的 Codex 出来之后,我公司也给我们同时配备了Claude 和 Codex,我会在不同项目中用不同的agent,Notchy应该能做到自动判断:
- 项目里有
CLAUDE.md→ 启动claude - 项目里有
AGENTS.md→ 启动codex - 两个都有 → 看 Settings 里的 Preferred Agent 设置来决定
- 两个都没有 → 不启动,给你一个普通 shell
终端状态检测也做了相应适配。原版只认 Claude 的输出模式(大写的 Esc to interrupt、Esc to cancel 等),Codex 的输出格式不一样——小写的 esc to cancel、you approved … to run …、Conversation interrupted。现在都能正确识别,notch 上的状态指示对两个 agent 都能工作。
这个改动的价值在于:Notchy 不再是一个”Claude Code 的前端”,而是一个通用的 AI coding agent 终端。以后再出新的 agent,加个 case 就行。
Tab 管理:三种 Tab,各司其职
原版只有 Xcode 自动检测的 tab。我加了一套完整的 tab 类型系统:
- Xcode tab(青色边框):自动创建,跟 Xcode 项目生命周期绑定
- Pinned tab(橙色边框):手动固定的 tab,跨重启持久化。固定时会通过
proc_pidinfo快照当前 shell 的 CWD,重启后自动cd回去并重新检测 AI agent,适用于非 Xcode 的项目。 - Normal tab(无边框):
+按钮创建的临时 tab,关掉 app 就没了
另外加了 Shadow Tab——右键一个 Xcode 或 Pinned tab,选 Shadow Tab,会在旁边开一个 plain shell,cd 到同一个目录但不启动 Claude/Codex。跑 git status、npm run build 这种临时命令特别方便,不用打断正在工作的 agent。名字后面会加个 $ 后缀以示区分。
关 Pinned 和 Xcode tab 之前会弹确认框,防止手滑。这些 tab 带着恢复状态,误关了成本很高。
IME 输入法支持
SwiftTerm 的 NSTextInputClient 实现有问题,输入法的 marked text(预编辑文本)直接被吞掉了。打拼音的时候只能看到候选窗,看不到自己输入了什么。
第一版我做了一个 HUD 风格的浮动面板,显示在光标上方。后来改成了 inline 渲染,和 macOS Terminal.app 的行为一致——用终端前景色画文字,背景色填充遮住底下的块状光标。视觉上自然多了。
这个功能对中文用户来说是刚需。
自动更新 (Sparkle)
手动下载更新太烦了,用户也不会主动去看 GitHub Releases。所以集成了 Sparkle——macOS 上事实标准的自动更新框架。
这块的详细过程我单独写了一篇:给 macOS App 加自动更新:Sparkle 入门。大家可以参考这里。
CI/CD 发布流水线
推一个 v* tag 到 GitHub,Actions 自动搞定剩下的事:
xcodebuild archive构建并用 Developer ID Application 签名notarytool提交公证(Apple 审查恶意代码)- 打包成 DMG 和 ZIP
- 用 EdDSA 私钥签名 ZIP,生成
appcast.xml - 把所有产物挂到 GitHub Release 上
如意要长期维护这个应用,这些都是必不可少的基础设施了。
其他细节
- 外接显示器支持:接了外接显示器(比如 Studio Display)的时候,鼠标悬停在外屏顶部中央(摄像头区域)也能唤出面板,和 MacBook notch 的交互保持一致
- 通话静音:检测到麦克风在使用(Zoom、FaceTime 等),自动把 Notchy 的提示音静音,不会在开会的时候突然”叮”一声
- Checkpoint 增强:加了一个 popover 列出所有 checkpoint,可以浏览、恢复、删除任意一个,不再只能操作最近的那个
- Settings 窗口:从一个简单的菜单 toggle 变成了完整的 Settings 窗口(
Cmd+,),分 General / Integrations / About 三个 tab - Notch 动画优化:改成更平滑的 ease-in-out 曲线,修了 notch 和屏幕顶部之间的缝隙,修了 hover → click 模式切换时 notch 缩小的问题
- 面板大小持久化:拖动调整大小后会记住,下次打开恢复。调整时右上角还会显示尺寸指示
为什么能替代 iTerm2
这个问题的答案很简单:我日常用终端 90% 的场景是跑 AI coding agent。
在这个场景下,Notchy 比 iTerm2 好用。Ctrl+` 一按就出来,不用切窗口;Xcode 项目自动检测,不用手动 cd;agent 自动启动,不用手动输命令;状态一目了然,notch 上的小药丸告诉你 agent 是在干活还是在等你。
剩下 10% 的临时命令?Shadow Tab 搞定。
当然,如果你的主要场景是 SSH 管理十几台服务器、或者需要 tmux 分屏,iTerm2 仍然是更好的选择。但如果你和我一样,日常就是在本地项目里跑 Claude Code 或 Codex——试试 Notchy 吧。
GitHub: bones7456/notchy,非常欢迎提issue、MR等。。。
安装方式:去 Releases 下载 DMG 或 zip,拖进 /Applications 就行。因为签名、公证过,所以不会弹 Gatekeeper 警告。
全文完。
给 macOS App 加自动更新:Sparkle 入门
最近给 Notchy 加了自动更新。原先的”检查更新”功能很糙:调一下 GitHub Releases API 看有没有新版本,发现有就开浏览器跳到 release 页面,让用户自己下 dmg、自己拖进 Applications。
这显然算不上”自动”。理想体验是:发现新版本 → 自动下载 → 校验签名 → 替换 App → 重启。这一套自己写也可以,但 macOS 生态二十年前就有现成的轮子 —— Sparkle。
接进去之后回头看,整个事情比想象中简单,但中间踩了几个不算直觉的坑,整理一下。

Sparkle 是什么
Sparkle 是 macOS 上事实标准的开源自动更新框架,从 2006 年用到现在。Transmission、VLC、Handbrake、Sequel Pro、Tower、Hammerspoon、iStat Menus …… 基本上你在 App Store 之外用过的、有自动更新的 Mac App,十有八九背后都是它。
工作流大致这样:
- App 启动或定时,Sparkle 去服务器拉一份
appcast.xml - xml 里列着最新版本号、下载地址、文件大小、加密签名
- 客户端对比版本,发现有更新就弹框问用户
- 用户点”安装”,Sparkle 后台下载 zip、校验签名是不是你这个开发者签的、没问题就解压、替换
.app、关掉旧进程、启动新的
对用户来说就是一个对话框加一次重启。对开发者来说就是发版时多产出一份 appcast.xml。
谁适合用
两种情况不太适合:上架 Mac App Store 的 App(商店本身有更新机制,且 App Store 也不允许 App 自己拉远端代码下来执行),以及强沙盒 App(Sparkle 2 技术上支持沙盒,但配置麻烦得多,要走 XPC service 隔离)。
比较适合的:自己 Developer ID 签名 + 公证 + 通过官网或 GitHub Releases 分发的独立 Mac App。这也是 Notchy 这类小工具最常见的发布方式。
什么是 EdDSA
Sparkle 在客户端校验更新包真伪用的是 EdDSA 签名。这一步对安全至关重要 —— 没有它,任何能劫持你 appcast URL 的人都能给你的用户推一个伪造的更新包。
EdDSA(Edwards-curve Digital Signature Algorithm)是基于椭圆曲线的数字签名算法。相比经典的 RSA,密钥短得多(公私钥各 32 字节,base64 后大约一行),签名快,对边信道攻击有自带防御。
具体在 Sparkle 里:
- 你本机生成一对密钥(私钥 + 公钥)
- 公钥写进 App 的
Info.plist(SUPublicEDKey字段,会被烧进每一份发出去的 App) - 私钥只留在你的本机 Keychain 和 CI 的 secret 里
- 每次发版,用私钥给 zip 算一个签名,写进
appcast.xml - 客户端下载 zip 后,用嵌在自己 binary 里的公钥校验签名
关键是:就算坏人完全控制了 appcast 服务器,他也伪造不出合法签名—— 除非他拿到了你的私钥。Apple 的公证和 Developer ID 不是 Sparkle 安全的最后一道防线,Sparkle 自带的 EdDSA 签名才是。
也因此:私钥一旦丢了,就再也没法给老用户推新版了—— 公钥已经烧进每一份分发出去的 App 里,没有匹配的私钥就永远签不出合法的更新包。生成后立刻备份到 1Password 或加密 U 盘里,是必做的事。
怎么接
整个接入分四部分:客户端、Info.plist、密钥、CI 流水线。一个个来。
1. SPM 引依赖
Xcode 里 File → Add Package Dependencies,地址:
|
1 |
https://github.com/sparkle-project/Sparkle |
版本选 up to next major,最低 2.x。Sparkle 2 是现代版本,支持沙盒、XPC 隔离、阶段化推送等。
引入之后写一个最简的 controller:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import Sparkle @MainActor final class UpdaterController { static let shared = UpdaterController() let controller: SPUStandardUpdaterController private init() { controller = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil ) } func checkForUpdates() { controller.checkForUpdates(nil) } } |
然后在 AppDelegate.applicationDidFinishLaunching 里引用一下让它实例化:
|
1 |
_ = UpdaterController.shared |
定时检查、自动下载、弹框 UI、安装重启 —— Sparkle 全包了。”Check for Updates…” 按钮只需要接到 controller.checkForUpdates(nil)。
2. 配 Info.plist
Sparkle 至少需要这三个 key:
|
1 2 3 4 5 6 |
<key>SUFeedURL</key> <string>https://你的域名/appcast.xml</string> <key>SUPublicEDKey</key> <string>你生成的 EdDSA 公钥(base64)</string> <key>SUEnableAutomaticChecks</key> <true/> |
SUFeedURL 一般有两种托管方式:
- 单独搞个 GitHub Pages / 自家 CDN,URL 固定
- 直接用 GitHub Release 的稳定链接:
https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml
第二种最省事 —— GitHub 这个 URL 永远 redirect 到最新 release 里叫 appcast.xml 的那个 asset,发版时把 xml 当资产上传一份就行,零额外服务。
3. 生成 EdDSA 密钥
Sparkle 自带 generate_keys 工具。SPM 解出来后能在这里找到:
|
1 |
~/Library/Developer/Xcode/DerivedData/<你的项目>-*/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys |
直接跑(不带参数)会在 macOS Keychain 里生成一对 ed25519 密钥,并打印公钥。把这个公钥贴进 SUPublicEDKey。
要拿私钥(CI 要用):
|
1 |
generate_keys -x ~/Desktop/private.key |
文件里就是私钥的 base64 字符串。把这个字符串塞进 CI 的 secret 里(GitHub Actions 里我用的名字是 SPARKLE_PRIVATE_KEY)。本地的私钥文件备份后立刻删掉,因为它是明文。
4. 发版流水线
每次发版多做两件事:
- 用私钥给 zip 签名:Sparkle 自带的
sign_update工具,输入 zip + 私钥文件,输出sparkle:edSignature="..." length="..."这一行 - 生成 appcast.xml:把版本号、下载 URL、上一步的签名、文件大小、release notes 套进 RSS 模板
最终 appcast.xml 长这样:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?xml version="1.0" encoding="utf-8"?> <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"> <channel> <title>YourApp</title> <item> <title>Version 1.2.3</title> <sparkle:version>1.2.3</sparkle:version> <sparkle:shortVersionString>1.2.3</sparkle:shortVersionString> <description><![CDATA[ <h2>Changes</h2> <ul><li>release notes 的 HTML</li></ul> ]]></description> <pubDate>Sat, 23 May 2026 10:00:00 +0000</pubDate> <enclosure url="https://.../YourApp-1.2.3.zip" sparkle:edSignature="..." length="2607122" type="application/octet-stream" /> </item> </channel> </rss> |
注意 <description> 里直接内嵌 release notes 的 HTML —— Sparkle 弹框会原地渲染。如果用 <sparkle:releaseNotesLink> 指向 GitHub release 详情页,Sparkle 会用 WebView 加载那个 URL,GitHub 整站的导航条、登录按钮、左侧栏全会被渲染进对话框里,体验极差。这是亲自踩过的坑。
几个不那么直觉的细节
Sparkle helper 的重签问题
这是当时折腾最久的一个。接入做完、编译一切正常、archive 顺利出来、codesign --verify --deep --strict 全绿、所有嵌套件都 --validated。一切看着都对,提交给 notarytool 跑公证 —— 几十秒后回来一个 status: Invalid。
紧接着脚本继续往下跑 staple,挂在了一个看似毫不相关的错误上:
|
1 2 |
CloudKit query for YourApp.app (...) failed due to "Record not found". The staple and validate action failed! Error 65. |
第一反应是网络抖动或 Apple 服务器问题,重跑一次 —— 还是一样。其实 CloudKit 这个错是 staple 在找还没存在的公证票据,根因是上一步 notarytool 返回了 Invalid。
跑一下 xcrun notarytool log <submission-id>,真相终于浮出水面:
|
1 2 3 4 5 |
"path": ".../Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater", "message": "The binary is not signed with a valid Developer ID certificate." "path": ".../Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater", "message": "The signature does not include a secure timestamp." … (Autoupdate、Downloader.xpc、Installer.xpc 同样两条 × 2) |
原因清楚了:Sparkle 通过 SPM 编译出来时,里面这四个 helper(Autoupdate、Updater.app、Downloader.xpc、Installer.xpc)自带的是 ad-hoc 签名,不是你的 Developer ID 签的,也没有安全时间戳。codesign --verify 只检查签名链是否完整、本机能否校验通过,不检查”谁签的”,所以看上去全绿 —— 但 Apple 公证要求每个嵌套可执行文件都用你的 Developer ID 加 secure timestamp 签。
解决办法:archive 完成之后,按由内向外的顺序手动重签 —— 四个 helper、framework 本身、外层 .app,每一步都带 --options runtime --timestamp,最外层那一次还要带回原本的 entitlements 文件。每改动一层嵌套件,外面的封印就破了,必须重新盖一次。
首次升级有断层
接入 Sparkle 的那一版才开始有 Sparkle,再老的版本没有。所以”老版本 → 你接入 Sparkle 的这一版”这次升级用户还得手动下,没办法。从下个版本起才是真的自动。
版本号字段都要写
Sparkle 客户端比较新旧版本时看的是 CFBundleShortVersionString 和 CFBundleVersion。两个字段都不能漏,且服务端 appcast 里 <sparkle:version> 和 <sparkle:shortVersionString> 要和 App 里的对得上,否则版本判断会出意外。
小结
接入 Sparkle 的核心动作就那几下:
- SPM 加依赖、写个最小的
SPUStandardUpdaterController - Info.plist 写好 feed URL 加公钥
- 生成 EdDSA 密钥对,公钥写 plist、私钥进 Keychain 和 CI secret
- 发版时多产出 appcast.xml,签好上传
接完之后,发版就是推个 tag,CI 自动签、公证、生成 appcast、上传。用户那边就是某天打开 App,弹框告诉他有新版本,点一下、重启,完事。
如果你也在写独立的 macOS 小工具,自动更新这一步真的值得做 —— 它把”我有时间发版”和”老用户能用上新功能”这两件事彻底脱钩。
Keychain和security简介
写 CCAS 的时候要做的事其实很本质:在多个 Claude 账号之间切换。第一个要弄清楚的是 —— Claude Code 把自己的 OAuth credentials 存在哪?
翻一下之前泄露的代码很容易就知道了:在 macOS Keychain 里,service 名字是 Claude Code-credentials。你现在就可以在终端里看一眼:
|
1 |
security find-generic-password -s "Claude Code-credentials" -w |
会打印出一坨 JSON,里面就是 access token、refresh token 这些。
这不是 Claude Code 的”小众”选择 —— 各种 IDE、CLI、Apple 自家的 Safari Mail,所有在 macOS 上要存”密码、token、密钥”的程序,默认就该放进 Keychain。它是这台机器上事实标准的敏感数据存储。
那它到底是什么?

Keychain 是什么
最直白的描述:Keychain 是 macOS 自带的加密键值数据库,专门为存敏感数据设计。每条记录由系统加密,挂在登录用户的身份下,由 securityd 这个常驻守护进程管理。
它的物理形态是磁盘上的几个文件,常见的有:
|
1 2 |
~/Library/Keychains/login.keychain-db # 用户登录 keychain /Library/Keychains/System.keychain # 系统级(如 WiFi 密码) |
你不会直接读这些文件 —— 它们是加密的 SQLite,主密钥跟你的登录密码绑定。读写都要走系统 API,由 securityd 解锁后再返回明文。
它解决了什么
如果不用 Keychain,自己拿个 JSON 文件存 token 行不行?技术上可以,但你要面对几个问题:
1. 磁盘加密
文件丢在 ~/.config/ 下是明文。任何能读你 home 目录的进程都能拿到(包括别的 app、误装的恶意脚本)。即便你给文件 chmod 600,磁盘镜像被拷走照样能看。
Keychain 用 AES 加密,主密钥来自你的登录密码 —— 机器没登录、密码不对,磁盘镜像里的内容就解不出来。
2. 进程隔离
文件系统只有”用户级”权限。但同一用户跑的所有 app 是平等的:Claude Code 写下的 token,理论上你装的任何一个三方 app 都能读。
Keychain 的每条记录带 ACL(access control list),记录”谁创建的、谁可以读”。当一个 app 试图读另一个 app 创建的条目时,系统会弹窗让用户确认。这是文件系统本身做不到的。
3. 用户可见、可管理
打开 Keychain Access.app,你能看到所有条目、谁创建的、什么时候改的,可以手动删。要 export 还得再输一次登录密码。
4. 跨设备同步
勾上 “iCloud Keychain” 之后,相应条目会端到端加密同步到你登录同一 Apple ID 的其他 Mac、iPhone、iPad。Safari 的密码、WiFi 密码、Apple Pay 卡号走的都是这个。
数据模型
Keychain 里有几类 item,最常用的是 generic password(任意 key-value 的密钥/token,多数 app 自定义存储用这个)和 internet password(带 protocol/host/port/path 字段,Safari 存网站密码用)。此外还有 certificate、key(公私钥对)、identity(cert + key 对)。
generic password 的核心字段就两个:
- service:通常用 reverse-DNS 风格的命名空间,例如
com.apple.account或Claude Code-credentials。 - account:同一 service 下区分多条记录的标识,通常是用户名或邮箱。
service + account 唯一定位一条记录。Password 字段本身可以是任意字节(你完全可以塞一整段 JSON 进去,Claude Code 就是这么干的)。
怎么读写:security 命令
Apple 给开发者两套接口:
- C API:
SecItem*这一族(旧的SecKeychain*已经不推荐)。Swift / Objective-C app 一般用这个。 - 命令行
/usr/bin/security:脚本和调试用。
CCAS 走的是后者,省得对接 C API;好处是用户自己在终端就能验证一切。最常用的三条:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 写入 security add-generic-password \ -s "com.example.myapp" \ -a "alice@example.com" \ -w "the-secret" # 读出 security find-generic-password \ -s "com.example.myapp" \ -a "alice@example.com" \ -w # 删除 security delete-generic-password \ -s "com.example.myapp" \ -a "alice@example.com" |
-s 是 service,-a 是 account,-w 表示”只把 password 内容打到 stdout”。不加 -w 会打印所有属性元数据,但隐去 password。
更多有用的子命令:
|
1 2 3 4 5 |
security list-keychains -d user # 列出当前 keychain search list security unlock-keychain # 手动解锁 security dump-keychain # 列出当前 keychain 中所有 item 的元数据 security add-internet-password ... # 写网站密码 security find-certificate ... # 找证书 |
几个不那么直觉的细节
search list:系统其实管理着一个 keychain “搜索列表”,security find-* 默认遍历整个列表。常见情况下只有 login.keychain-db,但用户/MDM 可能加挂别的。如果某条记录意外落在搜索列表外的 keychain 里,会出现”dump 看得到、find 找不到”的现象,这时候要在命令末尾显式带上 keychain 路径。
密码存二进制:用 -w "value" 写入时整个参数被当作 UTF-8 字符串。如果 value 里有换行、引号、控制字符,shell 转义会很烦人。更可靠的方式是 -X hex,把内容先转 hex 再交给 security,由它自己 decode 后存原始字节。
-U 不一定是真正的 upsert:add-generic-password -U 文档说”如果存在则更新”,但匹配条件偏严,遇到某些属性差异会判定为”新条目”再插一条。同 service+account 的重复记录会越积越多。要 idempotent 的话,先 delete 再 add 更稳。
account 不是严格过滤:find-generic-password -s SVC -a ACCT 如果 account 没精确匹配,可能 fallback 命中同 service 下别的 account —— 不报错,直接返回别人的内容。读完后再校验返回值是个好习惯。
小结
Keychain 是 macOS 上存敏感数据的标准答案。它做的事不复杂 —— 加密、隔离、用户可控、可同步 —— 但是把这四件事做齐了,且对开发者基本零成本。
下次写 Mac app 要存 token、API key 或者密码,不用犹豫,直接 SecItem / security 走起,比自己掂量”放哪、加什么密、谁能读”省太多事。
Claude Code Account Switcher
如今这AI时代,如果你也写写代码,我相信你肯定在用一些AI工具了吧?
如果你恰好用的也是Claude code,那大概率也会因为每月20$的pro套餐用量不够而烦恼吧?这时,如果你不差钱,可能就直接订阅200$的max套餐了,但如果你也觉得200刀有点下不去手,那可能再买一个20刀,就是更加可以接受的方案了。
此时,你就会遇到两个Claude账号频繁切换的问题了。。。那你可能就需要这个小工具了。
功能挺简单的,也无需我过多介绍了,直接看图就能明白了。这是一个macOS下的菜单栏小工具。可以同时登录多个Claude账号,能查看每个账号的余量,能帮你快速切换账号。

截图里,我的账号1是公司给开的enterprise账号,一个月200$的token(也是不经用的);账号2是我自己的pro账号。两种账号都是支持的。
工具以MIT开源。代码在GitHub。等我再整理一下,放个编译好的二进制吧。
uv 的 inline script metadata 太香了
最近又玩了下 uv,感觉它在“小工具开发”这个场景里特别顺手。
uv 本质上是一个把 Python 依赖管理、虚拟环境、脚本运行这些事情揉在一起的工具。它最大的优点当然是快,但更让我喜欢的是:很多原本零碎又烦的动作,被压缩成了几个很好记的命令。
比如平时写个小脚本,常常只是想:
- 调一下接口
- 批量处理一点文件
- 验证一个第三方库
- 给自己做个顺手的小命令
这类东西通常不值得认真建个项目,但又往往会依赖 requests、httpx、rich 之类的包。以前一般要先建 venv,再装依赖,可能还要改个 .gitignore,多少有点麻烦。
这时候 uv 很方便的一个点,就是可以直接用 --with:
|
1 |
uv run --with requests demo.py |
或者:
|
1 |
uv run --with httpx --with requests foo.py |
这种方式特别适合刚开始写小工具的时候。先跑起来,先验证思路,不用一上来就准备完整项目。
不过,--with 也有个问题:依赖信息是在命令里,不在脚本里。
今天你记得怎么跑,过几天自己可能都忘了。发给别人时,也得额外告诉对方要带哪些依赖。
这时候真正的大招就来了:inline script metadata。
你可以直接把依赖用这样的格式,写到脚本头部:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# /// script # requires-python = ">=3.11" # dependencies = [ # "httpx", # "rich", # ] # /// import httpx from rich import print resp = httpx.get("https://luy.li") print(resp.status_code) |
可以看到,去掉# /// script这些包裹层之后,里面的内容,其实就是TOML的语法。
这样之后就不用再写长命令了,直接:
|
1 |
uv run foo.py |
就行。这个体验非常好。因为脚本自己就说明白了:
- 需要什么 Python 版本
- 依赖哪些包
- 应该怎么运行
更进一步,你甚至可以再加个 shebang,把它写成一个可以直接执行的脚本:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = [ # "httpx", # "rich", # ] # /// import httpx from rich import print print(httpx.get("https://luy.li").status_code) |
这样给脚本加上执行权限(chmod +x)之后,连 uv run foo.py 都可以省掉,直接 ./foo.py 就可以跑起来了。
最后总结一下:
- 刚开始随手写个小工具,用
uv run --with ... - 觉得这个脚本值得留下来,就上
inline script metadata - 再彻底一点,可以用
#!/usr/bin/env -S uv run --script这个shebang
App Store Small Business Program
事情是这样的,3月初的时候,我上线了我的第一款收费APP(用于在睡眠期间监测和记录呼噜声的),然后陆陆续续也有几笔成交了。
于是我这个数据控,就研究了一下Apple的开发者后台,里面的一些看板和详细数据。由于我的APP一共也才成交了几笔而已,样本非常有限,还刻意和资深开发者朋友 TualatriX 聊了一下,还真让我发现了一些门道和问题。。。
那就是在app store connect里,Sales、Proceeds的数据,和你给APP的定价肯定都会不一样。原因有以下几层:
第一层,是汇率的影响。
因为你的 APP 是全球卖的。用户可能用的是欧元、日元、人民币付款,而你后台的结算通常是按美元(或你本地货币)来算的。中间一定会经历一次甚至两次货币转换。
关键点在于:Apple 用的不是实时汇率,而是自己的一套定价和结算汇率(按周期更新)。这就会带来几个结果:
- 同样标价 2.99,在不同国家对应的本地价格其实是不一样的
- 不同货币换算回来的“基准金额”,本身就有偏差
- 即使同一个国家,汇率调整周期不同,Proceeds 也可能轻微波动
换句话说,从一开始,你的“2.99”,在全球范围内就已经不是一个严格一致的数字了。
然后,才是第二层:税。
App Store 的价格是“含税展示价”,不同国家税率不同(比如 VAT、GST)。这些税是从用户支付的钱里先扣掉的。
接着是第三层:Apple 抽成。
而且这个抽成,是在“去税之后”的金额上算的(默认抽走30%)。再剩下的才是你真正拿到的 Proceeds。
所以完整链路其实是:本地定价 → 汇率换算 → 扣税 → Apple 抽成 → 最终收入(Proceeds)
那,作为开发者,有什么可以action的东西么?
显然,汇率和税,作为小卡拉米的我们,是没法去影响的,但苹果其实有个“App Store Small Business Program”,是可以给小开发者一些抽成上的优惠的。
具体来说,苹果针对年销售额不足1百万美元的小开发者,可以把默认的30%的抽成比例,降到15%。其实之前我也听说过这个计划,但当时也没细看,以为是默认开通的。但其实是要开发者去单独申请,并审核开通的。方法倒是很简单,就是用开发者账号登录后,在这个页面点“Enroll now”,填上相应的信息,就算提交成功了。
注意,提交成功,并不代表你enroll成功了,在收到以下邮件之前,抽成将还是30%。而且苹果处理这个请求相当地慢,以我为例花了3周的时间,而且我中间还催过一次。

好了,最后祝所有开发者都能APP大卖!早日被踢出Small Business Program!
清迈旅行
我发现,要坚持输出一些内容,还是挺难的,这不,离上一篇文章都已经快一个月了。经常有这样那样的事情,就推脱自己有点忙,哈哈!好在,今天还是想起来要写点东西了。
这次去清迈,是因为4月初有个Good Friday,新加坡会多一天假,而且刚好和国内的清明假期差不多时间。就想趁着小长假去哪里玩一下。一开始是计划去印尼看火山的,但机票、行程都没有选到合适的,后来又在科莫多岛、巴厘岛和斯里兰卡之间摇摆了很久,最终决定去个轻松的,几个人才定了清迈。
我们有4天3晚的时间,去掉来和回的两个半天,安排一个一日游行程和一天的自由活动。剩下的就是吃吃喝喝和每天一次的马杀鸡了。
一日游,我们是携程上找的“清迈黑庙+白庙+蓝庙一日游”,中文导游+跟车+门票+团餐的小团。389RMB一个人,我们选的是9人小团,如果是12人的团,价格会便宜蛮多。但是,相信我选贵的,这9人和12人,不仅仅是人数的差别。这两者使用的面包车是一样大小的,也就是说12人团的车,位置会很挤,相当于经济舱;而9人团的位置就宽敞很多。而且,由于经典离清迈老城还蛮远的(实际上,已经到旁边叫“清莱”的邦了),全天在车上的时间会超过6小时,所以乘车的舒适性对这个行程还是挺关键的。
好了,接下来就到了放图时间了。我按时间顺序来吧。
长颈族部落,这是一个3庙以外的小景点,可以选参加的。景点确实非常小,有几十个长颈族的部落成员,导游说以前他们是缅甸的,逃难到此,扎下根来,受泰国政府庇护,专门给他们开发了这块区域。

白庙的创始人,是一个当地的艺术家,前半生艺术成就很高,也买了很多画赚到不少钱,后半生开始参与公共事业,建设了这座庙。这座庙的艺术水准确实也非常高!非常地闪亮!

蓝庙就比较新了。建于2016年前后,由白庙的那个艺术家的学生打造,在旧庙遗址上重建,以深蓝象征智慧、金色象征佛光,融合现代艺术与传统佛教风格,成为清莱最具视觉冲击力的新地标之一。

蓝庙的冰激凌都是蓝色的

黑庙,则另一个艺术家于20世纪后期逐步建成,它其实不是寺庙,而是一组以黑色木屋与动物骨骼、毛皮等艺术构成的博物馆,通过强烈的原始与暗黑风格表达生死、欲望与人性主题。

黑庙其实占地面积挺大的,我们应该是逛了一个小时左右,还意犹未尽,这是部分展品:

以上就是一日游的所有景点了。
另外值得一提的,就是马杀鸡了,泰国的马杀鸡本身就很出名,清迈的马杀鸡店非常多,价格从便宜到中等都有(真的都不算贵),我们基本上从200多泰铢到700多泰铢的都体验了一下,怎么说呢?还是很解压的,基本上每个档位的服务也都对得起那个价格。所以,一般对着Google maps的评分做参考,就不会踩雷。当然,还是得体验一下贵一点的,毕竟换算一下还是很便宜。这个就不放图了。
还有就是,我们住在塔佩门附近,周日晚上的夜市(只有周日有,其他的都是小夜市),确实很热闹,可以逛逛。而且夜市里也有很多吃的小摊贩,品种很多,样式很齐,所以可以解决晚饭。但其他商品嘛,可能是因为逛过义务小商品市场了,又有万能的淘宝了,基本上也都不怎么会看得上。就看个热闹也挺好的。
自己玩的那天,我们去了素贴山徒步,行程一般,门票大人100,小孩50,会路过另外两个寺庙。而且当时空气质量还是有点堪忧(虽然山上比市区好多了),就不多说了吧。
就此,完毕!