├── Swatch.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── project.pbxproj ├── README.md ├── LICENSE ├── .gitignore └── Swatch └── main.swift /Swatch.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swatch 2 | Watcher for Unit Tests written in Swift 3 | 4 | Command line program to watch project files changed and run unit tests against them. 5 | 6 | 7 | ## Usage 8 | 9 | Change settings in `main.swift` agains your project. 10 | 11 | ```swift 12 | let project = Project(path: "{path_to_project}", name: "{project_name}") 13 | ``` 14 | 15 | Run project from Xcode or command line 16 | 17 | ```sh 18 | $ xcrun swift ./Swatch/main.swift 19 | ``` 20 | 21 | To run unit tests in Xcode press: 22 | 23 | `Cmd` + `Shift` + `e` 24 | 25 | ## Contributing 26 | 27 | Contributions to Swatch are welcomed and encouraged! Feel free to fork the project and submit a pull request with your changes! 28 | 29 | 30 | ## Author 31 | 32 | Vladimirs Matusevics, vladimir.matusevic@gmail.com, [Twitter](https://twitter.com/iGamesDev) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Vladimirs Matusevics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xcuserstate 23 | 24 | ## Obj-C/Swift specific 25 | *.hmap 26 | *.ipa 27 | *.dSYM.zip 28 | *.dSYM 29 | 30 | ## Playgrounds 31 | timeline.xctimeline 32 | playground.xcworkspace 33 | 34 | # Swift Package Manager 35 | # 36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 37 | # Packages/ 38 | .build/ 39 | 40 | # CocoaPods 41 | # 42 | # We recommend against adding the Pods directory to your .gitignore. However 43 | # you should judge for yourself, the pros and cons are mentioned at: 44 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 45 | # 46 | # Pods/ 47 | 48 | # Carthage 49 | # 50 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 51 | # Carthage/Checkouts 52 | 53 | Carthage/Build 54 | 55 | # fastlane 56 | # 57 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 58 | # screenshots whenever they are needed. 59 | # For more information about the recommended setup visit: 60 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 61 | 62 | fastlane/report.xml 63 | fastlane/Preview.html 64 | fastlane/screenshots 65 | fastlane/test_output 66 | 67 | nohup.out 68 | -------------------------------------------------------------------------------- /Swatch.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 9954D0A01E7B18D7002893B6 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9954D09F1E7B18D7002893B6 /* main.swift */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXCopyFilesBuildPhase section */ 14 | 9954D09A1E7B18D7002893B6 /* CopyFiles */ = { 15 | isa = PBXCopyFilesBuildPhase; 16 | buildActionMask = 2147483647; 17 | dstPath = /usr/share/man/man1/; 18 | dstSubfolderSpec = 0; 19 | files = ( 20 | ); 21 | runOnlyForDeploymentPostprocessing = 1; 22 | }; 23 | /* End PBXCopyFilesBuildPhase section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | 9954D09C1E7B18D7002893B6 /* Swatch */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = Swatch; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | 9954D09F1E7B18D7002893B6 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 28 | /* End PBXFileReference section */ 29 | 30 | /* Begin PBXFrameworksBuildPhase section */ 31 | 9954D0991E7B18D7002893B6 /* Frameworks */ = { 32 | isa = PBXFrameworksBuildPhase; 33 | buildActionMask = 2147483647; 34 | files = ( 35 | ); 36 | runOnlyForDeploymentPostprocessing = 0; 37 | }; 38 | /* End PBXFrameworksBuildPhase section */ 39 | 40 | /* Begin PBXGroup section */ 41 | 9954D0931E7B18D7002893B6 = { 42 | isa = PBXGroup; 43 | children = ( 44 | 9954D09E1E7B18D7002893B6 /* Swatch */, 45 | 9954D09D1E7B18D7002893B6 /* Products */, 46 | ); 47 | sourceTree = ""; 48 | }; 49 | 9954D09D1E7B18D7002893B6 /* Products */ = { 50 | isa = PBXGroup; 51 | children = ( 52 | 9954D09C1E7B18D7002893B6 /* Swatch */, 53 | ); 54 | name = Products; 55 | sourceTree = ""; 56 | }; 57 | 9954D09E1E7B18D7002893B6 /* Swatch */ = { 58 | isa = PBXGroup; 59 | children = ( 60 | 9954D09F1E7B18D7002893B6 /* main.swift */, 61 | ); 62 | path = Swatch; 63 | sourceTree = ""; 64 | }; 65 | /* End PBXGroup section */ 66 | 67 | /* Begin PBXNativeTarget section */ 68 | 9954D09B1E7B18D7002893B6 /* Swatch */ = { 69 | isa = PBXNativeTarget; 70 | buildConfigurationList = 9954D0A31E7B18D7002893B6 /* Build configuration list for PBXNativeTarget "Swatch" */; 71 | buildPhases = ( 72 | 9954D0981E7B18D7002893B6 /* Sources */, 73 | 9954D0991E7B18D7002893B6 /* Frameworks */, 74 | 9954D09A1E7B18D7002893B6 /* CopyFiles */, 75 | ); 76 | buildRules = ( 77 | ); 78 | dependencies = ( 79 | ); 80 | name = Swatch; 81 | productName = Swatch; 82 | productReference = 9954D09C1E7B18D7002893B6 /* Swatch */; 83 | productType = "com.apple.product-type.tool"; 84 | }; 85 | /* End PBXNativeTarget section */ 86 | 87 | /* Begin PBXProject section */ 88 | 9954D0941E7B18D7002893B6 /* Project object */ = { 89 | isa = PBXProject; 90 | attributes = { 91 | LastSwiftUpdateCheck = 0820; 92 | LastUpgradeCheck = 0820; 93 | ORGANIZATIONNAME = vmatusevic; 94 | TargetAttributes = { 95 | 9954D09B1E7B18D7002893B6 = { 96 | CreatedOnToolsVersion = 8.2.1; 97 | DevelopmentTeam = GVYJ5N3B47; 98 | LastSwiftMigration = 0820; 99 | ProvisioningStyle = Automatic; 100 | }; 101 | }; 102 | }; 103 | buildConfigurationList = 9954D0971E7B18D7002893B6 /* Build configuration list for PBXProject "Swatch" */; 104 | compatibilityVersion = "Xcode 3.2"; 105 | developmentRegion = English; 106 | hasScannedForEncodings = 0; 107 | knownRegions = ( 108 | en, 109 | ); 110 | mainGroup = 9954D0931E7B18D7002893B6; 111 | productRefGroup = 9954D09D1E7B18D7002893B6 /* Products */; 112 | projectDirPath = ""; 113 | projectRoot = ""; 114 | targets = ( 115 | 9954D09B1E7B18D7002893B6 /* Swatch */, 116 | ); 117 | }; 118 | /* End PBXProject section */ 119 | 120 | /* Begin PBXSourcesBuildPhase section */ 121 | 9954D0981E7B18D7002893B6 /* Sources */ = { 122 | isa = PBXSourcesBuildPhase; 123 | buildActionMask = 2147483647; 124 | files = ( 125 | 9954D0A01E7B18D7002893B6 /* main.swift in Sources */, 126 | ); 127 | runOnlyForDeploymentPostprocessing = 0; 128 | }; 129 | /* End PBXSourcesBuildPhase section */ 130 | 131 | /* Begin XCBuildConfiguration section */ 132 | 9954D0A11E7B18D7002893B6 /* Debug */ = { 133 | isa = XCBuildConfiguration; 134 | buildSettings = { 135 | ALWAYS_SEARCH_USER_PATHS = NO; 136 | CLANG_ANALYZER_NONNULL = YES; 137 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 138 | CLANG_CXX_LIBRARY = "libc++"; 139 | CLANG_ENABLE_MODULES = YES; 140 | CLANG_ENABLE_OBJC_ARC = YES; 141 | CLANG_WARN_BOOL_CONVERSION = YES; 142 | CLANG_WARN_CONSTANT_CONVERSION = YES; 143 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 144 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 145 | CLANG_WARN_EMPTY_BODY = YES; 146 | CLANG_WARN_ENUM_CONVERSION = YES; 147 | CLANG_WARN_INFINITE_RECURSION = YES; 148 | CLANG_WARN_INT_CONVERSION = YES; 149 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 150 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 151 | CLANG_WARN_UNREACHABLE_CODE = YES; 152 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 153 | CODE_SIGN_IDENTITY = "-"; 154 | COPY_PHASE_STRIP = NO; 155 | DEBUG_INFORMATION_FORMAT = dwarf; 156 | ENABLE_STRICT_OBJC_MSGSEND = YES; 157 | ENABLE_TESTABILITY = YES; 158 | GCC_C_LANGUAGE_STANDARD = gnu99; 159 | GCC_DYNAMIC_NO_PIC = NO; 160 | GCC_NO_COMMON_BLOCKS = YES; 161 | GCC_OPTIMIZATION_LEVEL = 0; 162 | GCC_PREPROCESSOR_DEFINITIONS = ( 163 | "DEBUG=1", 164 | "$(inherited)", 165 | ); 166 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 167 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 168 | GCC_WARN_UNDECLARED_SELECTOR = YES; 169 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 170 | GCC_WARN_UNUSED_FUNCTION = YES; 171 | GCC_WARN_UNUSED_VARIABLE = YES; 172 | MACOSX_DEPLOYMENT_TARGET = 10.12; 173 | MTL_ENABLE_DEBUG_INFO = YES; 174 | ONLY_ACTIVE_ARCH = YES; 175 | SDKROOT = macosx; 176 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 177 | }; 178 | name = Debug; 179 | }; 180 | 9954D0A21E7B18D7002893B6 /* Release */ = { 181 | isa = XCBuildConfiguration; 182 | buildSettings = { 183 | ALWAYS_SEARCH_USER_PATHS = NO; 184 | CLANG_ANALYZER_NONNULL = YES; 185 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 186 | CLANG_CXX_LIBRARY = "libc++"; 187 | CLANG_ENABLE_MODULES = YES; 188 | CLANG_ENABLE_OBJC_ARC = YES; 189 | CLANG_WARN_BOOL_CONVERSION = YES; 190 | CLANG_WARN_CONSTANT_CONVERSION = YES; 191 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 192 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 193 | CLANG_WARN_EMPTY_BODY = YES; 194 | CLANG_WARN_ENUM_CONVERSION = YES; 195 | CLANG_WARN_INFINITE_RECURSION = YES; 196 | CLANG_WARN_INT_CONVERSION = YES; 197 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 198 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 199 | CLANG_WARN_UNREACHABLE_CODE = YES; 200 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 201 | CODE_SIGN_IDENTITY = "-"; 202 | COPY_PHASE_STRIP = NO; 203 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 204 | ENABLE_NS_ASSERTIONS = NO; 205 | ENABLE_STRICT_OBJC_MSGSEND = YES; 206 | GCC_C_LANGUAGE_STANDARD = gnu99; 207 | GCC_NO_COMMON_BLOCKS = YES; 208 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 209 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 210 | GCC_WARN_UNDECLARED_SELECTOR = YES; 211 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 212 | GCC_WARN_UNUSED_FUNCTION = YES; 213 | GCC_WARN_UNUSED_VARIABLE = YES; 214 | MACOSX_DEPLOYMENT_TARGET = 10.12; 215 | MTL_ENABLE_DEBUG_INFO = NO; 216 | SDKROOT = macosx; 217 | }; 218 | name = Release; 219 | }; 220 | 9954D0A41E7B18D7002893B6 /* Debug */ = { 221 | isa = XCBuildConfiguration; 222 | buildSettings = { 223 | DEVELOPMENT_TEAM = GVYJ5N3B47; 224 | PRODUCT_NAME = "$(TARGET_NAME)"; 225 | SWIFT_VERSION = 4.0; 226 | }; 227 | name = Debug; 228 | }; 229 | 9954D0A51E7B18D7002893B6 /* Release */ = { 230 | isa = XCBuildConfiguration; 231 | buildSettings = { 232 | DEVELOPMENT_TEAM = GVYJ5N3B47; 233 | PRODUCT_NAME = "$(TARGET_NAME)"; 234 | SWIFT_VERSION = 4.0; 235 | }; 236 | name = Release; 237 | }; 238 | /* End XCBuildConfiguration section */ 239 | 240 | /* Begin XCConfigurationList section */ 241 | 9954D0971E7B18D7002893B6 /* Build configuration list for PBXProject "Swatch" */ = { 242 | isa = XCConfigurationList; 243 | buildConfigurations = ( 244 | 9954D0A11E7B18D7002893B6 /* Debug */, 245 | 9954D0A21E7B18D7002893B6 /* Release */, 246 | ); 247 | defaultConfigurationIsVisible = 0; 248 | defaultConfigurationName = Release; 249 | }; 250 | 9954D0A31E7B18D7002893B6 /* Build configuration list for PBXNativeTarget "Swatch" */ = { 251 | isa = XCConfigurationList; 252 | buildConfigurations = ( 253 | 9954D0A41E7B18D7002893B6 /* Debug */, 254 | 9954D0A51E7B18D7002893B6 /* Release */, 255 | ); 256 | defaultConfigurationIsVisible = 0; 257 | defaultConfigurationName = Release; 258 | }; 259 | /* End XCConfigurationList section */ 260 | }; 261 | rootObject = 9954D0941E7B18D7002893B6 /* Project object */; 262 | } 263 | -------------------------------------------------------------------------------- /Swatch/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // Swatch 4 | // 5 | // Created by Vladimirs Matusevics on 16/03/2017. 6 | // Copyright © 2017 Vladimirs Matusevics. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Cocoa 11 | import IOKit.hid 12 | 13 | struct Project { 14 | 15 | enum ProjectType: String { 16 | case workspace = "xcworkspace" 17 | case proj = "xcodeproj" 18 | } 19 | 20 | var path: String 21 | var name: String 22 | var type: ProjectType 23 | var targetName: String 24 | var testsBundleName: String 25 | var testsSuffix: String 26 | var destinationProperty: String 27 | 28 | init(path: String, 29 | name: String, 30 | type: ProjectType = .workspace, 31 | targetName: String = "", 32 | testsBundleName: String = "", 33 | testsSuffix: String = "Tests", 34 | destinationProperty: String = "platform=iOS Simulator,name=iPhone X,OS=11.0.1") { 35 | 36 | self.path = "/Users/\(Project.ownerUsername())\(path)" 37 | self.name = name 38 | self.type = type 39 | self.targetName = targetName.isEmpty ? name : targetName 40 | self.testsBundleName = testsBundleName.isEmpty ? "\(name)Tests" : testsBundleName 41 | self.testsSuffix = testsSuffix 42 | self.destinationProperty = destinationProperty 43 | } 44 | 45 | func fullPath() -> String { 46 | return "\(self.path)\(self.name)" 47 | } 48 | 49 | func fullName() -> String { 50 | return "\(self.name).\(self.type.rawValue)" 51 | } 52 | 53 | // Returns username of OSX machine 54 | static func ownerUsername() -> String { 55 | //! running on simulator so just grab the name from home dir /Users/{username}/Library... 56 | let usernameComponents = NSHomeDirectory().components(separatedBy: "/") 57 | guard usernameComponents.count > 2 else { fatalError() } 58 | return usernameComponents[2] 59 | } 60 | } 61 | 62 | enum App: String { 63 | case xcode = "Xcode" 64 | case appcode = "AppCode" 65 | case unknown 66 | } 67 | 68 | @discardableResult 69 | func shell(path: String, args: [String]) -> Int32 { 70 | 71 | let task = Process() 72 | task.launchPath = "/usr/bin/env" 73 | task.currentDirectoryPath = path 74 | task.arguments = args 75 | 76 | let pipe = Pipe() 77 | task.standardOutput = pipe 78 | 79 | task.launch() 80 | 81 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 82 | if let output = NSString(data: data, encoding: String.Encoding.utf8.rawValue) { 83 | print(output) 84 | Process.launchedProcess(launchPath: "/usr/bin/osascript", arguments: ["-e", "display notification \"Finished unit tests..\""]) 85 | } 86 | 87 | task.waitUntilExit() 88 | 89 | return task.terminationStatus 90 | } 91 | 92 | // MARK: KeyLogger 93 | 94 | class KeyLogger { 95 | 96 | lazy var manager: IOHIDManager = { 97 | return IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) 98 | }() 99 | 100 | lazy var openHIDManager: IOReturn = { 101 | return IOHIDManagerOpen(self.manager, IOOptionBits(kIOHIDOptionsTypeNone)) 102 | }() 103 | 104 | // Used in multiple matching dictionary 105 | lazy var deviceList: CFArray = { 106 | var array = [CFMutableDictionary]() 107 | array.append(self.createDeviceMatchingDictionary(inUsagePage: kHIDPage_GenericDesktop, inUsage: kHIDUsage_GD_Keyboard)) 108 | array.append(self.createDeviceMatchingDictionary(inUsagePage: kHIDPage_GenericDesktop, inUsage: kHIDUsage_GD_Keypad)) 109 | return array as CFArray 110 | }() 111 | 112 | private let appsToWatch: Set = [.xcode, .appcode] 113 | 114 | var activeAppName = App.xcode 115 | var shiftPressed = false 116 | var altPressed = false 117 | var actionPressed = false 118 | 119 | let daemon: Daemon! 120 | let project: Project! 121 | 122 | init(with project: Project, daemon: Daemon) { 123 | self.project = project 124 | self.daemon = daemon 125 | } 126 | 127 | func start() { 128 | if (CFGetTypeID(self.manager) != IOHIDManagerGetTypeID()) { 129 | print("Can't create manager") 130 | exit(1) 131 | } 132 | 133 | IOHIDManagerSetDeviceMatchingMultiple(self.manager, self.deviceList) 134 | 135 | let observer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) 136 | 137 | // App switching notification 138 | NSWorkspace.shared.notificationCenter.addObserver( 139 | self, 140 | selector: #selector(self.activatedApp), 141 | name: NSWorkspace.didActivateApplicationNotification, 142 | object: nil 143 | ) 144 | 145 | // Input value Call Backs 146 | IOHIDManagerRegisterInputValueCallback(self.manager, self.handleIOHIDInputValueCallback, observer) 147 | 148 | // Open HID Manager 149 | if self.openHIDManager != kIOReturnSuccess { 150 | print("Can't open HID!") 151 | } 152 | 153 | // Scheduling the loop 154 | self.scheduleHIDLoop() 155 | 156 | // Running in Loop 157 | RunLoop.current.run() 158 | } 159 | 160 | @objc dynamic func activatedApp(notification: NSNotification) { 161 | if let info = notification.userInfo, 162 | let app = info[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, 163 | let name = app.localizedName { 164 | self.activeAppName = App.init(rawValue: name) ?? .unknown 165 | } 166 | } 167 | 168 | // For Keyboard and Keypad 169 | func createDeviceMatchingDictionary(inUsagePage: Int, inUsage: Int ) -> CFMutableDictionary { 170 | // note: the usage is only valid if the usage page is also defined 171 | return [kIOHIDDeviceUsagePageKey: inUsagePage, kIOHIDDeviceUsageKey: inUsage] as! CFMutableDictionary 172 | } 173 | 174 | func scheduleHIDLoop() { 175 | IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) 176 | } 177 | 178 | let handleIOHIDInputValueCallback: IOHIDValueCallback = { context, result, sender, device in 179 | let `self` = Unmanaged.fromOpaque(context!).takeUnretainedValue() 180 | let elem = IOHIDValueGetElement(device) 181 | 182 | // check keys only for specific Apps 183 | if case .unknown = self.activeAppName { return } 184 | 185 | // fn 186 | if (IOHIDElementGetUsagePage(elem) != 0x07) { return } 187 | 188 | let scancode = IOHIDElementGetUsage(elem) 189 | if (scancode < 4 || scancode > 231) { return } 190 | 191 | let pressed = IOHIDValueGetIntegerValue(device) == 1 192 | 193 | if scancode == 225 { // left shift 194 | self.shiftPressed = pressed 195 | } 196 | 197 | if scancode == 227 { // left cmd 198 | self.altPressed = pressed 199 | } 200 | 201 | if scancode == 8 { // "e" 202 | self.actionPressed = pressed 203 | } 204 | 205 | if self.shiftPressed && self.altPressed && self.actionPressed { 206 | var args: [String] = ["xcodebuild", "test", "-workspace", self.project.fullName(), "-scheme", self.project.targetName, "-destination", self.project.destinationProperty] 207 | 208 | if !self.daemon.filesChanged.isEmpty { 209 | self.daemon.filesChanged.forEach() { filePath in 210 | var fileName = NSURL(fileURLWithPath: filePath).lastPathComponent! 211 | fileName = fileName.replacingOccurrences(of: ".swift", with: "") 212 | args.append("-only-testing:\(self.project.testsBundleName)/\(fileName)\(self.project.testsSuffix)") 213 | } 214 | self.daemon.resetFilesChanged() 215 | 216 | Process.launchedProcess(launchPath: "/usr/bin/osascript", arguments: ["-e", "display notification \"Running unit tests..\""]) 217 | print("\n\nRunning unit tests..\nargs: \(args)\n\n") 218 | shell(path: self.project.fullPath(), args: args) 219 | } else { 220 | print("No reason of running tests, no files changed") 221 | } 222 | } 223 | } 224 | } 225 | 226 | // MARK: FileWatcher 227 | 228 | public enum FileWatcher { 229 | 230 | // Errors that can be thrown from `FileWatcherProtocol` 231 | public enum Error: Swift.Error { 232 | // Trying to perform operation on watcher that requires started state 233 | case notStarted 234 | // Trying to start watcher that's already running 235 | case alreadyStarted 236 | // Trying to stop watcher that's already stopped 237 | case alreadyStopped 238 | // Failed to start the watcher, `reason` will contain more information why 239 | case failedToStart(reason: String) 240 | } 241 | 242 | // Status of refresh result 243 | public enum RefreshResult { 244 | // Watched file didn't change since last update. 245 | case noChanges 246 | case updated(data: Data) 247 | } 248 | 249 | // Closure used for File watcher updates 250 | public typealias UpdateClosure = (RefreshResult) -> Void 251 | } 252 | 253 | public protocol FileWatcherProtocol { 254 | func start(closure: @escaping FileWatcher.UpdateClosure) throws 255 | func stop() throws 256 | } 257 | 258 | public final class FileWatcherLocal: FileWatcherProtocol { 259 | private typealias CancelBlock = () -> Void 260 | 261 | private enum State { 262 | case Started(source: DispatchSourceFileSystemObject, fileHandle: CInt, callback: FileWatcher.UpdateClosure, cancel: CancelBlock) 263 | case Stopped 264 | } 265 | 266 | private let path: String 267 | private let refreshInterval: TimeInterval 268 | private let queue: DispatchQueue 269 | 270 | private var state: State = .Stopped 271 | private var isProcessing: Bool = false 272 | private var cancelReload: CancelBlock? 273 | private var previousContent: Data? 274 | 275 | /** 276 | Initializes watcher to specified path. 277 | 278 | - parameter path: Path of file to observe. 279 | - parameter refreshInterval: Refresh interval to use for updates. 280 | - parameter queue: Queue to use for firing `onChange` callback. 281 | 282 | - note: By default it throttles to 60 FPS, some editors can generate stupid multiple saves that mess with file system e.g. Sublime with AutoSave plugin is a mess and generates different file sizes, this will limit wasted time trying to load faster than 60 FPS, and no one should even notice it's throttled. 283 | */ 284 | public init(path: String, refreshInterval: TimeInterval = 1/60, queue: DispatchQueue = DispatchQueue.main) { 285 | self.path = path 286 | self.refreshInterval = refreshInterval 287 | self.queue = queue 288 | } 289 | 290 | public func start(closure: @escaping FileWatcher.UpdateClosure) throws { 291 | guard case .Stopped = state else { throw FileWatcher.Error.alreadyStarted } 292 | try startObserving(closure) 293 | } 294 | 295 | public func stop() throws { 296 | guard case let .Started(_, _, _, cancel) = state else { throw FileWatcher.Error.alreadyStopped } 297 | cancelReload?() 298 | cancelReload = nil 299 | cancel() 300 | 301 | isProcessing = false 302 | state = .Stopped 303 | } 304 | 305 | deinit { 306 | if case .Started = state { _ = try? stop() } 307 | } 308 | 309 | private func startObserving(_ closure: @escaping FileWatcher.UpdateClosure) throws { 310 | let handle = open(path, O_EVTONLY) 311 | 312 | if handle == -1 { throw FileWatcher.Error.failedToStart(reason: "Failed to open file") } 313 | 314 | let source = DispatchSource.makeFileSystemObjectSource( 315 | fileDescriptor: handle, 316 | eventMask: [.delete, .write, .extend, .attrib, .link, .rename, .revoke], 317 | queue: queue 318 | ) 319 | 320 | let cancelBlock = { 321 | source.cancel() 322 | } 323 | 324 | source.setEventHandler { 325 | let flags = source.data 326 | 327 | if flags.contains(.delete) || flags.contains(.rename) { 328 | _ = try? self.stop() 329 | _ = try? self.startObserving(closure) 330 | return 331 | } 332 | 333 | self.needsToReload() 334 | } 335 | 336 | source.setCancelHandler { 337 | close(handle) 338 | } 339 | 340 | source.resume() 341 | 342 | state = .Started(source: source, fileHandle: handle, callback: closure, cancel: cancelBlock) 343 | refresh() 344 | } 345 | 346 | private func needsToReload() { 347 | guard case .Started = state else { return } 348 | 349 | cancelReload?() 350 | cancelReload = throttle(after: refreshInterval) { self.refresh() } 351 | } 352 | 353 | // Force refresh, can only be used if the watcher was started and it's not processing. 354 | public func refresh() { 355 | guard case let .Started(_, _, closure, _) = state, isProcessing == false else { return } 356 | isProcessing = true 357 | 358 | let url = URL(fileURLWithPath: path) 359 | guard let content = try? Data(contentsOf: url, options: .uncached) else { 360 | isProcessing = false 361 | return 362 | } 363 | 364 | if content != previousContent { 365 | if previousContent != nil { 366 | queue.async { 367 | closure(.updated(data: content)) 368 | } 369 | } 370 | previousContent = content 371 | } else { 372 | queue.async { 373 | closure(.noChanges) 374 | } 375 | } 376 | 377 | isProcessing = false 378 | cancelReload = nil 379 | } 380 | 381 | private func throttle(after: Double, action: @escaping () -> Void) -> CancelBlock { 382 | var isCancelled = false 383 | DispatchQueue.main.asyncAfter(deadline: .now() + after) { 384 | if !isCancelled { 385 | action() 386 | } 387 | } 388 | 389 | return { 390 | isCancelled = true 391 | } 392 | } 393 | 394 | } 395 | 396 | class Daemon { 397 | 398 | var filesChanged: Set = [] 399 | 400 | var project: Project 401 | 402 | init(with project: Project) { 403 | self.project = project 404 | 405 | self.setup() 406 | } 407 | 408 | func resetFilesChanged() { 409 | self.filesChanged = [] 410 | } 411 | 412 | func addWatcher(forFileAtPath path: String) { 413 | let fileWatcher = FileWatcherLocal(path: path) 414 | try! fileWatcher.start() { result in 415 | switch result { 416 | case .noChanges: 417 | break 418 | case .updated(_): 419 | self.filesChanged.insert(path) 420 | print("\nself.filesChanged: \(self.filesChanged)") 421 | } 422 | } 423 | } 424 | 425 | func setup() { 426 | let path = "\(self.project.fullPath())/\(self.project.targetName)/" 427 | print(path) 428 | 429 | let enumerator = FileManager.default.enumerator(atPath: path) 430 | 431 | while let filePath = enumerator?.nextObject() as? String { 432 | if filePath.hasSuffix("swift") { 433 | print("Watching \"\(filePath)\"") 434 | self.addWatcher(forFileAtPath: "\(path)\(filePath)") 435 | } 436 | } 437 | 438 | return 439 | } 440 | 441 | } 442 | 443 | /** 444 | Initialize your project here 445 | 446 | - note: Example: 447 | If you have project located at "/Users/{username}/dev/ios/ProjectName 448 | init it like this: Project(path: "/dev/ios/", name: "ProjectName") 449 | */ 450 | 451 | let project = Project(path: "/dev/ios/", name: "Workouts") 452 | 453 | let daemon = Daemon(with: project) 454 | let keylogger = KeyLogger(with: project, daemon: daemon) 455 | 456 | keylogger.start() 457 | --------------------------------------------------------------------------------