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)