diff --git a/Package.swift b/Package.swift index cd20cf2..cb03fec 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,10 @@ let package = Package( "GitKit", .product(name: "Sparkle", package: "Sparkle"), ], - resources: [.copy("Resources/AppIcon.icns")] + resources: [ + .copy("Resources/AppIcon.icns"), + .copy("Resources/CHANGELOG.md"), + ] ), // Full Xcode (XCTest / swift-testing) isn't installed, so the engine's // verification suite runs as a standalone executable harness instead. diff --git a/Sources/Gitify/Model/AppDefaults.swift b/Sources/Gitify/Model/AppDefaults.swift index 86bf9fd..9e2f078 100644 --- a/Sources/Gitify/Model/AppDefaults.swift +++ b/Sources/Gitify/Model/AppDefaults.swift @@ -7,6 +7,7 @@ enum AppDefaults { private static let terminalKey = "defaultTerminalBundleID" private static let editorKey = "defaultEditorBundleID" + private static let lastSeenVersionKey = "whatsNew.lastSeenVersion" // MARK: - Accessors @@ -22,6 +23,12 @@ enum AppDefaults { set { UserDefaults.standard.set(newValue, forKey: editorKey) } } + /// The app version the user last saw the \u{201c}What\u{2019}s New\u{201d} sheet for. + static var lastSeenVersion: String? { + get { UserDefaults.standard.string(forKey: lastSeenVersionKey) } + set { UserDefaults.standard.set(newValue, forKey: lastSeenVersionKey) } + } + /// Resolves a bundle identifier to an application URL, or `nil` if not installed. static func appURL(for bundleID: String) -> URL? { NSWorkspace.shared.urlsForApplications(withBundleIdentifier: bundleID).first diff --git a/Sources/Gitify/Model/ChangelogParser.swift b/Sources/Gitify/Model/ChangelogParser.swift new file mode 100644 index 0000000..e0ea1b3 --- /dev/null +++ b/Sources/Gitify/Model/ChangelogParser.swift @@ -0,0 +1,107 @@ +import Foundation + +/// A single release entry parsed from CHANGELOG.md. +struct ReleaseEntry: Identifiable, Sendable { + let version: String + let date: String + let sections: [Section] + + var id: String { version } + + struct Section: Sendable { + let heading: String + let items: [String] + } +} + +/// Parses a keep-a-changelog style CHANGELOG.md into structured release entries. +enum ChangelogParser { + + /// Parse the full changelog text and return releases in document order (newest first). + static func parse(_ text: String) -> [ReleaseEntry] { + let lines = text.components(separatedBy: "\n") + var entries: [ReleaseEntry] = [] + + var currentVersion: String? + var currentDate: String? + var currentSections: [ReleaseEntry.Section] = [] + var currentHeading: String? + var currentItems: [String] = [] + + for line in lines { + // Release header: ## [1.12.0](url) (2026-06-30) + if line.hasPrefix("## [") { + // Flush previous entry + if let version = currentVersion, let date = currentDate { + if let heading = currentHeading, !currentItems.isEmpty { + currentSections.append(.init(heading: heading, items: currentItems)) + } + entries.append(.init(version: version, date: date, sections: currentSections)) + } + + // Parse new header + let stripped = line.dropFirst(4) // remove "## [" + if let closeBracket = stripped.firstIndex(of: "]") { + currentVersion = String(stripped[stripped.startIndex.. String { + // Remove patterns like ([#30](url)) and ([hash](url)) + var result = text + // Pattern: ([ ... ]( ... )) — parenthesised markdown links + while let openParen = result.range(of: " \\(\\[[^\\]]*\\]\\([^)]*\\)\\)", options: .regularExpression) { + result.removeSubrange(openParen) + } + result = result.trimmingCharacters(in: .whitespaces) + // Capitalize first letter for display. + guard let first = result.first else { return result } + return first.uppercased() + result.dropFirst() + } +} diff --git a/Sources/Gitify/Model/SparkleController.swift b/Sources/Gitify/Model/SparkleController.swift index b10303f..6bfc526 100644 --- a/Sources/Gitify/Model/SparkleController.swift +++ b/Sources/Gitify/Model/SparkleController.swift @@ -4,22 +4,39 @@ import Sparkle /// /// `SPUStandardUpdaterController` must be created and used on the main thread, /// so the entire class is confined to `@MainActor`. -@MainActor +@MainActor @Observable final class SparkleController { private let updaterController: SPUStandardUpdaterController + private var observation: NSKeyValueObservation? /// Whether the \u{201c}Check for Updates\u{2026}\u{201d} menu item should be enabled. - var canCheckForUpdates: Bool { - updaterController.updater.canCheckForUpdates - } + private(set) var canCheckForUpdates = false init() { + let startUpdater: Bool + #if DEBUG + startUpdater = false + #else + startUpdater = true + #endif + updaterController = SPUStandardUpdaterController( - startingUpdater: true, + startingUpdater: startUpdater, updaterDelegate: nil, userDriverDelegate: nil ) - + + guard startUpdater else { return } + + observation = updaterController.updater.observe( + \.canCheckForUpdates, + options: [.initial, .new] + ) { [weak self] updater, _ in + MainActor.assumeIsolated { + self?.canCheckForUpdates = updater.canCheckForUpdates + } + } + // Quiet check on launch; surfaces a prompt only if a newer version exists. updaterController.updater.checkForUpdatesInBackground() } diff --git a/Sources/Gitify/Resources/CHANGELOG.md b/Sources/Gitify/Resources/CHANGELOG.md new file mode 100644 index 0000000..8f5bd23 --- /dev/null +++ b/Sources/Gitify/Resources/CHANGELOG.md @@ -0,0 +1,150 @@ +# Changelog + +## [1.12.0](https://github.com/craigharman/gitify/compare/v1.11.0...v1.12.0) (2026-06-30) + + +### Features + +* add image diff viewer ([#30](https://github.com/craigharman/gitify/issues/30)) ([a3129f4](https://github.com/craigharman/gitify/commit/a3129f4378918bc1dcd06f9aaa7b094518db1bde)) + +## [1.11.0](https://github.com/craigharman/gitify/compare/v1.10.0...v1.11.0) (2026-06-27) + + +### Features + +* add "scan folders" to allow bulk import of repositories ([#28](https://github.com/craigharman/gitify/issues/28)) ([94f2352](https://github.com/craigharman/gitify/commit/94f2352dd6b5402f8f9ffe5fe5d2f8c5b7737a48)) + +## [1.10.0](https://github.com/craigharman/gitify/compare/v1.9.1...v1.10.0) (2026-06-24) + + +### Features + +* add remote SSH git repository management ([#26](https://github.com/craigharman/gitify/issues/26)) ([4fb4bf1](https://github.com/craigharman/gitify/commit/4fb4bf13db9ec857ed3c10922edbcea5201b5cbf)) + +## [1.9.1](https://github.com/craigharman/gitify/compare/v1.9.0...v1.9.1) (2026-06-23) + + +### Bug Fixes + +* merge conflicts not visible when changes are staged ([#24](https://github.com/craigharman/gitify/issues/24)) ([fa20ee4](https://github.com/craigharman/gitify/commit/fa20ee49ca4cad67e2f875539b1e3df3891deddc)) + +## [1.9.0](https://github.com/craigharman/gitify/compare/v1.8.3...v1.9.0) (2026-06-20) + + +### Features + +* add automerge ([#22](https://github.com/craigharman/gitify/issues/22)) ([6b290b1](https://github.com/craigharman/gitify/commit/6b290b14fee0e41208471fe5068692479abb725d)) + +## [1.8.3](https://github.com/craigharman/gitify/compare/v1.8.2...v1.8.3) (2026-06-20) + + +### Bug Fixes + +* add debugging to deployment script ([#20](https://github.com/craigharman/gitify/issues/20)) ([556fed0](https://github.com/craigharman/gitify/commit/556fed0268261e0115411126588383e4f76ca671)) + +## [1.8.2](https://github.com/craigharman/gitify/compare/v1.8.1...v1.8.2) (2026-06-20) + + +### Bug Fixes + +* access to sparkle key file ([#18](https://github.com/craigharman/gitify/issues/18)) ([723f659](https://github.com/craigharman/gitify/commit/723f6591ea18b14d3e24b7da08efed150c0b0f77)) + +## [1.8.1](https://github.com/craigharman/gitify/compare/v1.8.0...v1.8.1) (2026-06-20) + + +### Bug Fixes + +* download sparkle release as part of CI ([#16](https://github.com/craigharman/gitify/issues/16)) ([251c71a](https://github.com/craigharman/gitify/commit/251c71a3c5ab4b8fc3e3134e505d82f197affe97)) + +## [1.8.0](https://github.com/craigharman/gitify/compare/v1.7.0...v1.8.0) (2026-06-20) + + +### Features + +* add sidebar push/pull buttons ([#13](https://github.com/craigharman/gitify/issues/13)) ([308ef97](https://github.com/craigharman/gitify/commit/308ef976c959ad7c7e734cc82d66904f1fb44287)) +* fix deploy issue ([#12](https://github.com/craigharman/gitify/issues/12)) ([8ffc097](https://github.com/craigharman/gitify/commit/8ffc097a181415b5c9b3274325f5b64215e4d7d6)) + +## [1.7.0](https://github.com/craigharman/gitify/compare/v1.6.0...v1.7.0) (2026-06-19) + + +### Features + +* automatic software updates using sparkle ([#10](https://github.com/craigharman/gitify/issues/10)) ([2276fe0](https://github.com/craigharman/gitify/commit/2276fe007d87d82f7f23ad7b20c5156929081ea2)) + +## [1.6.0](https://github.com/craigharman/gitify/compare/v1.5.0...v1.6.0) (2026-06-19) + + +### Features + +* liquid glass interface added ([#8](https://github.com/craigharman/gitify/issues/8)) ([dc7ccff](https://github.com/craigharman/gitify/commit/dc7ccff1fab3a1dd56574e19249fa822f10fa1a4)) + +## [1.5.0](https://github.com/craigharman/gitify/compare/v1.4.2...v1.5.0) (2026-06-19) + + +### Features + +* continue and skip rebase ([06a5099](https://github.com/craigharman/gitify/commit/06a5099a3e7b6a7e2f87d1ce515bc5ce5ba4ab2f)) +* continue and skip rebase ([#6](https://github.com/craigharman/gitify/issues/6)) ([06a5099](https://github.com/craigharman/gitify/commit/06a5099a3e7b6a7e2f87d1ce515bc5ce5ba4ab2f)) + +## [1.4.2](https://github.com/craigharman/gitify/compare/v1.4.1...v1.4.2) (2026-06-18) + + +### Bug Fixes + +* copy SwiftPM resource bundle into .app to prevent launch crash ([67e9d5b](https://github.com/craigharman/gitify/commit/67e9d5bab42cb16b7815e8f0428a87d26f79be41)) + +## [1.4.1](https://github.com/craigharman/gitify/compare/v1.4.0...v1.4.1) (2026-06-18) + + +### Bug Fixes + +* versioning system ([f8899ee](https://github.com/craigharman/gitify/commit/f8899eedd958da6b8bd5fee1c44f7f6fb8469305)) + +## [1.4.0](https://github.com/craigharman/gitify/compare/v1.3.0...v1.4.0) (2026-06-18) + + +### Features + +* ability to remove repos from gitify ([ee1e39d](https://github.com/craigharman/gitify/commit/ee1e39d2d1e96806fec9e1f3445eacc3ac09e35d)) +* add a push after merge option ([92295ee](https://github.com/craigharman/gitify/commit/92295ee22d135dd8bd7266ec5e7afb893820db4e)) +* add merge rebase option if that merge error occurs ([09ebf84](https://github.com/craigharman/gitify/commit/09ebf84698d513580ed7faad7e0ba2770cfc4cf3)) +* add next/previous arrows to scroll through conflicts in conflict editor ([f9f11f8](https://github.com/craigharman/gitify/commit/f9f11f840b8388eb85ffa584ceb65eb417350ded)) +* add push/pull counter indicators to sidebar. ([b99c241](https://github.com/craigharman/gitify/commit/b99c241718205da8543a303209724f668f808f9a)) +* Add tree view for remote branches. ([58a6b58](https://github.com/craigharman/gitify/commit/58a6b5822ccd8aefc1b645386140e94099156de4)) +* commit inspect, commit context menu, overview stats, remote push, stash diff and conflict resolution ([735f4fb](https://github.com/craigharman/gitify/commit/735f4fb22486f66176624eff2c71ed4c72e9efa0)) +* Markdown rendering of readme.md on overview page ([02c42c9](https://github.com/craigharman/gitify/commit/02c42c94d55abb850aeb29b84e3b1c40babbe0f6)) +* Merge current branch to another branch ([11911fb](https://github.com/craigharman/gitify/commit/11911fb25da85bb82529eab6b89a6b185d2470dd)) +* search filter and submodules ([e67ae2d](https://github.com/craigharman/gitify/commit/e67ae2de92bd7ab5aea666d179096bb2ad449dd1)) +* sidebar status saved on exit and restored on start. ([6daa1e7](https://github.com/craigharman/gitify/commit/6daa1e7f931a54c7b9e905b3a8cd9935a79a71ed)) +* syntax highlighting added in conflict editor ([92151d3](https://github.com/craigharman/gitify/commit/92151d343942b0671c52c5aaf4e9374803e89760)) + + +### Bug Fixes + +* commit diff layout not showing when files staged. ([a884ff8](https://github.com/craigharman/gitify/commit/a884ff86c97d6b616cdecfddbc0c1d4fe7c6513e)) +* delete branch after merge conflict ([d9ed59b](https://github.com/craigharman/gitify/commit/d9ed59bd85889737808bb33ef1d6ecc95a5d8a8a)) +* The merge with push was reloading the merge window. This is now fixed. ([75b7cf9](https://github.com/craigharman/gitify/commit/75b7cf965081b2033c0cde859d8b9adc03eb3a4b)) +* warning banner header colour no longer spills into the sidebar. ([381abe0](https://github.com/craigharman/gitify/commit/381abe0fdb978d8f0af8bbfd0a8fa3cb75aef012)) + +## [1.3.0](https://github.com/craigharman/gitify/compare/v1.2.0...v1.3.0) (2026-06-18) + + +### Features + +* add a push after merge option ([92295ee](https://github.com/craigharman/gitify/commit/92295ee22d135dd8bd7266ec5e7afb893820db4e)) +* add merge rebase option if that merge error occurs ([09ebf84](https://github.com/craigharman/gitify/commit/09ebf84698d513580ed7faad7e0ba2770cfc4cf3)) + + +### Bug Fixes + +* The merge with push was reloading the merge window. This is now fixed. ([75b7cf9](https://github.com/craigharman/gitify/commit/75b7cf965081b2033c0cde859d8b9adc03eb3a4b)) + +## [1.2.0](https://github.com/craigharman/gitify/compare/v1.1.0...v1.2.0) (2026-06-18) + + +### Features + +* ability to remove repos from gitify ([ee1e39d](https://github.com/craigharman/gitify/commit/ee1e39d2d1e96806fec9e1f3445eacc3ac09e35d)) +* add next/previous arrows to scroll through conflicts in conflict editor ([f9f11f8](https://github.com/craigharman/gitify/commit/f9f11f840b8388eb85ffa584ceb65eb417350ded)) +* add push/pull counter indicators to sidebar. ([b99c241](https://github.com/craigharman/gitify/commit/b99c241718205da8543a303209724f668f808f9a)) +* syntax highlighting added in conflict editor ([92151d3](https://github.com/craigharman/gitify/commit/92151d343942b0671c52c5aaf4e9374803e89760)) diff --git a/Sources/Gitify/Views/RootView.swift b/Sources/Gitify/Views/RootView.swift index 1ff0446..851472d 100644 --- a/Sources/Gitify/Views/RootView.swift +++ b/Sources/Gitify/Views/RootView.swift @@ -5,6 +5,7 @@ import SwiftUI struct RootView: View { @Environment(AppModel.self) private var model @State private var showSSHServers = false + @State private var whatsNewEntries: WhatsNewContent? var body: some View { Group { @@ -44,5 +45,26 @@ struct RootView: View { } } .sheet(isPresented: $showSSHServers) { SSHServersView(model: model) } + .sheet(item: $whatsNewEntries) { entries in + WhatsNewView(entries: entries.items) + } + .onChange(of: whatsNewEntries?.id) { old, new in + if old != nil, new == nil { + AppDefaults.lastSeenVersion = currentAppVersion + } + } + .task { + let version = currentAppVersion + guard version != AppDefaults.lastSeenVersion else { return } + guard let url = Bundle.module.url(forResource: "CHANGELOG", withExtension: "md"), + let text = try? String(contentsOf: url, encoding: .utf8) else { return } + let entries = ChangelogParser.parse(text) + guard !entries.isEmpty else { return } + whatsNewEntries = WhatsNewContent(items: entries) + } + } + + private var currentAppVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" } } diff --git a/Sources/Gitify/Views/WhatsNewView.swift b/Sources/Gitify/Views/WhatsNewView.swift new file mode 100644 index 0000000..9657ae6 --- /dev/null +++ b/Sources/Gitify/Views/WhatsNewView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Wrapper to satisfy `Identifiable` for the `sheet(item:)` API. +struct WhatsNewContent: Identifiable { + let id = UUID() + let items: [ReleaseEntry] +} + +/// Shows release notes for the current version and recent prior releases. +struct WhatsNewView: View { + let entries: [ReleaseEntry] + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + scrollContent + Divider() + footer + } + .frame(width: 520, height: 480) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 14) { + Image(systemName: "sparkles") + .font(.system(size: 30)) + .foregroundStyle(.tint) + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 4) { + if let latest = entries.first { + Text("What\u{2019}s New in \(latest.version)") + .font(.title2.bold()) + Text(latest.date) + .foregroundStyle(.secondary) + } else { + Text("What\u{2019}s New") + .font(.title2.bold()) + } + } + Spacer() + } + .padding(24) + } + + // MARK: - Scrollable content + + private var scrollContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Latest release + if let latest = entries.first { + releaseSection(latest) + } + + // Recent updates (up to 3 prior releases) + let recent = Array(entries.dropFirst().prefix(3)) + if !recent.isEmpty { + Text("Recent Updates") + .font(.title3.bold()) + .padding(.top, 4) + + ForEach(recent) { entry in + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text(entry.version).font(.headline) + Text(entry.date).font(.caption).foregroundStyle(.secondary) + } + releaseSection(entry) + } + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// Renders the sections (Features, Bug Fixes, etc.) for a single release. + private func releaseSection(_ entry: ReleaseEntry) -> some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(entry.sections.enumerated()), id: \.offset) { _, section in + VStack(alignment: .leading, spacing: 6) { + Label(section.heading, systemImage: section.heading.lowercased().contains("fix") ? "ladybug" : "star") + .font(.subheadline.bold()) + .foregroundStyle(section.heading.lowercased().contains("fix") ? .orange : .blue) + + ForEach(section.items, id: \.self) { item in + HStack(alignment: .top, spacing: 8) { + Text("\u{2022}").foregroundStyle(.secondary) + Text(item) + } + .padding(.leading, 20) + } + } + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Spacer() + Button("Continue") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(24) + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 8010335..6dc9490 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -9,6 +9,9 @@ CONFIG="${1:-release}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" APP="${ROOT}/build/Gitify.app" +# Keep the bundled changelog in sync with the repo root copy. +cp "${ROOT}/CHANGELOG.md" "${ROOT}/Sources/Gitify/Resources/CHANGELOG.md" + echo "==> Building Gitify (${CONFIG})" swift build --product Gitify -c "${CONFIG}"