2 Commits

Author SHA1 Message Date
Micha
69904c07ce removed .gitkeep 2025-11-25 17:22:00 +01:00
Micha
01c89de738 add Sparkle appcast 2025-11-25 16:21:07 +01:00
17 changed files with 261 additions and 356 deletions

View File

@@ -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. - 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. - 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. - 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 Sparkles `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. - Further reduced MainView console noise by removing redundant refresh/onAppear logs.

View File

@@ -3,6 +3,4 @@
dynamic Data dynamic Data
static Data static Data
add a merker for "reboot required" add a marker for "reboot required"
Add dmg download option for macOS
Add versioning

View File

@@ -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 - Shows CPU load, memory usage, swap usage, and disk usage
- Periodic ping via `/api/ping` endpoint to check if a server is reachable - Periodic ping via `/api/ping` endpoint to check if a server is reachable
- Colored status indicator for each server in the list - Colored status indicator for each server in the list
- Automatic updates: - Automatic refreshes:
- Ping every 10 seconds - Ping every 10 seconds
- Server info every 60 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 - Organized layout using tabs: General / Resources / Services
- Stores API keys securely in the macOS Keychain - Stores API keys securely in the macOS Keychain
- Native macOS look & feel using SwiftUI - 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_API_BASE="https://git.24unix.net/api/v1"
# optional: GITEA_TARGET_COMMIT="master" # optional: GITEA_TARGET_COMMIT="master"
# optional: GITEA_PRERELEASE="false" # defaults to true until preferences are done # 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<version>` and upload both ZIP and DMG as release assets automatically. `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<version>` 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”). iKeyMon uses [Sparkle](https://sparkle-project.org/) for macOS-safe 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. 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).
- When a newer build is available, a prompt offers to open the DMG download (or you can revisit the release info inside Preferences). 2. `./scripts/build_release.sh` signs the ZIP with Sparkles `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 Sparkles built-in toggles for “Automatically check” and “Automatically download”, and the toolbar button simply calls Sparkles “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 Sparkles 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 ### Versioning workflow

View File

@@ -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..<maxCount {
let left = index < lhs.components.count ? lhs.components[index] : 0
let right = index < rhs.components.count ? rhs.components[index] : 0
if left != right {
return left < right
}
}
return false
}
}
enum UpdateServiceError: LocalizedError {
case invalidResponse
case noReleasesAvailable
case missingDownloadAsset
var errorDescription: String? {
switch self {
case .invalidResponse:
return "The update server returned an invalid response."
case .noReleasesAvailable:
return "No releases are currently available."
case .missingDownloadAsset:
return "The release did not include downloadable artifacts."
}
}
}
struct UpdateService {
private struct APIRelease: Decodable {
struct Asset: Decodable {
let name: String
let browserDownloadURL: URL?
enum CodingKeys: String, CodingKey {
case name
case browserDownloadURL = "browser_download_url"
}
}
let tagName: String
let body: String?
let prerelease: Bool
let draft: Bool
let htmlURL: URL?
let createdAt: Date
let assets: [Asset]
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case body
case prerelease
case draft
case htmlURL = "html_url"
case createdAt = "created_at"
case assets
}
}
private let baseURL = URL(string: "https://git.24unix.net/api/v1")!
private let owner = "tracer"
private let repo = "iKeyMon"
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func fetchLatestRelease(includePrerelease: Bool) async throws -> 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
}
}

View File

@@ -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)
}
}

View File

@@ -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
])
}
}

View File

@@ -12,15 +12,13 @@ struct MainView: View {
private static let serverOrderKeyStatic = "serverOrder" private static let serverOrderKeyStatic = "serverOrder"
private static let storedServersKeyStatic = "storedServers" private static let storedServersKeyStatic = "storedServers"
@EnvironmentObject private var updateViewModel: UpdateViewModel @EnvironmentObject private var sparkleUpdater: SparkleUpdater
@State var showAddServerSheet: Bool = false @State var showAddServerSheet: Bool = false
@State private var serverBeingEdited: Server? @State private var serverBeingEdited: Server?
@State private var serverToDelete: Server? @State private var serverToDelete: Server?
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false
@State private var isFetchingInfo: Bool = false @State private var isFetchingInfo: Bool = false
@State private var refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() @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? @State private var pingTimer: Timer?
private let serverOrderKey = MainView.serverOrderKeyStatic private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedServersKey = MainView.storedServersKeyStatic private let storedServersKey = MainView.storedServersKeyStatic
@@ -64,14 +62,9 @@ struct MainView: View {
} }
ToolbarItem { ToolbarItem {
Button { Button {
updateViewModel.checkForUpdates(userInitiated: true) sparkleUpdater.checkForUpdates()
} label: { } label: {
if updateViewModel.isChecking { Image(systemName: "square.and.arrow.down")
ProgressView()
.scaleEffect(0.6)
} else {
Image(systemName: "square.and.arrow.down")
}
} }
.help("Check for Updates") .help("Check for Updates")
} }
@@ -144,26 +137,8 @@ struct MainView: View {
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
pingAllServers() pingAllServers()
} }
updateViewModel.startAutomaticCheckIfNeeded()
} }
.frame(minWidth: 800, minHeight: 450) .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) { private func fetchServerInfo(for id: UUID) {
@@ -290,23 +265,9 @@ struct MainView: View {
return [] return []
} }
} }
private var availableReleaseBinding: Binding<ReleaseInfo?> {
Binding(
get: { updateViewModel.availableRelease },
set: { updateViewModel.availableRelease = $0 }
)
}
private var statusAlertBinding: Binding<UpdateViewModel.StatusAlert?> {
Binding(
get: { updateViewModel.statusAlert },
set: { updateViewModel.statusAlert = $0 }
)
}
} }
#Preview { #Preview {
MainView() MainView()
.environmentObject(UpdateViewModel()) .environmentObject(SparkleUpdater())
} }

View File

@@ -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("pingInterval") private var storedPingInterval: Int = 10
@AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60 @AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60
@AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true @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 pingIntervalSlider: Double = 10
@State private var refreshIntervalSlider: Double = 60 @State private var refreshIntervalSlider: Double = 60
@@ -126,11 +124,8 @@ struct PreferencesView: View {
case .alerts: case .alerts:
AlertsPreferencesView() AlertsPreferencesView()
case .updates: case .updates:
UpdatesPreferencesView( UpdatesPreferencesView()
autoCheckUpdates: $autoCheckUpdates, .environmentObject(sparkleUpdater)
includePrereleaseUpdates: $includePrereleaseUpdates
)
.environmentObject(updateViewModel)
} }
} }
} }
@@ -232,46 +227,39 @@ private struct MonitorPreferencesView: View {
} }
private struct UpdatesPreferencesView: View { private struct UpdatesPreferencesView: View {
@Binding var autoCheckUpdates: Bool @EnvironmentObject var sparkleUpdater: SparkleUpdater
@Binding var includePrereleaseUpdates: Bool
@EnvironmentObject var updateViewModel: UpdateViewModel private var automaticallyChecksBinding: Binding<Bool> {
Binding(
get: { sparkleUpdater.automaticallyChecksForUpdates },
set: { sparkleUpdater.automaticallyChecksForUpdates = $0 }
)
}
private var automaticallyDownloadsBinding: Binding<Bool> {
Binding(
get: { sparkleUpdater.automaticallyDownloadsUpdates },
set: { sparkleUpdater.automaticallyDownloadsUpdates = $0 }
)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 18) {
Toggle("Automatically check for updates", isOn: $autoCheckUpdates) Toggle("Automatically check for updates", isOn: automaticallyChecksBinding)
.toggleStyle(.switch) Toggle("Automatically download updates", isOn: automaticallyDownloadsBinding)
Toggle("Include pre-release builds", isOn: $includePrereleaseUpdates) Button(action: sparkleUpdater.checkForUpdates) {
.toggleStyle(.switch) Label("Check for Updates Now", systemImage: "sparkles")
HStack {
if updateViewModel.isChecking {
ProgressView()
.progressViewStyle(.circular)
Text("Checking for updates…")
.foregroundColor(.secondary)
} else {
Button("Check Now") {
updateViewModel.checkForUpdates(userInitiated: true)
}
}
} }
if let release = updateViewModel.latestFetchedRelease { Text("Updates are delivered via Sparkle. Configure your appcast URL and public EdDSA key in Info.plist (keys `SUFeedURL` and `SUPublicEDKey`).")
VStack(alignment: .leading, spacing: 4) { .font(.caption)
Text("Latest available: \(release.versionString)") .foregroundColor(.secondary)
.font(.subheadline)
if release.prerelease {
Text("Pre-release build")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.top, 4) .padding(.top, 4)
}
Spacer() Spacer()
} }
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }

View File

@@ -12,7 +12,7 @@ import AppKit
@main @main
struct iKeyMonApp: App { struct iKeyMonApp: App {
@StateObject private var updateViewModel = UpdateViewModel() @StateObject private var sparkleUpdater = SparkleUpdater()
init() { init() {
#if os(macOS) #if os(macOS)
@@ -25,7 +25,7 @@ struct iKeyMonApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
MainView() MainView()
.environmentObject(updateViewModel) .environmentObject(sparkleUpdater)
.onDisappear { .onDisappear {
NSApp.terminate(nil) NSApp.terminate(nil)
} }
@@ -35,7 +35,7 @@ struct iKeyMonApp: App {
Settings { Settings {
PreferencesView() PreferencesView()
.padding() .padding()
.environmentObject(updateViewModel) .environmentObject(sparkleUpdater)
} }
} }
} }

14
Sparkle/appcast.xml vendored Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>iKeyMon</title>
<item>
<title>26.0.13</title>
<pubDate>Tue, 25 Nov 2025 00:05:46 +0100</pubDate>
<sparkle:version>32</sparkle:version>
<sparkle:shortVersionString>26.0.13</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.13/iKeyMon-26.0.13.zip" length="4800781" type="application/octet-stream" sparkle:edSignature="KIGsFaFftWzENTEOHnpPEtk/WaUicS0xK9yMh7e98OKBxlsBkxfghoTu2xU8ZKlEqM6Ndhr5UQwZJE4uBsELAA=="/>
</item>
</channel>
</rss>

10
iKeyMon-Info.plist Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SUFeedURL</key>
<string>https://git.24unix.net/tracer/iKeyMon/raw/branch/master/Sparkle/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=</string>
</dict>
</plist>

View File

@@ -10,8 +10,22 @@
52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */; }; 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */; };
52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */; }; 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */; };
52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B9712ECF751C004DD4A2 /* signing.env.example */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
52A9BD152ED37BD8004DD4A2 /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 6;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
5203C24D2D997D2800576D4A /* iKeyMon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iKeyMon.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -19,6 +33,8 @@
52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; }; 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTES.md; sourceTree = "<group>"; }; 52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTES.md; sourceTree = "<group>"; };
52A9B9712ECF751C004DD4A2 /* signing.env.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = signing.env.example; sourceTree = "<group>"; }; 52A9B9712ECF751C004DD4A2 /* signing.env.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = signing.env.example; sourceTree = "<group>"; };
52A9BEC92ED3874F004DD4A2 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
52A9C38F2ED4D753004DD4A2 /* iKeyMon-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "iKeyMon-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -39,6 +55,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
52A9BD112ED377F7004DD4A2 /* Sparkle in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -48,6 +65,7 @@
5203C2442D997D2800576D4A = { 5203C2442D997D2800576D4A = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
52A9C38F2ED4D753004DD4A2 /* iKeyMon-Info.plist */,
52A9B8BE2ECB68B5004DD4A2 /* Sources */, 52A9B8BE2ECB68B5004DD4A2 /* Sources */,
52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */, 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */,
52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */, 52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */,
@@ -56,6 +74,8 @@
52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */, 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */,
52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */, 52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */,
52A9B9712ECF751C004DD4A2 /* signing.env.example */, 52A9B9712ECF751C004DD4A2 /* signing.env.example */,
52A9BEC92ED3874F004DD4A2 /* README.md */,
52A9BD122ED37E08004DD4A2 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -67,6 +87,13 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
52A9BD122ED37E08004DD4A2 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -77,6 +104,7 @@
5203C2492D997D2800576D4A /* Sources */, 5203C2492D997D2800576D4A /* Sources */,
5203C24A2D997D2800576D4A /* Frameworks */, 5203C24A2D997D2800576D4A /* Frameworks */,
5203C24B2D997D2800576D4A /* Resources */, 5203C24B2D997D2800576D4A /* Resources */,
52A9BD152ED37BD8004DD4A2 /* CopyFiles */,
); );
buildRules = ( buildRules = (
); );
@@ -88,6 +116,7 @@
); );
name = iKeyMon; name = iKeyMon;
packageProductDependencies = ( packageProductDependencies = (
52A9BD102ED377F7004DD4A2 /* Sparkle */,
); );
productName = iKeyMon; productName = iKeyMon;
productReference = 5203C24D2D997D2800576D4A /* iKeyMon.app */; productReference = 5203C24D2D997D2800576D4A /* iKeyMon.app */;
@@ -117,6 +146,9 @@
); );
mainGroup = 5203C2442D997D2800576D4A; mainGroup = 5203C2442D997D2800576D4A;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
52A9BD0F2ED377F7004DD4A2 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 5203C24E2D997D2800576D4A /* Products */; productRefGroup = 5203C24E2D997D2800576D4A /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -133,6 +165,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */, 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */,
52A9BECA2ED3874F004DD4A2 /* README.md in Resources */,
52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */, 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */,
52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */, 52A9B9722ECF751C004DD4A2 /* signing.env.example in Resources */,
); );
@@ -277,19 +310,22 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iKeyMon-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_SUFeedURL = "https://git.24unix.net/tracer/iKeyMon/releases/appcast.xml";
INFOPLIST_KEY_SUPublicEDKey = "EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 26.0.4; MARKETING_VERSION = 26.0.13;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -305,19 +341,22 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iKeyMon-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_SUFeedURL = "https://git.24unix.net/tracer/iKeyMon/releases/appcast.xml";
INFOPLIST_KEY_SUPublicEDKey = "EgJgrOGQ79L5me616jA7kDCEOgx+Rg11uYLYLLIyzTI=";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 26.0.4; MARKETING_VERSION = 26.0.13;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -347,6 +386,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = 5203C2452D997D2800576D4A /* Project object */;
} }

View File

@@ -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
}

View File

@@ -8,6 +8,67 @@ SCHEME="iKeyMon"
PROJECT="iKeyMon.xcodeproj" PROJECT="iKeyMon.xcodeproj"
CREDENTIALS_FILE="$ROOT_DIR/.signing.env" CREDENTIALS_FILE="$ROOT_DIR/.signing.env"
VERSION_FILE="$ROOT_DIR/version.json" 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 if [[ -f "$CREDENTIALS_FILE" ]]; then
set -a set -a
@@ -16,6 +77,9 @@ if [[ -f "$CREDENTIALS_FILE" ]]; then
set +a set +a
fi fi
: "${SPARKLE_APPCAST_OUTPUT:=$ROOT_DIR/Sparkle/appcast.xml}"
export SPARKLE_APPCAST_OUTPUT
"$ROOT_DIR/scripts/sync_version.sh" "$ROOT_DIR/scripts/sync_version.sh"
rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR" rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR"
@@ -71,6 +135,8 @@ popd >/dev/null
DMG_NAME="iKeyMon-${VERSION}.dmg" DMG_NAME="iKeyMon-${VERSION}.dmg"
hdiutil create -volname "iKeyMon" -srcfolder "$STAGING_DIR" -ov -format UDZO "$ARTIFACTS_DIR/$DMG_NAME" 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 if [[ -n "${NOTARY_APPLE_ID:-}" && -n "${NOTARY_TEAM_ID:-}" && -n "${NOTARY_PASSWORD:-}" ]]; then
echo "📝 Submitting DMG for notarization..." echo "📝 Submitting DMG for notarization..."
xcrun notarytool submit "$ARTIFACTS_DIR/$DMG_NAME" \ xcrun notarytool submit "$ARTIFACTS_DIR/$DMG_NAME" \
@@ -84,6 +150,8 @@ else
fi fi
rm -rf "$STAGING_DIR" rm -rf "$STAGING_DIR"
generate_appcast
if [[ -n "${GITEA_TOKEN:-}" && -n "${GITEA_OWNER:-}" && -n "${GITEA_REPO:-}" ]]; then 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" "$ROOT_DIR/scripts/publish_release.sh" "$VERSION" "$ARTIFACTS_DIR/$ZIP_NAME" "$ARTIFACTS_DIR/$DMG_NAME"
else else

View File

@@ -78,4 +78,8 @@ upload_asset() {
upload_asset "$ZIP_PATH" upload_asset "$ZIP_PATH"
upload_asset "$DMG_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." echo "🎉 Release ${RELEASE_TAG} assets uploaded."

View File

@@ -8,3 +8,9 @@ GITEA_REPO="iKeyMon"
# GITEA_API_BASE="https://git.24unix.net/api/v1" # GITEA_API_BASE="https://git.24unix.net/api/v1"
# GITEA_TARGET_COMMIT="master" # GITEA_TARGET_COMMIT="master"
# GITEA_PRERELEASE="false" # 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

View File

@@ -1,3 +1,3 @@
{ {
"marketing_version": "26.0.4" "marketing_version": "26.0.13"
} }