Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,76 @@ jobs:
-ov -format UDZO \
"dist/CodingBar-${{ github.ref_name }}.dmg"

# Sparkle's update channel: appcast.xml lists every version with an EdDSA
# signature over the matching zip. The private key lives only in this repo's
# SPARKLE_PRIVATE_KEY secret; the public half is baked into Info.plist
# (SUPublicEDKey) so the client refuses to install anything not signed by us.
# We download the official Sparkle release archive to get the `generate_appcast`
# tool — pinning the version protects against an upstream supply-chain swap.
- name: Validate Sparkle private key
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
echo "::error::SPARKLE_PRIVATE_KEY secret is missing. Generate a key pair locally with Sparkle's generate_keys, then add the private half to repository secrets."
exit 1
fi

# SPARKLE_VERSION should match the runtime Sparkle that ends up in Package.resolved
# so the appcast format the tool emits matches what the client expects. Bump them
# together — running `swift package update` and forgetting to bump here means CI
# signs the feed with an older tool than the client was built against.
- name: Fetch Sparkle tools (prebuilt)
env:
SPARKLE_VERSION: 2.9.3
run: |
ARCHIVE_PATH="$RUNNER_TEMP/Sparkle-${SPARKLE_VERSION}.tar.xz"
TOOLS_DIR="$RUNNER_TEMP/sparkle-tools"
curl -fL -o "$ARCHIVE_PATH" \
"https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
mkdir -p "$TOOLS_DIR"
tar -xf "$ARCHIVE_PATH" -C "$TOOLS_DIR"
GENERATE_APPCAST="$(find "$TOOLS_DIR" -type f -name generate_appcast | head -n1)"
if [ -z "$GENERATE_APPCAST" ] || [ ! -x "$GENERATE_APPCAST" ]; then
echo "::error::generate_appcast not found in Sparkle archive"; exit 1
fi
echo "SPARKLE_BIN=$(dirname "$GENERATE_APPCAST")" >> $GITHUB_ENV

- name: Generate signed appcast
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
# generate_appcast expects a directory containing zipped update bundles —
# stage just our release zip there so other artifacts don't leak into the feed.
SPARKLE_DIR="dist/sparkle"
KEY_FILE="$RUNNER_TEMP/sparkle_private.key"
mkdir -p "$SPARKLE_DIR"
cp "dist/CodingBar-${{ github.ref_name }}.zip" "$SPARKLE_DIR/"
printf "%s" "$SPARKLE_PRIVATE_KEY" > "$KEY_FILE"
# --download-url-prefix tells the appcast where the asset will be reachable
# AFTER the release is published. Using the tag-specific path (not
# /latest/download) makes older clients still able to install historical
# versions if needed.
"$SPARKLE_BIN/generate_appcast" \
--ed-key-file "$KEY_FILE" \
--download-url-prefix "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/" \
-o "dist/appcast.xml" \
"$SPARKLE_DIR"
rm "$KEY_FILE"
# Sanity check: every <item> must carry an EdDSA signature, otherwise
# clients will silently reject the update.
grep -q "sparkle:edSignature" "dist/appcast.xml"

- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
# appcast.xml is uploaded as a release asset; Info.plist's SUFeedURL points at
# https://github.com/.../releases/latest/download/appcast.xml, which always
# resolves to the most recent tag's appcast — no separate hosting needed.
run: |
gh release create "${{ github.ref_name }}" \
"dist/CodingBar-${{ github.ref_name }}.zip" \
"dist/CodingBar-${{ github.ref_name }}.dmg" \
"dist/appcast.xml" \
--title "CodingBar ${{ github.ref_name }}" \
--generate-notes
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ Two SwiftPM targets, data layer fully decoupled from UI:
- **`Models.swift` is a frozen cross-target contract.** `Snapshot` and every nested type are `Codable`/`Sendable` value types shared by both targets and the tests. Do not rename or restructure its public symbols without updating all call sites and the JSON contract.
- **Comment philosophy: high-signal WHY only.** This codebase deliberately keeps comments that explain non-obvious decisions (the Keychain re-prompt workaround, git-attribution approximation, layout/alignment rationale, concurrency reasoning, units, `0...1` fraction semantics, non-public API endpoint notes). Do **not** add narration that restates the code. If you remove a WHY comment, you are probably making a mistake.
- **Credentials never trigger a password prompt.** Claude's OAuth token lives in the Keychain entry `Claude Code-credentials`; a self-signed process reading it directly makes macOS re-prompt endlessly, so CodingBar **spawns the Apple-signed `/usr/bin/security`** (which is inside that entry's trusted ACL) to read it silently, and degrades gracefully to "quota unavailable" if it can't. Codex reads `~/.codex/auth.json`. See `Quota/Credentials.swift` — do not "simplify" this into `SecItemCopyMatching`.
- **Privacy boundary.** Two network paths, both read-only and carrying no local data: (1) the quota path (the user's own usage, with their OAuth token); (2) a **user-initiated** GitHub Releases GET in `UpdateChecker` (fired only when the user taps "检查更新" in Settings — never automatic, no auth, no telemetry). Never add code that uploads local logs, content, or telemetry.
- **Privacy boundary.** Two read-only network paths, both carrying no local data: (1) the quota path (the user's own usage, with their OAuth token); (2) Sparkle's update channel — the app pulls a small `appcast.xml` from GitHub Releases on a daily schedule **only after the user opts in** via Settings → "自动检查更新" (default off, persisted by Sparkle in NSUserDefaults), and downloads an update zip only after the user confirms in Sparkle's prompt. Every update is EdDSA-signature-verified against the public key in `Info.plist` (`SUPublicEDKey`), so a tampered feed/zip is silently rejected. No auth, no telemetry, no local data uploaded on either path. Never add code that uploads local logs, content, or telemetry.
- **Git attribution is approximate** (changes in the session's cwd within its time window), and the code labels it as such. Keep it honest.
- **Releases are ad-hoc signed** (no paid Developer ID). `Scripts/package.sh` assembles the `.app` and accepts `CODINGBAR_VERSION` to stamp `Info.plist` (CI passes the git tag). Pushing a `v*` tag triggers `.github/workflows/release.yml`, which builds, signs, packages a `.dmg`/`.zip`, and calls `gh release create … --generate-notes` (so the auto-body is minimal by design). The real per-version notes live in `release-notes/vX.Y.Z.md` — after CI publishes the release, overwrite the body with `gh release edit vX.Y.Z --notes-file release-notes/vX.Y.Z.md`. Add the notes file in the same PR that ships the feature, not as an after-thought.
- **Releases are ad-hoc signed** (no paid Developer ID). `Scripts/package.sh` assembles the `.app` and accepts `CODINGBAR_VERSION` to stamp `Info.plist` (CI passes the git tag). Pushing a `v*` tag triggers `.github/workflows/release.yml`, which builds, signs, packages a `.dmg`/`.zip`, generates an EdDSA-signed `appcast.xml` (Sparkle feed) using the `SPARKLE_PRIVATE_KEY` secret, and calls `gh release create … --generate-notes` (so the auto-body is minimal by design). The real per-version notes live in `release-notes/vX.Y.Z.md` — after CI publishes the release, overwrite the body with `gh release edit vX.Y.Z --notes-file release-notes/vX.Y.Z.md`. Add the notes file in the same PR that ships the feature, not as an after-thought.
- **Sparkle integration.** `package.sh` embeds `Sparkle.framework` from `.build/release/` into `Contents/Frameworks/` (SwiftPM stages the framework there automatically, including the nested Autoupdate / Updater.app / XPCServices — already ad-hoc signed by upstream). The binary needs `@executable_path/../Frameworks` added to its rpath via `install_name_tool` because SwiftPM doesn't know it's targeting an .app bundle. Signing order is inner-first WITHOUT `--deep` on the outer .app — `--deep` would rewrite Sparkle's nested signatures and break its internal trust chain.
- **Sparkle key setup (one-time, before the first auto-update-capable release).** (1) Download Sparkle's release archive and run `bin/generate_keys` — it writes the **private** key to the macOS Keychain ("Private key for signing Sparkle updates") and prints the **public** key to stdout. (2) Paste the public key into `Scripts/Info.plist`'s `SUPublicEDKey` (replacing `REPLACE_WITH_YOUR_PUBLIC_KEY`). (3) Export the private key with `bin/generate_keys -x sparkle_private.key`, paste its contents into a GitHub Actions repo secret named `SPARKLE_PRIVATE_KEY`, then `rm sparkle_private.key` (never commit it). To rotate the key, repeat all three steps — old clients won't be able to install new updates until they manually upgrade once.

## Design

Expand Down
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ import PackageDescription
let package = Package(
name: "CodingBar",
platforms: [.macOS(.v14)],
dependencies: [
// Sparkle powers in-app auto-update (EdDSA-signed appcast → silent install).
// Pinned to a 2.x range so we follow patch fixes without breaking changes.
.package(url: "https://github.com/sparkle-project/Sparkle.git", from: "2.8.1"),
],
targets: [
.target(
name: "CodingBarCore",
swiftSettings: [.swiftLanguageMode(.v5)]
),
.executableTarget(
name: "CodingBar",
dependencies: ["CodingBarCore"],
dependencies: [
"CodingBarCore",
.product(name: "Sparkle", package: "Sparkle"),
],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
Expand Down
17 changes: 17 additions & 0 deletions Scripts/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,22 @@
<true/>
<key>NSHighResolutionCapable</key>
<true/>

<!-- Sparkle in-app auto-update. SUFeedURL points at the latest GitHub release's
appcast.xml (which CI generates and signs); SUPublicEDKey is the EdDSA
public half — the matching private key lives only in the GitHub Actions
secret SPARKLE_PRIVATE_KEY. Sparkle hard-checks signatures against this
public key, so even if the feed/zip were tampered with, an attacker
couldn't forge an installable update without the private key. -->
<key>SUFeedURL</key>
<string>https://github.com/Gnonymous/CodingBar/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>3CWA9dR27M0p+P0QUSqNdDDK4LjIhUdlJltpVM2ExMo=</string>
<!-- Default off — opt-in. The user enables it in Settings, at which point Sparkle
starts the daily check schedule. -->
<key>SUEnableAutomaticChecks</key>
<false/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
</dict>
</plist>
34 changes: 30 additions & 4 deletions Scripts/package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,30 @@ rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/CodingBar"

# SwiftPM doesn't know it's targeting a .app bundle, so the binary's rpath only
# includes @loader_path. dyld then can't find @rpath/Sparkle.framework when we
# stage it under Contents/Frameworks/. Add the standard app-bundle rpath so the
# framework resolves at runtime. install_name_tool -add_rpath is idempotent-
# unfriendly (it errors if the path already exists), hence the `|| true` guard
# for repeat local builds.
install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodingBar" 2>/dev/null || true

cp "$ROOT/Scripts/Info.plist" "$APP/Contents/Info.plist"

# App icon (DIRECTION 03 pulse squircle). Committed as Scripts/AppIcon.icns;
# regenerate with `make icon`. CFBundleIconFile in Info.plist points at it.
[ -f "$ROOT/Scripts/AppIcon.icns" ] && cp "$ROOT/Scripts/AppIcon.icns" "$APP/Contents/Resources/CodingBar.icns"

# Embed Sparkle.framework so the packaged app can self-update. SwiftPM already
# stages the full framework (with Autoupdate / Updater.app / XPCServices, all
# pre-signed ad-hoc by upstream) at .build/release/Sparkle.framework — we just
# move it under Contents/Frameworks/ at the location dyld expects.
SPARKLE_SRC="$ROOT/.build/release/Sparkle.framework"
[ -d "$SPARKLE_SRC" ] || { echo "Sparkle.framework not found at $SPARKLE_SRC — run swift build -c release first"; exit 1; }
mkdir -p "$APP/Contents/Frameworks"
# Use ditto so framework symlinks are preserved exactly (cp -R can mangle them).
ditto "$SPARKLE_SRC" "$APP/Contents/Frameworks/Sparkle.framework"

# Stamp the version from $CODINGBAR_VERSION when set (CI passes the git tag);
# local builds keep the value baked into Info.plist.
if [ -n "${CODINGBAR_VERSION:-}" ]; then
Expand All @@ -31,10 +49,18 @@ if [ -n "${CODINGBAR_VERSION:-}" ]; then
fi

# Ad-hoc sign so Gatekeeper lets it run locally (no Developer ID, so users still
# right-click → Open or clear the quarantine flag on first launch). Fail LOUDLY:
# a silently-unsigned .app was shipping before because a stray SwiftPM resource
# bundle in Contents/MacOS tripped `codesign --deep`. `set -e` aborts on failure.
codesign --force --deep --sign - "$APP"
# right-click → Open or clear the quarantine flag on first launch). Sparkle's
# nested helpers (XPCServices, Updater.app, Autoupdate) ship pre-signed by
# upstream; signing inner-first WITHOUT --deep on the outer .app preserves
# those signatures intact. `--deep` here would rewrite them and break Sparkle's
# internal trust chain. Fail LOUDLY — `set -e` aborts on any codesign failure.
SPARKLE_VER="$APP/Contents/Frameworks/Sparkle.framework/Versions/B"
codesign --force --sign - --options runtime "$SPARKLE_VER/XPCServices/Installer.xpc"
codesign --force --sign - --options runtime "$SPARKLE_VER/XPCServices/Downloader.xpc"
codesign --force --sign - --options runtime "$SPARKLE_VER/Updater.app"
codesign --force --sign - --options runtime "$SPARKLE_VER/Autoupdate"
codesign --force --sign - --options runtime "$APP/Contents/Frameworks/Sparkle.framework"
codesign --force --sign - "$APP"
codesign --verify --deep --strict "$APP"

echo "✓ Built $APP"
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodingBar/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
loop = RefreshLoop(store: store)
loop.start()

// Sparkle's scheduled-check timer starts at SPUStandardUpdaterController init.
// Touch the singleton at launch (instead of lazily from SettingsView) so a user
// who flipped on "自动检查更新" once still gets daily checks even when they
// never reopen Settings again. In dev builds (`swift run`) UpdateManager.init
// skips Sparkle bootstrap entirely, so this is a no-op there.
_ = UpdateManager.shared

// Debug: auto-open the popover for screenshot verification.
if CommandLine.arguments.contains("--open-panel") {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
Expand Down
54 changes: 0 additions & 54 deletions Sources/CodingBar/UpdateChecker.swift

This file was deleted.

Loading
Loading