mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-18 23:26:18 +00:00
feat(settings): add about pane update checks
This commit is contained in:
@@ -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..<count {
|
||||||
|
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
|
||||||
|
let currentValue = index < currentParts.count ? currentParts[index] : 0
|
||||||
|
if candidateValue != currentValue {
|
||||||
|
return candidateValue > 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private enum SettingsSection: String, CaseIterable, Hashable {
|
private enum SettingsSection: String, CaseIterable, Hashable {
|
||||||
@@ -7,6 +8,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
|
|||||||
case siteLogins = "Site Logins"
|
case siteLogins = "Site Logins"
|
||||||
case power = "Power"
|
case power = "Power"
|
||||||
case engine = "Engine"
|
case engine = "Engine"
|
||||||
|
case about = "About"
|
||||||
|
|
||||||
var symbolName: String {
|
var symbolName: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -16,6 +18,16 @@ private enum SettingsSection: String, CaseIterable, Hashable {
|
|||||||
case .siteLogins: "key.fill"
|
case .siteLogins: "key.fill"
|
||||||
case .power: "moon.zzz"
|
case .power: "moon.zzz"
|
||||||
case .engine: "terminal"
|
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 {
|
private var settingsSidebar: some View {
|
||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
Section("Preferences") {
|
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)
|
Label(section.rawValue, systemImage: section.symbolName)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
@@ -77,10 +96,193 @@ struct SettingsView: View {
|
|||||||
PowerSettingsPane()
|
PowerSettingsPane()
|
||||||
case .engine:
|
case .engine:
|
||||||
EngineSettingsPane()
|
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 struct EngineSettingsPane: View {
|
||||||
private var executableURL: URL? {
|
private var executableURL: URL? {
|
||||||
Aria2DownloadEngine.findExecutable()
|
Aria2DownloadEngine.findExecutable()
|
||||||
|
|||||||
Reference in New Issue
Block a user