diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 3148fdba..d668fa2d 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 4CD883A42C30F0D7009A132A /* WallpaperColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD883A32C30F0D7009A132A /* WallpaperColors.swift */; }; A8055EC22AFEDE0B00459D13 /* Keycorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8055EC12AFEDE0B00459D13 /* Keycorder.swift */; }; A80900D52AA3F9F30085C63B /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80900D32AA3F9F20085C63B /* VisualEffectView.swift */; }; - A80D49BB2BAE479900493B67 /* WindowAction+Port.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D49BA2BAE479900493B67 /* WindowAction+Port.swift */; }; + A80D49BB2BAE479900493B67 /* Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D49BA2BAE479900493B67 /* Migrator.swift */; }; A81B98182BDC854F005FD78C /* AboutConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81B98172BDC854F005FD78C /* AboutConfiguration.swift */; }; A81D8D0A2C068B8700188E12 /* LuminarePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81D8D092C068B8700188E12 /* LuminarePreviewView.swift */; }; A81D8D0C2C06950000188E12 /* LuminareManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81D8D0B2C06950000188E12 /* LuminareManager.swift */; }; @@ -103,7 +103,7 @@ A80521312A84878200BF7E22 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; A8055EC12AFEDE0B00459D13 /* Keycorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keycorder.swift; sourceTree = ""; }; A80900D32AA3F9F20085C63B /* VisualEffectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; - A80D49BA2BAE479900493B67 /* WindowAction+Port.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowAction+Port.swift"; sourceTree = ""; }; + A80D49BA2BAE479900493B67 /* Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Migrator.swift; sourceTree = ""; }; A80FB1EB2C99152300139B4A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; A81B98172BDC854F005FD78C /* AboutConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutConfiguration.swift; sourceTree = ""; }; A81D8D092C068B8700188E12 /* LuminarePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LuminarePreviewView.swift; sourceTree = ""; }; @@ -225,6 +225,7 @@ A8D4327A2C13ED3C007BE4F2 /* Icon.swift */, A867C20D2C26522B005831BC /* Observer.swift */, A8D6D3002B6C894C0061B11F /* PaddingModel.swift */, + A80D49BA2BAE479900493B67 /* Migrator.swift */, 4C0F2ACE2C3CFD09006CB34D /* RectangleTranslationLayer.swift */, A86B97AC2AB79E2500099D7F /* ShakeEffect.swift */, A80900D32AA3F9F20085C63B /* VisualEffectView.swift */, @@ -305,7 +306,6 @@ A8BC4A6D2C2F4C9800B94B82 /* Window.swift */, A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */, A8F0125A2AEDD7660017307F /* WindowAction.swift */, - A80D49BA2BAE479900493B67 /* WindowAction+Port.swift */, 0A6DC3EA2BB869DE002AB05F /* WindowAction+Image.swift */, A8330AD32A3AC27600673C8D /* WindowDirection.swift */, A85DDBD92C1693D4008C103D /* WindowDirection+Snapping.swift */, @@ -588,7 +588,7 @@ A82DDBDE2AEC736300D7F974 /* AnimationConfiguration.swift in Sources */, A8789F6729805B190040512E /* RadialMenuView.swift in Sources */, A8330ABD2A3AC0CA00673C8D /* Bundle+Extensions.swift in Sources */, - A80D49BB2BAE479900493B67 /* WindowAction+Port.swift in Sources */, + A80D49BB2BAE479900493B67 /* Migrator.swift in Sources */, 4C0F2ACF2C3CFD09006CB34D /* RectangleTranslationLayer.swift in Sources */, A8D4327B2C13ED3C007BE4F2 /* Icon.swift in Sources */, A86B97AD2AB79E2500099D7F /* ShakeEffect.swift in Sources */, diff --git a/Loop/Extensions/Notification+Extensions.swift b/Loop/Extensions/Notification+Extensions.swift index 2f6ca5ca..e088c771 100644 --- a/Loop/Extensions/Notification+Extensions.swift +++ b/Loop/Extensions/Notification+Extensions.swift @@ -15,7 +15,6 @@ extension Notification.Name { static let activeStateChanged = Notification.Name("activeStateChanged") static let systemWindowManagerStateChanged = Notification.Name("systemWindowManagerStateChanged") - static let keybindsUpdated = Notification.Name("keybindsUpdated") static let didImportKeybindsSuccessfully = Notification.Name("didImportKeybindsSuccessfully") static let didExportKeybindsSuccessfully = Notification.Name("didExportKeybindsSuccessfully") diff --git a/Loop/Luminare/Loop/AdvancedConfiguration.swift b/Loop/Luminare/Loop/AdvancedConfiguration.swift index 16a1a8e1..cf19dbb3 100644 --- a/Loop/Luminare/Loop/AdvancedConfiguration.swift +++ b/Loop/Luminare/Loop/AdvancedConfiguration.swift @@ -157,7 +157,13 @@ struct AdvancedConfigurationView: View { LuminareSection("Keybinds") { HStack(spacing: 2) { Button { - WindowAction.importPrompt() + Task { + do { + try await Migrator.importPrompt() + } catch { + print("Error importing keybinds: \(error)") + } + } } label: { HStack { Text("Import") @@ -174,7 +180,13 @@ struct AdvancedConfigurationView: View { } Button { - WindowAction.exportPrompt() + Task { + do { + try await Migrator.exportPrompt() + } catch { + print("Error exporting keybinds: \(error)") + } + } } label: { HStack { Text("Export") @@ -193,7 +205,6 @@ struct AdvancedConfigurationView: View { Button { Defaults.reset(.keybinds) model.resetSuccessfully() - Notification.Name.keybindsUpdated.post() } label: { HStack { Text("Reset") diff --git a/Loop/Luminare/Settings/Keybindings/KeybindsConfigurationView.swift b/Loop/Luminare/Settings/Keybindings/KeybindsConfigurationView.swift index 775b99da..f72200f5 100644 --- a/Loop/Luminare/Settings/Keybindings/KeybindsConfigurationView.swift +++ b/Loop/Luminare/Settings/Keybindings/KeybindsConfigurationView.swift @@ -10,10 +10,6 @@ import Luminare import SwiftUI class KeybindsConfigurationModel: ObservableObject { - @Published var triggerKey = Defaults[.triggerKey] { - didSet { Defaults[.triggerKey] = triggerKey } - } - @Published var triggerDelay = Defaults[.triggerDelay] { didSet { Defaults[.triggerDelay] = triggerDelay } } @@ -26,10 +22,6 @@ class KeybindsConfigurationModel: ObservableObject { didSet { Defaults[.middleClickTriggersLoop] = middleClickTriggersLoop } } - @Published var keybinds = Defaults[.keybinds] { - didSet { Defaults[.keybinds] = keybinds } - } - @Published var currentEventMonitor: NSEventMonitor? @Published var selectedKeybinds = Set() } @@ -37,10 +29,13 @@ class KeybindsConfigurationModel: ObservableObject { struct KeybindsConfigurationView: View { @StateObject private var model = KeybindsConfigurationModel() + @Default(.triggerKey) var triggerKey + @Default(.keybinds) var keybinds + var body: some View { LuminareSection("Trigger Key", noBorder: true) { // TODO: Make long trigger keys fit in bounds - TriggerKeycorder($model.triggerKey) + TriggerKeycorder($triggerKey) .environmentObject(model) } @@ -61,12 +56,10 @@ struct KeybindsConfigurationView: View { LuminareList( "Keybinds", - items: $model.keybinds, + items: $keybinds, selection: $model.selectedKeybinds, addAction: { - model.keybinds.insert(.init(.noAction), at: 0) - // Post a notification that the keybinds have been updated - NotificationCenter.default.post(name: .keybindsUpdated, object: nil) + keybinds.insert(.init(.noAction), at: 0) }, content: { keybind in KeybindItemView(keybind) @@ -90,8 +83,5 @@ struct KeybindsConfigurationView: View { addText: "Add", removeText: "Remove" ) - .onReceive(.keybindsUpdated) { _ in - model.keybinds = Defaults[.keybinds] - } } } diff --git a/Loop/Utilities/Migrator.swift b/Loop/Utilities/Migrator.swift new file mode 100644 index 00000000..1c509d7e --- /dev/null +++ b/Loop/Utilities/Migrator.swift @@ -0,0 +1,370 @@ +// +// Migrator.swift +// Loop +// +// Created by Kai Azim on 2024-03-22. +// + +import Defaults +import SwiftUI + +// MARK: - Saved Keybinds Format + +/// Struct to represent the JSON contents of a Loop keybinds file. +struct SavedKeybindsFormat: Codable { + let version: String? + let triggerKey: Set? + let actions: [SavedWindowActionFormat] + + static func generateFromDefaults() -> SavedKeybindsFormat { + SavedKeybindsFormat( + version: Bundle.main.appVersion, + triggerKey: Defaults[.triggerKey], + actions: Defaults[.keybinds].map { SavedWindowActionFormat($0) } + ) + } +} + +// MARK: - SavedWindowActionFormat + +/// Struct to define the format of saved window actions. +struct SavedWindowActionFormat: Codable { + let direction: WindowDirection + let keybind: Set + let name: String? + let unit: CustomWindowActionUnit? + let anchor: CustomWindowActionAnchor? + let sizeMode: CustomWindowActionSizeMode? + let width: Double? + let height: Double? + let positionMode: CustomWindowActionPositionMode? + let xPoint: Double? + let yPoint: Double? + let cycle: [SavedWindowActionFormat]? + + /// Initialize from a WindowAction. + init(_ action: WindowAction) { + self.direction = action.direction + self.keybind = action.keybind + self.name = action.name + self.unit = action.unit + self.anchor = action.anchor + self.sizeMode = action.sizeMode + self.width = action.width + self.height = action.height + self.positionMode = action.positionMode + self.xPoint = action.xPoint + self.yPoint = action.yPoint + self.cycle = action.cycle?.map { SavedWindowActionFormat($0) } + } + + /// Converts the saved format back into a usable WindowAction object. + func convertToWindowAction() -> WindowAction { + WindowAction( + direction, + keybind: keybind, + name: name, + unit: unit, + anchor: anchor, + width: width, + height: height, + xPoint: xPoint, + yPoint: yPoint, + positionMode: positionMode, + sizeMode: sizeMode, + cycle: cycle?.map { $0.convertToWindowAction() + } + ) + } +} + +// MARK: - Migrator + +enum MigratorError: Error { + case keybindsEmpty + case failedToConvertToString + case mainWindowNotAvailableForPanel + case fileSelectionCancelled + case directorySelectionCancelled + case failedToReadFile + + var localizedDescription: String { + switch self { + case .keybindsEmpty: + "Keybinds are empty." + case .failedToConvertToString: + "Failed to convert keybinds to string." + case .mainWindowNotAvailableForPanel: + "Main window not available for panel." + case .fileSelectionCancelled: + "File selection was cancelled." + case .directorySelectionCancelled: + "Directory selection was cancelled." + case .failedToReadFile: + "Failed to read file." + } + } +} + +// Adds functionality for saving, loading, and managing window actions. +enum Migrator { + /// Presents a prompt to export current keybinds to a JSON file. + static func exportPrompt() async throws { + // Check if there are any keybinds to export. + guard !Defaults[.keybinds].isEmpty else { + showAlert( + .init( + localized: "Export empty keybinds alert title", + defaultValue: "No Keybinds Have Been Set" + ), + informativeText: .init( + localized: "Export empty keybinds alert description", + defaultValue: "You can't export something that doesn't exist!" + ) + ) + + throw MigratorError.keybindsEmpty + } + + let directoryURL = try await getSaveDirectoryURL() + let keybinds = SavedKeybindsFormat.generateFromDefaults() + try await saveKeybinds(keybinds, in: directoryURL) + + Notification.Name.didExportKeybindsSuccessfully.post() + } + + /// Presents a prompt to import keybinds from a JSON file. + static func importPrompt() async throws { + let fileURL = try await getKeybindsFileURL() + let jsonString = try String(contentsOf: fileURL) + + do { + try await importKeybinds(from: jsonString) + } catch { + if case MigratorError.failedToReadFile = error { + showAlert( + .init( + localized: "Error reading keybinds alert title", + defaultValue: "Error Reading Keybinds" + ), + informativeText: .init( + localized: "Error reading keybinds alert description", + defaultValue: "Make sure the file you selected is in the correct format." + ) + ) + } else { + throw error + } + } + } +} + +// MARK: Migrator + Export + +private extension Migrator { + @MainActor + static func getSaveDirectoryURL() async throws -> URL { + let savePanel = NSSavePanel() + savePanel.directoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + savePanel.title = .init(localized: "Export keybinds") + savePanel.nameFieldStringValue = "Loop Keybinds.json" + + guard let window = NSApplication.shared.mainWindow else { + throw MigratorError.mainWindowNotAvailableForPanel + } + + let result = await savePanel.beginSheetModal(for: window) + + guard result == .OK, let destUrl = savePanel.url else { + throw MigratorError.directorySelectionCancelled + } + + return destUrl + } + + static func saveKeybinds(_: SavedKeybindsFormat, in directoryURL: URL) async throws { + let keybinds = SavedKeybindsFormat.generateFromDefaults() + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + let data = try encoder.encode(keybinds) + + guard let json = String(data: data, encoding: .utf8) else { + throw MigratorError.failedToConvertToString + } + + try json.write( + to: directoryURL, + atomically: true, + encoding: .utf8 + ) + } +} + +// MARK: Migrator + Import + +private extension Migrator { + /// Presents a file picker to select a keybinds file. + @MainActor + static func getKeybindsFileURL() async throws -> URL { + let openPanel = NSOpenPanel() + openPanel.title = .init(localized: "Select a keybinds file") + openPanel.allowedContentTypes = [.json] + + guard let window = NSApplication.shared.mainWindow else { + throw MigratorError.mainWindowNotAvailableForPanel + } + + let result = await openPanel.beginSheetModal(for: window) + + guard result == .OK, let selectedFileURL = openPanel.url else { + throw MigratorError.fileSelectionCancelled + } + + return selectedFileURL + } + + /// Imports keybinds from a JSON string. + static func importKeybinds(from jsonString: String) async throws { + guard let data = jsonString.data(using: .utf8) else { + throw MigratorError.failedToReadFile + } + + /// First, try to import the general Loop keybinds format. + do { + let savedData = try await importLoopKeybinds(from: data) + await updateDefaults(with: savedData) + return + } catch { + print("Error importing Loop keybinds: \(error)") + } + + /// If that fails, try to import the old Loop (pre 1.2.0) keybinds format. + do { + let savedData = try await importLoopLegacyKeybinds(from: data) + await updateDefaults(with: savedData) + return + } catch { + print("Error importing Loop (pre 1.2.0) keybinds: \(error)") + } + + /// If that fails, try to import the Rectangle keybinds format. + do { + let savedData = try await importRectangleKeybinds(from: data) + await updateDefaults(with: savedData) + return + } catch { + print("Error importing Rectangle keybinds: \(error)") + } + + // If all attempts fail, show an error alert. + throw MigratorError.failedToReadFile + } + + /// Tries to import Loop's keybinds format. + static func importLoopKeybinds(from data: Data) async throws -> SavedKeybindsFormat { + let decoder = JSONDecoder() + let keybinds = try decoder.decode(SavedKeybindsFormat.self, from: data) + return keybinds + } + + /// Tries to import Loop's old (pre 1.2.0) keybinds format. + static func importLoopLegacyKeybinds(from data: Data) async throws -> SavedKeybindsFormat { + let decoder = JSONDecoder() + let keybinds = try decoder.decode([SavedWindowActionFormat].self, from: data) + return SavedKeybindsFormat(version: nil, triggerKey: nil, actions: keybinds) + } + + /// Tries to import Rectangle's keybinds format. + static func importRectangleKeybinds(from data: Data) async throws -> SavedKeybindsFormat { + let keybinds = try RectangleTranslationLayer.importKeybinds(from: data) + return SavedKeybindsFormat(version: nil, triggerKey: nil, actions: keybinds) + } + + // MARK: Saving Imports + + /// Updates the app's defaults with the imported keybinds. + static func updateDefaults(with savedData: SavedKeybindsFormat) async { + if let triggerKey = savedData.triggerKey { + Defaults[.triggerKey] = triggerKey + } + + if Defaults[.keybinds].isEmpty { + Defaults[.keybinds] = savedData.actions.map { $0.convertToWindowAction() } + + // Post a notification after updating the keybinds + Notification.Name.didImportKeybindsSuccessfully.post() + } else { + let result = await showAlertForImportDecision() + + switch result { + case .merge: + let newKeybinds = savedData.actions + .map { $0.convertToWindowAction() } + .filter { newKeybind in + !Defaults[.keybinds].contains { $0.keybind == newKeybind.keybind && $0.name == newKeybind.name } + } + + Defaults[.keybinds].append(contentsOf: newKeybinds) + + // Post a notification after updating the keybinds + Notification.Name.didImportKeybindsSuccessfully.post() + case .erase: + Defaults[.keybinds] = savedData.actions.map { $0.convertToWindowAction() } + + // Post a notification after updating the keybinds + Notification.Name.didImportKeybindsSuccessfully.post() + case .cancel: + // No action needed, no notification should be posted + break + } + } + } + + /// Presents a decision alert for how to handle imported keybinds. + static func showAlertForImportDecision() async -> ImportDecision { + await withCheckedContinuation { continuation in + showAlert( + .init(localized: "Import Keybinds"), + informativeText: .init(localized: "Do you want to merge or erase existing keybinds?"), + buttons: [ + .init(localized: "Import keybinds: merge", defaultValue: "Merge"), + .init(localized: "Import keybinds: erase", defaultValue: "Erase"), + .init(localized: "Import keybinds: cancel", defaultValue: "Cancel") + ] + ) { response in + switch response { + case .alertFirstButtonReturn: + continuation.resume(returning: .merge) + case .alertSecondButtonReturn: + continuation.resume(returning: .erase) + default: + continuation.resume(returning: .cancel) + } + } + } + } + + /// Utility function to show an alert with a completion handler. + static func showAlert( + _ messageText: String, + informativeText: String, + buttons: [String] = [], + completion: ((NSApplication.ModalResponse) -> ())? = nil + ) { + let alert = NSAlert() + alert.messageText = messageText + alert.informativeText = informativeText + buttons.forEach { alert.addButton(withTitle: $0) } + if let completion { + alert.beginSheetModal(for: NSApplication.shared.mainWindow!, completionHandler: completion) + } else { + alert.runModal() + } + } + + /// Enum to represent the decision made in the import decision alert. + enum ImportDecision { + case merge, erase, cancel + } +} diff --git a/Loop/Utilities/RectangleTranslationLayer.swift b/Loop/Utilities/RectangleTranslationLayer.swift index ebf91d9d..ed27ac6c 100644 --- a/Loop/Utilities/RectangleTranslationLayer.swift +++ b/Loop/Utilities/RectangleTranslationLayer.swift @@ -9,18 +9,6 @@ import AppKit import Defaults import Foundation -/// Represents an error that can occur in the RectangleTranslationLayer. -enum RectangleTranslationLayerError: Error { - case dataLoadFailed - - var localizedString: String { - switch self { - case .dataLoadFailed: - "Failed to convert string to data." - } - } -} - /// Represents a keyboard shortcut configuration for a Rectangle action. struct RectangleShortcut: Codable { let keyCode: Int @@ -57,29 +45,25 @@ enum RectangleTranslationLayer { /// Imports the keybinds from a JSON string. /// - Parameter jsonString: The JSON string to import the keybinds from. /// - Returns: An array of WindowAction instances corresponding to the keybinds. - static func importKeybinds(from jsonString: String) throws -> [WindowAction] { - guard let data = jsonString.data(using: .utf8) else { - throw RectangleTranslationLayerError.dataLoadFailed - } - + static func importKeybinds(from data: Data) throws -> [SavedWindowActionFormat] { let rectangleConfig = try JSONDecoder().decode(RectangleConfig.self, from: data) - let windowActions = translateRectangleConfigToWindowActions(rectangleConfig: rectangleConfig) - return windowActions - } - - /// Translates the RectangleConfig to an array of WindowActions for Loop. - /// - Parameter rectangleConfig: The RectangleConfig instance to translate. - /// - Returns: An array of WindowAction instances corresponding to the RectangleConfig. - private static func translateRectangleConfigToWindowActions(rectangleConfig: RectangleConfig) -> [WindowAction] { // Converts the Rectangle shortcuts into Loop's WindowActions. - rectangleConfig.shortcuts.compactMap { direction, shortcut in - guard let loopDirection = directionMapping[direction], !direction.contains("Todo") else { return nil } - return WindowAction( + let windowActions: [SavedWindowActionFormat] = rectangleConfig.shortcuts.compactMap { direction, shortcut in + guard + let loopDirection = directionMapping[direction], + !direction.contains("Todo") + else { + return nil + } + + return SavedWindowActionFormat(.init( loopDirection, keybind: Set([CGKeyCode(shortcut.keyCode)]), // Converts the integer keyCode to CGKeyCode. name: direction.capitalized.replacingOccurrences(of: " ", with: "") + "Cycle" - ) + )) } + + return windowActions } } diff --git a/Loop/Window Management/WindowAction+Port.swift b/Loop/Window Management/WindowAction+Port.swift deleted file mode 100644 index e2e24e50..00000000 --- a/Loop/Window Management/WindowAction+Port.swift +++ /dev/null @@ -1,237 +0,0 @@ -// -// WindowAction+Port.swift -// Loop -// -// Created by Kai Azim on 2024-03-22. -// - -import Defaults -import SwiftUI - -/// Extension of WindowAction to add functionality for saving, loading, and managing window actions. -extension WindowAction { - /// Nested struct to define the format of saved window actions. - private struct SavedWindowActionFormat: Codable { - // Properties representing the details of a window action. - var direction: WindowDirection - var keybind: Set - var name: String? - var unit: CustomWindowActionUnit? - var anchor: CustomWindowActionAnchor? - var sizeMode: CustomWindowActionSizeMode? - var width: Double? - var height: Double? - var positionMode: CustomWindowActionPositionMode? - var xPoint: Double? - var yPoint: Double? - var cycle: [SavedWindowActionFormat]? - - /// Converts the saved format back into a usable WindowAction object. - func convertToWindowAction() -> WindowAction { - WindowAction(direction, keybind: keybind, name: name, unit: unit, anchor: anchor, width: width, height: height, xPoint: xPoint, yPoint: yPoint, positionMode: positionMode, sizeMode: sizeMode, cycle: cycle?.map { $0.convertToWindowAction() }) - } - } - - /// Converts a WindowAction object into the saved format. - private func convertToSavedWindowActionFormat() -> SavedWindowActionFormat { - SavedWindowActionFormat(direction: direction, keybind: keybind, name: name, unit: unit, anchor: anchor, sizeMode: sizeMode, width: width, height: height, positionMode: positionMode, xPoint: xPoint, yPoint: yPoint, cycle: cycle?.map { $0.convertToSavedWindowActionFormat() }) - } - - // MARK: Export - - /// Presents a prompt to export current keybinds to a JSON file. - static func exportPrompt() { - // Check if there are any keybinds to export. - guard !Defaults[.keybinds].isEmpty else { - showAlert( - .init( - localized: "Export empty keybinds alert title", - defaultValue: "No Keybinds Have Been Set" - ), - informativeText: .init( - localized: "Export empty keybinds alert description", - defaultValue: "You can't export something that doesn't exist!" - ) - ) - return - } - - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let exportKeybinds = Defaults[.keybinds].map { $0.convertToSavedWindowActionFormat() } - let keybindsData = try encoder.encode(exportKeybinds) - if let json = String(data: keybindsData, encoding: .utf8) { - attemptSave(of: json) - } - } catch { - print("Error encoding keybinds: \(error.localizedDescription)") - } - } - - /// Attempts to save the exported JSON string to a file. - private static func attemptSave(of keybindsData: String) { - guard let data = keybindsData.data(using: .utf8) else { return } - let savePanel = NSSavePanel() - savePanel.directoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first - savePanel.title = .init(localized: "Export keybinds") - savePanel.nameFieldStringValue = "Loop Keybinds.json" - savePanel.allowedContentTypes = [.json] - - savePanel.beginSheetModal(for: NSApplication.shared.mainWindow!) { result in - guard result == .OK, let destUrl = savePanel.url else { return } - do { - try data.write(to: destUrl) - Notification.Name.didExportKeybindsSuccessfully.post() - } catch { - print("Error writing to file: \(error.localizedDescription)") - } - } - } - - // MARK: Import - - /// Presents a prompt to import keybinds from a JSON file. - static func importPrompt() { - let openPanel = NSOpenPanel() - openPanel.title = .init(localized: "Select a keybinds file") - openPanel.allowedContentTypes = [.json] - - openPanel.beginSheetModal(for: NSApplication.shared.mainWindow!) { result in - guard result == .OK, let selectedFileURL = openPanel.url else { return } - do { - let jsonString = try String(contentsOf: selectedFileURL) - importKeybinds(from: jsonString) - } catch { - print("Error reading file: \(error.localizedDescription)") - } - } - } - - /// Imports keybinds from a JSON string. - private static func importKeybinds(from jsonString: String) { - if importLoopKeybinds(from: jsonString) { return } - if importRectangleKeybinds(from: jsonString) { return } - - // If both attempts fail, show an error alert. - showAlert( - .init( - localized: "Error reading keybinds alert title", - defaultValue: "Error Reading Keybinds" - ), - informativeText: .init( - localized: "Error reading keybinds alert description", - defaultValue: "Make sure the file you selected is in the correct format." - ) - ) - } - - /// Tries to import Loop's keybinds format. - private static func importLoopKeybinds(from jsonString: String) -> Bool { - print("Attempting to import Loop keybinds...") - - guard let data = jsonString.data(using: .utf8) else { return false } - - do { - let decoder = JSONDecoder() - let importedKeybinds = try decoder.decode([SavedWindowActionFormat].self, from: data) - let windowActions = importedKeybinds.map { $0.convertToWindowAction() } - updateDefaults(with: windowActions) - return true - } catch { - return false - } - } - - /// Tries to import Rectangle's keybinds format. - private static func importRectangleKeybinds(from jsonString: String) -> Bool { - print("Attempting to import Rectangle keybinds...") - - do { - let importedKeybinds = try RectangleTranslationLayer.importKeybinds(from: jsonString) - updateDefaults(with: importedKeybinds) - return true - } catch { - return false - } - } - - /// Updates the app's defaults with the imported keybinds. - private static func updateDefaults(with actions: [WindowAction]) { - if Defaults[.keybinds].isEmpty { - Defaults[.keybinds] = actions - - // Post a notification after updating the keybinds - Notification.Name.keybindsUpdated.post() - Notification.Name.didImportKeybindsSuccessfully.post() - } else { - showAlertForImportDecision { decision in - switch decision { - case .merge: - let newKeybinds = actions.filter { savedKeybind in - !Defaults[.keybinds].contains { $0.keybind == savedKeybind.keybind && $0.name == savedKeybind.name } - } - Defaults[.keybinds].append(contentsOf: newKeybinds) - - // Post a notification after updating the keybinds - Notification.Name.keybindsUpdated.post() - Notification.Name.didImportKeybindsSuccessfully.post() - case .erase: - Defaults[.keybinds] = actions - - // Post a notification after updating the keybinds - Notification.Name.keybindsUpdated.post() - Notification.Name.didImportKeybindsSuccessfully.post() - case .cancel: - // No action needed, no notification should be posted - break - } - } - } - } - - /// Presents a decision alert for how to handle imported keybinds. - private static func showAlertForImportDecision(completion: @escaping (ImportDecision) -> ()) { - showAlert( - .init(localized: "Import Keybinds"), - informativeText: .init(localized: "Do you want to merge or erase existing keybinds?"), - buttons: [ - .init(localized: "Import keybinds: merge", defaultValue: "Merge"), - .init(localized: "Import keybinds: erase", defaultValue: "Erase"), - .init(localized: "Import keybinds: cancel", defaultValue: "Cancel") - ] - ) { response in - switch response { - case .alertFirstButtonReturn: - completion(.merge) - case .alertSecondButtonReturn: - completion(.erase) - default: - completion(.cancel) - } - } - } - - /// Utility function to show an alert with a completion handler. - private static func showAlert( - _ messageText: String, - informativeText: String, - buttons: [String] = [], - completion: ((NSApplication.ModalResponse) -> ())? = nil - ) { - let alert = NSAlert() - alert.messageText = messageText - alert.informativeText = informativeText - buttons.forEach { alert.addButton(withTitle: $0) } - if let completion { - alert.beginSheetModal(for: NSApplication.shared.mainWindow!, completionHandler: completion) - } else { - alert.runModal() - } - } - - /// Enum to represent the decision made in the import decision alert. - enum ImportDecision { - case merge, erase, cancel - } -}