From dc710d53aa209ff1b9e60edc22d3b93749116a55 Mon Sep 17 00:00:00 2001 From: Micha Date: Sat, 22 Nov 2025 18:56:55 +0100 Subject: [PATCH] included Sparkle --- CHANGELOG.md | 3 + README.md | 29 ++++ Sources/Extensions/View+Shimmer.swift | 9 +- Sources/Model/Updates/UpdateService.swift | 160 ++++++++++++++++++++++ Sources/ViewModels/UpdateViewModel.swift | 95 +++++++++++++ Sources/Views/MainView.swift | 56 +++++++- Sources/Views/PreferencesView.swift | 66 ++++++++- Sources/Views/Tabs/GeneralView.swift | 5 +- Sources/iKeyMonApp.swift | 4 + iKeyMon.xcodeproj/project.pbxproj | 8 +- scripts/build_release.sh | 26 ++-- scripts/publish_release.sh | 81 +++++++++++ scripts/sync_version.sh | 56 ++++++++ signing.env.example | 6 + version.json | 3 + 15 files changed, 579 insertions(+), 28 deletions(-) create mode 100644 Sources/Model/Updates/UpdateService.swift create mode 100644 Sources/ViewModels/UpdateViewModel.swift create mode 100755 scripts/publish_release.sh create mode 100755 scripts/sync_version.sh create mode 100644 version.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd4628..ab28483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,7 @@ - Added verbose logging in `MainView` to trace server loading, selection, and fetch/ping activity when the list appears empty. - Switched `MainView` and `ServerFormView` to the version-aware API client (`APIFactory`/`APIv2_12`) for server summaries and introduced a shared `PingService`. - 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. - Further reduced MainView console noise by removing redundant refresh/onAppear logs. diff --git a/README.md b/README.md index 642ce74..c7fd277 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ iKeyMon is a native macOS app written in SwiftUI that provides live monitoring f - Automatic updates: - 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) - Organized layout using tabs: General / Resources / Services - Stores API keys securely in the macOS Keychain - Native macOS look & feel using SwiftUI @@ -46,6 +47,34 @@ Use the helper script to produce distributables in `dist/`: ``` It cleans previous artifacts, builds the `Release` configuration, and drops both `iKeyMon-.zip` and `iKeyMon-.dmg` into the `dist` folder (ignored by git). To enable codesigning + notarization, copy `signing.env.example` to `.signing.env`, fill in your Developer ID identity, Apple ID, team ID, and app-specific password. The script sources that file locally (it remains gitignored) and performs signing/notarization when the values are present. + +To auto-publish the artifacts as a Gitea release, extend `.signing.env` with: + +``` +GITEA_TOKEN="..." +GITEA_OWNER="tracer" +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 +``` + +`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 + +- 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). + +### Versioning workflow + +- The canonical marketing version lives in `version.json` and follows the format `YY.major.minor` (example: `26.1.2`). Update that file manually whenever you cut a new release branch. +- The build number is derived automatically from the git commit count on the current branch (you can override it by exporting `BUILD_NUMBER` before running the script if needed). +- Run `./scripts/sync_version.sh` anytime after editing `version.json` (the release script already calls it). The helper updates `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` inside `iKeyMon.xcodeproj`, keeping Xcode, the app bundle, and release artifacts in sync. +- `scripts/build_release.sh` reads the same `version.json` for naming the generated ZIP/DMG, so the artifact names, Info.plist values, and UI displays all stay aligned. + ## 📦 License MIT — see [LICENSE](LICENSE) for details. diff --git a/Sources/Extensions/View+Shimmer.swift b/Sources/Extensions/View+Shimmer.swift index 89ee7bc..bcc9049 100644 --- a/Sources/Extensions/View+Shimmer.swift +++ b/Sources/Extensions/View+Shimmer.swift @@ -15,11 +15,10 @@ struct ShimmerModifier: ViewModifier { guard active else { return } animate() } - .onChange(of: active) { isActive in - if isActive { - phase = -1 - animate() - } + .onChange(of: active) { + guard active else { return } + phase = -1 + animate() } } diff --git a/Sources/Model/Updates/UpdateService.swift b/Sources/Model/Updates/UpdateService.swift new file mode 100644 index 0000000..142e470 --- /dev/null +++ b/Sources/Model/Updates/UpdateService.swift @@ -0,0 +1,160 @@ +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/UpdateViewModel.swift b/Sources/ViewModels/UpdateViewModel.swift new file mode 100644 index 0000000..4e519cc --- /dev/null +++ b/Sources/ViewModels/UpdateViewModel.swift @@ -0,0 +1,95 @@ +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 0ee50b9..043412d 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -12,6 +12,7 @@ struct MainView: View { private static let serverOrderKeyStatic = "serverOrder" private static let storedServersKeyStatic = "storedServers" + @EnvironmentObject private var updateViewModel: UpdateViewModel @State var showAddServerSheet: Bool = false @State private var serverBeingEdited: Server? @State private var serverToDelete: Server? @@ -30,9 +31,9 @@ struct MainView: View { @State private var selectedServerID: UUID? var body: some View { - - NavigationSplitView { - List(selection: $selectedServerID) { + var mainContent: some View { + NavigationSplitView { + List(selection: $selectedServerID) { ForEach(servers) { server in HStack { Image(systemName: "dot.circle.fill") @@ -61,6 +62,19 @@ struct MainView: View { } .help("Add Host") } + ToolbarItem { + Button { + updateViewModel.checkForUpdates(userInitiated: true) + } label: { + if updateViewModel.isChecking { + ProgressView() + .scaleEffect(0.6) + } else { + Image(systemName: "square.and.arrow.down") + } + } + .help("Check for Updates") + } } .navigationTitle("Servers") .onChange(of: selectedServerID) { @@ -76,7 +90,9 @@ struct MainView: View { } else { ContentUnavailableView("No Server Selected", systemImage: "server.rack") } + } } + return mainContent .sheet(isPresented: $showAddServerSheet) { ServerFormView( mode: .add, @@ -128,9 +144,26 @@ 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) { @@ -257,8 +290,23 @@ 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()) } diff --git a/Sources/Views/PreferencesView.swift b/Sources/Views/PreferencesView.swift index bf196c3..07e4193 100644 --- a/Sources/Views/PreferencesView.swift +++ b/Sources/Views/PreferencesView.swift @@ -2,13 +2,14 @@ import SwiftUI struct PreferencesView: View { private enum Tab: CaseIterable { - case monitor, notifications, alerts + case monitor, notifications, alerts, updates var title: String { switch self { case .monitor: return "Monitor" case .notifications: return "Notifications" case .alerts: return "Alerts" + case .updates: return "Updates" } } @@ -17,13 +18,17 @@ struct PreferencesView: View { case .monitor: return "waveform.path.ecg" case .notifications: return "bell.badge" case .alerts: return "exclamationmark.triangle" + case .updates: return "square.and.arrow.down" } } } + @EnvironmentObject private var updateViewModel: UpdateViewModel @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 @@ -77,14 +82,14 @@ struct PreferencesView: View { .padding(.vertical, 8) .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) + .background( + Capsule(style: .continuous) + .fill(backgroundColor(for: tab)) + ) } .buttonStyle(.plain) .focusable(false) .contentShape(Capsule()) - .background( - Capsule(style: .continuous) - .fill(backgroundColor(for: tab)) - ) .foregroundColor(selection == tab ? .white : .primary) .onHover { isHovering in hoveredTab = isHovering ? tab : (hoveredTab == tab ? nil : hoveredTab) @@ -120,6 +125,12 @@ struct PreferencesView: View { NotificationsPreferencesView() case .alerts: AlertsPreferencesView() + case .updates: + UpdatesPreferencesView( + autoCheckUpdates: $autoCheckUpdates, + includePrereleaseUpdates: $includePrereleaseUpdates + ) + .environmentObject(updateViewModel) } } } @@ -220,6 +231,51 @@ private struct MonitorPreferencesView: View { } } +private struct UpdatesPreferencesView: View { + @Binding var autoCheckUpdates: Bool + @Binding var includePrereleaseUpdates: Bool + @EnvironmentObject var updateViewModel: UpdateViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Toggle("Automatically check for updates", isOn: $autoCheckUpdates) + .toggleStyle(.switch) + + 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) + } + } + } + + 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) + } + } + .padding(.top, 4) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + private struct NotificationsPreferencesView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { diff --git a/Sources/Views/Tabs/GeneralView.swift b/Sources/Views/Tabs/GeneralView.swift index b9cc62f..3eea132 100644 --- a/Sources/Views/Tabs/GeneralView.swift +++ b/Sources/Views/Tabs/GeneralView.swift @@ -59,10 +59,11 @@ struct GeneralView: View { var description = os.label.trimmingCharacters(in: .whitespacesAndNewlines) if description.isEmpty { description = distro - } else if !distro.isEmpty && distro.caseInsensitiveCompare(description) != .orderedSame { + } else if !distro.isEmpty && description.range(of: distro, options: [.caseInsensitive]) == nil { description += " • \(distro)" } - if !os.architecture.isEmpty { + if !os.architecture.isEmpty && + description.range(of: os.architecture, options: [.caseInsensitive]) == nil { description += " (\(os.architecture))" } if !description.isEmpty { diff --git a/Sources/iKeyMonApp.swift b/Sources/iKeyMonApp.swift index d981e87..892a4d0 100644 --- a/Sources/iKeyMonApp.swift +++ b/Sources/iKeyMonApp.swift @@ -12,6 +12,8 @@ import AppKit @main struct iKeyMonApp: App { + @StateObject private var updateViewModel = UpdateViewModel() + init() { #if os(macOS) if let customIcon = NSImage(named: "AppIcon") { @@ -23,6 +25,7 @@ struct iKeyMonApp: App { var body: some Scene { WindowGroup { MainView() + .environmentObject(updateViewModel) .onDisappear { NSApp.terminate(nil) } @@ -32,6 +35,7 @@ struct iKeyMonApp: App { Settings { PreferencesView() .padding() + .environmentObject(updateViewModel) } } } diff --git a/iKeyMon.xcodeproj/project.pbxproj b/iKeyMon.xcodeproj/project.pbxproj index 9a83700..cfa5f75 100644 --- a/iKeyMon.xcodeproj/project.pbxproj +++ b/iKeyMon.xcodeproj/project.pbxproj @@ -277,7 +277,7 @@ CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_TEAM = Q5486ZVAFT; ENABLE_HARDENED_RUNTIME = YES; @@ -289,7 +289,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 26.0.3; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -305,7 +305,7 @@ CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_TEAM = Q5486ZVAFT; ENABLE_HARDENED_RUNTIME = YES; @@ -317,7 +317,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 26.0.3; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/scripts/build_release.sh b/scripts/build_release.sh index 6183ed0..eab101d 100755 --- a/scripts/build_release.sh +++ b/scripts/build_release.sh @@ -7,12 +7,17 @@ ARTIFACTS_DIR="$ROOT_DIR/dist" SCHEME="iKeyMon" PROJECT="iKeyMon.xcodeproj" CREDENTIALS_FILE="$ROOT_DIR/.signing.env" +VERSION_FILE="$ROOT_DIR/version.json" if [[ -f "$CREDENTIALS_FILE" ]]; then + set -a # shellcheck disable=SC1090 source "$CREDENTIALS_FILE" + set +a fi +"$ROOT_DIR/scripts/sync_version.sh" + rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR" mkdir -p "$ARTIFACTS_DIR" @@ -51,14 +56,13 @@ ln -s /Applications "$STAGING_DIR/Applications" mkdir -p "$STAGING_DIR/.background" cp "$ROOT_DIR/Assets/dmg_background.png" "$STAGING_DIR/.background/background.png" -VERSION=$(xcodebuild \ - -project "$ROOT_DIR/$PROJECT" \ - -scheme "$SCHEME" \ - -configuration Release \ - -showBuildSettings | awk '/MARKETING_VERSION/ {print $3; exit}') -if [[ -z "$VERSION" ]]; then - VERSION="dev" -fi +VERSION="$(python3 - <<'PY' "$VERSION_FILE" +import json, sys +with open(sys.argv[1], "r", encoding="utf-8") as handle: + data = json.load(handle) +print(data.get("marketing_version", "dev")) +PY +)" ZIP_NAME="iKeyMon-${VERSION}.zip" pushd "$(dirname "$APP_PATH")" >/dev/null zip -r "$ARTIFACTS_DIR/$ZIP_NAME" "$(basename "$APP_PATH")" @@ -80,6 +84,12 @@ else fi rm -rf "$STAGING_DIR" +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 + echo "ℹ️ Skipping Gitea release publishing (GITEA_* variables not fully set)." +fi + echo "✅ Build complete. Artifacts:" echo " - $ARTIFACTS_DIR/$ZIP_NAME" echo " - $ARTIFACTS_DIR/$DMG_NAME" diff --git a/scripts/publish_release.sh b/scripts/publish_release.sh new file mode 100755 index 0000000..7668c04 --- /dev/null +++ b/scripts/publish_release.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="$1" +ZIP_PATH="$2" +DMG_PATH="$3" + +: "${GITEA_TOKEN:?Set GITEA_TOKEN in .signing.env}" +: "${GITEA_OWNER:?Set GITEA_OWNER in .signing.env}" +: "${GITEA_REPO:?Set GITEA_REPO in .signing.env}" + +TARGET_COMMIT="${GITEA_TARGET_COMMIT:-$(git -C "$ROOT_DIR" rev-parse HEAD)}" +API_BASE="${GITEA_API_BASE:-https://git.24unix.net/api/v1}" +API_BASE="${API_BASE%/}" +RELEASE_TAG="v${VERSION}" +API_URL="${API_BASE}/repos/${GITEA_OWNER}/${GITEA_REPO}" + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq is required to parse Gitea responses." >&2 + exit 1 +fi + +PRERELEASE_FLAG="${GITEA_PRERELEASE:-true}" + +create_payload="$(jq -n \ + --arg tag "$RELEASE_TAG" \ + --arg name "iKeyMon ${VERSION}" \ + --arg target "$TARGET_COMMIT" \ + --argjson prerelease "$PRERELEASE_FLAG" \ + '{ tag_name: $tag, name: $name, target_commitish: $target, draft: false, prerelease: $prerelease }')" + +response_file="$(mktemp)" +http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST \ + -d "$create_payload" \ + "${API_URL}/releases") + +if [[ "$http_code" == "201" ]]; then + echo "✅ Created release ${RELEASE_TAG}" +elif [[ "$http_code" == "409" ]]; then + echo "ℹ️ Release ${RELEASE_TAG} already exists, fetching existing ID." +else + echo "❌ Failed to create release (HTTP ${http_code}):" + cat "$response_file" + rm -f "$response_file" + exit 1 +fi + +if [[ "$http_code" == "409" ]]; then + curl -sS \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${API_URL}/releases/tags/${RELEASE_TAG}" >"$response_file" +fi + +release_id=$(jq -r '.id' "$response_file") +rm -f "$response_file" + +if [[ -z "$release_id" || "$release_id" == "null" ]]; then + echo "❌ Could not determine release ID for ${RELEASE_TAG}" + exit 1 +fi + +upload_asset() { + local file="$1" + local filename + filename="$(basename "$file")" + + echo "⬆️ Uploading ${filename}" + curl -sS \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${file}" \ + "${API_URL}/releases/${release_id}/assets" >/dev/null +} + +upload_asset "$ZIP_PATH" +upload_asset "$DMG_PATH" + +echo "🎉 Release ${RELEASE_TAG} assets uploaded." diff --git a/scripts/sync_version.sh b/scripts/sync_version.sh new file mode 100755 index 0000000..a8245f0 --- /dev/null +++ b/scripts/sync_version.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION_FILE="$ROOT_DIR/version.json" +PROJECT_FILE="$ROOT_DIR/iKeyMon.xcodeproj/project.pbxproj" + +if [[ ! -f "$VERSION_FILE" ]]; then + echo "❌ version.json not found at $VERSION_FILE" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq is required but not found in PATH" >&2 + exit 1 +fi + +MARKETING_VERSION="$(jq -r '.marketing_version // empty' "$VERSION_FILE")" +if [[ -z "$MARKETING_VERSION" ]]; then + echo "❌ marketing_version missing in $VERSION_FILE" >&2 + exit 1 +fi + +if [[ ! "$MARKETING_VERSION" =~ ^[0-9]{2}\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ marketing_version '$MARKETING_VERSION' must follow YY.major.minor (e.g. 26.1.2)" >&2 + exit 1 +fi + +BUILD_NUMBER="${BUILD_NUMBER:-$(git -C "$ROOT_DIR" rev-list --count HEAD)}" +if [[ -z "$BUILD_NUMBER" ]]; then + echo "❌ Unable to derive BUILD_NUMBER" >&2 + exit 1 +fi + +update_setting() { + local key="$1" + local value="$2" + local tmp + tmp="$(mktemp)" + LC_ALL=C sed -E "s/(${key}[[:space:]]*=[[:space:]]*)[^;]+;/\\1${value};/g" "$PROJECT_FILE" >"$tmp" + if cmp -s "$tmp" "$PROJECT_FILE"; then + if ! grep -q "${key} = ${value};" "$PROJECT_FILE"; then + rm -f "$tmp" + echo "❌ Failed to update ${key} in $PROJECT_FILE" >&2 + exit 1 + fi + rm -f "$tmp" + return + fi + mv "$tmp" "$PROJECT_FILE" +} + +update_setting "MARKETING_VERSION" "$MARKETING_VERSION" +update_setting "CURRENT_PROJECT_VERSION" "$BUILD_NUMBER" + +echo "✅ Synced marketing version $MARKETING_VERSION and build $BUILD_NUMBER into Xcode project." diff --git a/signing.env.example b/signing.env.example index 6097d50..16c862c 100644 --- a/signing.env.example +++ b/signing.env.example @@ -2,3 +2,9 @@ CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID1234)" NOTARY_APPLE_ID="appleid@example.com" NOTARY_TEAM_ID="TEAMID1234" NOTARY_PASSWORD="app-specific-password" +GITEA_TOKEN="personal-access-token" +GITEA_OWNER="tracer" +GITEA_REPO="iKeyMon" +# GITEA_API_BASE="https://git.24unix.net/api/v1" +# GITEA_TARGET_COMMIT="master" +# GITEA_PRERELEASE="false" diff --git a/version.json b/version.json new file mode 100644 index 0000000..be4ab1e --- /dev/null +++ b/version.json @@ -0,0 +1,3 @@ +{ + "marketing_version": "26.0.3" +}