Files
vibe-foreman/scripts/release.sh
T
b6d90cac2b feat: 重写为原生 Swift 灵动岛,修掉通知跳转与主窗口数据链路 (#4)
删掉 Tauri 版(app/),全部重写为 SwiftUI + SwiftPM(HubKit/,8.2k 行源码 +
2k 行测试,131 个测试)。核心动机是旧版"点通知跳不到对应终端"——
根因是身份链路全程用会漂移的字符串:项目名 → tmux 窗口名 → pane_title →
AppleScript 子串匹配。实测 9 个 tmux 窗口只有 2 个能和 projects.yaml 对上,
其中一个窗口名是 `2.1.173`(tmux automatic-rename 把它改成了 claude 的版本号)。

主要变化
- 会话状态直接读 ~/.claude/sessions/<PID>.json(Claude Code 自己维护的权威源),
  不再用 spinner 字符猜 busy;死进程的残留 json 用 kill(pid,0) 过滤
- 跳转改用 iTerm jobPid + 进程祖先链求交,全程只有整数 PID 和 UUID;
  bg 会话不在任何终端里,单独用稳定 job 名兜底。实测 9/9 绑定成功
- hook 从 bash 换成 Swift 的 hubctl,走 Unix socket;Hook 没运行时立即放行
- 高风险操作在岛上审批,六重防误点;拒绝是超时和异常的默认值
- 主窗口:会话 / 项目 / 用量 / 审批日志 / 设置

修掉的真实缺陷(详见 HubKit/NOTES.md)
- 超时阶梯是反的:ApprovalCoordinator 和 hubctl 读超时都是 60s,同时到期时
  hubctl 按 fail-open 放行了一条已被服务端判定拒绝的 git push --force origin main。
  改成 60/65/75/90 严格递增,并区分「连不上」(放行)与「连上但没应答」(拒绝)
- 点击全部被吞:app 是 .accessory + panel 是 .nonactivatingPanel,
  NSHostingView.acceptsFirstMouse 默认 false,每次点击都算 first mouse 被丢弃
- hitTest 假设 NSHostingView 恒为 flipped,而 isFlipped 是 final 的可读写属性
- 形态切换靠替换 rootView,导致动画不可能发生且 @State 每次归零
- 全局 leftMouseDown 监听会收到点在自己身上的点击,展开态一点就收起
- hook 进程死后审批卡不消失,且会挡住后面的请求(按 clientPid 扫描清理)
- app 读的 projects.yaml 不是脚本维护的那份,且那份被写坏成
  `projects: []` + 117 条内容,解析器静默返回 0 —— 三个页面因此全空
- 用量对 945MB/341 个 jsonl 整份读进内存,跑不完;改流式 + 增量缓存后
  7 天首扫 3.6s、命中缓存 0.22s、全量 4s
- 辅助二进制放 Contents/MacOS/ 且共用 bundle id 会被 exec 瞬间 SIGKILL
- 同一 bundle id 对应多个 .app 让存活判断和 TCC 授权互相打架

刻意的取舍
- 放弃 Liquid Glass 做岛的基底:它会跟着背景亮度双向补偿,而刘海恒为纯黑,
  两者物理上互斥。改用固定 86% 暗色基底 + 背景模糊 + 边缘高光
- 用 SwiftPM + 打包脚本而不是 .xcodeproj:手写 xcodeproj 的 XML 无法 review,
  且 SwiftPM 保住了 swift test

Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:20:12 +08:00

184 lines
7.0 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 打发布包:universal .app + .dmg。
#
# 和 build-swift-app.sh 的分工:
# build-swift-app.sh 日常开发。只编当前架构,装进 /Applications,几秒钟。
# release.sh 发布。universal 双架构 + dmg + 校验和,几分钟。
#
# 刻意**不**装进 /Applications:发布构建和你正在用的那个是两回事,
# 打个包不该把你手上跑着的实例换掉。要用新版自己 open dmg 装。
set -euo pipefail
HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_DIR="${HUB_DIR}/HubKit"
APP_NAME="Claude Hub"
BUNDLE_ID="dev.hengjun.claude-hub"
CERT_NAME="Claude Hub Dev"
VERSION="${1:-}"
if [ -z "${VERSION}" ]; then
VERSION=$(grep -m1 'CFBundleShortVersionString' "${HUB_DIR}/scripts/build-swift-app.sh" \
| sed -E 's/.*<string>([^<]+)<\/string>.*/\1/')
fi
# 构建号用提交数,比手工维护可靠:它单调递增,且能反查到具体代码。
BUILD_NUMBER=$(git -C "${HUB_DIR}" rev-list --count HEAD 2>/dev/null || echo 1)
COMMIT=$(git -C "${HUB_DIR}" rev-parse --short HEAD 2>/dev/null || echo "unknown")
DIRTY=""
if ! git -C "${HUB_DIR}" diff --quiet HEAD 2>/dev/null; then DIRTY="+dirty"; fi
STAGE="${PKG_DIR}/.build/release-stage"
APP_PATH="${STAGE}/${APP_NAME}.app"
OUT_DIR="${HUB_DIR}/release"
DMG_PATH="${OUT_DIR}/${APP_NAME// /-}-${VERSION}.dmg"
log() { printf '\033[36m▸\033[0m %s\n' "$1"; }
ok() { printf '\033[32m✓\033[0m %s\n' "$1"; }
warn() { printf '\033[33m!\033[0m %s\n' "$1"; }
fail() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }
# ---------- 环境 ----------
command -v swift >/dev/null || fail "找不到 swift,请先安装 Xcode"
SDK_VERSION=$(xcrun --show-sdk-version)
[ "${SDK_VERSION%%.*}" -ge 26 ] || fail "需要 macOS 26 SDK(当前 ${SDK_VERSION})"
log "版本 ${VERSION} (build ${BUILD_NUMBER}, ${COMMIT}${DIRTY})"
if [ -n "${DIRTY}" ]; then
warn "工作区有未提交的改动 —— 这个包对应不到任何一个提交"
fi
# ---------- 测试 ----------
#
# 发布前必须全绿。日常构建可以跳过测试,发布不行。
log "跑测试…"
cd "${PKG_DIR}"
if ! swift test 2>&1 | tail -3; then
fail "测试没过,不打包"
fi
# ---------- 编译(双架构)----------
#
# `--arch arm64 --arch x86_64` 让 SwiftPM 直接产出 fat binary。
# macOS 26 是最后一个支持 Intel 的版本,还有人在上面跑,所以带上 x86_64。
log "编译 universal(arm64 + x86_64)…"
swift build -c release --arch arm64 --arch x86_64 --product ClaudeHub
swift build -c release --arch arm64 --arch x86_64 --product hubctl
BUILD_DIR="${PKG_DIR}/.build/apple/Products/Release"
[ -f "${BUILD_DIR}/ClaudeHub" ] || fail "没找到构建产物 ${BUILD_DIR}/ClaudeHub"
for binary in ClaudeHub hubctl; do
archs=$(lipo -archs "${BUILD_DIR}/${binary}")
case "$archs" in
*arm64*x86_64*|*x86_64*arm64*) ok "${binary}: ${archs}" ;;
*) fail "${binary} 不是 universal(${archs})" ;;
esac
done
# ---------- 组装 ----------
log "组装 ${APP_NAME}.app…"
rm -rf "${STAGE}"
mkdir -p "${APP_PATH}/Contents/MacOS" \
"${APP_PATH}/Contents/Resources" \
"${APP_PATH}/Contents/Helpers"
cp "${BUILD_DIR}/ClaudeHub" "${APP_PATH}/Contents/MacOS/ClaudeHub"
# hubctl 放 Helpers/ 而不是 MacOS/ —— 见 HubKit/NOTES.md,
# 和主程序共用 bundle identifier 会被 exec 瞬间 SIGKILL。
cp "${BUILD_DIR}/hubctl" "${APP_PATH}/Contents/Helpers/hubctl"
[ -f "${HUB_DIR}/Resources/AppIcon.icns" ] \
&& cp "${HUB_DIR}/Resources/AppIcon.icns" "${APP_PATH}/Contents/Resources/AppIcon.icns"
cat > "${APP_PATH}/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key> <string>${APP_NAME}</string>
<key>CFBundleDisplayName</key> <string>${APP_NAME}</string>
<key>CFBundleExecutable</key> <string>ClaudeHub</string>
<key>CFBundleIdentifier</key> <string>${BUNDLE_ID}</string>
<key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleShortVersionString</key><string>${VERSION}</string>
<key>CFBundleVersion</key> <string>${BUILD_NUMBER}</string>
<key>CFBundleIconFile</key> <string>AppIcon</string>
<key>LSMinimumSystemVersion</key> <string>26.0</string>
<key>LSUIElement</key> <true/>
<key>NSAppleEventsUsageDescription</key>
<string>Claude Hub 需要控制终端来跳转到对应的会话标签页。</string>
<!-- 打包时的提交号。用户报问题时能直接对上代码。 -->
<key>HubGitCommit</key> <string>${COMMIT}${DIRTY}</string>
</dict>
</plist>
PLIST
# ---------- 签名 ----------
#
# 先签内层再签外层,不用 --deep(理由见 NOTES.md)。
if security find-certificate -c "${CERT_NAME}" >/dev/null 2>&1; then
SIGN_IDENTITY="${CERT_NAME}"
log "用「${CERT_NAME}」签名…"
else
SIGN_IDENTITY="-"
warn "没有自签名证书「${CERT_NAME}」,退回 ad-hoc 签名"
fi
codesign --force --sign "${SIGN_IDENTITY}" --identifier "${BUNDLE_ID}.hubctl" \
"${APP_PATH}/Contents/Helpers/hubctl"
codesign --force --sign "${SIGN_IDENTITY}" --identifier "${BUNDLE_ID}" "${APP_PATH}"
codesign --verify --deep --strict "${APP_PATH}" || fail "签名校验失败"
ok "签名通过"
# 冒烟测试:签名不对的话 hubctl 会被瞬间 SIGKILL,而那种失败没有任何输出。
"${APP_PATH}/Contents/Helpers/hubctl" doctor >/dev/null 2>&1 || true
if [ "$?" -gt 1 ]; then fail "hubctl 无法执行 —— 多半是签名问题"; fi
ok "hubctl 冒烟测试通过"
# ---------- dmg ----------
log "打 dmg…"
mkdir -p "${OUT_DIR}"
rm -f "${DMG_PATH}"
DMG_STAGE="${STAGE}/dmg"
rm -rf "${DMG_STAGE}"
mkdir -p "${DMG_STAGE}"
cp -R "${APP_PATH}" "${DMG_STAGE}/"
# 拖进去就装:给个 /Applications 的替身。
ln -s /Applications "${DMG_STAGE}/Applications"
cp "${HUB_DIR}/scripts/setup-swift-hooks.sh" "${DMG_STAGE}/安装 hook.command" 2>/dev/null || true
chmod +x "${DMG_STAGE}/安装 hook.command" 2>/dev/null || true
hdiutil create -volname "${APP_NAME} ${VERSION}" \
-srcfolder "${DMG_STAGE}" -ov -format UDZO "${DMG_PATH}" >/dev/null
rm -rf "${DMG_STAGE}"
SIZE=$(du -h "${DMG_PATH}" | cut -f1)
SHA=$(shasum -a 256 "${DMG_PATH}" | cut -d' ' -f1)
echo
ok "完成:${DMG_PATH}"
echo " 版本 ${VERSION} (build ${BUILD_NUMBER}, ${COMMIT}${DIRTY})"
echo " 架构 arm64 + x86_64"
echo " 大小 ${SIZE}"
echo " SHA256 ${SHA}"
echo
cat <<'NOTICE'
分发提醒:这个包是**自签名、未公证**的。
你自己的机器上没问题(证书在你钥匙串里),但别人下载后打开会被 Gatekeeper 拦,
提示"无法打开,因为无法验证开发者"。对方需要:
右键点 app → 打开 → 再点"打开"
或者在终端里:
xattr -dr com.apple.quarantine "/Applications/Claude Hub.app"
要做到双击即用,需要 Apple Developer Program($99/年)的
Developer ID 证书 + notarytool 公证。
NOTICE