Feat/auto update (#2)

* feat: add auto-update check against GitHub Releases

* fix: Pause/Resume menu item not updating after click

* fix: resolve UUID collision for UpdateService.swift in project file
This commit is contained in:
ParrotXray 2026-06-10 19:45:05 +08:00 committed by GitHub
parent 11fa407029
commit b778ce3a21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 101 additions and 6 deletions

View File

@ -57,6 +57,7 @@
BB0000000000000000000003 /* ObjCExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* ObjCExceptionCatcher.m */; };
EE0000000000000000000001 /* WELogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0000000000000000000002 /* WELogger.swift */; };
CC0000000000000000000001 /* WallpaperDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000002 /* WallpaperDirectory.swift */; };
CC000000000000000000000D /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000000000000000000000E /* UpdateService.swift */; };
DD0000000000000000000001 /* WallpaperWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0000000000000000000002 /* WallpaperWindowManager.swift */; };
CC0000000000000000000003 /* ZipImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000004 /* ZipImporter.swift */; };
CC0000000000000000000005 /* SteamCmdService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000006 /* SteamCmdService.swift */; };
@ -129,6 +130,7 @@
BB0000000000000000000002 /* ObjCExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCExceptionCatcher.m; sourceTree = "<group>"; };
EE0000000000000000000002 /* WELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WELogger.swift; sourceTree = "<group>"; };
CC0000000000000000000002 /* WallpaperDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperDirectory.swift; sourceTree = "<group>"; };
CC000000000000000000000E /* UpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateService.swift; sourceTree = "<group>"; };
DD0000000000000000000002 /* WallpaperWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperWindowManager.swift; sourceTree = "<group>"; };
CC0000000000000000000004 /* ZipImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipImporter.swift; sourceTree = "<group>"; };
CC0000000000000000000006 /* SteamCmdService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamCmdService.swift; sourceTree = "<group>"; };
@ -220,6 +222,7 @@
BB0000000000000000000002 /* ObjCExceptionCatcher.m */,
EE0000000000000000000002 /* WELogger.swift */,
CC0000000000000000000002 /* WallpaperDirectory.swift */,
CC000000000000000000000E /* UpdateService.swift */,
DD0000000000000000000002 /* WallpaperWindowManager.swift */,
CC0000000000000000000004 /* ZipImporter.swift */,
CC0000000000000000000006 /* SteamCmdService.swift */,
@ -470,6 +473,7 @@
BB0000000000000000000003 /* ObjCExceptionCatcher.m in Sources */,
EE0000000000000000000001 /* WELogger.swift in Sources */,
CC0000000000000000000001 /* WallpaperDirectory.swift in Sources */,
CC000000000000000000000D /* UpdateService.swift in Sources */,
DD0000000000000000000001 /* WallpaperWindowManager.swift in Sources */,
CC0000000000000000000003 /* ZipImporter.swift in Sources */,
CC0000000000000000000005 /* SteamCmdService.swift in Sources */,
@ -705,4 +709,4 @@
/* End XCConfigurationList section */
};
rootObject = 5EC47E6E2A2DB2E600120CCC /* Project object */;
}
}

View File

@ -49,7 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
wallpaperWindowManager.showAll()
UpdateService.shared.checkForUpdates()
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {

View File

@ -26,6 +26,10 @@ extension AppDelegate {
openMainWindow()
}
@objc func checkForUpdates() {
UpdateService.shared.checkForUpdates(userInitiated: true)
}
@objc func openSupportWebpage() {
NSWorkspace.shared.open(URL(string: "https://github.com/haren724/open-wallpaper-engine-mac/wiki")!)
}
@ -86,6 +90,11 @@ extension AppDelegate {
.separator(),
.init(title: String(localized: "Check for Updates"),
systemImage: "arrow.down.circle",
action: #selector(checkForUpdates),
keyEquivalent: "u"),
.init(title: String(localized: "Support & FAQ"),
systemImage: "person.fill.questionmark",
action: #selector(openSupportWebpage),
@ -145,10 +154,10 @@ extension AppDelegate {
.sink { [weak self] rate in
guard let menu = self?.statusItem.menu else { return }
let isPaused = rate == 0
if let idx = menu.items.firstIndex(where: { $0.title == "Pause" || $0.title == "Resume" }) {
if let idx = menu.items.firstIndex(where: { $0.title == String(localized: "Pause") || $0.title == String(localized: "Resume") }) {
menu.items[idx] = isPaused
? .init(title: "Resume", systemImage: "play.fill", action: #selector(AppDelegate.shared.resume), keyEquivalent: "p")
: .init(title: "Pause", systemImage: "pause.fill", action: #selector(AppDelegate.shared.pause), keyEquivalent: "p")
? .init(title: String(localized: "Resume"), systemImage: "play.fill", action: #selector(AppDelegate.shared.resume), keyEquivalent: "p")
: .init(title: String(localized: "Pause"), systemImage: "pause.fill", action: #selector(AppDelegate.shared.pause), keyEquivalent: "p")
}
}
.store(in: &cancellables)
@ -163,4 +172,4 @@ extension AppDelegate: NSMenuDelegate {
recentItem.submenu = buildRecentWallpapersMenu()
}
}
}
}

View File

@ -0,0 +1,82 @@
import Foundation
import AppKit
class UpdateService {
static let shared = UpdateService()
private let releasesURL = "https://api.github.com/repos/parrotxray/mac-wallpaperengine/releases/latest"
private var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
}
func checkForUpdates(userInitiated: Bool = false) {
guard let url = URL(string: releasesURL) else { return }
var request = URLRequest(url: url)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: request) { [weak self] data, _, error in
guard let self,
let data,
error == nil,
let release = try? JSONDecoder().decode(GHRelease.self, from: data) else {
if userInitiated {
DispatchQueue.main.async { self?.showUpToDateAlert() }
}
return
}
let latestVersion = release.tagName.trimmingCharacters(in: CharacterSet(charactersIn: "v"))
if self.isNewer(latestVersion, than: self.currentVersion) {
DispatchQueue.main.async { self.showUpdateAlert(release: release) }
} else if userInitiated {
DispatchQueue.main.async { self.showUpToDateAlert() }
}
}.resume()
}
private func isNewer(_ latest: String, than current: String) -> Bool {
func baseParts(_ v: String) -> [Int] {
let base = v.components(separatedBy: "-").first ?? v
return base.split(separator: ".").compactMap { Int($0) }
}
let latestParts = baseParts(latest)
let currentParts = baseParts(current)
let count = max(latestParts.count, currentParts.count)
for i in 0..<count {
let l = i < latestParts.count ? latestParts[i] : 0
let c = i < currentParts.count ? currentParts[i] : 0
if l != c { return l > c }
}
return false
}
private func showUpdateAlert(release: GHRelease) {
let alert = NSAlert()
alert.messageText = String(localized: "Update Available")
alert.informativeText = String(localized: "Version \(release.tagName) is available. You have \(currentVersion).")
alert.addButton(withTitle: String(localized: "Download"))
alert.addButton(withTitle: String(localized: "Later"))
if alert.runModal() == .alertFirstButtonReturn, let url = URL(string: release.htmlURL) {
NSWorkspace.shared.open(url)
}
}
private func showUpToDateAlert() {
let alert = NSAlert()
alert.messageText = String(localized: "You're up to date")
alert.informativeText = String(localized: "Version \(currentVersion) is the latest.")
alert.runModal()
}
}
private struct GHRelease: Decodable {
let tagName: String
let htmlURL: String
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
}
}