diff --git a/Sources/Firelink/AppUpdateChecker.swift b/Sources/Firelink/AppUpdateChecker.swift new file mode 100644 index 0000000..0252b0d --- /dev/null +++ b/Sources/Firelink/AppUpdateChecker.swift @@ -0,0 +1,113 @@ +import Foundation + +@MainActor +final class AppUpdateChecker: ObservableObject { + enum Status: Equatable { + case idle + case checking + case upToDate(String) + case updateAvailable(latestVersion: String, releaseURL: URL) + case unavailable(String) + + var message: String { + switch self { + case .idle: + "Check GitHub Releases for the latest Firelink build." + case .checking: + "Checking for updates..." + case .upToDate(let version): + "Firelink is up to date. Latest version: \(version)." + case .updateAvailable(let latestVersion, _): + "Version \(latestVersion) is available." + case .unavailable(let message): + message + } + } + } + + @Published private(set) var status: Status = .idle + @Published private(set) var lastChecked: Date? + + let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")! + + private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")! + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func checkForUpdates(currentVersion: String) async { + status = .checking + lastChecked = Date() + + do { + var request = URLRequest(url: latestReleaseURL) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("Firelink", forHTTPHeaderField: "User-Agent") + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + status = .unavailable("Could not read the update server response.") + return + } + + guard httpResponse.statusCode == 200 else { + status = .unavailable("No published Firelink release was found.") + return + } + + let release = try JSONDecoder().decode(GitHubRelease.self, from: data) + let latestVersion = release.version + if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) { + status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL) + } else { + status = .upToDate(latestVersion) + } + } catch { + status = .unavailable("Could not check for updates. Try again later.") + } + } +} + +private struct GitHubRelease: Decodable { + let tagName: String + let htmlURL: URL + + var version: String { + tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV")) + } + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlURL = "html_url" + } +} + +private enum VersionComparator { + static func isVersion(_ candidate: String, newerThan current: String) -> Bool { + let candidateParts = parts(from: candidate) + let currentParts = parts(from: current) + let count = max(candidateParts.count, currentParts.count) + + for index in 0.. currentValue + } + } + + return false + } + + private static func parts(from version: String) -> [Int] { + version + .trimmingCharacters(in: CharacterSet(charactersIn: "vV")) + .split(separator: ".") + .map { component in + let digits = component.prefix(while: \.isNumber) + return Int(digits) ?? 0 + } + } +} diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index 009cec2..e6db133 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI private enum SettingsSection: String, CaseIterable, Hashable { @@ -7,6 +8,7 @@ private enum SettingsSection: String, CaseIterable, Hashable { case siteLogins = "Site Logins" case power = "Power" case engine = "Engine" + case about = "About" var symbolName: String { switch self { @@ -16,6 +18,16 @@ private enum SettingsSection: String, CaseIterable, Hashable { case .siteLogins: "key.fill" case .power: "moon.zzz" case .engine: "terminal" + case .about: "info.circle" + } + } + + var groupTitle: String { + switch self { + case .about: + "App" + default: + "Preferences" } } } @@ -52,7 +64,14 @@ struct SettingsView: View { private var settingsSidebar: some View { List(selection: $selection) { Section("Preferences") { - ForEach(SettingsSection.allCases, id: \.self) { section in + ForEach(SettingsSection.allCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in + Label(section.rawValue, systemImage: section.symbolName) + .tag(section) + } + } + + Section("App") { + ForEach(SettingsSection.allCases.filter { $0.groupTitle == "App" }, id: \.self) { section in Label(section.rawValue, systemImage: section.symbolName) .tag(section) } @@ -77,10 +96,193 @@ struct SettingsView: View { PowerSettingsPane() case .engine: EngineSettingsPane() + case .about: + AboutSettingsPane() } } } +private struct AboutSettingsPane: View { + @StateObject private var updateChecker = AppUpdateChecker() + @State private var availableUpdate: AvailableUpdate? + + private let developerProfileURL = URL(string: "https://github.com/nimbold")! + private let projectURL = URL(string: "https://github.com/nimbold/Firelink")! + private let aria2URL = URL(string: "https://aria2.github.io/")! + private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")! + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0" + } + + private var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development" + } + + var body: some View { + Form { + Section { + HStack(alignment: .center, spacing: 14) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 56, height: 56) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text("Firelink") + .font(.title2.weight(.semibold)) + Text("Version \(appVersion) (\(buildNumber))") + .foregroundStyle(.secondary) + Text("A native macOS download manager for fast, organized, segmented transfers.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + Section("Developer") { + LabeledContent("Created by") { + Text("NimBold") + } + + LabeledContent("GitHub") { + Link("@nimbold", destination: developerProfileURL) + } + + LabeledContent("Source") { + Link("nimbold/Firelink", destination: projectURL) + } + } + + Section("Credits") { + LabeledContent("Download engine") { + Link("aria2", destination: aria2URL) + } + + Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Updates") { + LabeledContent("Status") { + Label(updateChecker.status.message, systemImage: updateStatusSymbol) + .foregroundStyle(updateStatusColor) + } + + if let lastChecked = updateChecker.lastChecked { + LabeledContent("Last checked") { + Text(lastChecked, format: .dateTime.month().day().hour().minute()) + .foregroundStyle(.secondary) + } + } + + HStack { + Button { + Task { + await updateChecker.checkForUpdates(currentVersion: appVersion) + } + } label: { + Label("Check for Updates", systemImage: "arrow.clockwise") + } + .disabled(updateChecker.status == .checking) + + Button { + openReleasesPage() + } label: { + Label("Open Releases", systemImage: "arrow.up.right.square") + } + + Spacer() + } + + if case .updateAvailable(_, let releaseURL) = updateChecker.status { + Button { + NSWorkspace.shared.open(releaseURL) + } label: { + Label("Download Latest Version", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + } + } + + Section("Legal") { + LabeledContent("License") { + Link("MIT License", destination: licenseURL) + } + + Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .onChange(of: updateChecker.status) { _, status in + if case .updateAvailable(let latestVersion, let releaseURL) = status { + availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL) + } + } + .alert("Update Available", isPresented: Binding( + get: { availableUpdate != nil }, + set: { isPresented in + if !isPresented { + availableUpdate = nil + } + } + )) { + Button("Not Now", role: .cancel) { + availableUpdate = nil + } + Button("Yes") { + if let releaseURL = availableUpdate?.url { + NSWorkspace.shared.open(releaseURL) + } + availableUpdate = nil + } + } message: { + Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?") + } + } + + private var updateStatusSymbol: String { + switch updateChecker.status { + case .idle: + "sparkle.magnifyingglass" + case .checking: + "arrow.clockwise" + case .upToDate: + "checkmark.seal.fill" + case .updateAvailable: + "arrow.down.circle.fill" + case .unavailable: + "exclamationmark.triangle.fill" + } + } + + private var updateStatusColor: Color { + switch updateChecker.status { + case .idle, .checking: + .secondary + case .upToDate: + .green + case .updateAvailable: + .accentColor + case .unavailable: + .orange + } + } + + private func openReleasesPage() { + NSWorkspace.shared.open(updateChecker.releasesURL) + } + + private struct AvailableUpdate: Equatable { + var version: String + var url: URL + } +} + private struct EngineSettingsPane: View { private var executableURL: URL? { Aria2DownloadEngine.findExecutable()