├── 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 |
--------------------------------------------------------------------------------