From c20d68fdc6613684837da0f3ba64ed4c1c981e34 Mon Sep 17 00:00:00 2001 From: Christoffer Winterkvist Date: Wed, 16 Dec 2020 16:45:15 +0100 Subject: [PATCH] Refactor icon loading - Simplify the icon loading - Use paths instead of a model to load icons from disk - Add `PathFinderController` to make sure that we have all the correct application paths inside the application commands --- App/Sources/Application/Saloon.swift | 5 +- .../Applications/PathFinderController.swift | 36 +++++ .../Sources/Logic/CoreController.swift | 1 + .../Extensions/Command+Extensions.swift | 17 +- ViewKit/Sources/Helpers/IconController.swift | 150 ------------------ ViewKit/Sources/Helpers/IconLoader.swift | 17 +- .../Views/Commands/AppleScriptView.swift | 5 +- .../Views/Commands/ApplicationView.swift | 2 +- .../Views/Commands/KeyboardCommandView.swift | 2 +- .../Views/Commands/OpenCommandView.swift | 2 +- .../Views/Commands/ShellScriptView.swift | 2 +- .../Sources/Views/EditGroup/EditGroup.swift | 2 +- ViewKit/Sources/Views/Shared/IconView.swift | 39 +---- .../Views/WorkflowList/WorkflowListView.swift | 2 +- 14 files changed, 73 insertions(+), 209 deletions(-) create mode 100644 LogicFramework/Sources/Applications/PathFinderController.swift delete mode 100644 ViewKit/Sources/Helpers/IconController.swift diff --git a/App/Sources/Application/Saloon.swift b/App/Sources/Application/Saloon.swift index 1ced4108..f36f09ce 100644 --- a/App/Sources/Application/Saloon.swift +++ b/App/Sources/Application/Saloon.swift @@ -47,6 +47,7 @@ class Saloon: ViewKitStore, MenubarControllerDelegate { private var menuBarController: MenubarController? private var subscriptions = Set() private var loaded: Bool = false + private let pathFinderController = PathFinderController() @Published var state: ApplicationState = .launching(LaunchView()) @@ -64,7 +65,7 @@ class Saloon: ViewKitStore, MenubarControllerDelegate { return } - let groups = try storageController.load() + var groups = try storageController.load() let groupsController = Self.factory.groupsController(groups: groups) let hotKeyController = try? Self.factory.hotkeyController() let coreController = Self.factory.coreController( @@ -73,6 +74,8 @@ class Saloon: ViewKitStore, MenubarControllerDelegate { hotKeyController: hotKeyController ) + groups = pathFinderController.patch(groups, applications: coreController.installedApplications) + self.coreController = coreController let context = FeatureFactory(coreController: coreController).featureContext( diff --git a/LogicFramework/Sources/Applications/PathFinderController.swift b/LogicFramework/Sources/Applications/PathFinderController.swift new file mode 100644 index 00000000..3a24fecc --- /dev/null +++ b/LogicFramework/Sources/Applications/PathFinderController.swift @@ -0,0 +1,36 @@ +import Foundation +import ModelKit + +/// Make sure that all current path reference are correct for the persisted groups +public class PathFinderController { + public init() {} + + public func patch(_ groups: [Group], applications: [Application]) -> [Group] { + var appDictionary = [String: Application]() + for app in applications { + appDictionary[app.bundleIdentifier] = app + } + + var groups = groups + for (gOffset, group) in groups.enumerated() { + for (wOffset, workflow) in group.workflows.enumerated() { + for (cOffset, command) in workflow.commands.enumerated() { + + if case .application(let appCommand) = command, + let application = appDictionary[appCommand.application.bundleIdentifier], + application.path != appCommand.application.path { + let newCommand = Command.application(.init( + id: appCommand.id, + name: appCommand.name, + application: application + )) + + groups[gOffset].workflows[wOffset].commands[cOffset] = newCommand + } + } + } + } + + return groups + } +} diff --git a/LogicFramework/Sources/Logic/CoreController.swift b/LogicFramework/Sources/Logic/CoreController.swift index 3e92e28c..8f6d9fe8 100644 --- a/LogicFramework/Sources/Logic/CoreController.swift +++ b/LogicFramework/Sources/Logic/CoreController.swift @@ -35,6 +35,7 @@ public final class CoreController: NSObject, CoreControlling, let workflowController: WorkflowControlling let workspace: WorkspaceProviding var cache = [String: Int]() + private(set) public var installedApplications = [Application]() public var groups: [Group] { return groupsController.groups } diff --git a/ViewKit/Sources/Extensions/Command+Extensions.swift b/ViewKit/Sources/Extensions/Command+Extensions.swift index 5ada6ca0..b66d0cf4 100644 --- a/ViewKit/Sources/Extensions/Command+Extensions.swift +++ b/ViewKit/Sources/Extensions/Command+Extensions.swift @@ -1,23 +1,16 @@ import ModelKit extension Command { - var icon: Icon { - let icon: Icon + var icon: String { switch self { case .application(let applicationCommand): - icon = Icon(identifier: applicationCommand.application.bundleIdentifier, - path: applicationCommand.application.path) + return applicationCommand.application.path case .script: - icon = Icon(identifier: "com.apple.ScriptEditor2", - path: "/System/Applications/Utilities/Script Editor.app") + return "/System/Applications/Utilities/Script Editor.app" case .keyboard: - icon = Icon(identifier: "com.apple.preference.keyboard", - path: "/System/Library/PreferencePanes/Keyboard.prefPane") + return "/System/Library/PreferencePanes/Keyboard.prefPane" case .open: - icon = Icon(identifier: "com.apple.finder", - path: "/System/Library/CoreServices/Finder.app") + return "/System/Library/CoreServices/Finder.app" } - - return icon } } diff --git a/ViewKit/Sources/Helpers/IconController.swift b/ViewKit/Sources/Helpers/IconController.swift deleted file mode 100644 index 10373df6..00000000 --- a/ViewKit/Sources/Helpers/IconController.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation -import Cocoa -import ModelKit -import OSLog - -enum IconControllerError: Error { - case tiffRepresentationFailed - case bitmapImageRepFailed - case representationUsingTiffFailed - case saveImageToDestinationFailed(URL) -} - -public class IconController: ObservableObject { - fileprivate let osLog = OSLog(subsystem: "com.zenangst.Keyboard-Cowboy", - category: String(describing: IconController.self)) - @Published var icon: NSImage? - public static var installedApplications = [Application]() - private(set) public static var cache = NSCache() - private let queue: DispatchQueue = .init(label: "com.zenangst.Keyboard-Cowboy.IconController", - qos: .userInteractive) - private let fileManager: FileManager - private let workspace: NSWorkspace - - init(fileManager: FileManager = .init(), - workspace: NSWorkspace = .shared) { - self.fileManager = fileManager - self.workspace = workspace - } - - public static func clearAll() { - cache.removeAllObjects() - } - - public func preLoadIcon(identifier: String, at path: String, size: CGSize) { - os_signpost(.begin, log: osLog, name: #function) - defer { os_signpost(.end, log: osLog, name: #function) } - let cacheKey = "\(identifier)_\(size.string)" as NSString - guard let image = Self.cache.object(forKey: cacheKey) else { - return - } - self.icon = image - } - - public func loadIcon(identifier: String, at path: String, size: CGSize) { - queue.async { [weak self] in - self?._loadIcon(identifier: identifier, at: path, size: size) - } - } - - public func _loadIcon(identifier: String, at path: String, size: CGSize) { - let cacheKey = "\(identifier)_\(size.string)" as NSString - os_signpost(.begin, log: osLog, name: "loadIcon") - defer { os_signpost(.end, log: osLog, name: "loadIcon") } - if let image = Self.cache.object(forKey: cacheKey) { - commit(image) - return - } else if let cachedImage = loadImageFromDisk(withFilename: identifier, size: size) { - Self.cache.setObject(cachedImage, forKey: cacheKey) - commit(cachedImage) - return - } - - var applicationPath = path - - if let application = Self.installedApplications - .first(where: { $0.bundleIdentifier.lowercased() == identifier.lowercased() }) { - applicationPath = application.path - } - - var image = self.workspace.icon(forFile: applicationPath) - var imageRect: CGRect = .init(origin: .zero, size: size) - let imageRef = image.cgImage(forProposedRect: &imageRect, context: nil, hints: nil) - - if let imageRef = imageRef { - image = NSImage(cgImage: imageRef, size: imageRect.size) - } - - try? self.saveImageToDisk(image, withFilename: identifier, size: size) - Self.cache.setObject(image, forKey: cacheKey) - self.commit(image) - } - - private func commit(_ image: NSImage) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.icon = image - } - } - - private func tiffDataFromImage(_ image: NSImage) throws -> Data { - guard let tiff = image.tiffRepresentation else { throw IconControllerError.tiffRepresentationFailed } - guard let imgRep = NSBitmapImageRep(data: tiff) else { throw IconControllerError.bitmapImageRepFailed } - guard let data = imgRep.representation(using: .png, properties: [:]) else { - throw IconControllerError.representationUsingTiffFailed - } - - return data - } - - private func loadImageFromDisk(withFilename filename: String, size: CGSize) -> NSImage? { - guard let applicationFile = try? applicationCacheDirectory() - .appendingPathComponent("\(filename)_\(size.string).png"), - FileManager.default.fileExists(atPath: applicationFile.path) else { - return nil - } - - return NSImage(contentsOfFile: applicationFile.path) - } - - func saveImage(_ image: NSImage, to destination: URL, override: Bool = false) throws { - let data = try tiffDataFromImage(image) - do { - if fileManager.fileExists(atPath: destination.path), override == false { - try fileManager.removeItem(at: destination) - } - try data.write(to: destination) - } catch { - throw IconControllerError.saveImageToDestinationFailed(destination) - } - } - - private func saveImageToDisk(_ image: NSImage, withFilename fileName: String, size: CGSize) throws { - let applicationFile = try applicationCacheDirectory() - .appendingPathComponent("\(fileName)_\(size.string).png") - try saveImage(image, to: applicationFile) - } - - private func applicationCacheDirectory() throws -> URL { - let url = try FileManager.default.url(for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true) - .appendingPathComponent(Bundle.main.bundleIdentifier!) - .appendingPathComponent("IconCache") - - if !FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.createDirectory(at: url, - withIntermediateDirectories: true, - attributes: nil) - } - - return url - } -} - -private extension CGSize { - var string: String { - return "\(Int(width))x\(Int(height))" - } -} diff --git a/ViewKit/Sources/Helpers/IconLoader.swift b/ViewKit/Sources/Helpers/IconLoader.swift index 3b0b3a9a..457d820f 100644 --- a/ViewKit/Sources/Helpers/IconLoader.swift +++ b/ViewKit/Sources/Helpers/IconLoader.swift @@ -1,7 +1,18 @@ +import Cocoa import SwiftUI -public protocol IconLoader: ObservableObject where ObjectWillChangePublisher.Output == Void { - associatedtype State +class IconLoader: ObservableObject { + @Published public private(set) var image: Image? - var icon: State { get } + func load(_ path: String) { + image = Image(nsImage: NSWorkspace.shared.icon(forFile: path)) + } + + func cancel() { + image = nil + } + + deinit { + cancel() + } } diff --git a/ViewKit/Sources/Views/Commands/AppleScriptView.swift b/ViewKit/Sources/Views/Commands/AppleScriptView.swift index 6a9164b4..0a2fe062 100644 --- a/ViewKit/Sources/Views/Commands/AppleScriptView.swift +++ b/ViewKit/Sources/Views/Commands/AppleScriptView.swift @@ -11,10 +11,7 @@ struct AppleScriptView: View { var body: some View { HStack { ZStack { - IconView(icon: Icon( - identifier: "script-editor-file", - path: "/System/Applications/Utilities/Script Editor.app/Contents/Resources/script-editor-dummy.scptd") - ) + IconView(path: "/System/Applications/Utilities/Script Editor.app/Contents/Resources/script-editor-dummy.scptd") PlayArrowView() }.frame(width: 32, height: 32) diff --git a/ViewKit/Sources/Views/Commands/ApplicationView.swift b/ViewKit/Sources/Views/Commands/ApplicationView.swift index ca13a8ab..ac2ed452 100644 --- a/ViewKit/Sources/Views/Commands/ApplicationView.swift +++ b/ViewKit/Sources/Views/Commands/ApplicationView.swift @@ -11,7 +11,7 @@ struct ApplicationView: View { var body: some View { HStack { if case .application(let model) = command { - IconView(icon: Icon(identifier: model.application.bundleIdentifier, path: model.application.path)) + IconView(path: model.application.path) .frame(width: 32, height: 32) } VStack(alignment: .leading, spacing: 2) { diff --git a/ViewKit/Sources/Views/Commands/KeyboardCommandView.swift b/ViewKit/Sources/Views/Commands/KeyboardCommandView.swift index 881de292..190fec66 100644 --- a/ViewKit/Sources/Views/Commands/KeyboardCommandView.swift +++ b/ViewKit/Sources/Views/Commands/KeyboardCommandView.swift @@ -9,7 +9,7 @@ struct KeyboardCommandView: View { var body: some View { HStack { ZStack { - IconView(icon: Icon(identifier: "keyboard-shortcut", path: "/System/Library/PreferencePanes/Keyboard.prefPane")) + IconView(path: "/System/Library/PreferencePanes/Keyboard.prefPane") .frame(width: 32, height: 32) } VStack(alignment: .leading, spacing: 0) { diff --git a/ViewKit/Sources/Views/Commands/OpenCommandView.swift b/ViewKit/Sources/Views/Commands/OpenCommandView.swift index 9df26a32..193c4f14 100644 --- a/ViewKit/Sources/Views/Commands/OpenCommandView.swift +++ b/ViewKit/Sources/Views/Commands/OpenCommandView.swift @@ -11,7 +11,7 @@ struct OpenCommandView: View { HStack { ZStack { if case .open(let openCommand) = command { - IconView(icon: Icon(identifier: openCommand.path, path: openCommand.application?.path ?? "")) + IconView(path: openCommand.application?.path ?? "") } }.frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 0) { diff --git a/ViewKit/Sources/Views/Commands/ShellScriptView.swift b/ViewKit/Sources/Views/Commands/ShellScriptView.swift index 2405e0c6..8161ef4d 100644 --- a/ViewKit/Sources/Views/Commands/ShellScriptView.swift +++ b/ViewKit/Sources/Views/Commands/ShellScriptView.swift @@ -11,7 +11,7 @@ struct ShellScriptView: View { var body: some View { HStack { ZStack { - IconView(icon: Icon(identifier: "shell-script-file", path: "/System/Applications/Utilities/Terminal.app")) + IconView(path: "/System/Applications/Utilities/Terminal.app") .frame(width: 32, height: 32) PlayArrowView() } diff --git a/ViewKit/Sources/Views/EditGroup/EditGroup.swift b/ViewKit/Sources/Views/EditGroup/EditGroup.swift index d88039bf..d0d0aa43 100644 --- a/ViewKit/Sources/Views/EditGroup/EditGroup.swift +++ b/ViewKit/Sources/Views/EditGroup/EditGroup.swift @@ -130,7 +130,7 @@ private extension EditGroup { ForEach(installedApplications.filter({ bundleIdentifiers.contains($0.bundleIdentifier) }), id: \.id) { application in VStack(spacing: 0) { HStack { - IconView(icon: Icon(identifier: application.bundleIdentifier, path: application.path)) + IconView(path: application.path) Text(application.displayName) Spacer() Button("-", action: { diff --git a/ViewKit/Sources/Views/Shared/IconView.swift b/ViewKit/Sources/Views/Shared/IconView.swift index 489ee50d..116c7503 100644 --- a/ViewKit/Sources/Views/Shared/IconView.swift +++ b/ViewKit/Sources/Views/Shared/IconView.swift @@ -1,38 +1,13 @@ import SwiftUI -struct Icon: Hashable, Identifiable { - let identifier: String - let path: String - let id: String - - init(identifier: String, path: String) { - self.identifier = identifier - self.path = path - self.id = identifier + path - } -} - struct IconView: View { - @ObservedObject var iconLoader = IconController() - - let icon: Icon - - init(icon: Icon) { - self.icon = icon - iconLoader.preLoadIcon(identifier: icon.identifier, at: icon.path, size: CGSize(width: 32, height: 32)) - } + let path: String + @StateObject var iconLoader = IconLoader() var body: some View { ZStack { - if iconLoader.icon != nil { - Image(nsImage: iconLoader.icon!) - } - }.onAppear { - if iconLoader.icon == nil { - iconLoader.loadIcon(identifier: icon.identifier, at: icon.path, size: CGSize(width: 32, height: 32)) - } - } - .frame(width: 32, height: 32) + iconLoader.image + }.onAppear { iconLoader.load(path) } } } @@ -43,10 +18,8 @@ struct IconView_Previews: PreviewProvider, TestPreviewProvider { static var testPreview: some View { Group { - IconView(icon: Icon(identifier: "com.apple.Finder", - path: "/System/Library/CoreServices/Finder.app")) - IconView(icon: Icon(identifier: "keyboard", - path: "/System/Library/PreferencePanes/Keyboard.prefPane")) + IconView(path: "/System/Library/CoreServices/Finder.app") + IconView(path: "/System/Library/PreferencePanes/Keyboard.prefPane") }.frame(width: 48, height: 48) } } diff --git a/ViewKit/Sources/Views/WorkflowList/WorkflowListView.swift b/ViewKit/Sources/Views/WorkflowList/WorkflowListView.swift index 2328c489..2f7f0957 100644 --- a/ViewKit/Sources/Views/WorkflowList/WorkflowListView.swift +++ b/ViewKit/Sources/Views/WorkflowList/WorkflowListView.swift @@ -63,7 +63,7 @@ private extension WorkflowListView { ? workflow.commands.count > 1 ? 0.9 + ( 0.05 * cgIndex) : 1.0 : 1.0 - IconView(icon: command.icon).frame(width: 48, height: 48) + IconView(path: command.icon).frame(width: 48, height: 48) .scaleEffect(scale, anchor: .center) .offset(x: isHovering ? multiplier : 0, y: isHovering ? multiplier : 0)