From e379bef55a1b0968e124cd4dce201e0d21119176 Mon Sep 17 00:00:00 2001 From: Anas Lakhani Date: Sun, 25 Oct 2026 08:52:10 +0500 Subject: [PATCH 1/2] =?UTF-8?q?Add=20Check=20for=20Updates=E2=80=A6=20to?= =?UTF-8?q?=20the=20app=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asks GitHub for the latest release and, when it is newer, offers to open its download page. A beta counts as older than its release, so 1.2.0-beta.1 isn't offered 1.1.4. It only checks when chosen, so the README now says RemoveMacAI makes no network requests except that one. Fixes #44 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Localization/ru.lproj/Localizable.strings | 8 ++++ README.md | 2 +- Sources/removemacai/App/RemoveMacAIApp.swift | 24 ++++++++++ Sources/removemacai/SelfTest.swift | 4 ++ Sources/removemacai/Updates.swift | 49 ++++++++++++++++++++ 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 Sources/removemacai/Updates.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f00db8c..5c6f22d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- The app has a Check for Updates… item in the RemoveMacAI menu. It asks GitHub for the latest release and offers to open its download page. It only checks when you choose it, so RemoveMacAI still makes no network requests on its own (#44). - Block Music launches is now called Stop Music from opening, even by hand, and its warning says Music won't open at all until you undo it. The old name read as if only AirPods were blocked (#67). ## 1.2.0-beta.1 diff --git a/Localization/ru.lproj/Localizable.strings b/Localization/ru.lproj/Localizable.strings index c4b0e49..93d2673 100644 --- a/Localization/ru.lproj/Localizable.strings +++ b/Localization/ru.lproj/Localizable.strings @@ -3,6 +3,7 @@ "RemoveMacAI" = "RemoveMacAI"; "About RemoveMacAI" = "О программе RemoveMacAI"; +"Check for Updates…" = "Проверить обновления…"; "Reload" = "Обновить"; "How RemoveMacAI Works" = "Как работает RemoveMacAI"; "RemoveMacAI on GitHub" = "RemoveMacAI на GitHub"; @@ -411,3 +412,10 @@ "RemoveMacAI needs your password to change background items for all users." = "RemoveMacAI требуется ваш пароль, чтобы изменить фоновые объекты для всех пользователей."; "RemoveMacAI needs your password to move items only an administrator can change." = "RemoveMacAI требуется ваш пароль, чтобы переместить объекты, которые может изменять только администратор."; "Turn off everything except 1 kept feature" = "Выключить всё, кроме 1 оставленной функции"; +"RemoveMacAI %@ is available." = "Доступна версия RemoveMacAI %@."; +"You have %@. Download the new version from GitHub and replace this app with it." = "У вас версия %@. Скачайте новую версию с GitHub и замените ею это приложение."; +"Download" = "Скачать"; +"Not Now" = "Не сейчас"; +"RemoveMacAI is up to date." = "У вас последняя версия RemoveMacAI."; +"You have %@, the latest version." = "У вас версия %@ — она последняя."; +"RemoveMacAI couldn't check for updates." = "RemoveMacAI не удалось проверить обновления."; diff --git a/README.md b/README.md index fac0049..193c721 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ It holds the Apple Intelligence restrictions, the download block for removed mod - **Models** are removed through Apple's asset service, and the profile points their downloads at a closed local port so macOS doesn't fetch them again. - **Storage** cleanup moves files to the Trash, so they aren't gone until you empty it. Unavailable simulators and Time Machine local snapshots are deleted right away by `simctl` and `tmutil`. -System Integrity Protection stays on, nothing under `/System` is modified, and RemoveMacAI makes no network requests and collects no data. +System Integrity Protection stays on, nothing under `/System` is modified, and RemoveMacAI collects no data. It makes no network requests, except to GitHub when you choose Check for Updates… in the app. ## What it doesn't do diff --git a/Sources/removemacai/App/RemoveMacAIApp.swift b/Sources/removemacai/App/RemoveMacAIApp.swift index 9722bf6..a9df552 100644 --- a/Sources/removemacai/App/RemoveMacAIApp.swift +++ b/Sources/removemacai/App/RemoveMacAIApp.swift @@ -16,6 +16,7 @@ struct RemoveMacAIApp: App { CommandGroup(replacing: .newItem) {} CommandGroup(replacing: .appInfo) { Button("About RemoveMacAI") { AppDelegate.showAbout() } + Button("Check for Updates…") { AppDelegate.checkForUpdates() } } CommandGroup(after: .toolbar) { Button("Reload") { model.reload(resetChoices: true) }.keyboardShortcut("r") @@ -54,6 +55,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + /// Asks GitHub for the latest release, only when chosen from the menu. + static func checkForUpdates() { + Task { @MainActor in + let alert = NSAlert() + switch await Updates.latest() { + case .success(let release) where Updates.isNewer(release.version, than: appVersion): + alert.messageText = String(localized: "RemoveMacAI \(release.version) is available.") + alert.informativeText = String(localized: "You have \(appVersion). Download the new version from GitHub and replace this app with it.") + alert.addButton(withTitle: String(localized: "Download")) + alert.addButton(withTitle: String(localized: "Not Now")) + if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.open(release.page) } + return + case .success: + alert.messageText = String(localized: "RemoveMacAI is up to date.") + alert.informativeText = String(localized: "You have \(appVersion), the latest version.") + case .failure(let error): + alert.messageText = String(localized: "RemoveMacAI couldn't check for updates.") + alert.informativeText = error.localizedDescription + } + alert.runModal() + } + } + static func showAbout() { let credits = NSMutableAttributedString( string: String(localized: "Debloats macOS and can undo every change.\n\nThe Apple Intelligence part builds on pared by 4evy, which first mapped Apple's asset service and model sets."), diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index 3d3073d..c737bc4 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -286,6 +286,10 @@ func selfTest() -> Bool { let exited = Shell.run("/bin/sh", ["-c", Shell.adminScript(["exit 3", "echo after-exit"])]) check(!exited.ok && exited.output.contains("after-exit"), "a step that exits doesn't stop the rest") check(Shell.run("/bin/sh", ["-c", Shell.adminScript(["true", "true"])]).ok, "administrator steps succeed together") + check(Updates.isNewer("1.2.0", than: "1.2.0-beta.1") && !Updates.isNewer("1.1.4", than: "1.2.0-beta.1") + && Updates.isNewer("1.2.0-beta.10", than: "1.2.0-beta.9") && !Updates.isNewer("1.2.0-beta.1", than: "1.2.0") + && Updates.isNewer("1.10.0", than: "1.9.9") && !Updates.isNewer("1.1.4", than: "1.1.4"), + "update checks treat a beta as older than its release and compare numbers as numbers") check(Term.pad("abc", 5) == "abc " && Term.pad("abcdef", 5) == "abcdef ", "columns never run together") check(Term.colorEnabled(env: [:], tty: true) && !Term.colorEnabled(env: [:], tty: false), "color follows the terminal when neither NO_COLOR nor CLICOLOR_FORCE is set") diff --git a/Sources/removemacai/Updates.swift b/Sources/removemacai/Updates.swift new file mode 100644 index 0000000..80445c5 --- /dev/null +++ b/Sources/removemacai/Updates.swift @@ -0,0 +1,49 @@ +import Foundation + +/// The newest release on GitHub. Only asked when someone chooses Check for +/// Updates, so RemoveMacAI otherwise makes no network requests. +enum Updates { + static let latestURL = URL(string: "https://api.github.com/repos/omlahore/RemoveMacAI/releases/latest")! + static let releasesPage = URL(string: "https://github.com/omlahore/RemoveMacAI/releases/latest")! + + struct Release { + let version: String + let page: URL + } + + /// The latest release that isn't a beta, or why it couldn't be read. + static func latest() async -> Result { + var request = URLRequest(url: latestURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + do { + let (data, _) = try await URLSession.shared.data(for: request) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let tag = json["tag_name"] as? String + else { throw URLError(.cannotParseResponse) } + let page = (json["html_url"] as? String).flatMap(URL.init(string:)) ?? releasesPage + return .success(Release(version: tag.hasPrefix("v") ? String(tag.dropFirst()) : tag, page: page)) + } catch { + return .failure(error) + } + } + + /// Whether latest is newer than current. A beta comes before its release, + /// so 1.2.0 is newer than 1.2.0-beta.1 and 1.1.4 is not. + static func isNewer(_ latest: String, than current: String) -> Bool { + func parts(_ v: String) -> (numbers: [Int], beta: String?) { + let split = v.split(separator: "-", maxSplits: 1).map(String.init) + return (split[0].split(separator: ".").map { Int($0) ?? 0 }, split.count > 1 ? split[1] : nil) + } + let a = parts(latest), b = parts(current) + for i in 0.. y } + } + switch (a.beta, b.beta) { + case (nil, nil): return false + case (nil, _): return true + case (_, nil): return false + case (let x?, let y?): return x.compare(y, options: .numeric) == .orderedDescending + } + } +} From 0b1c627d30abe662537f8c194e3c78e1cabfa220 Mon Sep 17 00:00:00 2001 From: Anas Lakhani Date: Sun, 25 Oct 2026 09:07:25 +0500 Subject: [PATCH 2/2] Check the HTTP status and the tag before comparing versions A rate limit or outage still sends a JSON body, so a non-2xx status is now reported as such, with a plain message for a rate limit. A tag that isn't a version number, such as a bare v, is rejected instead of reaching the comparison, which no longer indexes an empty split. Co-Authored-By: Claude Opus 5.5 --- Sources/removemacai/SelfTest.swift | 3 +++ Sources/removemacai/Updates.swift | 30 +++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index c737bc4..b04b36d 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -290,6 +290,9 @@ func selfTest() -> Bool { && Updates.isNewer("1.2.0-beta.10", than: "1.2.0-beta.9") && !Updates.isNewer("1.2.0-beta.1", than: "1.2.0") && Updates.isNewer("1.10.0", than: "1.9.9") && !Updates.isNewer("1.1.4", than: "1.1.4"), "update checks treat a beta as older than its release and compare numbers as numbers") + check(Updates.isVersion("1.2.0") && Updates.isVersion("1.2.0-beta.1") && !Updates.isVersion("") + && !Updates.isVersion("latest") && !Updates.isVersion("1.2.") && !Updates.isNewer("", than: "1.2.0"), + "an update check only accepts version numbers, and an empty one can't crash it") check(Term.pad("abc", 5) == "abc " && Term.pad("abcdef", 5) == "abcdef ", "columns never run together") check(Term.colorEnabled(env: [:], tty: true) && !Term.colorEnabled(env: [:], tty: false), "color follows the terminal when neither NO_COLOR nor CLICOLOR_FORCE is set") diff --git a/Sources/removemacai/Updates.swift b/Sources/removemacai/Updates.swift index 80445c5..f9528fd 100644 --- a/Sources/removemacai/Updates.swift +++ b/Sources/removemacai/Updates.swift @@ -16,23 +16,47 @@ enum Updates { var request = URLRequest(url: latestURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") do { - let (data, _) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.data(for: request) + // A rate limit or outage still sends a body, so the status comes first. + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw UpdateError.status(http.statusCode) + } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let tag = json["tag_name"] as? String else { throw URLError(.cannotParseResponse) } + let version = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + guard isVersion(version) else { throw UpdateError.notAVersion(tag) } let page = (json["html_url"] as? String).flatMap(URL.init(string:)) ?? releasesPage - return .success(Release(version: tag.hasPrefix("v") ? String(tag.dropFirst()) : tag, page: page)) + return .success(Release(version: version, page: page)) } catch { return .failure(error) } } + enum UpdateError: LocalizedError { + case status(Int) + case notAVersion(String) + + var errorDescription: String? { + switch self { + case .status(403), .status(429): return "GitHub is limiting requests right now. Try again in a while." + case .status(let code): return "GitHub answered with HTTP \(code)." + case .notAVersion(let tag): return "The latest release is tagged \"\(tag)\", which isn't a version number." + } + } + } + + /// Numbers separated by dots, optionally followed by a dash and a label, such as 1.2.0-beta.1. + static func isVersion(_ v: String) -> Bool { + v.range(of: #"^\d+(\.\d+)*(-[0-9A-Za-z.]+)?$"#, options: .regularExpression) != nil + } + /// Whether latest is newer than current. A beta comes before its release, /// so 1.2.0 is newer than 1.2.0-beta.1 and 1.1.4 is not. static func isNewer(_ latest: String, than current: String) -> Bool { func parts(_ v: String) -> (numbers: [Int], beta: String?) { let split = v.split(separator: "-", maxSplits: 1).map(String.init) - return (split[0].split(separator: ".").map { Int($0) ?? 0 }, split.count > 1 ? split[1] : nil) + return ((split.first ?? "").split(separator: ".").map { Int($0) ?? 0 }, split.count > 1 ? split[1] : nil) } let a = parts(latest), b = parts(current) for i in 0..