+ Data caching + Adoptable app icons

This commit is contained in:
David Bureš 2025-10-07 12:51:32 +02:00
parent a1ec1f26bb
commit b64c9003f1
No known key found for this signature in database
10 changed files with 237 additions and 33 deletions

View File

@ -7604,6 +7604,18 @@
} }
} }
}, },
"action.reveal-%@-in-finder" : {
"comment" : "A button that reveals the location of a specific adoptable package in the Finder. The argument is the name of the adoptable package.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Reveal %@ in Finder"
}
}
}
},
"action.reveal-applications-folder-in-finder" : { "action.reveal-applications-folder-in-finder" : {
"localizations" : { "localizations" : {
"cs" : { "cs" : {
@ -15145,7 +15157,7 @@
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Adoptable packages" "value" : "Adoptable apps"
} }
} }
} }

View File

@ -35,7 +35,11 @@ enum DataDownloadingError: LocalizedError
} }
} }
func downloadDataFromURL(_ url: URL, parameters: [URLQueryItem]? = nil) async throws(DataDownloadingError) -> Data func downloadDataFromURL(
_ url: URL,
parameters: [URLQueryItem]? = nil,
cachingPolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
) async throws(DataDownloadingError) -> Data
{ {
let sessionConfiguration: URLSessionConfiguration = .default let sessionConfiguration: URLSessionConfiguration = .default
if AppConstants.shared.proxySettings != nil if AppConstants.shared.proxySettings != nil
@ -57,7 +61,7 @@ func downloadDataFromURL(_ url: URL, parameters: [URLQueryItem]? = nil) async th
throw DataDownloadingError.invalidURL throw DataDownloadingError.invalidURL
} }
var request: URLRequest = .init(url: modifiedURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10) var request: URLRequest = .init(url: modifiedURL, cachePolicy: cachingPolicy, timeoutInterval: 10)
request.httpMethod = "GET" request.httpMethod = "GET"

View File

@ -18,12 +18,30 @@ extension BrewPackagesTracker
case couldNotGetContentsOfApplicationsFolder(error: String) case couldNotGetContentsOfApplicationsFolder(error: String)
} }
enum HomebrewDataCacheUsePolicy
{
case useCachedData
case forceDataFetch
var cachePolicy: URLRequest.CachePolicy
{
switch self {
case .useCachedData:
return .returnCacheDataElseLoad
case .forceDataFetch:
return .reloadIgnoringLocalCacheData
}
}
}
/// Get a list of casks that can be adopted into the Homebrew updating mechanism /// Get a list of casks that can be adopted into the Homebrew updating mechanism
func getAdoptableCasks() async throws(AdoptableCasksLoadingError) -> Set<AdoptableCaskComparable> func getAdoptableCasks(
cacheUsePolicy: HomebrewDataCacheUsePolicy
) async throws(AdoptableCasksLoadingError) -> Set<AdoptableCaskComparable>
{ {
do do
{ {
let allCasksJson: Data = try await self.loadAllCasksJson() let allCasksJson: Data = try await self.loadAllCasksJson(cachingPolicy: cacheUsePolicy)
AppConstants.shared.logger.debug("Successfully loaded all Casks JSON") AppConstants.shared.logger.debug("Successfully loaded all Casks JSON")
@ -66,9 +84,11 @@ extension BrewPackagesTracker
} }
/// Download a JSON list of all available casks /// Download a JSON list of all available casks
private func loadAllCasksJson() async throws(DataDownloadingError) -> Data private func loadAllCasksJson(
cachingPolicy: HomebrewDataCacheUsePolicy
) async throws(DataDownloadingError) -> Data
{ {
return try await downloadDataFromURL(.init(string: "https://formulae.brew.sh/api/cask.json")!) return try await downloadDataFromURL(.init(string: "https://formulae.brew.sh/api/cask.json")!, cachingPolicy: cachingPolicy.cachePolicy)
} }
enum CasksJsonParsingError: Error enum CasksJsonParsingError: Error

View File

@ -0,0 +1,124 @@
//
// Application.swift
// Cork
//
// Created by David Bureš - P on 07.10.2025.
//
import Foundation
import SwiftUI
import CorkShared
// TODO: Move this over to the `ApplicationInspector` external library once we figure out how to use Tuist projects as external dependencies
public struct Application
{
public let name: String
public let iconPath: URL?
public let iconImage: Image?
public enum ApplicationInitializationError: LocalizedError
{
public enum MandatoryAppInformation: String, Sendable
{
case name
public var description: String
{
switch self {
case .name:
return "Name"
}
}
}
case applicationExecutableNotReadable(checkedPath: String)
case couldNotAccessApplicationExecutable(error: Error)
case couldNotReadBundle(applicationPath: String)
case couldNotGetInfoDictionary
case couldNotGetMandatoryAppInformation(_ mandatoryInformation: MandatoryAppInformation)
public var errorDescription: String?
{
switch self {
case .applicationExecutableNotReadable(let checkedPath):
return "Couldn't read application executable at \(checkedPath)"
case .couldNotAccessApplicationExecutable(let error):
return "Couldn't read application executable: \(error)"
case .couldNotReadBundle(let applicationPath):
return "Couldn't read application bundle at \(applicationPath)"
case .couldNotGetInfoDictionary:
return "Couldn't read application info.plist"
case .couldNotGetMandatoryAppInformation(let mandatoryInformation):
return "Couldn't read mandatory app information: \(mandatoryInformation.description)"
}
}
}
public init(from appURL: URL) throws(ApplicationInitializationError)
{
do
{
guard FileManager.default.isReadableFile(atPath: appURL.path) == true else
{
throw ApplicationInitializationError.applicationExecutableNotReadable(checkedPath: appURL.path)
}
guard let appBundle: Bundle = .init(url: appURL)
else
{
throw ApplicationInitializationError.couldNotReadBundle(applicationPath: appURL.absoluteString)
}
AppConstants.shared.logger.debug("Will try to initialize and App object form bundle \(appBundle)")
guard let appBundleInfoDictionary: [String: Any] = appBundle.infoDictionary
else
{
throw ApplicationInitializationError.couldNotGetInfoDictionary
}
guard let appName: String = Application.getAppName(fromInfoDictionary: appBundleInfoDictionary) else
{
throw ApplicationInitializationError.couldNotGetMandatoryAppInformation(.name)
}
self.name = appName
self.iconPath = Application.getAppIconPath(fromInfoDictionary: appBundleInfoDictionary, appBundle: appBundle)
if let iconPath = self.iconPath
{
self.iconImage = .init(
nsImage: .init(byReferencing: iconPath)
)
}
else
{
self.iconImage = nil
}
}
catch let applicationDirectoryAccessError
{
throw .couldNotAccessApplicationExecutable(error: applicationDirectoryAccessError)
}
}
private static func getAppName(fromInfoDictionary infoDictionary: [String: Any]) -> String?
{
return infoDictionary["CFBundleName"] as? String
}
private static func getAppIconPath(fromInfoDictionary infoDictionary: [String: Any], appBundle: Bundle) -> URL?
{
guard let iconFileName: String = infoDictionary["CFBundleIconFile"] as? String
else
{
return nil
}
return appBundle.resourceURL?.appendingPathComponent(iconFileName, conformingTo: .icns)
}
}

View File

@ -116,6 +116,7 @@ struct InstallationInitialView: View
)) ))
} }
.disabled(foundPackageSelection == nil) .disabled(foundPackageSelection == nil)
.labelStyle(.titleOnly)
} }
@ViewBuilder @ViewBuilder

View File

@ -22,7 +22,7 @@ struct PreviewPackageButton: View
openWindow(value: packageToPreview) openWindow(value: packageToPreview)
} label: { } label: {
Text("preview-package.action") Label("preview-package.action", systemImage: "scope")
} }
.keyboardShortcut("p", modifiers: [.command, .option]) .keyboardShortcut("p", modifiers: [.command, .option])
} }
@ -37,7 +37,7 @@ struct PreviewPackageButtonWithCustomAction: View
{ {
action() action()
} label: { } label: {
Text("preview-package.action") Label("preview-package.action", systemImage: "scope")
} }
.keyboardShortcut("p", modifiers: [.command, .option]) .keyboardShortcut("p", modifiers: [.command, .option])
} }
@ -57,7 +57,7 @@ struct PreviewPackageButtonWithCustomLabel: View
{ {
openWindow(value: packageToPreview) openWindow(value: packageToPreview)
} label: { } label: {
Text(label) Label(label, systemImage: "scope")
} }
.keyboardShortcut("p", modifiers: [.command, .option]) .keyboardShortcut("p", modifiers: [.command, .option])
} }

View File

@ -133,13 +133,20 @@ struct StartPage: View
.transition(.push(from: .top)) .transition(.push(from: .top))
.task .task
{ {
do if brewPackagesTracker.adoptableCasks.isEmpty
{ {
brewPackagesTracker.adoptableCasks = try await brewPackagesTracker.getAdoptableCasks() do
{
brewPackagesTracker.adoptableCasks = try await brewPackagesTracker.getAdoptableCasks(cacheUsePolicy: .useCachedData)
}
catch let adoptablePackagesLoadingError
{
AppConstants.shared.logger.error("Failed to load adoptable casks: \(adoptablePackagesLoadingError)")
}
} }
catch let adoptablePackagesLoadingError else
{ {
AppConstants.shared.logger.error("Failed to load adoptable casks: \(adoptablePackagesLoadingError)") AppConstants.shared.logger.debug("Adoptable casks are already loaded, will not reload")
} }
} }

View File

@ -34,24 +34,7 @@ struct AdoptablePackagesBox: View
{ {
List(brewPackagesTracker.adoptableCasks.sorted(by: { $0.caskName < $1.caskName })) List(brewPackagesTracker.adoptableCasks.sorted(by: { $0.caskName < $1.caskName }))
{ adoptableCask in { adoptableCask in
HStack(alignment: .firstTextBaseline, spacing: 5) AdoptablePackageListItem(adoptableCask: adoptableCask)
{
Text(adoptableCask.caskExecutable)
Text("(\(adoptableCask.caskName))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.contextMenu
{
PreviewPackageButtonWithCustomLabel(label: "action.preview-package-app-would-be-adopted-as.\(adoptableCask.caskName)", packageToPreview: .init(name: adoptableCask.caskName, type: .cask, installedIntentionally: true))
RevealInFinderButtonWithArbitraryAction
{
URL.applicationDirectory.appendingPathComponent(adoptableCask.caskExecutable, conformingTo: .executable).revealInFinder(.openParentDirectoryAndHighlightTarget)
}
}
} }
.listStyle(.bordered(alternatesRowBackgrounds: true)) .listStyle(.bordered(alternatesRowBackgrounds: true))
} }
@ -99,3 +82,54 @@ struct AdoptablePackagesBox: View
} }
} }
} }
struct AdoptablePackageListItem: View
{
let adoptableCask: BrewPackagesTracker.AdoptableCaskComparable
var adoptableCaskAppLocation: URL
{
return URL.applicationDirectory.appendingPathComponent(adoptableCask.caskExecutable, conformingTo: .application)
}
var adoptableCaskApp: Application?
{
return try? .init(from: adoptableCaskAppLocation)
}
var body: some View
{
HStack(alignment: .center, spacing: 5)
{
if let adoptableCaskApp
{
if let adoptableCaskIcon = adoptableCaskApp.iconImage
{
adoptableCaskIcon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 35)
}
}
HStack(alignment: .firstTextBaseline, spacing: 5)
{
Text(adoptableCask.caskExecutable)
Text("(\(adoptableCask.caskName))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.contextMenu
{
PreviewPackageButtonWithCustomLabel(label: "action.preview-package-app-would-be-adopted-as.\(adoptableCask.caskName)", packageToPreview: .init(name: adoptableCask.caskName, type: .cask, installedIntentionally: true))
Button {
adoptableCaskAppLocation.revealInFinder(.openParentDirectoryAndHighlightTarget)
} label: {
Label("action.reveal-\(adoptableCask.caskExecutable)-in-finder", systemImage: "finder")
}
}
}
}

View File

@ -37,7 +37,8 @@ func corkTarget(configureWithSelfCompiled: Bool) -> ProjectDescription.Target {
.external(name: "ButtonKit"), .external(name: "ButtonKit"),
.package(product: "SwiftLintBuildToolPlugin", type: .plugin), .package(product: "SwiftLintBuildToolPlugin", type: .plugin),
.external(name: "Defaults"), .external(name: "Defaults"),
.external(name: "DefaultsMacros") .external(name: "DefaultsMacros"),
.external(name: "ApplicationInspector")
], settings: .settings(configurations: [ ], settings: .settings(configurations: [
.debug( .debug(
name: "Debug", name: "Debug",

View File

@ -25,6 +25,7 @@ let package = Package(
.package(url: "https://github.com/sindresorhus/LaunchAtLogin-Modern", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/sindresorhus/LaunchAtLogin-Modern", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/sindresorhus/Defaults", .upToNextMajor(from: "9.0.2")), .package(url: "https://github.com/sindresorhus/Defaults", .upToNextMajor(from: "9.0.2")),
.package(url: "https://github.com/buresdv/DavidFoundation", .upToNextMajor(from: "2.0.1")), .package(url: "https://github.com/buresdv/DavidFoundation", .upToNextMajor(from: "2.0.1")),
.package(url: "https://github.com/buresdv/ApplicationInspector", branch: "master"),
.package(url: "https://github.com/Dean151/ButtonKit", .upToNextMajor(from: "0.6.1")), .package(url: "https://github.com/Dean151/ButtonKit", .upToNextMajor(from: "0.6.1")),
.package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", .upToNextMajor(from: "0.56.1")), .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", .upToNextMajor(from: "0.56.1")),
], ],