diff --git a/CHANGELOG.md b/CHANGELOG.md index ab28483..e20a4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,5 +9,6 @@ - Detection now probes `meta.api_version` so future API versions are selected automatically, and the ping loop logs only failures to keep output quiet. - Introduced repository-wide version management via `version.json` + `scripts/sync_version.sh`, ensuring Xcode targets and release artifacts stay aligned. - Enhanced `scripts/build_release.sh` to timestamp/harden signatures, notarize DMGs, and optionally publish tagged releases (pre-release by default) with ZIP/DMG assets directly to Gitea when credentials are configured. -- Added a lightweight update checker that polls the latest Gitea release, surfaces alerts + download shortcuts, and exposes new toggles (auto-check + prerelease channel) inside Preferences. +- Integrated Sparkle (via Swift Package Manager) to handle automatic update checks, downloads, signature verification, and relaunches, replacing the previous custom updater UI. Preferences now simply surface Sparkle's check/download toggles. +- `scripts/build_release.sh` can optionally run Sparkle’s `generate_appcast` (when signing key and download prefix env vars are set), producing a ready-to-host `appcast.xml` alongside the ZIP/DMG artifacts. - Further reduced MainView console noise by removing redundant refresh/onAppear logs. diff --git a/NOTES.md b/NOTES.md index f7637ed..8cd053a 100644 --- a/NOTES.md +++ b/NOTES.md @@ -3,6 +3,4 @@ dynamic Data static Data -add a merker for "reboot required" -Add dmg download option for macOS -Add versioning +add a marker for "reboot required" diff --git a/README.md b/README.md index c7fd277..a9e1772 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ iKeyMon is a native macOS app written in SwiftUI that provides live monitoring f - Shows CPU load, memory usage, swap usage, and disk usage - Periodic ping via `/api/ping` endpoint to check if a server is reachable - Colored status indicator for each server in the list -- Automatic updates: +- Automatic refreshes: - Ping every 10 seconds - Server info every 60 seconds -- Optional automatic update checks that watch the latest Gitea release (with manual “Check now” button and prerelease channel toggle) +- Built-in Sparkle updater (automatic checks, downloads, and relaunch once a signed release is available) - Organized layout using tabs: General / Resources / Services - Stores API keys securely in the macOS Keychain - Native macOS look & feel using SwiftUI @@ -57,16 +57,27 @@ GITEA_REPO="iKeyMon" # optional: GITEA_API_BASE="https://git.24unix.net/api/v1" # optional: GITEA_TARGET_COMMIT="master" # optional: GITEA_PRERELEASE="false" # defaults to true until preferences are done +# optional Sparkle feed helpers: +# SPARKLE_EDDSA_KEY_FILE="$HOME/.config/Sparkle/iKeyMon.key" +# SPARKLE_DOWNLOAD_BASE_TEMPLATE="https://git.24unix.net/tracer/iKeyMon/releases/download/v{{VERSION}}" +# SPARKLE_APPCAST_OUTPUT="$ROOT_DIR/Sparkle/appcast.xml" # default ``` `GITEA_TARGET_COMMIT` defaults to the current `HEAD` commit, so overriding it lets you publish from another branch if needed. Whenever those variables are set, the script will create (or reuse) tag `v` and upload both ZIP and DMG as release assets automatically. -### Update checks +### Sparkle updates -- The app can automatically query the Gitea release feed on launch (Preferences → Updates → “Automatically check for updates”). -- “Check for updates” also lives in the main window toolbar; if you're already up to date, you'll get a confirmation alert. -- Preferences let you opt into prerelease builds (enabled by default for now because current releases are flagged prerelease) and show the latest fetched release number for reference. -- When a newer build is available, a prompt offers to open the DMG download (or you can revisit the release info inside Preferences). +iKeyMon uses [Sparkle](https://sparkle-project.org/) for macOS-safe updates. + +1. Generate an EdDSA key pair once (`./Packages/Sparkle/bin/generate_keys`). Store the private key on-disk (for example `~/.config/Sparkle/iKeyMon.key`, which the build script expects) and copy the public key into the `SUPublicEDKey` entry (see Info.plist notes below). +2. `./scripts/build_release.sh` signs the ZIP with Sparkle’s `sign_update` tool and invokes `generate_appcast` automatically when the Sparkle variables are present. The generated feed is written to `Sparkle/appcast.xml`, so commit that file after every release. Point `SPARKLE_DOWNLOAD_BASE_TEMPLATE` at your release download prefix to ensure the feed URLs resolve correctly. +3. Set `SUFeedURL` in Info.plist (or the corresponding build setting) to the raw URL of `Sparkle/appcast.xml` inside this repo (e.g. `https://git.24unix.net/tracer/iKeyMon/raw/branch/master/Sparkle/appcast.xml`). + +Preferences expose Sparkle’s built-in toggles for “Automatically check” and “Automatically download”, and the toolbar button simply calls Sparkle’s “Check for Updates…” sheet. + +> `./scripts/build_release.sh` will call `generate_appcast` for you when `SPARKLE_EDDSA_KEY_FILE` and either `SPARKLE_DOWNLOAD_BASE_TEMPLATE` (with `{{VERSION}}` placeholder) or `SPARKLE_DOWNLOAD_BASE_URL` are set. It tries to locate Sparkle’s CLI in DerivedData automatically, but you can override the path via `SPARKLE_GENERATE_APPCAST`. The resulting feed is written to `SPARKLE_APPCAST_OUTPUT` (defaults to `Sparkle/appcast.xml`). + +> Build settings include `INFOPLIST_KEY_SUFeedURL` and `INFOPLIST_KEY_SUPublicEDKey`. Make sure to fill both before shipping a build so Sparkle knows where to fetch updates and how to verify them. ### Versioning workflow diff --git a/Sources/Model/Updates/UpdateService.swift b/Sources/Model/Updates/UpdateService.swift deleted file mode 100644 index 142e470..0000000 --- a/Sources/Model/Updates/UpdateService.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Foundation - -struct ReleaseInfo: Identifiable { - let id = UUID() - let version: VersionNumber - let versionString: String - let notes: String - let downloadURL: URL - let releaseURL: URL - let prerelease: Bool -} - -struct VersionNumber: Comparable, CustomStringConvertible { - let rawValue: String - private let components: [Int] - - init(_ raw: String) { - self.rawValue = raw - self.components = raw - .split(separator: ".") - .map { Int($0) ?? 0 } - } - - var description: String { rawValue } - - static func < (lhs: VersionNumber, rhs: VersionNumber) -> Bool { - let maxCount = max(lhs.components.count, rhs.components.count) - for index in 0.. ReleaseInfo { - let releasesURL = baseURL - .appendingPathComponent("repos") - .appendingPathComponent(owner) - .appendingPathComponent(repo) - .appendingPathComponent("releases") - - var request = URLRequest(url: releasesURL) - request.addValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, - 200..<300 ~= httpResponse.statusCode else { - throw UpdateServiceError.invalidResponse - } - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let releases = try decoder.decode([APIRelease].self, from: data) - - let filtered = releases - .filter { !$0.draft } - .filter { includePrerelease || !$0.prerelease } - .sorted(by: { $0.createdAt > $1.createdAt }) - - guard let release = filtered.first else { - throw UpdateServiceError.noReleasesAvailable - } - - guard let asset = preferredAsset(from: release.assets), - let downloadURL = asset.browserDownloadURL else { - throw UpdateServiceError.missingDownloadAsset - } - - let cleanTag = release.tagName.trimmingCharacters(in: .whitespacesAndNewlines) - let version = cleanTag.hasPrefix("v") ? String(cleanTag.dropFirst()) : cleanTag - - return ReleaseInfo( - version: VersionNumber(version), - versionString: version, - notes: release.body ?? "", - downloadURL: downloadURL, - releaseURL: release.htmlURL ?? releasesURL, - prerelease: release.prerelease - ) - } - - private func preferredAsset(from assets: [APIRelease.Asset]) -> APIRelease.Asset? { - return assets - .sorted { lhs, rhs in - priority(for: lhs.name) > priority(for: rhs.name) - } - .first(where: { $0.browserDownloadURL != nil }) - } - - private func priority(for name: String) -> Int { - if name.lowercased().hasSuffix(".dmg") { - return 3 - } - if name.lowercased().hasSuffix(".zip") { - return 2 - } - return 1 - } -} diff --git a/Sources/ViewModels/SparkleUpdater.swift b/Sources/ViewModels/SparkleUpdater.swift new file mode 100644 index 0000000..48342eb --- /dev/null +++ b/Sources/ViewModels/SparkleUpdater.swift @@ -0,0 +1,26 @@ +import Sparkle +import Foundation + +@MainActor +final class SparkleUpdater: NSObject, ObservableObject { + let controller: SPUStandardUpdaterController + + override init() { + self.controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + super.init() + } + + var automaticallyChecksForUpdates: Bool { + get { controller.updater.automaticallyChecksForUpdates } + set { controller.updater.automaticallyChecksForUpdates = newValue } + } + + var automaticallyDownloadsUpdates: Bool { + get { controller.updater.automaticallyDownloadsUpdates } + set { controller.updater.automaticallyDownloadsUpdates = newValue } + } + + func checkForUpdates() { + controller.checkForUpdates(nil) + } +} diff --git a/Sources/ViewModels/UpdateViewModel.swift b/Sources/ViewModels/UpdateViewModel.swift deleted file mode 100644 index 4e519cc..0000000 --- a/Sources/ViewModels/UpdateViewModel.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -#if os(macOS) -import AppKit -#endif - -@MainActor -final class UpdateViewModel: ObservableObject { - struct StatusAlert: Identifiable { - let id = UUID() - let title: String - let message: String - } - - @Published var availableRelease: ReleaseInfo? - @Published var latestFetchedRelease: ReleaseInfo? - @Published var statusAlert: StatusAlert? - @Published var isChecking = false - - private let service: UpdateService - private let userDefaults: UserDefaults - private let autoCheckKey = "autoCheckUpdates" - private let includePrereleaseKey = "includePrereleaseUpdates" - - init(service: UpdateService = UpdateService(), userDefaults: UserDefaults = .standard) { - self.service = service - self.userDefaults = userDefaults - registerDefaultsIfNeeded() - } - - func startAutomaticCheckIfNeeded() { - if userDefaults.bool(forKey: autoCheckKey) { - checkForUpdates(userInitiated: false) - } - } - - func checkForUpdates(userInitiated: Bool) { - guard !isChecking else { return } - isChecking = true - - Task { - do { - let release = try await service.fetchLatestRelease(includePrerelease: userDefaults.bool(forKey: includePrereleaseKey)) - handle(release: release, userInitiated: userInitiated) - } catch { - if userInitiated { - statusAlert = StatusAlert( - title: "Update Check Failed", - message: error.localizedDescription - ) - } - } - self.isChecking = false - } - } - - func openReleaseNotes() { - guard let releaseURL = availableRelease?.releaseURL ?? latestFetchedRelease?.releaseURL else { return } - #if os(macOS) - NSWorkspace.shared.open(releaseURL) - #endif - } - - func downloadLatest() { - let release = availableRelease ?? latestFetchedRelease - availableRelease = nil - guard let downloadURL = release?.downloadURL else { return } - #if os(macOS) - NSWorkspace.shared.open(downloadURL) - #endif - } - - private func handle(release: ReleaseInfo, userInitiated: Bool) { - let currentVersion = VersionNumber( - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - ) - - latestFetchedRelease = release - - if release.version > currentVersion { - availableRelease = release - } else if userInitiated { - statusAlert = StatusAlert( - title: "You're Up To Date", - message: "iKeyMon \(currentVersion.rawValue) is the latest version." - ) - } - } - - private func registerDefaultsIfNeeded() { - userDefaults.register(defaults: [ - autoCheckKey: true, - includePrereleaseKey: true - ]) - } -} diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index 043412d..6fe0327 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -12,15 +12,13 @@ struct MainView: View { private static let serverOrderKeyStatic = "serverOrder" private static let storedServersKeyStatic = "storedServers" - @EnvironmentObject private var updateViewModel: UpdateViewModel + @EnvironmentObject private var sparkleUpdater: SparkleUpdater @State var showAddServerSheet: Bool = false @State private var serverBeingEdited: Server? @State private var serverToDelete: Server? @State private var showDeleteConfirmation = false @State private var isFetchingInfo: Bool = false @State private var refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() - @State private var progress: Double = 0 - @State private var lastRefresh = Date() @State private var pingTimer: Timer? private let serverOrderKey = MainView.serverOrderKeyStatic private let storedServersKey = MainView.storedServersKeyStatic @@ -64,14 +62,9 @@ struct MainView: View { } ToolbarItem { Button { - updateViewModel.checkForUpdates(userInitiated: true) + sparkleUpdater.checkForUpdates() } label: { - if updateViewModel.isChecking { - ProgressView() - .scaleEffect(0.6) - } else { - Image(systemName: "square.and.arrow.down") - } + Image(systemName: "square.and.arrow.down") } .help("Check for Updates") } @@ -144,26 +137,8 @@ struct MainView: View { pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in pingAllServers() } - updateViewModel.startAutomaticCheckIfNeeded() } .frame(minWidth: 800, minHeight: 450) - .alert(item: availableReleaseBinding) { release in - Alert( - title: Text("Update Available"), - message: Text("iKeyMon \(release.versionString) is available."), - primaryButton: .default(Text("Download")) { - updateViewModel.downloadLatest() - }, - secondaryButton: .cancel(Text("Later")) - ) - } - .alert(item: statusAlertBinding) { alert in - Alert( - title: Text(alert.title), - message: Text(alert.message), - dismissButton: .default(Text("OK")) - ) - } } private func fetchServerInfo(for id: UUID) { @@ -290,23 +265,9 @@ struct MainView: View { return [] } } - - private var availableReleaseBinding: Binding { - Binding( - get: { updateViewModel.availableRelease }, - set: { updateViewModel.availableRelease = $0 } - ) - } - - private var statusAlertBinding: Binding { - Binding( - get: { updateViewModel.statusAlert }, - set: { updateViewModel.statusAlert = $0 } - ) - } } #Preview { MainView() - .environmentObject(UpdateViewModel()) + .environmentObject(SparkleUpdater()) } diff --git a/Sources/Views/PreferencesView.swift b/Sources/Views/PreferencesView.swift index 07e4193..a434be8 100644 --- a/Sources/Views/PreferencesView.swift +++ b/Sources/Views/PreferencesView.swift @@ -22,13 +22,11 @@ struct PreferencesView: View { } } } - @EnvironmentObject private var updateViewModel: UpdateViewModel + @EnvironmentObject private var sparkleUpdater: SparkleUpdater @AppStorage("pingInterval") private var storedPingInterval: Int = 10 @AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60 @AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true - @AppStorage("autoCheckUpdates") private var autoCheckUpdates: Bool = true - @AppStorage("includePrereleaseUpdates") private var includePrereleaseUpdates: Bool = true @State private var pingIntervalSlider: Double = 10 @State private var refreshIntervalSlider: Double = 60 @@ -126,11 +124,8 @@ struct PreferencesView: View { case .alerts: AlertsPreferencesView() case .updates: - UpdatesPreferencesView( - autoCheckUpdates: $autoCheckUpdates, - includePrereleaseUpdates: $includePrereleaseUpdates - ) - .environmentObject(updateViewModel) + UpdatesPreferencesView() + .environmentObject(sparkleUpdater) } } } @@ -232,46 +227,39 @@ private struct MonitorPreferencesView: View { } private struct UpdatesPreferencesView: View { - @Binding var autoCheckUpdates: Bool - @Binding var includePrereleaseUpdates: Bool - @EnvironmentObject var updateViewModel: UpdateViewModel + @EnvironmentObject var sparkleUpdater: SparkleUpdater + + private var automaticallyChecksBinding: Binding { + Binding( + get: { sparkleUpdater.automaticallyChecksForUpdates }, + set: { sparkleUpdater.automaticallyChecksForUpdates = $0 } + ) + } + + private var automaticallyDownloadsBinding: Binding { + Binding( + get: { sparkleUpdater.automaticallyDownloadsUpdates }, + set: { sparkleUpdater.automaticallyDownloadsUpdates = $0 } + ) + } var body: some View { - VStack(alignment: .leading, spacing: 16) { - Toggle("Automatically check for updates", isOn: $autoCheckUpdates) - .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 18) { + Toggle("Automatically check for updates", isOn: automaticallyChecksBinding) + Toggle("Automatically download updates", isOn: automaticallyDownloadsBinding) - Toggle("Include pre-release builds", isOn: $includePrereleaseUpdates) - .toggleStyle(.switch) - - HStack { - if updateViewModel.isChecking { - ProgressView() - .progressViewStyle(.circular) - Text("Checking for updates…") - .foregroundColor(.secondary) - } else { - Button("Check Now") { - updateViewModel.checkForUpdates(userInitiated: true) - } - } + Button(action: sparkleUpdater.checkForUpdates) { + Label("Check for Updates Now", systemImage: "sparkles") } - if let release = updateViewModel.latestFetchedRelease { - VStack(alignment: .leading, spacing: 4) { - Text("Latest available: \(release.versionString)") - .font(.subheadline) - if release.prerelease { - Text("Pre-release build") - .font(.caption) - .foregroundColor(.secondary) - } - } + Text("Updates are delivered via Sparkle. Configure your appcast URL and public EdDSA key in Info.plist (keys `SUFeedURL` and `SUPublicEDKey`).") + .font(.caption) + .foregroundColor(.secondary) .padding(.top, 4) - } Spacer() } + .toggleStyle(.switch) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/iKeyMonApp.swift b/Sources/iKeyMonApp.swift index 892a4d0..5195560 100644 --- a/Sources/iKeyMonApp.swift +++ b/Sources/iKeyMonApp.swift @@ -12,7 +12,7 @@ import AppKit @main struct iKeyMonApp: App { - @StateObject private var updateViewModel = UpdateViewModel() + @StateObject private var sparkleUpdater = SparkleUpdater() init() { #if os(macOS) @@ -25,7 +25,7 @@ struct iKeyMonApp: App { var body: some Scene { WindowGroup { MainView() - .environmentObject(updateViewModel) + .environmentObject(sparkleUpdater) .onDisappear { NSApp.terminate(nil) } @@ -35,7 +35,7 @@ struct iKeyMonApp: App { Settings { PreferencesView() .padding() - .environmentObject(updateViewModel) + .environmentObject(sparkleUpdater) } } } diff --git a/Sparkle/.gitkeep b/Sparkle/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Sparkle/.gitkeep @@ -0,0 +1 @@ + diff --git a/Sparkle/appcast.xml b/Sparkle/appcast.xml new file mode 100644 index 0000000..972987a --- /dev/null +++ b/Sparkle/appcast.xml @@ -0,0 +1,14 @@ + + + + iKeyMon + + 26.0.13 + Tue, 25 Nov 2025 00:05:46 +0100 + 32 + 26.0.13 + 15.2 + + + + diff --git a/iKeyMon-Info.plist b/iKeyMon-Info.plist new file mode 100644 index 0000000..1781f3a --- /dev/null +++ b/iKeyMon-Info.plist @@ -0,0 +1,10 @@ + + + + + SUFeedURL + https://git.24unix.net/tracer/iKeyMon/raw/branch/master/Sparkle/appcast.xml + SUPublicEDKey + EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI= + + diff --git a/iKeyMon.xcodeproj/project.pbxproj b/iKeyMon.xcodeproj/project.pbxproj index 36a2edc..c70b6d3 100644 --- a/iKeyMon.xcodeproj/project.pbxproj +++ b/iKeyMon.xcodeproj/project.pbxproj @@ -10,8 +10,22 @@ 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */; }; 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */; }; 52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B9712ECF751C004DD4A2 /* signing.env.example */; }; + 52A9BD112ED377F7004DD4A2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 52A9BD102ED377F7004DD4A2 /* Sparkle */; }; + 52A9BECA2ED3874F004DD4A2 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9BEC92ED3874F004DD4A2 /* README.md */; }; /* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + 52A9BD152ED37BD8004DD4A2 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 6; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 5203C24D2D997D2800576D4A /* iKeyMon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iKeyMon.app; sourceTree = BUILT_PRODUCTS_DIR; }; 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -19,6 +33,8 @@ 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTES.md; sourceTree = ""; }; 52A9B9712ECF751C004DD4A2 /* signing.env.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = signing.env.example; sourceTree = ""; }; + 52A9BEC92ED3874F004DD4A2 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 52A9C38F2ED4D753004DD4A2 /* iKeyMon-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "iKeyMon-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -39,6 +55,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 52A9BD112ED377F7004DD4A2 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -48,6 +65,7 @@ 5203C2442D997D2800576D4A = { isa = PBXGroup; children = ( + 52A9C38F2ED4D753004DD4A2 /* iKeyMon-Info.plist */, 52A9B8BE2ECB68B5004DD4A2 /* Sources */, 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */, 52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */, @@ -56,6 +74,8 @@ 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */, 52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */, 52A9B9712ECF751C004DD4A2 /* signing.env.example */, + 52A9BEC92ED3874F004DD4A2 /* README.md */, + 52A9BD122ED37E08004DD4A2 /* Frameworks */, ); sourceTree = ""; }; @@ -67,6 +87,13 @@ name = Products; sourceTree = ""; }; + 52A9BD122ED37E08004DD4A2 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -77,6 +104,7 @@ 5203C2492D997D2800576D4A /* Sources */, 5203C24A2D997D2800576D4A /* Frameworks */, 5203C24B2D997D2800576D4A /* Resources */, + 52A9BD152ED37BD8004DD4A2 /* CopyFiles */, ); buildRules = ( ); @@ -88,6 +116,7 @@ ); name = iKeyMon; packageProductDependencies = ( + 52A9BD102ED377F7004DD4A2 /* Sparkle */, ); productName = iKeyMon; productReference = 5203C24D2D997D2800576D4A /* iKeyMon.app */; @@ -117,6 +146,9 @@ ); mainGroup = 5203C2442D997D2800576D4A; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 52A9BD0F2ED377F7004DD4A2 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 5203C24E2D997D2800576D4A /* Products */; projectDirPath = ""; @@ -133,6 +165,7 @@ buildActionMask = 2147483647; files = ( 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */, + 52A9BECA2ED3874F004DD4A2 /* README.md in Resources */, 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */, 52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */, ); @@ -277,19 +310,22 @@ CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_TEAM = Q5486ZVAFT; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "iKeyMon-Info.plist"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_SUFeedURL = "https://git.24unix.net/tracer/iKeyMon/releases/appcast.xml"; + INFOPLIST_KEY_SUPublicEDKey = "EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI="; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 26.0.4; + MARKETING_VERSION = 26.0.13; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -305,19 +341,22 @@ CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_TEAM = Q5486ZVAFT; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "iKeyMon-Info.plist"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_SUFeedURL = "https://git.24unix.net/tracer/iKeyMon/releases/appcast.xml"; + INFOPLIST_KEY_SUPublicEDKey = "EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI="; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 26.0.4; + MARKETING_VERSION = 26.0.13; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -347,6 +386,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 52A9BD0F2ED377F7004DD4A2 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.8.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 52A9BD102ED377F7004DD4A2 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 52A9BD0F2ED377F7004DD4A2 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 5203C2452D997D2800576D4A /* Project object */; } diff --git a/iKeyMon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iKeyMon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..f55129a --- /dev/null +++ b/iKeyMon.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", + "version" : "2.8.1" + } + } + ], + "version" : 3 +} diff --git a/scripts/build_release.sh b/scripts/build_release.sh index eab101d..7dbe022 100755 --- a/scripts/build_release.sh +++ b/scripts/build_release.sh @@ -8,6 +8,67 @@ SCHEME="iKeyMon" PROJECT="iKeyMon.xcodeproj" CREDENTIALS_FILE="$ROOT_DIR/.signing.env" VERSION_FILE="$ROOT_DIR/version.json" +DERIVED_DATA_ROOT="${DERIVED_DATA_ROOT:-$HOME/Library/Developer/Xcode/DerivedData}" + +find_generate_appcast() { + if [[ -n "${SPARKLE_GENERATE_APPCAST:-}" && -x "${SPARKLE_GENERATE_APPCAST}" ]]; then + echo "$SPARKLE_GENERATE_APPCAST" + return + fi + + if [[ -d "$DERIVED_DATA_ROOT" ]]; then + local candidate + candidate="$(find "$DERIVED_DATA_ROOT" -path "*/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast" -type f 2>/dev/null | head -n 1 || true)" + if [[ -n "$candidate" ]]; then + echo "$candidate" + return + fi + fi +} + +generate_appcast() { + local generator + generator="$(find_generate_appcast)" + local download_prefix="" + if [[ -n "${SPARKLE_DOWNLOAD_BASE_TEMPLATE:-}" ]]; then + download_prefix="${SPARKLE_DOWNLOAD_BASE_TEMPLATE//\{\{VERSION\}\}/$VERSION}" + else + download_prefix="${SPARKLE_DOWNLOAD_BASE_URL:-}" + fi + + if [[ -z "$generator" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" || -z "$download_prefix" ]]; then + echo "ℹ️ Skipping Sparkle appcast generation (generator/key/download prefix not configured)." + return + fi + + local output="$SPARKLE_APPCAST_OUTPUT" + mkdir -p "$(dirname "$output")" + local staging_dir + staging_dir="$(mktemp -d)" + cp "$ARTIFACTS_DIR"/*.zip "$staging_dir"/ 2>/dev/null || true + echo "🧾 Generating Sparkle appcast at $output" + if ! "$generator" \ + --download-url-prefix "$download_prefix" \ + --ed-key-file "$SPARKLE_EDDSA_KEY_FILE" \ + -o "$output" \ + "$staging_dir"; then + echo "⚠️ Sparkle appcast generation failed." + fi + rm -rf "$staging_dir" +} + +sign_update_artifacts() { + local signer + signer="$(find "$DERIVED_DATA_ROOT" -path "*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update" -type f 2>/dev/null | head -n 1 || true)" + if [[ -z "$signer" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" ]]; then + echo "ℹ️ Skipping Sparkle signing (sign_update or SPARKLE_EDDSA_KEY_FILE missing)." + return + fi + echo "🔑 Signing ${ZIP_NAME} for Sparkle feed" + if ! "$signer" "${ARTIFACTS_DIR}/${ZIP_NAME}" --ed-key-file "${SPARKLE_EDDSA_KEY_FILE}"; then + echo "⚠️ sign_update failed (continuing without signature)" + fi +} if [[ -f "$CREDENTIALS_FILE" ]]; then set -a @@ -16,6 +77,9 @@ if [[ -f "$CREDENTIALS_FILE" ]]; then set +a fi +: "${SPARKLE_APPCAST_OUTPUT:=$ROOT_DIR/Sparkle/appcast.xml}" +export SPARKLE_APPCAST_OUTPUT + "$ROOT_DIR/scripts/sync_version.sh" rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR" @@ -71,6 +135,8 @@ popd >/dev/null DMG_NAME="iKeyMon-${VERSION}.dmg" hdiutil create -volname "iKeyMon" -srcfolder "$STAGING_DIR" -ov -format UDZO "$ARTIFACTS_DIR/$DMG_NAME" +sign_update_artifacts + if [[ -n "${NOTARY_APPLE_ID:-}" && -n "${NOTARY_TEAM_ID:-}" && -n "${NOTARY_PASSWORD:-}" ]]; then echo "📝 Submitting DMG for notarization..." xcrun notarytool submit "$ARTIFACTS_DIR/$DMG_NAME" \ @@ -84,6 +150,8 @@ else fi rm -rf "$STAGING_DIR" +generate_appcast + if [[ -n "${GITEA_TOKEN:-}" && -n "${GITEA_OWNER:-}" && -n "${GITEA_REPO:-}" ]]; then "$ROOT_DIR/scripts/publish_release.sh" "$VERSION" "$ARTIFACTS_DIR/$ZIP_NAME" "$ARTIFACTS_DIR/$DMG_NAME" else diff --git a/scripts/publish_release.sh b/scripts/publish_release.sh index 7668c04..41b0958 100755 --- a/scripts/publish_release.sh +++ b/scripts/publish_release.sh @@ -78,4 +78,8 @@ upload_asset() { upload_asset "$ZIP_PATH" upload_asset "$DMG_PATH" +if [[ -n "${SPARKLE_APPCAST_OUTPUT:-}" && -f "${SPARKLE_APPCAST_OUTPUT}" ]]; then + upload_asset "$SPARKLE_APPCAST_OUTPUT" +fi + echo "🎉 Release ${RELEASE_TAG} assets uploaded." diff --git a/signing.env.example b/signing.env.example index 16c862c..24d0a4e 100644 --- a/signing.env.example +++ b/signing.env.example @@ -8,3 +8,9 @@ GITEA_REPO="iKeyMon" # GITEA_API_BASE="https://git.24unix.net/api/v1" # GITEA_TARGET_COMMIT="master" # GITEA_PRERELEASE="false" + +# Sparkle appcast generation (optional) +# SPARKLE_EDDSA_KEY_FILE="$HOME/.config/Sparkle/iKeyMon.key" +# SPARKLE_DOWNLOAD_BASE_TEMPLATE="https://git.24unix.net/tracer/iKeyMon/releases/download/v{{VERSION}}" +# SPARKLE_APPCAST_OUTPUT="$ROOT_DIR/Sparkle/appcast.xml" # defaults to this path +# SPARKLE_GENERATE_APPCAST="/path/to/generate_appcast" # auto-detected if unset diff --git a/version.json b/version.json index 755b642..551d686 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "marketing_version": "26.0.4" + "marketing_version": "26.0.13" }