Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions Sources/DefaultAppsCore/Homebrew.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
29 changes: 25 additions & 4 deletions Sources/MacOSDefaultAppsApp/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Sources/MacOSDefaultAppsApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions Sources/MacOSDefaultAppsApp/MacOSDefaultAppsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ struct MacOSDefaultAppsApp: App {
.keyboardShortcut("r", modifiers: .command)
}
}
Settings {
SettingsView()
}
}

private func post(_ name: Notification.Name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Original file line number Diff line number Diff line change
Expand Up @@ -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ę.";
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Original file line number Diff line number Diff line change
Expand Up @@ -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 会更新此应用。";
19 changes: 19 additions & 0 deletions Sources/MacOSDefaultAppsApp/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 26 additions & 0 deletions Tests/DefaultAppsCoreTests/HomebrewTests.swift
Original file line number Diff line number Diff line change
@@ -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")]))
}
}
57 changes: 54 additions & 3 deletions Tests/MacOSDefaultAppsAppTests/AppStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
Loading