Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Loop/Extensions/Defaults+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ extension Defaults.Keys {
static let excludedApps = Key<[URL]>("excludedApps", default: [], iCloud: true)

// About
static let includeDevelopmentVersions = Key<Bool>("includeDevelopmentVersions", default: false, iCloud: true)
#if RELEASE
static let includeDevelopmentVersions = Key<Bool>("includeDevelopmentVersions", default: false, iCloud: true)
#else
// Development versions should check for development updates by default.
static let includeDevelopmentVersions = Key<Bool>("includeDevelopmentVersions", default: true, iCloud: true)
#endif
}

// MARK: - Hidden Settings
Expand Down
14 changes: 14 additions & 0 deletions Loop/Extensions/OperatingSystemVersion+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// OperatingSystemVersion+Extensions.swift
// Loop
//
// Created by Kai Azim on 2026-01-05.
//

import Foundation

extension OperatingSystemVersion: @retroactive CustomStringConvertible {
public var description: String {
"\(majorVersion).\(minorVersion).\(patchVersion)"
}
}
20 changes: 17 additions & 3 deletions Loop/Updater/UpdateView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,24 @@ struct UpdateView: View {

func changelogView() -> some View {
ScrollView(showsIndicators: false) {
LazyVStack {
VStack { // Using LazyVStack seems to cause visual glitches
ForEach(updater.changelog, id: \.title) { item in
if !item.body.isEmpty {
ChangelogSectionView(item: item)
ChangelogSectionView(
isExpanded: Binding(
get: {
updater.expandedChangelogSections.contains(item.title)
},
set: { newValue in
if newValue {
updater.expandedChangelogSections.insert(item.title)
} else {
updater.expandedChangelogSections.remove(item.title)
}
}
),
item: item
)
}
}
}
Expand All @@ -201,7 +215,7 @@ struct ChangelogSectionView: View {
@Environment(\.luminareAnimation) var luminareAnimation
@Environment(\.luminareCornerRadii) var luminareCornerRadii

@State var isExpanded = false
@Binding var isExpanded: Bool
let item: (title: String, body: [Updater.ChangelogNote])

var body: some View {
Expand Down
46 changes: 45 additions & 1 deletion Loop/Updater/Updater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ final class Updater: ObservableObject {
@Published private(set) var targetRelease: Release?
@Published private(set) var progressBar: Double = 0
@Published private(set) var updateState: UpdateAvailability = .notChecked
@Published private(set) var changelog: [(title: String, body: [ChangelogNote])] = .init()
@Published private(set) var updatesEnabled: Bool = Updater.checkIfUpdatesEnabled()
@Published private(set) var changelog: [(title: String, body: [ChangelogNote])] = .init()
@Published var expandedChangelogSections: Set<String> = [] // By title

private var windowController: NSWindowController?
private var includeDevelopmentVersions: Bool { Defaults[.includeDevelopmentVersions] }
Expand Down Expand Up @@ -207,17 +208,56 @@ final class Updater: ObservableObject {
isUpdateAvailable = versionBuild > currentBuild
}

// If the update's tag and build number passes the checks above, check the minimum macOS version
if isUpdateAvailable {
let lines = release.body
.split(whereSeparator: \.isNewline)
.reversed()

for line in lines {
if let minimumMacOSVersion = extractMinimumMacOSVersion(from: String(line)) {
if !ProcessInfo.processInfo.isOperatingSystemAtLeast(minimumMacOSVersion) {
Log.warn("Minimum macOS version requirement for next update not met (required: \(minimumMacOSVersion))", category: .updater)
isUpdateAvailable = false
} else {
Log.success("Minimum macOS version requirement for next update is met", category: .updater)
}
}
}
}

updateState = isUpdateAvailable ? .available : .unavailable

if isUpdateAvailable {
Log.notice("Update available: \(release.name)", category: .updater)

targetRelease = release
processChangelog(release.body)
} else {
Log.info("No update available.", category: .updater)
}
}
}

func extractMinimumMacOSVersion(from changelog: String) -> OperatingSystemVersion? {
let regex = /Minimum macOS version:\s*(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?/

guard let match = changelog.firstMatch(of: regex.ignoresCase()),
let major = Int(match.major)
else {
return nil
}

let minor = match.minor.flatMap { Int($0) } ?? 0
let patch = match.patch.flatMap { Int($0) } ?? 0

return OperatingSystemVersion(
majorVersion: major,
minorVersion: minor,
patchVersion: patch
)
}

private func processChangelog(_ body: String) {
changelog = .init()

Expand Down Expand Up @@ -279,6 +319,10 @@ final class Updater: ObservableObject {
))
}
}

if let firstSection = changelog.first {
expandedChangelogSections = [firstSection.title]
}
}

func showUpdateWindow() async {
Expand Down