diff --git a/Package.swift b/Package.swift index 4a20404..806b695 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( .executableTarget( name: "ForelApp", dependencies: ["ForelCore"], - resources: [.copy("Resources")] + resources: [.copy("Resources"), .copy("../../CHANGELOG.md")] ), .testTarget( name: "ForelCoreTests", diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index 0fa5442..0d2fe4c 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -20,6 +20,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { setUpStatusBar() } showMainWindowOnFirstLaunch() + if CommandLine.arguments.contains("-forelAfterUpdate") { + updater.loadReleaseNotesFromChangelog() + openMainWindow() + } } /// A brand-new install otherwise only shows up as a menu bar icon diff --git a/Sources/ForelApp/Update/UpdaterManager.swift b/Sources/ForelApp/Update/UpdaterManager.swift index 2ba37cd..e219622 100644 --- a/Sources/ForelApp/Update/UpdaterManager.swift +++ b/Sources/ForelApp/Update/UpdaterManager.swift @@ -26,6 +26,7 @@ final class UpdaterManager: ObservableObject { let tagName: String let htmlUrl: URL + let body: String let assets: [Asset] let draft: Bool let prerelease: Bool @@ -33,6 +34,7 @@ final class UpdaterManager: ObservableObject { enum CodingKeys: String, CodingKey { case tagName = "tag_name" case htmlUrl = "html_url" + case body case assets case draft case prerelease @@ -58,6 +60,8 @@ final class UpdaterManager: ObservableObject { @Published private(set) var isChecking = false @Published private(set) var isInstalling = false @Published private(set) var installError: String? + @Published var showReleaseNotes = true + @Published var releaseNotes: (version: String, body: String, url: URL)? = ("1.0.0", "- Correction bug X\n- Nouveau filtre Y", URL(string: "https://github.com/lab421/forel/releases/tag/v1.0.0")!) private let db: Database private var timer: Timer? @@ -189,6 +193,65 @@ final class UpdaterManager: ObservableObject { release.tagName.hasPrefix("v") ? String(release.tagName.dropFirst()) : release.tagName } + /// Loads the section of CHANGELOG.md for the currently installed version + /// and populates `releaseNotes` so the UI can show a sheet. + func loadReleaseNotesFromChangelog() { + let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + loadReleaseNotesFromChangelog(version: current) + } + + /// Loads the section of CHANGELOG.md matching `version` (or the first + /// versioned section if `version` is nil). + func loadReleaseNotesFromChangelog(version: String?) { + guard let url = Bundle.module.url(forResource: "CHANGELOG", withExtension: "md"), + let content = try? String(contentsOf: url, encoding: .utf8) else { return } + + let lines = content.components(separatedBy: .newlines) + let header = version.map { "## [\($0)]" } + var sectionLines: [String] = [] + var inSection = false + var foundFirst = false + + for line in lines { + if line.hasPrefix("## [") { + if inSection { break } + if let header { + if line.hasPrefix(header) { + inSection = true + } + } else if !foundFirst { + inSection = true + foundFirst = true + } + continue + } + if inSection { + sectionLines.append(line) + } + } + + let body = sectionLines.drop { $0.trimmingCharacters(in: .whitespaces).isEmpty }.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty else { return } + let v = version ?? Self.findFirstVersion(in: lines) + if let url = URL(string: "https://github.com/\(Self.repo)/releases/tag/v\(v)") { + releaseNotes = (v, body, url) + } + showReleaseNotes = true + } + + private static func findFirstVersion(in lines: [String]) -> String { + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("## [") { + let start = trimmed.index(trimmed.startIndex, offsetBy: 4) + if let end = trimmed.firstIndex(of: "]") { + return String(trimmed[start.. Bool { compareVersions(candidate, current) == .orderedDescending } @@ -305,7 +368,7 @@ final class UpdaterManager: ObservableObject { /usr/bin/hdiutil detach "$MNT" -quiet 2>/dev/null || /usr/bin/hdiutil detach "$MNT" -force -quiet 2>/dev/null || true /bin/rmdir "$MNT" 2>/dev/null /bin/rm -f "$DMG" "$SCRIPT" - /usr/bin/open "$LAUNCH" + /usr/bin/open "$LAUNCH" --args -forelAfterUpdate """ let scriptURL = FileManager.default.temporaryDirectory .appendingPathComponent("forel-update-\(pid)-\(UUID().uuidString).sh") diff --git a/Sources/ForelApp/Views/ContentView.swift b/Sources/ForelApp/Views/ContentView.swift index f97bd02..4726d0a 100644 --- a/Sources/ForelApp/Views/ContentView.swift +++ b/Sources/ForelApp/Views/ContentView.swift @@ -3,6 +3,7 @@ import ForelCore struct ContentView: View { @EnvironmentObject var model: AppModel + @EnvironmentObject var updater: UpdaterManager var body: some View { NavigationSplitView { @@ -27,9 +28,10 @@ struct ContentView: View { } .preferredColorScheme(model.appTheme.colorScheme) .tint(ForelTheme.accent) - // ForelTheme.accent is a plain static var, not observable; bumping the - // identity here forces every descendant to rebuild and re-read it. .id(model.accentVersion) + .sheet(isPresented: $updater.showReleaseNotes) { + ReleaseNotesView(version: updater.releaseNotes?.version ?? "", markdown: updater.releaseNotes?.body ?? "", url: updater.releaseNotes?.url) + } } private var errorBinding: Binding { @@ -44,3 +46,69 @@ struct ContentView: View { ) } } + +private struct ReleaseNotesView: View { + let version: String + let markdown: String + let url: URL? + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(ForelTheme.accent.opacity(0.18)) + Image(systemName: "sparkles.rectangle.stack") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ForelTheme.accent) + } + .frame(width: 34, height: 34) + VStack(alignment: .leading, spacing: 1) { + Text("What's New in \(version)") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(ForelTheme.primaryText) + Text("Forel has been updated") + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + } + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + } + .buttonStyle(.plain) + .foregroundStyle(ForelTheme.secondaryText) + } + + Divider().overlay(ForelTheme.divider) + + ScrollView { + Text(markdown) + .font(.system(size: 12)) + .foregroundStyle(ForelTheme.primaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .scrollIndicators(.never) + + HStack { + if let url { + Button("View on GitHub") { + NSWorkspace.shared.open(url) + } + .buttonStyle(SecondaryButtonStyle()) + } + Spacer() + Button("Continue") { dismiss() } + .buttonStyle(PrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) + } + } + .padding(22) + .frame(width: 520, height: 420) + .background(ForelTheme.background) + } +}