-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement keyboard and mouse commands
- Loading branch information
Showing
30 changed files
with
1,313 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import SendKeysLib | ||
|
||
if #available(OSX 10.11, *) { | ||
SendKeysCli.main() | ||
} else { | ||
print("OS version 10.11 or higher is required.") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import Foundation | ||
|
||
class Animator { | ||
typealias AnimationCallback = (_ progress: Double) -> Void | ||
|
||
let duration: TimeInterval | ||
let frequency: TimeInterval | ||
let animateFn: AnimationCallback | ||
|
||
init(_ duration: TimeInterval, _ frequency: TimeInterval, _ animateFn: @escaping AnimationCallback) { | ||
self.duration = duration | ||
self.frequency = frequency | ||
self.animateFn = animateFn | ||
} | ||
|
||
func animate() { | ||
let startDate = Date() | ||
|
||
while -startDate.timeIntervalSinceNow < duration { | ||
let progress = min(-startDate.timeIntervalSinceNow as Double / duration as Double, 1) | ||
let easedValue = easeInOut(progress) | ||
|
||
Sleeper.sleep(seconds: frequency) | ||
animateFn(easedValue) | ||
} | ||
|
||
animateFn(1) | ||
} | ||
|
||
func easeInOut(_ x: Double) -> Double { | ||
return x < 0.5 ? 2 * x * x : 1 - pow(-2 * x + 2, 2) / 2; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import Cocoa | ||
|
||
class AppActivator: NSObject { | ||
private var application: NSRunningApplication! | ||
private let filterName: String | ||
|
||
init(appName: String) { | ||
filterName = appName | ||
} | ||
|
||
func activate() throws { | ||
guard let app = NSWorkspace.shared.runningApplications.filter ({ | ||
return $0.localizedName == self.filterName || $0.bundleIdentifier?.contains(self.filterName) ?? false | ||
}).first else { | ||
throw RuntimeError("Application \(self.filterName) not found") | ||
} | ||
|
||
guard app.activationPolicy != .prohibited else { | ||
throw RuntimeError("Application \(self.filterName) prohibits activation") | ||
} | ||
|
||
self.application = app | ||
|
||
self.unhideAppIfNeeded() | ||
self.activateAppIfNeeded() | ||
} | ||
|
||
private func unhideAppIfNeeded() { | ||
if application.isHidden { | ||
application.addObserver(self, forKeyPath: "isHidden", options: .new, context: nil) | ||
application.unhide() | ||
} | ||
} | ||
|
||
private func activateAppIfNeeded() { | ||
if !application.isHidden && !application.isActive { | ||
application.addObserver(self, forKeyPath: "isActive", options: .new, context: nil) | ||
application.activate(options: .activateIgnoringOtherApps) | ||
} | ||
} | ||
|
||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { | ||
if keyPath == "isHidden" { | ||
application.removeObserver(self, forKeyPath: "isHidden") | ||
activateAppIfNeeded() | ||
} else if keyPath == "isActive" { | ||
application.removeObserver(self, forKeyPath: "isActive") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
public enum CommandType { | ||
case keyPress | ||
case pause | ||
case stickyPause | ||
case mouseMove | ||
case mouseClick | ||
case mouseDrag | ||
case continuation | ||
} | ||
|
||
public struct Command: Equatable { | ||
let type: CommandType | ||
let arguments: [String] | ||
|
||
public init(_ type: CommandType, _ arguments: [String]) { | ||
self.type = type | ||
self.arguments = arguments | ||
} | ||
|
||
public init(_ type: CommandType) { | ||
self.init(type, []) | ||
} | ||
|
||
public static func == (lhs: Command, rhs: Command) -> Bool { | ||
return lhs.type == rhs.type && lhs.arguments == rhs.arguments; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import Foundation | ||
|
||
public protocol CommandExecutorProtocol { | ||
func execute(_ command: Command) | ||
} | ||
|
||
public class CommandExecutor: CommandExecutorProtocol { | ||
private let keyPresser = KeyPresser() | ||
private let mouseController = MouseController() | ||
|
||
public func execute(_ command: Command) { | ||
switch command.type { | ||
case .keyPress: | ||
executeKeyPress(command) | ||
case .pause, .stickyPause: | ||
executePause(command) | ||
case .mouseMove: | ||
executeMouseMove(command) | ||
case .mouseClick: | ||
executeMouseClick(command) | ||
case .mouseDrag: | ||
executeMouseDrag(command) | ||
default: | ||
fatalError("Unrecognized command type \(command.type)") | ||
} | ||
} | ||
|
||
private func executeKeyPress(_ command: Command) { | ||
var modifiers: [String] = [] | ||
|
||
if command.arguments.count > 1 { | ||
modifiers = command.arguments[1].components(separatedBy: ",") | ||
} | ||
|
||
try! keyPresser.pressKey(key: command.arguments[0], modifiers: modifiers) | ||
} | ||
|
||
private func executePause(_ command: Command) { | ||
Sleeper.sleep(seconds: Double(command.arguments[0])!) | ||
} | ||
|
||
private func executeMouseMove(_ command: Command) { | ||
let x1 = Double(command.arguments[0])! | ||
let y1 = Double(command.arguments[1])! | ||
let x2 = Double(command.arguments[2])! | ||
let y2 = Double(command.arguments[3])! | ||
let duration: TimeInterval = Double(command.arguments[4])! | ||
|
||
mouseController.move( | ||
start: CGPoint(x: x1, y: y1), | ||
end: CGPoint(x: x2, y: y2), | ||
duration: duration | ||
) | ||
} | ||
|
||
private func executeMouseClick(_ command: Command) { | ||
let button = command.arguments[0] | ||
let clicks = Int(command.arguments[1])! | ||
|
||
try! mouseController.click( | ||
CGPoint(x: -1, y: -1), | ||
button: getMouseButton(button: button), | ||
clickCount: clicks | ||
) | ||
} | ||
|
||
private func executeMouseDrag(_ command: Command) { | ||
let x1 = Double(command.arguments[0])! | ||
let y1 = Double(command.arguments[1])! | ||
let x2 = Double(command.arguments[2])! | ||
let y2 = Double(command.arguments[3])! | ||
let duration: TimeInterval = Double(command.arguments[4])! | ||
let button = command.arguments[5] | ||
|
||
try! mouseController.drag( | ||
start: CGPoint(x: x1, y: y1), | ||
end: CGPoint(x: x2, y: y2), | ||
duration: duration, | ||
button: getMouseButton(button: button) | ||
) | ||
} | ||
|
||
private func getMouseButton(button: String) throws -> CGMouseButton { | ||
switch button { | ||
case "left": | ||
return CGMouseButton.left | ||
case "center": | ||
return CGMouseButton.center | ||
case "right": | ||
return CGMouseButton.right | ||
default: | ||
throw RuntimeError("Unknown mouse button: \(button)") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import Foundation | ||
|
||
public class CommandMatcher { | ||
let expression: NSRegularExpression | ||
|
||
public func createCommand(_ arguments: [String?]) -> Command { | ||
fatalError("Not implemented") | ||
} | ||
|
||
public init(_ expression: NSRegularExpression) { | ||
self.expression = expression | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import Foundation | ||
|
||
public class CommandsIterator: IteratorProtocol { | ||
public typealias Element = Command | ||
|
||
private let commandMatchers: [CommandMatcher] = [ | ||
KeyPressCommandMatcher(), | ||
StickyPauseCommandMatcher(), | ||
PauseCommandMatcher(), | ||
ContinuationCommandMatcher(), | ||
NewlineCommandMatcher(), | ||
MouseMoveCommandMatcher(), | ||
MouseClickCommandMatcher(), | ||
MouseDragCommandMatcher(), | ||
DefaultCommandMatcher() | ||
] | ||
|
||
let commandString: String | ||
var index = 0; | ||
|
||
public init(_ commandString: String) { | ||
self.commandString = commandString | ||
} | ||
|
||
public func next() -> Element? { | ||
let length = commandString.utf16.count | ||
if index < length { | ||
var matchResult: NSTextCheckingResult?; | ||
let matcher = commandMatchers.first { (matcher: CommandMatcher) -> Bool in | ||
matchResult = matcher.expression.firstMatch(in: commandString, options: .anchored, range: NSMakeRange(index, length - index)) | ||
return matchResult != nil | ||
} | ||
|
||
if matcher != nil { | ||
let args = getArguments(commandString, matchResult!) | ||
let command = matcher!.createCommand(args) | ||
|
||
if matchResult != nil { | ||
let range = Range(matchResult!.range, in: commandString) | ||
index = range!.upperBound.utf16Offset(in: commandString) | ||
} | ||
|
||
return command | ||
} else { | ||
fatalError("Unmatched sequence.") | ||
} | ||
} | ||
return nil | ||
} | ||
|
||
private func getArguments(_ commandString: String, _ matchResult: NSTextCheckingResult) -> [String?] { | ||
var args: [String?] = []; | ||
let numberOfRanges = matchResult.numberOfRanges | ||
|
||
for i in 0..<numberOfRanges { | ||
let range = Range(matchResult.range(at: i), in: commandString) | ||
let arg = range == nil ? nil : String(commandString[range!]) | ||
args.append(arg) | ||
} | ||
|
||
return args | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import Foundation | ||
|
||
public class CommandsProcessor { | ||
var defaultPause: Double | ||
let commandExecutor: CommandExecutorProtocol | ||
let numberFormatter = NumberFormatter() | ||
|
||
public init(defaultPause: Double, commandExecutor: CommandExecutorProtocol? = nil) { | ||
self.defaultPause = defaultPause | ||
self.commandExecutor = commandExecutor ?? CommandExecutor() | ||
|
||
numberFormatter.usesSignificantDigits = true | ||
numberFormatter.minimumSignificantDigits = 1 | ||
numberFormatter.maximumSignificantDigits = 3 | ||
} | ||
|
||
private func getDefaultPauseCommand() -> Command { | ||
return Command(.pause, [numberFormatter.string(from: NSNumber(value: defaultPause))!]) | ||
} | ||
|
||
public func process(_ commandString: String) { | ||
let commands = IteratorSequence(CommandsIterator(commandString)) | ||
var shouldDefaultPause = false | ||
var shouldIgnoreNextCommand = false | ||
|
||
for command in commands { | ||
if shouldIgnoreNextCommand { | ||
shouldIgnoreNextCommand = false | ||
continue | ||
} | ||
|
||
if command.type == .continuation { | ||
shouldIgnoreNextCommand = true | ||
continue | ||
} | ||
|
||
if command.type == .pause { | ||
shouldDefaultPause = false | ||
} else if command.type == .stickyPause { | ||
shouldDefaultPause = false | ||
defaultPause = Double(command.arguments[0])! | ||
} else if shouldDefaultPause { | ||
executeCommand(getDefaultPauseCommand()) | ||
shouldDefaultPause = true | ||
} else { | ||
shouldDefaultPause = true | ||
} | ||
|
||
executeCommand(command) | ||
} | ||
} | ||
|
||
func executeCommand(_ command: Command) { | ||
commandExecutor.execute(command) | ||
} | ||
} |
11 changes: 11 additions & 0 deletions
11
Sources/SendKeysLib/Commands/ContinuationCommandMatcher.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import Foundation | ||
|
||
public class ContinuationCommandMatcher: CommandMatcher { | ||
public init() { | ||
super.init(try! NSRegularExpression(pattern: "\\<\\\\\\>")) | ||
} | ||
|
||
override public func createCommand(_ arguments: [String?]) -> Command { | ||
return Command(.continuation, []) | ||
} | ||
} |
Oops, something went wrong.