From 1614017e7f1006d8cfaa11113e3989f1dfb13e81 Mon Sep 17 00:00:00 2001 From: Eohan G Date: Wed, 24 Jun 2026 01:00:31 +0800 Subject: [PATCH] feat(updates): in-app auto-update via Sparkle (EdDSA-signed appcast) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the manual UpdateChecker (which only opened GitHub Releases) with Sparkle 2.x: daily background checks (opt-in toggle in Settings, default off), EdDSA signature verification against a public key in Info.plist, silent install + restart through Sparkle's standard user driver. After the first install, subsequent updates skip the Gatekeeper prompt entirely — no more "download from website + right-click → Open" for every version. Why opt-in: the prior Privacy boundary in CLAUDE.md committed to "never automatic" outside the quota path. The toggle defaults off, so users who never flip it keep the original guarantee. CLAUDE.md is updated to reflect the new contract (opt-in automatic check, still no telemetry / no auth / no local data uploaded). Why ad-hoc + Sparkle works without an Apple Developer ID: Sparkle's trust anchor is the EdDSA public key in Info.plist, not Apple's notarization chain. First install still gets one Gatekeeper prompt (ad-hoc signature limitation); every subsequent update is verified by Sparkle's own signature check. Build/sign notes: - SwiftPM stages Sparkle.framework under .build/release/ — package.sh ditto's it into Contents/Frameworks/ and adds @executable_path/../Frameworks to the binary's rpath (SwiftPM doesn't know it's targeting an .app bundle). - Codesign order is inner-first WITHOUT --deep on the outer .app; --deep would rewrite Sparkle's pre-signed nested binaries (XPCServices / Updater.app / Autoupdate) and break its internal trust chain. - UpdateManager.shared is touched at applicationDidFinishLaunching so the scheduled-check timer starts at launch, not lazily when Settings is opened. - CI runs Sparkle's generate_appcast with the SPARKLE_PRIVATE_KEY secret; sparkle:edSignature is grep-asserted in the generated appcast.xml so an unsigned feed can't ship. One-time maintainer setup is documented in CLAUDE.md's new "Sparkle key setup" bullet. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 64 ++++++++++++++ CLAUDE.md | 6 +- Package.resolved | 15 ++++ Package.swift | 10 ++- Scripts/Info.plist | 17 ++++ Scripts/package.sh | 34 +++++++- Sources/CodingBar/AppDelegate.swift | 7 ++ Sources/CodingBar/UpdateChecker.swift | 54 ------------ Sources/CodingBar/UpdateManager.swift | 87 +++++++++++++++++++ .../CodingBar/Views/Panel/SettingsView.swift | 57 ++++++------ 10 files changed, 265 insertions(+), 86 deletions(-) create mode 100644 Package.resolved delete mode 100644 Sources/CodingBar/UpdateChecker.swift create mode 100644 Sources/CodingBar/UpdateManager.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e981f99..8690d29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 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 diff --git a/CLAUDE.md b/CLAUDE.md index 13f5531..6d2af05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..c3b4dd9 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "a45e3519352af004cf6f72a80293b9b729a7fc51d1a23f837aaed81c8ef981db", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle.git", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 64f799c..d08dfe7 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,11 @@ 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", @@ -13,7 +18,10 @@ let package = Package( ), .executableTarget( name: "CodingBar", - dependencies: ["CodingBarCore"], + dependencies: [ + "CodingBarCore", + .product(name: "Sparkle", package: "Sparkle"), + ], swiftSettings: [.swiftLanguageMode(.v5)] ), .testTarget( diff --git a/Scripts/Info.plist b/Scripts/Info.plist index ea1be1a..8f55901 100644 --- a/Scripts/Info.plist +++ b/Scripts/Info.plist @@ -24,5 +24,22 @@ NSHighResolutionCapable + + + SUFeedURL + https://github.com/Gnonymous/CodingBar/releases/latest/download/appcast.xml + SUPublicEDKey + 3CWA9dR27M0p+P0QUSqNdDDK4LjIhUdlJltpVM2ExMo= + + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 diff --git a/Scripts/package.sh b/Scripts/package.sh index fecb9ba..2b1d5eb 100755 --- a/Scripts/package.sh +++ b/Scripts/package.sh @@ -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 @@ -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" diff --git a/Sources/CodingBar/AppDelegate.swift b/Sources/CodingBar/AppDelegate.swift index e6a5850..df1f522 100644 --- a/Sources/CodingBar/AppDelegate.swift +++ b/Sources/CodingBar/AppDelegate.swift @@ -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 diff --git a/Sources/CodingBar/UpdateChecker.swift b/Sources/CodingBar/UpdateChecker.swift deleted file mode 100644 index 41194ce..0000000 --- a/Sources/CodingBar/UpdateChecker.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -// MARK: - User-initiated update check. -// -// PRIVACY: CodingBar reads usage 100% locally; the only other network access is the -// quota path. This adds ONE more, strictly user-initiated, read-only GET to the public -// GitHub Releases API (no auth, no local data sent) — fired only when the user taps -// "检查更新" in Settings. Never automatic, never telemetry. -enum UpdateChecker { - static let repo = "Gnonymous/CodingBar" - - /// `CFBundleShortVersionString` from the packaged .app (stamped by package.sh); - /// "dev" when run via `swift run` (no version-bearing bundle). - static var currentVersion: String { - (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "dev" - } - - static var releasesPageURL: URL { URL(string: "https://github.com/\(repo)/releases/latest")! } - - enum Result: Equatable { - case upToDate(String) // current == latest - case updateAvailable(String) // latest tag, newer than current - case failed - } - - static func check() async -> Result { - guard let api = URL(string: "https://api.github.com/repos/\(repo)/releases/latest") else { return .failed } - var req = URLRequest(url: api, timeoutInterval: 10) - req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - guard let (data, resp) = try? await URLSession.shared.data(for: req), - let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let tag = (obj["tag_name"] as? String)?.trimmingCharacters(in: .whitespaces), !tag.isEmpty - else { return .failed } - - let latest = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag - let current = currentVersion - // A dev build has no real version to compare against — just point at the latest. - if current == "dev" { return .updateAvailable(latest) } - return isNewer(latest, than: current) ? .updateAvailable(latest) : .upToDate(current) - } - - /// Numeric dotted-version compare ("1.10.0" > "1.9.0"). Non-numeric chars in a - /// component are stripped; missing components count as 0. - static func isNewer(_ latest: String, than current: String) -> Bool { - func parts(_ s: String) -> [Int] { s.split(separator: ".").map { Int($0.filter(\.isNumber)) ?? 0 } } - let a = parts(latest), b = parts(current) - for i in 0.. y } - } - return false - } -} diff --git a/Sources/CodingBar/UpdateManager.swift b/Sources/CodingBar/UpdateManager.swift new file mode 100644 index 0000000..9335e22 --- /dev/null +++ b/Sources/CodingBar/UpdateManager.swift @@ -0,0 +1,87 @@ +import Foundation +import Sparkle + +// MARK: - In-app auto-update (Sparkle 2.x). +// +// PRIVACY: CodingBar reads usage 100% locally; the only other network paths are +// (1) the quota path (user's own OAuth token, user's own data) and now (2) Sparkle's +// appcast pull + update zip download. Sparkle is OPT-IN — default off, the user +// enables it from Settings. When on, Sparkle pulls a small XML feed daily, verifies +// every update against the EdDSA public key baked into Info.plist (SUPublicEDKey), +// and only installs binaries whose signature matches. No auth, no telemetry, no +// local data uploaded. +// +// `UpdateManager.shared` is a thin façade on `SPUStandardUpdaterController`. +// Sparkle's standard user driver supplies the "新版本可用 → 立刻更新" dialog, +// download progress, and restart prompt — we just expose the toggle/button. +@MainActor +final class UpdateManager: ObservableObject { + static let shared = UpdateManager() + + /// `CFBundleShortVersionString` from the packaged .app (stamped by package.sh); + /// "dev" when run via `swift run` (no version-bearing bundle). + static var currentVersion: String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "dev" + } + + /// Used by the Settings footer's "GitHub →" link; safe to call even when Sparkle + /// can't run (dev builds have no Info.plist feed URL). + static var releasesPageURL: URL { URL(string: "https://github.com/Gnonymous/CodingBar/releases/latest")! } + + /// nil in dev (swift run) — Sparkle refuses to start without a real .app bundle + /// holding SUFeedURL/SUPublicEDKey. Guard every Sparkle call against this so the + /// dev workflow stays functional. + private let controller: SPUStandardUpdaterController? + + /// Whether Sparkle is wired up. False in dev builds; SettingsView surfaces a + /// disabled control + a hint in that case. + var canUpdate: Bool { controller != nil } + + /// Bound to the Settings toggle. Sparkle persists this in NSUserDefaults + /// (`SUEnableAutomaticChecks`), so we don't manage storage ourselves. + var automaticChecksEnabled: Bool { + get { controller?.updater.automaticallyChecksForUpdates ?? false } + set { controller?.updater.automaticallyChecksForUpdates = newValue } + } + + @Published private(set) var hasAvailableUpdate = false + + private init() { + // SPUStandardUpdaterController(startingUpdater: true) crashes if Info.plist + // lacks SUFeedURL — true for `swift run` dev builds. Detect a real .app + // bundle by checking the path, NOT by checking Info.plist keys (Sparkle + // reads its own defaults too late for us to catch the failure). + if Bundle.main.bundlePath.hasSuffix(".app") { + let c = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil + ) + controller = c + Self.observe(updater: c.updater) { [weak self] in self?.hasAvailableUpdate = $0 } + } else { + controller = nil + } + } + + /// "立刻检查" button. Sparkle's standard user driver takes over from here — + /// it shows the version dialog, downloads, validates the EdDSA signature, and + /// prompts to restart. We just kick it off. + func checkForUpdates() { + controller?.checkForUpdates(nil) + } + + /// Light wrapper around the three Sparkle notifications we care about so the + /// observer block can live in a single place. We don't keep the observer tokens + /// — NotificationCenter retains the blocks strongly for the app's lifetime, but + /// the manager is a singleton, so there's nothing to leak from: the controller, + /// observers and closures all share a single, process-wide lifetime. + private static func observe(updater: SPUUpdater, _ setHasUpdate: @escaping (Bool) -> Void) { + let center = NotificationCenter.default + center.addObserver(forName: NSNotification.Name.SUUpdaterDidFindValidUpdate, + object: updater, queue: .main) { _ in setHasUpdate(true) } + center.addObserver(forName: NSNotification.Name.SUUpdaterDidNotFindUpdate, + object: updater, queue: .main) { _ in setHasUpdate(false) } + center.addObserver(forName: NSNotification.Name.SUUpdaterWillRestart, + object: updater, queue: .main) { _ in setHasUpdate(false) } + } +} + diff --git a/Sources/CodingBar/Views/Panel/SettingsView.swift b/Sources/CodingBar/Views/Panel/SettingsView.swift index 0719efd..14e9a1a 100644 --- a/Sources/CodingBar/Views/Panel/SettingsView.swift +++ b/Sources/CodingBar/Views/Panel/SettingsView.swift @@ -14,9 +14,8 @@ struct SettingsView: View { @State private var launchAtLogin = false @State private var launchUnavailable = false @State private var launchNeedsApproval = false - @State private var updateState: UpdateState = .idle - - private enum UpdateState: Equatable { case idle, checking, done(UpdateChecker.Result) } + @ObservedObject private var updater = UpdateManager.shared + @State private var autoUpdate = UpdateManager.shared.automaticChecksEnabled // This view owns `store`, so it reads the language directly (it can't read an // environment value it sets on itself); descendants still read \.lang. @@ -34,6 +33,8 @@ struct SettingsView: View { divider segRow(lang.t("Language", "界面语言"), [(AppLanguage.en, "English"), (AppLanguage.zh, "中文")], $store.language) divider + autoUpdateRow + divider updateRow } .padding(.vertical, 2) @@ -64,7 +65,7 @@ struct SettingsView: View { HStack(spacing: 6) { Text("CodingBar \(versionLabel)").font(.system(size: 10)).foregroundStyle(dc.fg3) Spacer() - Button { NSWorkspace.shared.open(UpdateChecker.releasesPageURL) } label: { + Button { NSWorkspace.shared.open(UpdateManager.releasesPageURL) } label: { Text("GitHub →").font(.system(size: 10, weight: .medium)).foregroundStyle(dc.accent).contentShape(Rectangle()) } .buttonStyle(.plain).focusEffectDisabled() @@ -98,19 +99,33 @@ struct SettingsView: View { } } + /// Opt-in toggle. Default off — Sparkle only schedules background checks once + /// the user explicitly enables this, keeping the "no automatic network" promise + /// truthful for users who never flip it. + private var autoUpdateRow: some View { + row( + label: lang.t("Auto-check for updates", "自动检查更新"), + caption: updater.canUpdate ? nil : lang.t("Available in the packaged app", "打包为 App 后可用") + ) { + Toggle("", isOn: $autoUpdate) + .labelsHidden().toggleStyle(.switch).tint(dc.accent) + .disabled(!updater.canUpdate) + .onChange(of: autoUpdate) { _, on in updater.automaticChecksEnabled = on } + } + } + + /// Manual "立刻检查" — Sparkle's standard user driver provides the + /// version dialog, progress and restart prompt; we just trigger it. + /// When Sparkle has already detected a pending update, swap the label to + /// "立刻更新" so the affordance reads exactly as it would in the prompt itself. private var updateRow: some View { - row(label: lang.t("Check for updates", "检查更新"), caption: nil) { - switch updateState { - case .idle: - actionLink(lang.t("Check", "检查"), color: dc.accent) { runUpdateCheck() } - case .checking: - ProgressView().controlSize(.small).scaleEffect(0.85).frame(height: 18) - case .done(.upToDate(let v)): - Text(lang.t("Up to date · v\(v)", "已是最新 v\(v)")).font(.system(size: 10.5)).foregroundStyle(dc.fg3) - case .done(.updateAvailable(let v)): - actionLink(lang.t("New version v\(v) →", "有新版本 v\(v) →"), color: dc.accent) { NSWorkspace.shared.open(UpdateChecker.releasesPageURL) } - case .done(.failed): - actionLink(lang.t("Check failed · retry", "检查失败 · 重试"), color: dc.warn) { runUpdateCheck() } + row(label: lang.t("Check now", "立刻检查"), caption: nil) { + if !updater.canUpdate { + Text(lang.t("Dev build", "开发版")).font(.system(size: 10.5)).foregroundStyle(dc.fg3) + } else if updater.hasAvailableUpdate { + actionLink(lang.t("Update now →", "立刻更新 →"), color: dc.accent) { updater.checkForUpdates() } + } else { + actionLink(lang.t("Check", "检查"), color: dc.accent) { updater.checkForUpdates() } } } } @@ -165,18 +180,10 @@ struct SettingsView: View { // MARK: Actions private var versionLabel: String { - let v = UpdateChecker.currentVersion + let v = UpdateManager.currentVersion return v == "dev" ? lang.t("Dev build", "开发版") : "v\(v)" } - private func runUpdateCheck() { - updateState = .checking - Task { - let result = await UpdateChecker.check() - await MainActor.run { updateState = .done(result) } - } - } - private func syncLaunchState() { let status = SMAppService.mainApp.status launchAtLogin = (status == .enabled)