From 01908f11f3b85a551f643b2f679f1e595afbf8e3 Mon Sep 17 00:00:00 2001 From: Soc Sieng Date: Mon, 28 Oct 2024 21:45:20 +1100 Subject: [PATCH] feat: add support for reading configuration from home directory --- .editorconfig | 4 ++ Package.resolved | 9 +++++ Package.swift | 2 + README.md | 9 +++++ .../Configuration/AllConfiguration.swift | 19 +++++++++ .../Configuration/ConfigLoader.swift | 25 ++++++++++++ .../Configuration/MousePositionConfig.swift | 19 +++++++++ .../Configuration/SendConfig.swift | 34 ++++++++++++++++ .../Configuration/TransformerConfig.swift | 16 ++++++++ Sources/SendKeysLib/KeyMappings.swift | 2 +- Sources/SendKeysLib/MousePosition.swift | 28 ++++++++----- Sources/SendKeysLib/Sender.swift | 40 ++++++++++++++----- Sources/SendKeysLib/Transformer.swift | 23 +++++++---- Sources/SendKeysLib/Utilities.swift | 3 +- examples/.sendkeysrc.yml | 18 +++++++++ 15 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 Sources/SendKeysLib/Configuration/AllConfiguration.swift create mode 100644 Sources/SendKeysLib/Configuration/ConfigLoader.swift create mode 100644 Sources/SendKeysLib/Configuration/MousePositionConfig.swift create mode 100644 Sources/SendKeysLib/Configuration/SendConfig.swift create mode 100644 Sources/SendKeysLib/Configuration/TransformerConfig.swift create mode 100644 examples/.sendkeysrc.yml diff --git a/.editorconfig b/.editorconfig index 589f816..c461925 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,7 @@ root = true end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true + +[*.swift] +indent_style = space +indent_size = 4 diff --git a/Package.resolved b/Package.resolved index acc7d5d..3105689 100644 --- a/Package.resolved +++ b/Package.resolved @@ -45,6 +45,15 @@ "revision": "0687f71944021d616d34d922343dcef086855920", "version": "600.0.1" } + }, + { + "package": "Yams", + "repositoryURL": "https://github.com/jpsim/Yams.git", + "state": { + "branch": null, + "revision": "3036ba9d69cf1fd04d433527bc339dc0dc75433d", + "version": "5.1.3" + } } ] }, diff --git a/Package.swift b/Package.swift index b89d5f1..893174e 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,7 @@ let package = Package( // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), .package(url: "https://github.com/apple/swift-format", from: "600.0.0"), + .package(url: "https://github.com/jpsim/Yams.git", from: "5.1.3"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -23,6 +24,7 @@ let package = Package( name: "SendKeysLib", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Yams", package: "Yams"), ]), .testTarget( name: "SendKeysTests", diff --git a/README.md b/README.md index 178a30b..6bb657a 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,15 @@ SendKeys will use `--application-name` to activate the first application instanc name or bundle id (case insensitive). If there are no exact matches, it will attempt to match on whole words for the application name, followed by the bundle id. +## Configuration + +Common arguments can be stored in the [`~/.sendkeysrc.yml`](./docs/examples/.senkeysrc.yml) configuration file. +Configuration values are applied in the following priority order: + +1. Command line arguments +2. Configuration file +3. CLI default values + ## Prerequisites This application will only run on macOS 10.11 or later. diff --git a/Sources/SendKeysLib/Configuration/AllConfiguration.swift b/Sources/SendKeysLib/Configuration/AllConfiguration.swift new file mode 100644 index 0000000..28304f0 --- /dev/null +++ b/Sources/SendKeysLib/Configuration/AllConfiguration.swift @@ -0,0 +1,19 @@ +struct AllConfiguration: Codable { + var send: SendConfig? + var mousePosition: MousePositionConfig? + var transformer: TransformerConfig? + + init(send: SendConfig? = nil, mousePosition: MousePositionConfig? = nil, transformer: TransformerConfig? = nil) { + self.send = send + self.mousePosition = mousePosition + self.transformer = transformer + } + + func merge(with other: AllConfiguration?) -> AllConfiguration { + return AllConfiguration( + send: other?.send?.merge(with: self.send) ?? self.send, + mousePosition: other?.mousePosition?.merge(with: self.mousePosition) ?? self.mousePosition, + transformer: other?.transformer?.merge(with: self.transformer) ?? self.transformer + ) + } +} diff --git a/Sources/SendKeysLib/Configuration/ConfigLoader.swift b/Sources/SendKeysLib/Configuration/ConfigLoader.swift new file mode 100644 index 0000000..dc76a07 --- /dev/null +++ b/Sources/SendKeysLib/Configuration/ConfigLoader.swift @@ -0,0 +1,25 @@ +import Foundation +import Yams + +struct ConfigLoader { + static func loadConfig() -> AllConfiguration { + let defaultConfigFiles = [ + NSString("~/.sendkeysrc.yml").expandingTildeInPath, NSString("~/.sendkeysrc.yaml").expandingTildeInPath, + ] + + for configFile in defaultConfigFiles { + if FileManager.default.fileExists(atPath: configFile) { + if let contents = FileManager.default.contents(atPath: configFile) { + do { + let decoder = YAMLDecoder() + return try decoder.decode(AllConfiguration.self, from: contents) + } catch { + print("Unable to read \(configFile): \(error)") + } + } + } + } + + return AllConfiguration() + } +} diff --git a/Sources/SendKeysLib/Configuration/MousePositionConfig.swift b/Sources/SendKeysLib/Configuration/MousePositionConfig.swift new file mode 100644 index 0000000..1e5823b --- /dev/null +++ b/Sources/SendKeysLib/Configuration/MousePositionConfig.swift @@ -0,0 +1,19 @@ +struct MousePositionConfig: Codable { + var watch: Bool? + var output: OutputMode? + var duration: Double? + + init(watch: Bool? = nil, output: OutputMode? = nil, duration: Double? = nil) { + self.watch = watch + self.output = output + self.duration = duration + } + + func merge(with other: MousePositionConfig?) -> MousePositionConfig { + return MousePositionConfig( + watch: other?.watch ?? self.watch, + output: other?.output ?? self.output, + duration: other?.duration ?? self.duration + ) + } +} diff --git a/Sources/SendKeysLib/Configuration/SendConfig.swift b/Sources/SendKeysLib/Configuration/SendConfig.swift new file mode 100644 index 0000000..8327b2f --- /dev/null +++ b/Sources/SendKeysLib/Configuration/SendConfig.swift @@ -0,0 +1,34 @@ +struct SendConfig: Codable { + var activate: Bool? + var animationInterval: Double? + var delay: Double? + var initialDelay: Double? + var keyboardLayout: KeyMappings.Layouts? + var targeted: Bool? + var terminateCommand: String? + + init( + activate: Bool? = nil, animationInterval: Double? = nil, delay: Double? = nil, initialDelay: Double? = nil, + keyboardLayout: KeyMappings.Layouts? = nil, targeted: Bool? = nil, terminateCommand: String? = nil + ) { + self.activate = activate + self.animationInterval = animationInterval + self.delay = delay + self.initialDelay = initialDelay + self.keyboardLayout = keyboardLayout + self.targeted = targeted + self.terminateCommand = terminateCommand + } + + func merge(with other: SendConfig?) -> SendConfig { + return SendConfig( + activate: other?.activate ?? self.activate, + animationInterval: other?.animationInterval ?? self.animationInterval, + delay: other?.delay ?? self.delay, + initialDelay: other?.initialDelay ?? self.initialDelay, + keyboardLayout: other?.keyboardLayout ?? self.keyboardLayout, + targeted: other?.targeted ?? self.targeted, + terminateCommand: other?.terminateCommand ?? self.terminateCommand + ) + } +} diff --git a/Sources/SendKeysLib/Configuration/TransformerConfig.swift b/Sources/SendKeysLib/Configuration/TransformerConfig.swift new file mode 100644 index 0000000..64ab043 --- /dev/null +++ b/Sources/SendKeysLib/Configuration/TransformerConfig.swift @@ -0,0 +1,16 @@ +struct TransformerConfig: Codable { + var indent: Bool? + var autoClose: String? + + init(indent: Bool? = nil, autoClose: String? = nil) { + self.indent = indent + self.autoClose = autoClose + } + + func merge(with other: TransformerConfig?) -> TransformerConfig { + return TransformerConfig( + indent: other?.indent ?? self.indent, + autoClose: other?.autoClose ?? self.autoClose + ) + } +} diff --git a/Sources/SendKeysLib/KeyMappings.swift b/Sources/SendKeysLib/KeyMappings.swift index c012a0d..b716d64 100644 --- a/Sources/SendKeysLib/KeyMappings.swift +++ b/Sources/SendKeysLib/KeyMappings.swift @@ -1,7 +1,7 @@ import ArgumentParser struct KeyMappings { - enum Layouts: String, ExpressibleByArgument { + enum Layouts: String, Codable, ExpressibleByArgument { case qwerty case colemak case dvorak diff --git a/Sources/SendKeysLib/MousePosition.swift b/Sources/SendKeysLib/MousePosition.swift index e744ad7..d0b1004 100644 --- a/Sources/SendKeysLib/MousePosition.swift +++ b/Sources/SendKeysLib/MousePosition.swift @@ -12,20 +12,24 @@ class MousePosition: ParsableCommand { abstract: "Prints the current mouse position." ) - @Flag(name: .shortAndLong, help: "Watch and display the mouse positions as the mouse is clicked.") - var watch = false + @Flag( + name: .shortAndLong, inversion: FlagInversion.prefixedNo, + help: "Watch and display the mouse positions as the mouse is clicked.") + var watch: Bool? @Option( name: NameSpecification([.customShort("o"), .customLong("output", withSingleDash: false)]), help: "Displays results as either a series of coordinates or commands.") - var mode = OutputMode.coordinates + var mode: OutputMode? @Option( name: .shortAndLong, help: "Duration (in seconds) to output for mouse events. A negative value uses elapsed time between mouse events." ) - var duration: Double = -1 + var duration: Double? + + var config: MousePositionConfig static let eventProcessor = MouseEventProcessor() @@ -38,10 +42,15 @@ class MousePosition: ParsableCommand { } required init() { + self.config = MousePositionConfig(watch: false, output: .commands, duration: -1) } func run() { - if watch { + self.config = self.config + .merge(with: ConfigLoader.loadConfig().mousePosition) + .merge(with: MousePositionConfig(watch: watch, output: mode, duration: duration)) + + if self.config.watch! { watchMouseInput() } else { printMousePosition(nil) @@ -112,13 +121,12 @@ class MousePosition: ParsableCommand { let command: MousePosition = bridge(ptr: UnsafeRawPointer(refcon)!) if let mouseEvent = MousePosition.eventProcessor.consumeEvent(type: eventType, event: event) { - // if duration is set, override all mouse event durations - if command.duration >= 0 { - mouseEvent.duration = command.duration + if command.config.duration! >= 0 { + mouseEvent.duration = command.config.duration! } - switch command.mode { + switch command.config.output! { case .coordinates: if mouseEvent.eventType == .click { command.printMousePosition(mouseEvent.endPoint) @@ -148,7 +156,7 @@ class MousePosition: ParsableCommand { func eventCallback(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged? { - switch mode { + switch self.config.output! { case .coordinates: printMousePosition(nil) case .commands: diff --git a/Sources/SendKeysLib/Sender.swift b/Sources/SendKeysLib/Sender.swift index bcddd11..8a588db 100644 --- a/Sources/SendKeysLib/Sender.swift +++ b/Sources/SendKeysLib/Sender.swift @@ -20,16 +20,17 @@ public struct Sender: ParsableCommand { @Flag( name: .long, inversion: FlagInversion.prefixedNo, help: "Activate the specified app or process before sending commands.") - var activate: Bool = true + var activate: Bool? - @Flag(name: .long, help: "Only send keystrokes to the targeted app or process.") - var targeted: Bool = false + @Flag( + name: .long, inversion: FlagInversion.prefixedNo, help: "Only send keystrokes to the targeted app or process.") + var targeted: Bool? @Option(name: .shortAndLong, help: "Default delay between keystrokes in seconds.") - var delay: Double = 0.1 + var delay: Double? @Option(name: .shortAndLong, help: "Initial delay before sending commands in seconds.") - var initialDelay: Double = 1 + var initialDelay: Double? @Option(name: NameSpecification([.customShort("f"), .long]), help: "File containing keystroke instructions.") var inputFile: String? @@ -38,15 +39,21 @@ public struct Sender: ParsableCommand { var characters: String? @Option(help: "Number of seconds between animation updates.") - var animationInterval: Double = 0.01 + var animationInterval: Double? @Option(name: .shortAndLong, help: "Character sequence to use to terminate execution (e.g. f12:command).") var terminateCommand: String? @Option(name: .long, help: "Keyboard layout to use for sending keystrokes.") - var keyboardLayout: KeyMappings.Layouts = .qwerty + var keyboardLayout: KeyMappings.Layouts? + + var config: SendConfig - public init() {} + public init() { + self.config = SendConfig( + activate: true, animationInterval: 0.01, delay: 0.1, initialDelay: 1, keyboardLayout: .qwerty, + targeted: false, terminateCommand: nil) + } public mutating func run() throws { let accessEnabled = AXIsProcessTrustedWithOptions( @@ -62,6 +69,21 @@ public struct Sender: ParsableCommand { let app: NSRunningApplication? = try activator.find() let keyPresser: KeyPresser + self.config = self.config + .merge(with: ConfigLoader.loadConfig().send) + .merge( + with: SendConfig( + activate: activate, animationInterval: animationInterval, delay: delay, initialDelay: initialDelay, + keyboardLayout: keyboardLayout, targeted: targeted, terminateCommand: terminateCommand)) + + let activate = activate ?? self.config.activate! + let targeted = targeted ?? self.config.targeted! + let delay = delay ?? self.config.delay! + let initialDelay = initialDelay ?? self.config.initialDelay! + let animationInterval = animationInterval ?? self.config.animationInterval! + let terminateCommand = terminateCommand ?? self.config.terminateCommand + let keyboardLayout = keyboardLayout ?? self.config.keyboardLayout! + KeyPresser.setKeyboardLayout(keyboardLayout) if targeted { @@ -89,7 +111,7 @@ public struct Sender: ParsableCommand { } var listener: TerminationListener? - if terminateCommand != nil { + if terminateCommand != nil && !terminateCommand!.isEmpty { listener = TerminationListener(sequence: terminateCommand!) { Sender.exit() } diff --git a/Sources/SendKeysLib/Transformer.swift b/Sources/SendKeysLib/Transformer.swift index d776199..7d23280 100644 --- a/Sources/SendKeysLib/Transformer.swift +++ b/Sources/SendKeysLib/Transformer.swift @@ -9,15 +9,17 @@ class Transformer: ParsableCommand { "Transforms raw text input into application friendly character sequences. Examples include accounting for applications that automatically indent source code and insert closing brackets." ) - @Option(name: .shortAndLong, help: "Determines if the application automatically inserts indentation.") - var indent = true + @Flag( + name: .shortAndLong, inversion: FlagInversion.prefixedNo, + help: "Determines if the application automatically inserts indentation.") + var indent: Bool? @Option( name: .shortAndLong, help: "Specifies which brackets are automatically closed by the application and don't need to be explicitly closed." ) - var autoClose = "}])" + var autoClose: String? @Option( name: NameSpecification([.customShort("f"), .long]), @@ -27,16 +29,21 @@ class Transformer: ParsableCommand { @Option(name: .shortAndLong, help: "String of characters to transform.") var characters: String? + var config: TransformerConfig + public init(indent: Bool, autoClose: String = "}])") { - self.indent = indent - self.autoClose = autoClose + self.config = TransformerConfig(indent: indent, autoClose: autoClose) } required init() { + self.config = TransformerConfig(indent: true, autoClose: "}])") } func run() { var commandString: String? + self.config = self.config + .merge(with: ConfigLoader.loadConfig().transformer) + .merge(with: TransformerConfig(indent: indent, autoClose: autoClose)) if !(inputFile ?? "").isEmpty { if let data = FileManager.default.contents(atPath: inputFile!) { @@ -69,17 +76,17 @@ class Transformer: ParsableCommand { func transform(_ input: String) -> String { var output = input - if indent { + if self.config.indent! { let removeIndentExpression = try! NSRegularExpression(pattern: "^[\\t ]+", options: .anchorsMatchLines) let range = NSRange(location: 0, length: output.count) output = removeIndentExpression.stringByReplacingMatches( in: output, options: [], range: range, withTemplate: "") } - if !autoClose.isEmpty { + if !self.config.autoClose!.isEmpty { let removeBracketExpression = try! NSRegularExpression( pattern: - "\\n[\\t ]*[\(NSRegularExpression.escapedPattern(for: autoClose).replacingOccurrences(of: "]", with: "\\]"))]+" + "\\n[\\t ]*[\(NSRegularExpression.escapedPattern(for: self.config.autoClose!).replacingOccurrences(of: "]", with: "\\]"))]+" ) let range = NSRange(location: 0, length: output.count) output = removeBracketExpression.stringByReplacingMatches( diff --git a/Sources/SendKeysLib/Utilities.swift b/Sources/SendKeysLib/Utilities.swift index f96e5ad..0b810db 100644 --- a/Sources/SendKeysLib/Utilities.swift +++ b/Sources/SendKeysLib/Utilities.swift @@ -6,7 +6,8 @@ func isTty() -> Bool { func getRegexGroups(_ expression: NSRegularExpression, _ input: String) -> [String?]? { var groups: [String?] = [] - let matchResult = expression.firstMatch(in: input, options: .anchored, range: NSRange(location: 0, length: input.utf8.count)) + let matchResult = expression.firstMatch( + in: input, options: .anchored, range: NSRange(location: 0, length: input.utf8.count)) if matchResult == nil { return nil diff --git a/examples/.sendkeysrc.yml b/examples/.sendkeysrc.yml new file mode 100644 index 0000000..537c4cc --- /dev/null +++ b/examples/.sendkeysrc.yml @@ -0,0 +1,18 @@ +# All properties are optional +send: + activate: true + animationInterval: 0.01 + delay: 0.1 + initialDelay: 1 + keyboardLayout: qwerty + targeted: false + terminateCommand: "c:control" + +mousePosition: + watch: true + output: commands + duration: 1 + +transform: + indent: true + autoClose: "}])"