diff --git a/CHANGELOG.md b/CHANGELOG.md index d8de1e4..8b07c6f 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). - The app has an Install Command Line Tool… item in the RemoveMacAI menu. It asks first, then links `/usr/local/bin/removemacai` to the app, so the app from the zip gets the `removemacai` command too. It never replaces a file there that isn't a link (#60). - `removemacai background` with an action other than `off` or `on`, such as the typo `of`, now fails and says which actions it takes, instead of listing the items and exiting 0 (#82). - The app is translated into Hindi and Urdu (#101). Urdu keeps the names of macOS screens and buttons in English, since macOS has no Urdu interface. diff --git a/Localization/ru.lproj/Localizable.strings b/Localization/ru.lproj/Localizable.strings index e441c3f..a227cc9 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…" = "Проверить обновления…"; "Install Command Line Tool…" = "Установить инструмент командной строки…"; "Reload" = "Обновить"; "How RemoveMacAI Works" = "Как работает RemoveMacAI"; @@ -412,6 +413,13 @@ "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 не удалось проверить обновления."; "The removemacai command is already installed." = "Команда removemacai уже установлена."; "Open Terminal and run removemacai status." = "Откройте Терминал и выполните removemacai status."; diff --git a/README.md b/README.md index 4ede9df..442c2fb 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 c96f956..5fbf704 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: .appSettings) { Button("Install Command Line Tool…") { AppDelegate.installCommandLineTool() } @@ -57,6 +58,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() + } + } + /// Links the app's binary into /usr/local/bin, so the app from the zip gets /// the removemacai command the installer and Homebrew give. Asks first, and /// never replaces a file there that isn't a link. diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index 2d7d925..c90ed59 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -305,6 +305,13 @@ func selfTest() -> Bool { check(!refused.ok && refused.output.contains("isn't a link") && (try? String(contentsOfFile: toolFile, encoding: .utf8)) == "keep", "the command line tool never replaces a file that isn't a link") 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(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 new file mode 100644 index 0000000..f9528fd --- /dev/null +++ b/Sources/removemacai/Updates.swift @@ -0,0 +1,73 @@ +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, 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: 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.first ?? "").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 + } + } +}