Skip to content

Commit a751572

Browse files
committed
feat(settings): add about section in settings popover and check for updates feature
1 parent 7dbc64d commit a751572

4 files changed

Lines changed: 259 additions & 1 deletion

File tree

Reframed.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@
145145
A10000000000000000000048 /* CustomToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000048 /* CustomToggle.swift */; };
146146
A10000000000000000000049 /* IconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000049 /* IconButton.swift */; };
147147
A1000000000000000000004A /* InlineEditableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000004A /* InlineEditableText.swift */; };
148+
A1000000000000000000004B /* SettingsAboutTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000004B /* SettingsAboutTab.swift */; };
149+
A1000000000000000000004C /* UpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000004C /* UpdateChecker.swift */; };
148150
A2000000000000000000002A /* EditorView+TransportBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000002A /* EditorView+TransportBar.swift */; };
149151
A2000000000000000000002B /* ResizePopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000002B /* ResizePopover.swift */; };
150152
A2000000000000000000002C /* ExportResultSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000002C /* ExportResultSheet.swift */; };
@@ -291,6 +293,8 @@
291293
B10000000000000000000048 /* CustomToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomToggle.swift; sourceTree = "<group>"; };
292294
B10000000000000000000049 /* IconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButton.swift; sourceTree = "<group>"; };
293295
B1000000000000000000004A /* InlineEditableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineEditableText.swift; sourceTree = "<group>"; };
296+
B1000000000000000000004B /* SettingsAboutTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAboutTab.swift; sourceTree = "<group>"; };
297+
B1000000000000000000004C /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.swift; sourceTree = "<group>"; };
294298
B2000000000000000000002A /* EditorView+TransportBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditorView+TransportBar.swift"; sourceTree = "<group>"; };
295299
B2000000000000000000002B /* ResizePopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizePopover.swift; sourceTree = "<group>"; };
296300
B2000000000000000000002C /* ExportResultSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportResultSheet.swift; sourceTree = "<group>"; };
@@ -417,6 +421,7 @@
417421
DA000000000000000000001D /* RNNoiseProcessor.swift */,
418422
CB0000000000000000000002 /* CIImageExtensions.swift */,
419423
B1000000000000000000003E /* KeyboardShortcut.swift */,
424+
B1000000000000000000004C /* UpdateChecker.swift */,
420425
);
421426
path = Utilities;
422427
sourceTree = "<group>";
@@ -475,6 +480,7 @@
475480
B10000000000000000000048 /* CustomToggle.swift */,
476481
B10000000000000000000049 /* IconButton.swift */,
477482
B1000000000000000000004A /* InlineEditableText.swift */,
483+
B1000000000000000000004B /* SettingsAboutTab.swift */,
478484
);
479485
path = UI;
480486
sourceTree = "<group>";
@@ -801,6 +807,8 @@
801807
A10000000000000000000048 /* CustomToggle.swift in Sources */,
802808
A10000000000000000000049 /* IconButton.swift in Sources */,
803809
A1000000000000000000004A /* InlineEditableText.swift in Sources */,
810+
A1000000000000000000004B /* SettingsAboutTab.swift in Sources */,
811+
A1000000000000000000004C /* UpdateChecker.swift in Sources */,
804812
A2000000000000000000002A /* EditorView+TransportBar.swift in Sources */,
805813
A2000000000000000000002B /* ResizePopover.swift in Sources */,
806814
A2000000000000000000002C /* ExportResultSheet.swift in Sources */,

Reframed/UI/SettingsAboutTab.swift

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import SwiftUI
2+
3+
extension SettingsView {
4+
var aboutContent: some View {
5+
VStack(spacing: Layout.sectionSpacing) {
6+
appInfoSection
7+
updateSection
8+
linksSection
9+
}
10+
.frame(maxWidth: .infinity)
11+
}
12+
13+
private var appInfoSection: some View {
14+
VStack(spacing: 12) {
15+
if let appIcon = NSImage(named: NSImage.applicationIconName) {
16+
Image(nsImage: appIcon)
17+
.resizable()
18+
.frame(width: 80, height: 80)
19+
}
20+
21+
Text("Reframed")
22+
.font(.system(size: 20, weight: .semibold))
23+
.foregroundStyle(ReframedColors.primaryText)
24+
25+
VStack(spacing: 4) {
26+
Text("Version \(UpdateChecker.currentVersion)")
27+
.font(.system(size: 12))
28+
.foregroundStyle(ReframedColors.secondaryText)
29+
30+
Text("Screen recording & editing for macOS")
31+
.font(.system(size: 12))
32+
.foregroundStyle(ReframedColors.tertiaryText)
33+
}
34+
}
35+
.frame(maxWidth: .infinity)
36+
.padding(.top, 8)
37+
}
38+
39+
private var updateSection: some View {
40+
VStack(spacing: 12) {
41+
Button {
42+
Task { await checkForUpdates() }
43+
} label: {
44+
HStack(spacing: 6) {
45+
if updateCheckInProgress {
46+
ProgressView()
47+
.controlSize(.mini)
48+
}
49+
Text(updateCheckInProgress ? "Checking..." : "Check for Updates")
50+
}
51+
}
52+
.buttonStyle(SettingsButtonStyle())
53+
.disabled(updateCheckInProgress)
54+
55+
if let status = updateStatus {
56+
updateStatusView(status)
57+
}
58+
}
59+
}
60+
61+
@ViewBuilder
62+
private func updateStatusView(_ status: UpdateStatus) -> some View {
63+
switch status {
64+
case .upToDate:
65+
HStack(spacing: 6) {
66+
Image(systemName: "checkmark.circle.fill")
67+
.foregroundStyle(.green)
68+
.font(.system(size: 13))
69+
Text("You're up to date!")
70+
.font(.system(size: 12))
71+
.foregroundStyle(ReframedColors.secondaryText)
72+
}
73+
74+
case .available(let version, let url):
75+
VStack(spacing: 8) {
76+
HStack(spacing: 6) {
77+
Image(systemName: "arrow.up.circle.fill")
78+
.foregroundStyle(.blue)
79+
.font(.system(size: 13))
80+
Text("Version \(version) is available")
81+
.font(.system(size: 12, weight: .medium))
82+
.foregroundStyle(ReframedColors.primaryText)
83+
}
84+
85+
Button("Download Update") {
86+
if let downloadURL = URL(string: url) {
87+
NSWorkspace.shared.open(downloadURL)
88+
}
89+
}
90+
.buttonStyle(SettingsButtonStyle())
91+
}
92+
93+
case .error(let message):
94+
HStack(spacing: 6) {
95+
Image(systemName: "exclamationmark.triangle.fill")
96+
.foregroundStyle(.orange)
97+
.font(.system(size: 13))
98+
Text(message)
99+
.font(.system(size: 12))
100+
.foregroundStyle(ReframedColors.secondaryText)
101+
}
102+
}
103+
}
104+
105+
private var linksSection: some View {
106+
VStack(spacing: 8) {
107+
Divider()
108+
.background(ReframedColors.divider)
109+
110+
HStack(spacing: 16) {
111+
linkButton("GitHub", icon: "arrow.up.right.square", url: "https://github.com/jkuri/Reframed")
112+
linkButton("Issues", icon: "ladybug", url: "https://github.com/jkuri/Reframed/issues")
113+
linkButton("Releases", icon: "shippingbox", url: "https://github.com/jkuri/Reframed/releases")
114+
}
115+
.padding(.top, 4)
116+
117+
Text("Jan Kuri")
118+
.font(.system(size: 11))
119+
.foregroundStyle(ReframedColors.tertiaryText)
120+
.padding(.top, 4)
121+
}
122+
}
123+
124+
private func linkButton(_ title: String, icon: String, url: String) -> some View {
125+
Button {
126+
if let linkURL = URL(string: url) {
127+
NSWorkspace.shared.open(linkURL)
128+
}
129+
} label: {
130+
HStack(spacing: 4) {
131+
Image(systemName: icon)
132+
.font(.system(size: 11))
133+
Text(title)
134+
.font(.system(size: 12))
135+
}
136+
.foregroundStyle(ReframedColors.secondaryText)
137+
}
138+
.buttonStyle(.plain)
139+
.onHover { hovering in
140+
if hovering {
141+
NSCursor.pointingHand.push()
142+
} else {
143+
NSCursor.pop()
144+
}
145+
}
146+
}
147+
}

Reframed/UI/SettingsView.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ private enum SettingsTab: String, CaseIterable {
66
case recording = "Recording"
77
case devices = "Devices"
88
case shortcuts = "Shortcuts"
9+
case about = "About"
910

1011
var icon: String {
1112
switch self {
1213
case .general: "gearshape"
1314
case .recording: "record.circle"
1415
case .devices: "mic.and.signal.meter"
1516
case .shortcuts: "keyboard"
17+
case .about: "info.circle"
1618
}
1719
}
1820
}
@@ -27,6 +29,8 @@ struct SettingsView: View {
2729
@State var appearance: String = ConfigService.shared.appearance
2830
@State var showMicPopover = false
2931
@State var showCameraPopover = false
32+
@State var updateCheckInProgress = false
33+
@State var updateStatus: UpdateStatus? = nil
3034
@Environment(\.colorScheme) private var colorScheme
3135

3236
let fpsOptions = [24, 30, 40, 50, 60]
@@ -66,12 +70,14 @@ struct SettingsView: View {
6670
devicesContent
6771
case .shortcuts:
6872
shortcutsContent
73+
case .about:
74+
aboutContent
6975
}
7076
}
7177
.padding(Layout.settingsPadding)
7278
}
7379
}
74-
.frame(width: 600, height: 500)
80+
.frame(width: 700, height: 540)
7581
.background(ReframedColors.panelBackground)
7682
}
7783

@@ -170,6 +176,13 @@ struct SettingsView: View {
170176
}
171177
}
172178

179+
func checkForUpdates() async {
180+
updateCheckInProgress = true
181+
updateStatus = nil
182+
updateStatus = await UpdateChecker.checkForUpdates()
183+
updateCheckInProgress = false
184+
}
185+
173186
func chooseOutputFolder() {
174187
let panel = NSOpenPanel()
175188
panel.canChooseDirectories = true
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Foundation
2+
3+
struct GitHubRelease: Decodable, Sendable {
4+
let tagName: String
5+
let name: String?
6+
let htmlUrl: String
7+
let publishedAt: String?
8+
let body: String?
9+
10+
enum CodingKeys: String, CodingKey {
11+
case tagName = "tag_name"
12+
case name
13+
case htmlUrl = "html_url"
14+
case publishedAt = "published_at"
15+
case body
16+
}
17+
}
18+
19+
enum UpdateStatus: Sendable {
20+
case upToDate
21+
case available(version: String, url: String)
22+
case error(String)
23+
}
24+
25+
@MainActor
26+
enum UpdateChecker {
27+
private static let repo = "jkuri/Reframed"
28+
29+
static var currentVersion: String {
30+
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
31+
}
32+
33+
static var buildNumber: String {
34+
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
35+
}
36+
37+
static func checkForUpdates() async -> UpdateStatus {
38+
let urlString = "https://api.github.com/repos/\(repo)/releases/latest"
39+
guard let url = URL(string: urlString) else {
40+
return .error("Invalid URL")
41+
}
42+
43+
var request = URLRequest(url: url)
44+
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
45+
request.timeoutInterval = 10
46+
47+
do {
48+
let (data, response) = try await URLSession.shared.data(for: request)
49+
50+
guard let httpResponse = response as? HTTPURLResponse else {
51+
return .error("Invalid response")
52+
}
53+
54+
if httpResponse.statusCode == 404 {
55+
return .error("No releases found")
56+
}
57+
58+
guard httpResponse.statusCode == 200 else {
59+
return .error("GitHub API error (\(httpResponse.statusCode))")
60+
}
61+
62+
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
63+
let latestVersion = release.tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
64+
65+
if compareVersions(latestVersion, isNewerThan: currentVersion) {
66+
return .available(version: latestVersion, url: release.htmlUrl)
67+
} else {
68+
return .upToDate
69+
}
70+
} catch is CancellationError {
71+
return .error("Request cancelled")
72+
} catch {
73+
return .error(error.localizedDescription)
74+
}
75+
}
76+
77+
private static func compareVersions(_ latest: String, isNewerThan current: String) -> Bool {
78+
let latestParts = latest.split(separator: ".").compactMap { Int($0) }
79+
let currentParts = current.split(separator: ".").compactMap { Int($0) }
80+
81+
let maxCount = max(latestParts.count, currentParts.count)
82+
for i in 0..<maxCount {
83+
let l = i < latestParts.count ? latestParts[i] : 0
84+
let c = i < currentParts.count ? currentParts[i] : 0
85+
if l > c { return true }
86+
if l < c { return false }
87+
}
88+
return false
89+
}
90+
}

0 commit comments

Comments
 (0)