From b516a1ab28edecc06d57bc48ef8e2787dac458a5 Mon Sep 17 00:00:00 2001 From: klabast <74070528+klabast@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:52:17 +0200 Subject: [PATCH] feat(app): update check is opt-in, off for homebrew installs the launch check hit api.github.com without asking and couldn't be turned off (#10). first launch now asks, settings has the toggle, and a cask install never checks since brew upgrade owns updates there. --- README.md | 2 +- Sources/DefaultAppsCore/Homebrew.swift | 13 +++++ Sources/MacOSDefaultAppsApp/AppStore.swift | 29 ++++++++-- Sources/MacOSDefaultAppsApp/ContentView.swift | 14 +++++ .../MacOSDefaultAppsApp.swift | 3 + .../Resources/de.lproj/Localizable.strings | 6 ++ .../Resources/es.lproj/Localizable.strings | 6 ++ .../Resources/fr.lproj/Localizable.strings | 6 ++ .../Resources/it.lproj/Localizable.strings | 6 ++ .../Resources/nl.lproj/Localizable.strings | 6 ++ .../Resources/pl.lproj/Localizable.strings | 6 ++ .../Resources/pt.lproj/Localizable.strings | 6 ++ .../zh-Hans.lproj/Localizable.strings | 6 ++ .../MacOSDefaultAppsApp/SettingsView.swift | 19 +++++++ .../DefaultAppsCoreTests/HomebrewTests.swift | 26 +++++++++ .../AppStoreTests.swift | 57 ++++++++++++++++++- 16 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 Sources/DefaultAppsCore/Homebrew.swift create mode 100644 Sources/MacOSDefaultAppsApp/SettingsView.swift create mode 100644 Tests/DefaultAppsCoreTests/HomebrewTests.swift diff --git a/README.md b/README.md index b61ab28..7ee6e21 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ scripts/package-app.sh # app: build/macOSDefaultApps.app ## Notes -- The app checks GitHub for a newer release at launch and tells you. It installs nothing; `brew upgrade` or the release page does that. +- The app asks on first launch whether it may check GitHub for a newer release; the answer can be changed in Settings (⌘,). Homebrew installs never check, `brew upgrade` covers them. It installs nothing itself. - Changing the default browser triggers a macOS consent dialog. It can't be silenced. - There is no API to remove an association, only to point it somewhere else. LaunchServices clears out stale ones. - The extension `ts` is MPEG-2 Transport Stream system-wide, not TypeScript. diff --git a/Sources/DefaultAppsCore/Homebrew.swift b/Sources/DefaultAppsCore/Homebrew.swift new file mode 100644 index 0000000..500ff8b --- /dev/null +++ b/Sources/DefaultAppsCore/Homebrew.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum Homebrew { + public static let standardPrefixes = [URL(filePath: "/opt/homebrew"), URL(filePath: "/usr/local")] + + /// True when the app came in through `brew install --cask`, so + /// `brew upgrade` already covers updates. + public static func managesApp(prefixes: [URL] = standardPrefixes) -> Bool { + prefixes.contains { + FileManager.default.fileExists(atPath: $0.appending(path: "Caskroom/macosdefaultapps").path) + } + } +} diff --git a/Sources/MacOSDefaultAppsApp/AppStore.swift b/Sources/MacOSDefaultAppsApp/AppStore.swift index d2f36c4..c903707 100644 --- a/Sources/MacOSDefaultAppsApp/AppStore.swift +++ b/Sources/MacOSDefaultAppsApp/AppStore.swift @@ -64,11 +64,16 @@ final class AppStore { var presetName = "" var availableUpdate: String? + private(set) var askAboutUpdates = false + + static let checkForUpdatesKey = "checkForUpdates" private let registry: any HandlerRegistry private let discovery: any TypeDiscovery private let writer: any HandlerWriter private let releases: any ReleaseFeed + private let defaults: UserDefaults + let brewManaged: Bool private let presets: PresetStore private let restorePoint: RestorePoint @@ -77,20 +82,36 @@ final class AppStore { discovery: any TypeDiscovery = InstalledAppScanner(), writer: any HandlerWriter = LaunchServicesWriter(), releases: any ReleaseFeed = GitHubReleases(), - locations: Locations = .standard + locations: Locations = .standard, + defaults: UserDefaults = .standard, + brewManaged: Bool = Homebrew.managesApp() ) { self.registry = registry self.discovery = discovery self.writer = writer self.releases = releases + self.defaults = defaults + self.brewManaged = brewManaged presets = PresetStore(directory: locations.presets) restorePoint = RestorePoint(directory: locations.state, registry: registry) } - /// No network, rate limit, whatever: an update check that fails is not - /// news the user needs. + /// Nothing leaves the machine until the user said yes once. Homebrew + /// installs never check: `brew upgrade` owns updates there. No network, + /// rate limit, whatever: a check that fails is not news the user needs. func checkForUpdate() async { - availableUpdate = try? await UpdateCheck(feed: releases).newerVersion() + guard !brewManaged else { return } + switch defaults.object(forKey: Self.checkForUpdatesKey) as? Bool { + case nil: askAboutUpdates = true + case false?: return + case true?: availableUpdate = try? await UpdateCheck(feed: releases).newerVersion() + } + } + + func answerUpdateQuestion(check: Bool) async { + defaults.set(check, forKey: Self.checkForUpdatesKey) + askAboutUpdates = false + await checkForUpdate() } // views read these several times per render; one pass per change instead diff --git a/Sources/MacOSDefaultAppsApp/ContentView.swift b/Sources/MacOSDefaultAppsApp/ContentView.swift index d40f1c2..319a674 100644 --- a/Sources/MacOSDefaultAppsApp/ContentView.swift +++ b/Sources/MacOSDefaultAppsApp/ContentView.swift @@ -52,6 +52,20 @@ struct ContentView: View { localized: "Version \(store.availableUpdate ?? "") is available, you have \(Version.current).", bundle: .module)) } + .alert( + t("Check for updates?"), + isPresented: Binding(get: { store.askAboutUpdates }, set: { _ in }) + ) { + Button(t("Check at Launch")) { + Task { await store.answerUpdateQuestion(check: true) } + } + .keyboardShortcut(.defaultAction) + Button(t("Don't Check")) { + Task { await store.answerUpdateQuestion(check: false) } + } + } message: { + Text(t("At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings.")) + } .task { await store.reload() } .task { await store.checkForUpdate() } .onReceive( diff --git a/Sources/MacOSDefaultAppsApp/MacOSDefaultAppsApp.swift b/Sources/MacOSDefaultAppsApp/MacOSDefaultAppsApp.swift index 5f4e7fe..7488830 100644 --- a/Sources/MacOSDefaultAppsApp/MacOSDefaultAppsApp.swift +++ b/Sources/MacOSDefaultAppsApp/MacOSDefaultAppsApp.swift @@ -44,6 +44,9 @@ struct MacOSDefaultAppsApp: App { .keyboardShortcut("r", modifiers: .command) } } + Settings { + SettingsView() + } } private func post(_ name: Notification.Name) { diff --git a/Sources/MacOSDefaultAppsApp/Resources/de.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/de.lproj/Localizable.strings index cfc1c5d..58f8db8 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/de.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/de.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "Update verfügbar"; "Go to GitHub" = "Zu GitHub"; "Version %@ is available, you have %@." = "Version %@ ist verfügbar, installiert ist %@."; +"Check for updates?" = "Nach Updates suchen?"; +"Check at Launch" = "Beim Start prüfen"; +"Don't Check" = "Nicht prüfen"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "Beim Start kann die App GitHub nach dem neuesten Release fragen. Sonst wird nichts gesendet. Das lässt sich in den Einstellungen ändern."; +"Check GitHub for updates at launch" = "Beim Start auf GitHub nach Updates suchen"; +"Installed with Homebrew: brew upgrade updates the app." = "Mit Homebrew installiert: brew upgrade aktualisiert die App."; diff --git a/Sources/MacOSDefaultAppsApp/Resources/es.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/es.lproj/Localizable.strings index 899ccd6..2627c0e 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/es.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/es.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "Actualización disponible"; "Go to GitHub" = "Ir a GitHub"; "Version %@ is available, you have %@." = "La versión %@ está disponible, tienes la %@."; +"Check for updates?" = "¿Buscar actualizaciones?"; +"Check at Launch" = "Comprobar al iniciar"; +"Don't Check" = "No comprobar"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "Al iniciar, la app puede consultar a GitHub la última versión. No se envía nada más. Puedes cambiarlo en Ajustes."; +"Check GitHub for updates at launch" = "Buscar actualizaciones en GitHub al iniciar"; +"Installed with Homebrew: brew upgrade updates the app." = "Instalada con Homebrew: brew upgrade actualiza la app."; diff --git a/Sources/MacOSDefaultAppsApp/Resources/fr.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/fr.lproj/Localizable.strings index 4f31e86..4fe922c 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/fr.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/fr.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "Mise à jour disponible"; "Go to GitHub" = "Ouvrir GitHub"; "Version %@ is available, you have %@." = "La version %@ est disponible, vous avez la %@."; +"Check for updates?" = "Rechercher les mises à jour ?"; +"Check at Launch" = "Vérifier au lancement"; +"Don't Check" = "Ne pas vérifier"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "Au lancement, l’app peut demander à GitHub la dernière version. Rien d’autre n’est envoyé. Modifiable dans les Réglages."; +"Check GitHub for updates at launch" = "Rechercher les mises à jour sur GitHub au lancement"; +"Installed with Homebrew: brew upgrade updates the app." = "Installée avec Homebrew : brew upgrade met l’app à jour."; diff --git a/Sources/MacOSDefaultAppsApp/Resources/it.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/it.lproj/Localizable.strings index e5f608c..2f66d72 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/it.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/it.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "Aggiornamento disponibile"; "Go to GitHub" = "Vai su GitHub"; "Version %@ is available, you have %@." = "È disponibile la versione %@, hai la %@."; +"Check for updates?" = "Cercare aggiornamenti?"; +"Check at Launch" = "Controlla all’avvio"; +"Don't Check" = "Non controllare"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "All’avvio l’app può chiedere a GitHub l’ultima versione. Non viene inviato nient’altro. Puoi cambiarlo nelle Impostazioni."; +"Check GitHub for updates at launch" = "Cerca aggiornamenti su GitHub all’avvio"; +"Installed with Homebrew: brew upgrade updates the app." = "Installata con Homebrew: brew upgrade aggiorna l’app."; diff --git a/Sources/MacOSDefaultAppsApp/Resources/nl.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/nl.lproj/Localizable.strings index 5b9813d..34dca58 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/nl.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/nl.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "Update beschikbaar"; "Go to GitHub" = "Naar GitHub"; "Version %@ is available, you have %@." = "Versie %@ is beschikbaar, je hebt %@."; +"Check for updates?" = "Zoeken naar updates?"; +"Check at Launch" = "Controleren bij starten"; +"Don't Check" = "Niet controleren"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "Bij het starten kan de app GitHub vragen naar de nieuwste release. Verder wordt niets verstuurd. Je kunt dit wijzigen in Instellingen."; +"Check GitHub for updates at launch" = "Bij starten op GitHub naar updates zoeken"; +"Installed with Homebrew: brew upgrade updates the app." = "Geïnstalleerd met Homebrew: brew upgrade werkt de app bij."; diff --git a/Sources/MacOSDefaultAppsApp/Resources/pl.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/pl.lproj/Localizable.strings index d09f072..64d0aba 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/pl.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/pl.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "Dostępna aktualizacja"; "Go to GitHub" = "Przejdź do GitHub"; "Version %@ is available, you have %@." = "Dostępna jest wersja %@, masz %@."; +"Check for updates?" = "Sprawdzać aktualizacje?"; +"Check at Launch" = "Sprawdzaj przy starcie"; +"Don't Check" = "Nie sprawdzaj"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "Przy starcie aplikacja może zapytać GitHub o najnowsze wydanie. Nic więcej nie jest wysyłane. Możesz to zmienić w Ustawieniach."; +"Check GitHub for updates at launch" = "Sprawdzaj aktualizacje na GitHub przy starcie"; +"Installed with Homebrew: brew upgrade updates the app." = "Zainstalowano przez Homebrew: brew upgrade aktualizuje aplikację."; diff --git a/Sources/MacOSDefaultAppsApp/Resources/pt.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/pt.lproj/Localizable.strings index 7d07aa2..c41c005 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/pt.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/pt.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "Atualização disponível"; "Go to GitHub" = "Ir para o GitHub"; "Version %@ is available, you have %@." = "A versão %@ está disponível, tens a %@."; +"Check for updates?" = "Procurar atualizações?"; +"Check at Launch" = "Verificar ao abrir"; +"Don't Check" = "Não verificar"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "Ao abrir, a app pode perguntar ao GitHub qual é a versão mais recente. Nada mais é enviado. Pode alterar isto nas Definições."; +"Check GitHub for updates at launch" = "Procurar atualizações no GitHub ao abrir"; +"Installed with Homebrew: brew upgrade updates the app." = "Instalada com Homebrew: brew upgrade atualiza a app."; diff --git a/Sources/MacOSDefaultAppsApp/Resources/zh-Hans.lproj/Localizable.strings b/Sources/MacOSDefaultAppsApp/Resources/zh-Hans.lproj/Localizable.strings index e8f5eec..1b91a60 100644 --- a/Sources/MacOSDefaultAppsApp/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/MacOSDefaultAppsApp/Resources/zh-Hans.lproj/Localizable.strings @@ -47,3 +47,9 @@ "Update available" = "有可用更新"; "Go to GitHub" = "前往 GitHub"; "Version %@ is available, you have %@." = "有新版本 %@,当前版本为 %@。"; +"Check for updates?" = "检查更新?"; +"Check at Launch" = "启动时检查"; +"Don't Check" = "不检查"; +"At launch the app can ask GitHub for the latest release. Nothing else is sent. You can change this in Settings." = "启动时,应用可以向 GitHub 查询最新版本,不会发送其他任何内容。可在“设置”中更改。"; +"Check GitHub for updates at launch" = "启动时在 GitHub 上检查更新"; +"Installed with Homebrew: brew upgrade updates the app." = "通过 Homebrew 安装:brew upgrade 会更新此应用。"; diff --git a/Sources/MacOSDefaultAppsApp/SettingsView.swift b/Sources/MacOSDefaultAppsApp/SettingsView.swift new file mode 100644 index 0000000..dd7e8cb --- /dev/null +++ b/Sources/MacOSDefaultAppsApp/SettingsView.swift @@ -0,0 +1,19 @@ +import DefaultAppsCore +import SwiftUI + +struct SettingsView: View { + @AppStorage(AppStore.checkForUpdatesKey) private var checkForUpdates = false + private let brewManaged = Homebrew.managesApp() + + var body: some View { + Form { + if brewManaged { + Text(t("Installed with Homebrew: brew upgrade updates the app.")) + } else { + Toggle(t("Check GitHub for updates at launch"), isOn: $checkForUpdates) + } + } + .padding(20) + .frame(width: 420) + } +} diff --git a/Tests/DefaultAppsCoreTests/HomebrewTests.swift b/Tests/DefaultAppsCoreTests/HomebrewTests.swift new file mode 100644 index 0000000..bf32c3f --- /dev/null +++ b/Tests/DefaultAppsCoreTests/HomebrewTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import DefaultAppsCore + +@Suite("homebrew") +struct HomebrewTests { + let root = FileManager.default.temporaryDirectory + .appending(path: "mda-homebrew-tests-\(UUID().uuidString)") + + @Test("an installed cask means brew owns updates") + func caskInstalled() throws { + try FileManager.default.createDirectory( + at: root.appending(path: "Caskroom/macosdefaultapps/0.7.1"), withIntermediateDirectories: true) + + #expect(Homebrew.managesApp(prefixes: [root.appending(path: "missing"), root])) + } + + @Test("no cask, or no homebrew at all, means nobody updates it for us") + func noCask() throws { + try FileManager.default.createDirectory( + at: root.appending(path: "Caskroom/something-else"), withIntermediateDirectories: true) + + #expect(!Homebrew.managesApp(prefixes: [root])) + #expect(!Homebrew.managesApp(prefixes: [root.appending(path: "missing")])) + } +} diff --git a/Tests/MacOSDefaultAppsAppTests/AppStoreTests.swift b/Tests/MacOSDefaultAppsAppTests/AppStoreTests.swift index 1fc928c..2fd346a 100644 --- a/Tests/MacOSDefaultAppsAppTests/AppStoreTests.swift +++ b/Tests/MacOSDefaultAppsAppTests/AppStoreTests.swift @@ -40,12 +40,14 @@ struct AppStoreTests { let xcode = app("com.apple.dt.Xcode", "Xcode") let textEdit = app("com.apple.TextEdit", "TextEdit") let locations: Locations + let defaults: UserDefaults - init() { + init() throws { let root = FileManager.default.temporaryDirectory .appending(path: "mda-app-tests-\(UUID().uuidString)") locations = Locations( presets: root.appending(path: "presets"), state: root.appending(path: "state")) + defaults = try #require(UserDefaults(suiteName: "mda-app-tests-\(UUID().uuidString)")) } /// md is in the bundled catalog, ipynb is not; both open in Xcode. @@ -64,11 +66,12 @@ struct AppStoreTests { func makeStore( registry: any HandlerRegistry, discovery: any TypeDiscovery = FakeDiscovery(extensions: ["ipynb"]), - writer: FakeWriter = FakeWriter(), releases: any ReleaseFeed = FakeReleaseFeed(tag: "v0.0.1") + writer: FakeWriter = FakeWriter(), releases: any ReleaseFeed = FakeReleaseFeed(tag: "v0.0.1"), + brewManaged: Bool = false ) -> AppStore { AppStore( registry: registry, discovery: discovery, writer: writer, releases: releases, - locations: locations) + locations: locations, defaults: defaults, brewManaged: brewManaged) } @Test("reading the filtered snapshot repeatedly costs one pass, not one per read") @@ -99,6 +102,7 @@ struct AppStoreTests { @Test("a newer release is offered, a failed check stays quiet") func updateCheck() async { + defaults.set(true, forKey: AppStore.checkForUpdatesKey) let store = makeStore(registry: registry, releases: FakeReleaseFeed(tag: "v9.9.9")) await store.checkForUpdate() #expect(store.availableUpdate == "9.9.9") @@ -111,6 +115,53 @@ struct AppStoreTests { #expect(offline.errorMessage == nil) } + @Test("first launch asks before talking to github") + func updateCheckAsksFirst() async { + let store = makeStore(registry: registry, releases: FakeReleaseFeed(tag: "v9.9.9")) + + await store.checkForUpdate() + + #expect(store.askAboutUpdates) + #expect(store.availableUpdate == nil) + } + + @Test("saying yes remembers it and checks right away") + func updateCheckYes() async { + let store = makeStore(registry: registry, releases: FakeReleaseFeed(tag: "v9.9.9")) + await store.checkForUpdate() + + await store.answerUpdateQuestion(check: true) + + #expect(!store.askAboutUpdates) + #expect(store.availableUpdate == "9.9.9") + #expect(defaults.object(forKey: AppStore.checkForUpdatesKey) as? Bool == true) + } + + @Test("saying no remembers it: no check, no asking again") + func updateCheckNo() async { + let store = makeStore(registry: registry, releases: FakeReleaseFeed(tag: "v9.9.9")) + await store.checkForUpdate() + await store.answerUpdateQuestion(check: false) + + let nextLaunch = makeStore(registry: registry, releases: FakeReleaseFeed(tag: "v9.9.9")) + await nextLaunch.checkForUpdate() + + #expect(store.availableUpdate == nil) + #expect(!nextLaunch.askAboutUpdates) + #expect(nextLaunch.availableUpdate == nil) + } + + @Test("a homebrew install never asks and never checks") + func updateCheckHomebrew() async { + let store = makeStore(registry: registry, releases: FakeReleaseFeed(tag: "v9.9.9"), brewManaged: true) + await store.checkForUpdate() + defaults.set(true, forKey: AppStore.checkForUpdatesKey) + await store.checkForUpdate() + + #expect(!store.askAboutUpdates) + #expect(store.availableUpdate == nil) + } + @Test("reload joins the bundled catalog with discovered types and records the restore point") func reload() async throws { let store = makeStore(registry: registry)