├── .gitignore ├── Applications ├── WindowAlignment.xcodeproj │ └── project.pbxproj └── WindowAlignment │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 512x512.png │ │ │ ├── 512x512@2x.png │ │ │ └── Contents.json │ │ └── Contents.json │ └── MainMenu.xcstrings │ └── Sources │ ├── AppDelegate.swift │ ├── Config.swift │ ├── LoginItem.swift │ ├── MainApp.swift │ ├── MainMenu.swift │ └── Service.swift ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── Sources ├── HotKey │ ├── CarbonEventHandler.swift │ └── HotKey.swift ├── Scripting │ ├── Parser.swift │ ├── ParserCombinator.swift │ ├── Runtime.swift │ └── Tokenizer.swift ├── WindowManager │ ├── Accessibility.swift │ └── WindowManager.swift ├── WindowManagerExtension │ ├── Accessibility.swift │ ├── SkyLightService.swift │ └── WindowManager.swift └── WindowManagerExtern │ ├── WindowManagerExtern.m │ └── include │ ├── Accessibility.h │ └── SkyLight.h └── Tests └── ScriptingTests ├── ParserTests.swift ├── RuntimeTests.swift └── TokenizerTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swiftpm/config/registries.json 3 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 4 | project.xcworkspace/ 5 | xcuserdata/ 6 | /*.xcodeproj 7 | /.build 8 | -------------------------------------------------------------------------------- /Applications/WindowAlignment.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 543BAAE72A60F1E400AEB455 /* Scripting in Frameworks */ = {isa = PBXBuildFile; productRef = 543BAAE62A60F1E400AEB455 /* Scripting */; }; 11 | 543EBC1E2A58CF0F00AD2272 /* WindowManagerExtension in Frameworks */ = {isa = PBXBuildFile; productRef = 543EBC1D2A58CF0F00AD2272 /* WindowManagerExtension */; }; 12 | 54BF0D182A52573700ACDDD8 /* MainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54BF0D172A52573700ACDDD8 /* MainApp.swift */; }; 13 | 54BF0D1C2A52573800ACDDD8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54BF0D1B2A52573800ACDDD8 /* Assets.xcassets */; }; 14 | 54BF0D1F2A52573800ACDDD8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54BF0D1E2A52573800ACDDD8 /* Preview Assets.xcassets */; }; 15 | 54BF0D2D2A52996200ACDDD8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54BF0D2C2A52996200ACDDD8 /* AppDelegate.swift */; }; 16 | 54BF0D2F2A529DAF00ACDDD8 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54BF0D2E2A529DAF00ACDDD8 /* MainMenu.swift */; }; 17 | 54CC9B282A57C8410046211B /* HotKey in Frameworks */ = {isa = PBXBuildFile; productRef = 54CC9B272A57C8410046211B /* HotKey */; }; 18 | 54CC9B322A57CF460046211B /* WindowManager in Frameworks */ = {isa = PBXBuildFile; productRef = 54CC9B312A57CF460046211B /* WindowManager */; }; 19 | 54F7A5742A6118B2003E35C0 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F7A5732A6118B2003E35C0 /* Config.swift */; }; 20 | 54F7A5762A611F48003E35C0 /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F7A5752A611F48003E35C0 /* Service.swift */; }; 21 | 54FCFCD82AB920D600C2DECA /* MainMenu.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 54FCFCD72AB920D600C2DECA /* MainMenu.xcstrings */; }; 22 | 54FE055F2A61F6EF000179BD /* LoginItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54FE055E2A61F6EF000179BD /* LoginItem.swift */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | 543EBC1B2A58CC3B00AD2272 /* WindowAlignment */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = WindowAlignment; path = ..; sourceTree = ""; }; 27 | 54BF0D142A52573700ACDDD8 /* WindowAlignment.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WindowAlignment.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | 54BF0D172A52573700ACDDD8 /* MainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainApp.swift; sourceTree = ""; }; 29 | 54BF0D1B2A52573800ACDDD8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 30 | 54BF0D1E2A52573800ACDDD8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 31 | 54BF0D2C2A52996200ACDDD8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 32 | 54BF0D2E2A529DAF00ACDDD8 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = ""; }; 33 | 54F7A5732A6118B2003E35C0 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 34 | 54F7A5752A611F48003E35C0 /* Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; 35 | 54FCFCD72AB920D600C2DECA /* MainMenu.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = MainMenu.xcstrings; sourceTree = ""; }; 36 | 54FE055E2A61F6EF000179BD /* LoginItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginItem.swift; sourceTree = ""; }; 37 | /* End PBXFileReference section */ 38 | 39 | /* Begin PBXFrameworksBuildPhase section */ 40 | 54BF0D112A52573700ACDDD8 /* Frameworks */ = { 41 | isa = PBXFrameworksBuildPhase; 42 | buildActionMask = 2147483647; 43 | files = ( 44 | 543BAAE72A60F1E400AEB455 /* Scripting in Frameworks */, 45 | 54CC9B282A57C8410046211B /* HotKey in Frameworks */, 46 | 54CC9B322A57CF460046211B /* WindowManager in Frameworks */, 47 | 543EBC1E2A58CF0F00AD2272 /* WindowManagerExtension in Frameworks */, 48 | ); 49 | runOnlyForDeploymentPostprocessing = 0; 50 | }; 51 | /* End PBXFrameworksBuildPhase section */ 52 | 53 | /* Begin PBXGroup section */ 54 | 54BF0D0B2A52573700ACDDD8 = { 55 | isa = PBXGroup; 56 | children = ( 57 | 54BF0D162A52573700ACDDD8 /* WindowAlignment */, 58 | 54CC9B2B2A57C8CD0046211B /* Packages */, 59 | 54BF0D152A52573700ACDDD8 /* Products */, 60 | 54CC9B302A57CF460046211B /* Frameworks */, 61 | ); 62 | sourceTree = ""; 63 | }; 64 | 54BF0D152A52573700ACDDD8 /* Products */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | 54BF0D142A52573700ACDDD8 /* WindowAlignment.app */, 68 | ); 69 | name = Products; 70 | sourceTree = ""; 71 | }; 72 | 54BF0D162A52573700ACDDD8 /* WindowAlignment */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | 54BF0D1D2A52573800ACDDD8 /* Preview Content */, 76 | 54BF0D2B2A52793000ACDDD8 /* Resources */, 77 | 54BF0D2A2A52792B00ACDDD8 /* Sources */, 78 | ); 79 | path = WindowAlignment; 80 | sourceTree = ""; 81 | }; 82 | 54BF0D1D2A52573800ACDDD8 /* Preview Content */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | 54BF0D1E2A52573800ACDDD8 /* Preview Assets.xcassets */, 86 | ); 87 | path = "Preview Content"; 88 | sourceTree = ""; 89 | }; 90 | 54BF0D2A2A52792B00ACDDD8 /* Sources */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | 54BF0D2C2A52996200ACDDD8 /* AppDelegate.swift */, 94 | 54F7A5732A6118B2003E35C0 /* Config.swift */, 95 | 54FE055E2A61F6EF000179BD /* LoginItem.swift */, 96 | 54BF0D172A52573700ACDDD8 /* MainApp.swift */, 97 | 54BF0D2E2A529DAF00ACDDD8 /* MainMenu.swift */, 98 | 54F7A5752A611F48003E35C0 /* Service.swift */, 99 | ); 100 | path = Sources; 101 | sourceTree = ""; 102 | }; 103 | 54BF0D2B2A52793000ACDDD8 /* Resources */ = { 104 | isa = PBXGroup; 105 | children = ( 106 | 54BF0D1B2A52573800ACDDD8 /* Assets.xcassets */, 107 | 54FCFCD72AB920D600C2DECA /* MainMenu.xcstrings */, 108 | ); 109 | path = Resources; 110 | sourceTree = ""; 111 | }; 112 | 54CC9B2B2A57C8CD0046211B /* Packages */ = { 113 | isa = PBXGroup; 114 | children = ( 115 | 543EBC1B2A58CC3B00AD2272 /* WindowAlignment */, 116 | ); 117 | name = Packages; 118 | sourceTree = ""; 119 | }; 120 | 54CC9B302A57CF460046211B /* Frameworks */ = { 121 | isa = PBXGroup; 122 | children = ( 123 | ); 124 | name = Frameworks; 125 | sourceTree = ""; 126 | }; 127 | /* End PBXGroup section */ 128 | 129 | /* Begin PBXNativeTarget section */ 130 | 54BF0D132A52573700ACDDD8 /* WindowAlignment */ = { 131 | isa = PBXNativeTarget; 132 | buildConfigurationList = 54BF0D232A52573800ACDDD8 /* Build configuration list for PBXNativeTarget "WindowAlignment" */; 133 | buildPhases = ( 134 | 54BF0D102A52573700ACDDD8 /* Sources */, 135 | 54BF0D112A52573700ACDDD8 /* Frameworks */, 136 | 54BF0D122A52573700ACDDD8 /* Resources */, 137 | 54E29DC42A6E476D008F08E1 /* ShellScript */, 138 | ); 139 | buildRules = ( 140 | ); 141 | dependencies = ( 142 | ); 143 | name = WindowAlignment; 144 | packageProductDependencies = ( 145 | 54CC9B272A57C8410046211B /* HotKey */, 146 | 54CC9B312A57CF460046211B /* WindowManager */, 147 | 543EBC1D2A58CF0F00AD2272 /* WindowManagerExtension */, 148 | 543BAAE62A60F1E400AEB455 /* Scripting */, 149 | ); 150 | productName = WindowAlignment; 151 | productReference = 54BF0D142A52573700ACDDD8 /* WindowAlignment.app */; 152 | productType = "com.apple.product-type.application"; 153 | }; 154 | /* End PBXNativeTarget section */ 155 | 156 | /* Begin PBXProject section */ 157 | 54BF0D0C2A52573700ACDDD8 /* Project object */ = { 158 | isa = PBXProject; 159 | attributes = { 160 | BuildIndependentTargetsInParallel = 1; 161 | LastSwiftUpdateCheck = 1500; 162 | LastUpgradeCheck = 1500; 163 | TargetAttributes = { 164 | 54BF0D132A52573700ACDDD8 = { 165 | CreatedOnToolsVersion = 15.0; 166 | }; 167 | }; 168 | }; 169 | buildConfigurationList = 54BF0D0F2A52573700ACDDD8 /* Build configuration list for PBXProject "WindowAlignment" */; 170 | compatibilityVersion = "Xcode 14.0"; 171 | developmentRegion = en; 172 | hasScannedForEncodings = 0; 173 | knownRegions = ( 174 | en, 175 | Base, 176 | ja, 177 | ); 178 | mainGroup = 54BF0D0B2A52573700ACDDD8; 179 | packageReferences = ( 180 | ); 181 | productRefGroup = 54BF0D152A52573700ACDDD8 /* Products */; 182 | projectDirPath = ""; 183 | projectRoot = ""; 184 | targets = ( 185 | 54BF0D132A52573700ACDDD8 /* WindowAlignment */, 186 | ); 187 | }; 188 | /* End PBXProject section */ 189 | 190 | /* Begin PBXResourcesBuildPhase section */ 191 | 54BF0D122A52573700ACDDD8 /* Resources */ = { 192 | isa = PBXResourcesBuildPhase; 193 | buildActionMask = 2147483647; 194 | files = ( 195 | 54BF0D1F2A52573800ACDDD8 /* Preview Assets.xcassets in Resources */, 196 | 54BF0D1C2A52573800ACDDD8 /* Assets.xcassets in Resources */, 197 | 54FCFCD82AB920D600C2DECA /* MainMenu.xcstrings in Resources */, 198 | ); 199 | runOnlyForDeploymentPostprocessing = 0; 200 | }; 201 | /* End PBXResourcesBuildPhase section */ 202 | 203 | /* Begin PBXShellScriptBuildPhase section */ 204 | 54E29DC42A6E476D008F08E1 /* ShellScript */ = { 205 | isa = PBXShellScriptBuildPhase; 206 | alwaysOutOfDate = 1; 207 | buildActionMask = 2147483647; 208 | files = ( 209 | ); 210 | inputFileListPaths = ( 211 | ); 212 | inputPaths = ( 213 | ); 214 | outputFileListPaths = ( 215 | ); 216 | outputPaths = ( 217 | ); 218 | runOnlyForDeploymentPostprocessing = 0; 219 | shellPath = /bin/sh; 220 | shellScript = "#!/usr/bin/env bash\n/usr/bin/tccutil reset Accessibility \"${PRODUCT_BUNDLE_IDENTIFIER}\" || true\n"; 221 | }; 222 | /* End PBXShellScriptBuildPhase section */ 223 | 224 | /* Begin PBXSourcesBuildPhase section */ 225 | 54BF0D102A52573700ACDDD8 /* Sources */ = { 226 | isa = PBXSourcesBuildPhase; 227 | buildActionMask = 2147483647; 228 | files = ( 229 | 54BF0D2D2A52996200ACDDD8 /* AppDelegate.swift in Sources */, 230 | 54F7A5742A6118B2003E35C0 /* Config.swift in Sources */, 231 | 54FE055F2A61F6EF000179BD /* LoginItem.swift in Sources */, 232 | 54F7A5762A611F48003E35C0 /* Service.swift in Sources */, 233 | 54BF0D2F2A529DAF00ACDDD8 /* MainMenu.swift in Sources */, 234 | 54BF0D182A52573700ACDDD8 /* MainApp.swift in Sources */, 235 | ); 236 | runOnlyForDeploymentPostprocessing = 0; 237 | }; 238 | /* End PBXSourcesBuildPhase section */ 239 | 240 | /* Begin XCBuildConfiguration section */ 241 | 54BF0D212A52573800ACDDD8 /* Debug */ = { 242 | isa = XCBuildConfiguration; 243 | buildSettings = { 244 | ALWAYS_SEARCH_USER_PATHS = NO; 245 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 246 | CLANG_ANALYZER_NONNULL = YES; 247 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 248 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 249 | CLANG_ENABLE_MODULES = YES; 250 | CLANG_ENABLE_OBJC_ARC = YES; 251 | CLANG_ENABLE_OBJC_WEAK = YES; 252 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 253 | CLANG_WARN_BOOL_CONVERSION = YES; 254 | CLANG_WARN_COMMA = YES; 255 | CLANG_WARN_CONSTANT_CONVERSION = YES; 256 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 257 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 258 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 259 | CLANG_WARN_EMPTY_BODY = YES; 260 | CLANG_WARN_ENUM_CONVERSION = YES; 261 | CLANG_WARN_INFINITE_RECURSION = YES; 262 | CLANG_WARN_INT_CONVERSION = YES; 263 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 264 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 265 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 266 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 267 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 268 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 269 | CLANG_WARN_STRICT_PROTOTYPES = YES; 270 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 271 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 272 | CLANG_WARN_UNREACHABLE_CODE = YES; 273 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 274 | COPY_PHASE_STRIP = NO; 275 | DEBUG_INFORMATION_FORMAT = dwarf; 276 | ENABLE_STRICT_OBJC_MSGSEND = YES; 277 | ENABLE_TESTABILITY = YES; 278 | GCC_C_LANGUAGE_STANDARD = gnu17; 279 | GCC_DYNAMIC_NO_PIC = NO; 280 | GCC_NO_COMMON_BLOCKS = YES; 281 | GCC_OPTIMIZATION_LEVEL = 0; 282 | GCC_PREPROCESSOR_DEFINITIONS = ( 283 | "DEBUG=1", 284 | "$(inherited)", 285 | ); 286 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 287 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 288 | GCC_WARN_UNDECLARED_SELECTOR = YES; 289 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 290 | GCC_WARN_UNUSED_FUNCTION = YES; 291 | GCC_WARN_UNUSED_VARIABLE = YES; 292 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 293 | MACOSX_DEPLOYMENT_TARGET = 13.0; 294 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 295 | MTL_FAST_MATH = YES; 296 | ONLY_ACTIVE_ARCH = YES; 297 | SDKROOT = macosx; 298 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 299 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 300 | }; 301 | name = Debug; 302 | }; 303 | 54BF0D222A52573800ACDDD8 /* Release */ = { 304 | isa = XCBuildConfiguration; 305 | buildSettings = { 306 | ALWAYS_SEARCH_USER_PATHS = NO; 307 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 308 | CLANG_ANALYZER_NONNULL = YES; 309 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 310 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 311 | CLANG_ENABLE_MODULES = YES; 312 | CLANG_ENABLE_OBJC_ARC = YES; 313 | CLANG_ENABLE_OBJC_WEAK = YES; 314 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 315 | CLANG_WARN_BOOL_CONVERSION = YES; 316 | CLANG_WARN_COMMA = YES; 317 | CLANG_WARN_CONSTANT_CONVERSION = YES; 318 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 319 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 320 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 321 | CLANG_WARN_EMPTY_BODY = YES; 322 | CLANG_WARN_ENUM_CONVERSION = YES; 323 | CLANG_WARN_INFINITE_RECURSION = YES; 324 | CLANG_WARN_INT_CONVERSION = YES; 325 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 326 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 327 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 328 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 329 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 330 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 331 | CLANG_WARN_STRICT_PROTOTYPES = YES; 332 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 333 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 334 | CLANG_WARN_UNREACHABLE_CODE = YES; 335 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 336 | COPY_PHASE_STRIP = NO; 337 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 338 | ENABLE_NS_ASSERTIONS = NO; 339 | ENABLE_STRICT_OBJC_MSGSEND = YES; 340 | GCC_C_LANGUAGE_STANDARD = gnu17; 341 | GCC_NO_COMMON_BLOCKS = YES; 342 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 343 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 344 | GCC_WARN_UNDECLARED_SELECTOR = YES; 345 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 346 | GCC_WARN_UNUSED_FUNCTION = YES; 347 | GCC_WARN_UNUSED_VARIABLE = YES; 348 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 349 | MACOSX_DEPLOYMENT_TARGET = 13.0; 350 | MTL_ENABLE_DEBUG_INFO = NO; 351 | MTL_FAST_MATH = YES; 352 | SDKROOT = macosx; 353 | SWIFT_COMPILATION_MODE = wholemodule; 354 | }; 355 | name = Release; 356 | }; 357 | 54BF0D242A52573800ACDDD8 /* Debug */ = { 358 | isa = XCBuildConfiguration; 359 | buildSettings = { 360 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 361 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 362 | CODE_SIGN_STYLE = Manual; 363 | COMBINE_HIDPI_IMAGES = YES; 364 | CURRENT_PROJECT_VERSION = 2; 365 | DEVELOPMENT_ASSET_PATHS = "\"WindowAlignment/Preview Content\""; 366 | ENABLE_PREVIEWS = YES; 367 | GENERATE_INFOPLIST_FILE = YES; 368 | INFOPLIST_KEY_CFBundleDisplayName = WindowAlignment; 369 | INFOPLIST_KEY_LSUIElement = YES; 370 | LD_RUNPATH_SEARCH_PATHS = ( 371 | "$(inherited)", 372 | "@executable_path/../Frameworks", 373 | ); 374 | MARKETING_VERSION = 0.2.0; 375 | PRODUCT_BUNDLE_IDENTIFIER = at.niw.WindowAlignment; 376 | PRODUCT_NAME = "$(TARGET_NAME)"; 377 | SWIFT_EMIT_LOC_STRINGS = YES; 378 | SWIFT_VERSION = 5.0; 379 | }; 380 | name = Debug; 381 | }; 382 | 54BF0D252A52573800ACDDD8 /* Release */ = { 383 | isa = XCBuildConfiguration; 384 | buildSettings = { 385 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 386 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 387 | CODE_SIGN_STYLE = Manual; 388 | COMBINE_HIDPI_IMAGES = YES; 389 | CURRENT_PROJECT_VERSION = 2; 390 | DEVELOPMENT_ASSET_PATHS = "\"WindowAlignment/Preview Content\""; 391 | ENABLE_PREVIEWS = YES; 392 | GENERATE_INFOPLIST_FILE = YES; 393 | INFOPLIST_KEY_CFBundleDisplayName = WindowAlignment; 394 | INFOPLIST_KEY_LSUIElement = YES; 395 | LD_RUNPATH_SEARCH_PATHS = ( 396 | "$(inherited)", 397 | "@executable_path/../Frameworks", 398 | ); 399 | MARKETING_VERSION = 0.2.0; 400 | PRODUCT_BUNDLE_IDENTIFIER = at.niw.WindowAlignment; 401 | PRODUCT_NAME = "$(TARGET_NAME)"; 402 | SWIFT_EMIT_LOC_STRINGS = YES; 403 | SWIFT_VERSION = 5.0; 404 | }; 405 | name = Release; 406 | }; 407 | /* End XCBuildConfiguration section */ 408 | 409 | /* Begin XCConfigurationList section */ 410 | 54BF0D0F2A52573700ACDDD8 /* Build configuration list for PBXProject "WindowAlignment" */ = { 411 | isa = XCConfigurationList; 412 | buildConfigurations = ( 413 | 54BF0D212A52573800ACDDD8 /* Debug */, 414 | 54BF0D222A52573800ACDDD8 /* Release */, 415 | ); 416 | defaultConfigurationIsVisible = 0; 417 | defaultConfigurationName = Release; 418 | }; 419 | 54BF0D232A52573800ACDDD8 /* Build configuration list for PBXNativeTarget "WindowAlignment" */ = { 420 | isa = XCConfigurationList; 421 | buildConfigurations = ( 422 | 54BF0D242A52573800ACDDD8 /* Debug */, 423 | 54BF0D252A52573800ACDDD8 /* Release */, 424 | ); 425 | defaultConfigurationIsVisible = 0; 426 | defaultConfigurationName = Release; 427 | }; 428 | /* End XCConfigurationList section */ 429 | 430 | /* Begin XCSwiftPackageProductDependency section */ 431 | 543BAAE62A60F1E400AEB455 /* Scripting */ = { 432 | isa = XCSwiftPackageProductDependency; 433 | productName = Scripting; 434 | }; 435 | 543EBC1D2A58CF0F00AD2272 /* WindowManagerExtension */ = { 436 | isa = XCSwiftPackageProductDependency; 437 | productName = WindowManagerExtension; 438 | }; 439 | 54CC9B272A57C8410046211B /* HotKey */ = { 440 | isa = XCSwiftPackageProductDependency; 441 | productName = HotKey; 442 | }; 443 | 54CC9B312A57CF460046211B /* WindowManager */ = { 444 | isa = XCSwiftPackageProductDependency; 445 | productName = WindowManager; 446 | }; 447 | /* End XCSwiftPackageProductDependency section */ 448 | }; 449 | rootObject = 54BF0D0C2A52573700ACDDD8 /* Project object */; 450 | } 451 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Resources/Assets.xcassets/AppIcon.appiconset/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niw/WindowAlignment/982dea113c46aedacf28f5f9fee25f88f4fa2e3b/Applications/WindowAlignment/Resources/Assets.xcassets/AppIcon.appiconset/512x512.png -------------------------------------------------------------------------------- /Applications/WindowAlignment/Resources/Assets.xcassets/AppIcon.appiconset/512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niw/WindowAlignment/982dea113c46aedacf28f5f9fee25f88f4fa2e3b/Applications/WindowAlignment/Resources/Assets.xcassets/AppIcon.appiconset/512x512@2x.png -------------------------------------------------------------------------------- /Applications/WindowAlignment/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "filename" : "512x512.png", 45 | "idiom" : "mac", 46 | "scale" : "1x", 47 | "size" : "512x512" 48 | }, 49 | { 50 | "filename" : "512x512@2x.png", 51 | "idiom" : "mac", 52 | "scale" : "2x", 53 | "size" : "512x512" 54 | } 55 | ], 56 | "info" : { 57 | "author" : "xcode", 58 | "version" : 1 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Resources/MainMenu.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "About %@" : { 5 | "comment" : "A main menu item to present a window about the application.", 6 | "localizations" : { 7 | "ja" : { 8 | "stringUnit" : { 9 | "state" : "translated", 10 | "value" : "%@について" 11 | } 12 | } 13 | } 14 | }, 15 | "Configuration Error" : { 16 | "comment" : "A main menu item appears when there is an error in the configuration file.", 17 | "localizations" : { 18 | "ja" : { 19 | "stringUnit" : { 20 | "state" : "translated", 21 | "value" : "設定ファイルに問題があります" 22 | } 23 | } 24 | } 25 | }, 26 | "Open Configuration File" : { 27 | "comment" : "A main menu item to open the configuration file.", 28 | "localizations" : { 29 | "ja" : { 30 | "stringUnit" : { 31 | "state" : "translated", 32 | "value" : "設定ファイルを開く" 33 | } 34 | } 35 | } 36 | }, 37 | "Quit %@" : { 38 | "comment" : "A main menu item to terminate the application.", 39 | "localizations" : { 40 | "ja" : { 41 | "stringUnit" : { 42 | "state" : "translated", 43 | "value" : "%@を終了" 44 | } 45 | } 46 | } 47 | }, 48 | "Reload Configuration" : { 49 | "comment" : "A main menu item to reload the configuration file.", 50 | "localizations" : { 51 | "ja" : { 52 | "stringUnit" : { 53 | "state" : "translated", 54 | "value" : "設定ファイルを再読み込み" 55 | } 56 | } 57 | } 58 | }, 59 | "Start on Login" : { 60 | "comment" : "A main menu item to start the application on login.", 61 | "localizations" : { 62 | "ja" : { 63 | "stringUnit" : { 64 | "state" : "translated", 65 | "value" : "ログイン時に起動" 66 | } 67 | } 68 | } 69 | }, 70 | "Unknown Error" : { 71 | "comment" : "A main menu item appears when there is an unknown error.", 72 | "localizations" : { 73 | "ja" : { 74 | "stringUnit" : { 75 | "state" : "translated", 76 | "value" : "不明なエラー" 77 | } 78 | } 79 | } 80 | }, 81 | "Waiting Accessibility Access…" : { 82 | "comment" : "A main menu item appears when the application is waiting Accessibility Access.", 83 | "localizations" : { 84 | "ja" : { 85 | "stringUnit" : { 86 | "state" : "translated", 87 | "value" : "アクセシビリティアクセスを許可してください…" 88 | } 89 | } 90 | } 91 | } 92 | }, 93 | "version" : "1.0" 94 | } -------------------------------------------------------------------------------- /Applications/WindowAlignment/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // WindowAlignment 4 | // 5 | // Created by Yoshimasa Niwa on 7/2/23. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | 11 | @MainActor 12 | final class AppDelegate: NSObject, ObservableObject { 13 | var localizedName: String { 14 | for case let infoDictionary? in [ 15 | Bundle.main.localizedInfoDictionary, 16 | Bundle.main.infoDictionary 17 | ] { 18 | for key in [ 19 | "CFBundleDisplayName", 20 | "CFBundleName" 21 | ] { 22 | if let localizedName = infoDictionary[key] as? String { 23 | return localizedName 24 | } 25 | } 26 | } 27 | 28 | // Should not reach here. 29 | return "" 30 | } 31 | 32 | func presentAboutPanel() { 33 | if (NSApp.activationPolicy() == .accessory) { 34 | NSApp.activate(ignoringOtherApps: true) 35 | } 36 | NSApp.orderFrontStandardAboutPanel() 37 | } 38 | 39 | func terminate() { 40 | NSApp.terminate(nil) 41 | } 42 | 43 | @Published 44 | private(set) var loginItem: LoginItem? 45 | 46 | @Published 47 | private(set) var service: Service? 48 | 49 | func reloadService() { 50 | let configFilePath = (NSHomeDirectory() as NSString).appendingPathComponent(".window_alignment.json") 51 | 52 | let service = Service(configFilePath: configFilePath) 53 | self.service = service 54 | 55 | Task { 56 | try await service.start() 57 | } 58 | } 59 | 60 | func openConfigFile() { 61 | guard let service = service else { 62 | return 63 | } 64 | let configFileURL = URL(filePath: service.configFilePath) 65 | NSWorkspace.shared.open(configFileURL) 66 | } 67 | } 68 | 69 | extension AppDelegate: NSApplicationDelegate { 70 | func applicationDidFinishLaunching(_ notification: Notification) { 71 | loginItem = LoginItem() 72 | reloadService() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Sources/Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.swift 3 | // WindowAlignment 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Config: Equatable, Codable { 11 | struct Action: Equatable, Codable { 12 | struct HotKey: Equatable, Codable { 13 | enum Modifiers: String, Equatable, Codable { 14 | case shift 15 | case control 16 | case option 17 | case command 18 | } 19 | 20 | var keyCode: UInt32 21 | var modifiers: [Modifiers] 22 | } 23 | 24 | struct Move: Equatable, Codable { 25 | var x: String 26 | var y: String 27 | } 28 | 29 | struct Resize: Equatable, Codable { 30 | var width: String 31 | var height: String 32 | } 33 | 34 | var hotKey: HotKey 35 | var move: Move? 36 | var resize: Resize? 37 | } 38 | 39 | var actions: [Action] 40 | } 41 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Sources/LoginItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginItem.swift 3 | // WindowAlignment 4 | // 5 | // Created by Yoshimasa Niwa on 7/14/23. 6 | // 7 | 8 | import Foundation 9 | import ServiceManagement 10 | 11 | private extension SMAppService { 12 | var isEnabled: Bool { 13 | status == .enabled 14 | } 15 | } 16 | 17 | @MainActor 18 | final class LoginItem: ObservableObject { 19 | @Published 20 | var isEnabled: Bool { 21 | didSet { 22 | guard isEnabled != oldValue else { 23 | return 24 | } 25 | update() 26 | } 27 | } 28 | 29 | private var isUpdating: Bool = false 30 | 31 | private func update() { 32 | guard !isUpdating else { 33 | return 34 | } 35 | isUpdating = true 36 | defer { 37 | isUpdating = false 38 | } 39 | 40 | do { 41 | if isEnabled { 42 | try SMAppService.mainApp.register() 43 | } else { 44 | try SMAppService.mainApp.unregister() 45 | } 46 | } catch { 47 | } 48 | isEnabled = SMAppService.mainApp.isEnabled 49 | } 50 | 51 | init() { 52 | isEnabled = SMAppService.mainApp.isEnabled 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Sources/MainApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainApp.swift 3 | // WindowAlignment 4 | // 5 | // Created by Yoshimasa Niwa on 7/2/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | @main 12 | struct MainApp: App { 13 | @NSApplicationDelegateAdaptor 14 | private var appDelegate: AppDelegate 15 | 16 | var body: some Scene { 17 | MenuBarExtra(appDelegate.localizedName, systemImage: "macwindow.on.rectangle") { 18 | MainMenu() 19 | // `@NSApplicationDelegateAdaptor` supposed to put the object in the Environment 20 | // as its documentation said, however, in reality, it only works for `WindowGroup` views. 21 | // Therefore we need to manually put it here for `MenuBarExtra` views. 22 | .environmentObject(appDelegate) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Sources/MainMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainMenu.swift 3 | // WindowAlignment 4 | // 5 | // Created by Yoshimasa Niwa on 7/2/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct ServiceStatusView: View { 12 | @ObservedObject 13 | var service: Service 14 | 15 | var body: some View { 16 | switch service.state { 17 | case .configError: 18 | Text( 19 | "Configuration Error", 20 | tableName: "MainMenu", 21 | comment: "A main menu item appears when there is an error in the configuration file." 22 | ) 23 | case .error: 24 | Text( 25 | "Unknown Error", 26 | tableName: "MainMenu", 27 | comment: "A main menu item appears when there is an unknown error." 28 | ) 29 | case .waitingProcessTrusted: 30 | Text( 31 | "Waiting Accessibility Access…", 32 | tableName: "MainMenu", 33 | comment: "A main menu item appears when the application is waiting Accessibility Access." 34 | ) 35 | case .none, .ready: 36 | EmptyView() 37 | } 38 | } 39 | } 40 | 41 | struct LoginItemView: View { 42 | @ObservedObject 43 | var loginItem: LoginItem 44 | 45 | var body: some View { 46 | Toggle(isOn: $loginItem.isEnabled) { 47 | Text( 48 | "Start on Login", 49 | tableName: "MainMenu", 50 | comment: "A main menu item to start the application on login." 51 | ) 52 | } 53 | } 54 | } 55 | 56 | struct MainMenu: View { 57 | @EnvironmentObject 58 | private var appDelegate: AppDelegate 59 | 60 | var body: some View { 61 | if let service = appDelegate.service { 62 | Section { 63 | ServiceStatusView(service: service) 64 | } 65 | } 66 | Section { 67 | Button { 68 | appDelegate.openConfigFile() 69 | } label: { 70 | Text( 71 | "Open Configuration File", 72 | tableName: "MainMenu", 73 | comment: "A main menu item to open the configuration file." 74 | ) 75 | } 76 | .keyboardShortcut("O") 77 | Button { 78 | appDelegate.reloadService() 79 | } label: { 80 | Text( 81 | "Reload Configuration", 82 | tableName: "MainMenu", 83 | comment: "A main menu item to reload the configuration file." 84 | ) 85 | } 86 | .keyboardShortcut("R") 87 | } 88 | Section { 89 | if let loginItem = appDelegate.loginItem { 90 | LoginItemView(loginItem: loginItem) 91 | } 92 | Button { 93 | appDelegate.presentAboutPanel() 94 | } label: { 95 | Text( 96 | "About \(appDelegate.localizedName)", 97 | tableName: "MainMenu", 98 | comment: "A main menu item to present a window about the application." 99 | ) 100 | } 101 | Button { 102 | appDelegate.terminate() 103 | } label: { 104 | Text( 105 | "Quit \(appDelegate.localizedName)", 106 | tableName: "MainMenu", 107 | comment: "A main menu item to terminate the application." 108 | ) 109 | } 110 | .keyboardShortcut("Q") 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Applications/WindowAlignment/Sources/Service.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Service.swift 3 | // WindowAlignment 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import Foundation 9 | import HotKey 10 | import Scripting 11 | import WindowManager 12 | import WindowManagerExtension 13 | 14 | private extension Config { 15 | static func load(from configFileURL: URL) throws -> Self { 16 | let decoder = JSONDecoder() 17 | decoder.keyDecodingStrategy = .convertFromSnakeCase 18 | let data = try Data(contentsOf: configFileURL) 19 | return try decoder.decode(self, from: data) 20 | } 21 | 22 | func save(to configFileURL: URL) throws { 23 | let encoder = JSONEncoder() 24 | encoder.keyEncodingStrategy = .convertToSnakeCase 25 | encoder.outputFormatting = .prettyPrinted 26 | let data = try encoder.encode(self) 27 | try data.write(to: configFileURL) 28 | } 29 | 30 | static var example: Self { 31 | .init(actions: [ 32 | .init( 33 | hotKey: .init( 34 | keyCode: HotKey.KeyCode.up.rawValue, 35 | modifiers: [.shift, .command] 36 | ), 37 | move: .init( 38 | x: "screen.x + screen.width * 0.125", 39 | y: "screen.y" 40 | ), 41 | resize: .init( 42 | width: "screen.width - (screen.width * 0.125) * 2", 43 | height: "screen.height" 44 | ) 45 | ), 46 | .init( 47 | hotKey: .init( 48 | keyCode: HotKey.KeyCode.down.rawValue, 49 | modifiers: [.shift, .command] 50 | ), 51 | move: .init( 52 | x: "screen.x", 53 | y: "screen.y" 54 | ), 55 | resize: .init( 56 | width: "screen.width", 57 | height: "screen.height" 58 | ) 59 | ) 60 | ]) 61 | } 62 | } 63 | 64 | private extension HotKey.Modifiers { 65 | static func build(from modifiers: [Config.Action.HotKey.Modifiers]) -> Self { 66 | modifiers.reduce([]) { result, modifier in 67 | let hotKeyModifier: Self 68 | switch modifier { 69 | case .shift: 70 | hotKeyModifier = .shift 71 | case .control: 72 | hotKeyModifier = .control 73 | case .option: 74 | hotKeyModifier = .option 75 | case .command: 76 | hotKeyModifier = .command 77 | } 78 | return result.union(hotKeyModifier) 79 | } 80 | } 81 | } 82 | 83 | enum ServiceError: Error { 84 | case failedToAddHotKey(Service.Action) 85 | } 86 | 87 | @MainActor 88 | final class Service: ObservableObject { 89 | struct Action { 90 | var keyCode: HotKey.KeyCode 91 | var modifiers: HotKey.Modifiers 92 | 93 | struct MoveCode { 94 | var x: Runtime.Code 95 | var y: Runtime.Code 96 | } 97 | 98 | struct ResizeCode { 99 | var width: Runtime.Code 100 | var height: Runtime.Code 101 | } 102 | 103 | var moveCode: MoveCode? 104 | var resizeCode: ResizeCode? 105 | 106 | init(config action: Config.Action) throws { 107 | keyCode = HotKey.KeyCode.raw(action.hotKey.keyCode) 108 | modifiers = HotKey.Modifiers.build(from: action.hotKey.modifiers) 109 | moveCode = try action.move.map { move in 110 | try MoveCode( 111 | x: .compile(source: move.x), 112 | y: .compile(source: move.y) 113 | ) 114 | } 115 | resizeCode = try action.resize.map { resize in 116 | try ResizeCode( 117 | width: .compile(source: resize.width), 118 | height: .compile(source: resize.height) 119 | ) 120 | } 121 | } 122 | } 123 | 124 | private func hotKeyDidPress(for action: Action) { 125 | guard let window = WindowManager.App.focused?.focusedWindow, 126 | let screen = window.screen 127 | else { 128 | return 129 | } 130 | 131 | let screenBounds = screen.visibleBounds 132 | let windowSize = window.size 133 | let windowPosition = window.position 134 | 135 | let runtime = Runtime { name in 136 | switch name { 137 | case "screen.width": 138 | return screenBounds.size.width 139 | case "screen.height": 140 | return screenBounds.size.height 141 | case "screen.x": 142 | return screenBounds.origin.x 143 | case "screen.y": 144 | return screenBounds.origin.y 145 | case "window.width": 146 | return windowSize?.width ?? 0.0 147 | case "window.height": 148 | return windowSize?.height ?? 0.0 149 | case "window.x": 150 | return windowPosition?.x ?? 0.0 151 | case "window.y": 152 | return windowPosition?.y ?? 0.0 153 | default: 154 | return nil 155 | } 156 | } 157 | 158 | do { 159 | if let moveCode = action.moveCode, 160 | let x = try runtime.run(code: moveCode.x), 161 | let y = try runtime.run(code: moveCode.y) { 162 | window.move(to: CGPoint(x: x, y: y)) 163 | } 164 | if let resizeCode = action.resizeCode, 165 | let width = try runtime.run(code: resizeCode.width), 166 | let height = try runtime.run(code: resizeCode.height) { 167 | window.resize(to: CGSize(width: width, height: height)) 168 | } 169 | } catch { 170 | } 171 | } 172 | 173 | private var hotKeys = [HotKey]() 174 | 175 | private func addHotKey(for action: Action) throws { 176 | guard let hotKey = HotKey.add( 177 | keyCode: action.keyCode, 178 | modifiers: action.modifiers, 179 | handler: { [weak self] in 180 | self?.hotKeyDidPress(for: action) 181 | } 182 | ) else { 183 | throw ServiceError.failedToAddHotKey(action) 184 | } 185 | hotKeys.append(hotKey) 186 | } 187 | 188 | enum State: String, Equatable { 189 | case none 190 | case configError 191 | case error 192 | case waitingProcessTrusted 193 | case ready 194 | } 195 | 196 | @Published 197 | private(set) var state: State = .none 198 | 199 | let configFilePath: String 200 | 201 | init(configFilePath: String) { 202 | self.configFilePath = configFilePath 203 | } 204 | 205 | func start() async throws { 206 | guard state == .none else { 207 | return 208 | } 209 | 210 | let actions: [Action] 211 | do { 212 | let configFileURL = URL(filePath: configFilePath) 213 | 214 | if !FileManager.default.fileExists(atPath: configFilePath) { 215 | try Config.example.save(to: configFileURL) 216 | } 217 | 218 | let config = try Config.load(from: configFileURL) 219 | 220 | actions = try config.actions.map { action in 221 | try Action(config: action) 222 | } 223 | } catch { 224 | state = .configError 225 | throw error 226 | } 227 | 228 | state = .waitingProcessTrusted 229 | do { 230 | try await Accessibility.waitForBeingProcessTrusted() 231 | 232 | for action in actions { 233 | try addHotKey(for: action) 234 | } 235 | } catch { 236 | state = .error 237 | throw error 238 | } 239 | 240 | state = .ready 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Yoshimasa Niwa 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := WindowAlignment 2 | 3 | BUNDLE_ID := at.niw.$(NAME) 4 | 5 | PROJECT_PATH := Applications/$(NAME).xcodeproj 6 | 7 | BUILD_PATH := .build 8 | 9 | ARCHIVE_PATH := $(BUILD_PATH)/archive 10 | ARCHIVE_PRODUCT_BUNDLE_PATH := $(ARCHIVE_PATH).xcarchive/Products/Applications/$(NAME).app 11 | 12 | RELEASE_ARCHIVE_PATH := $(BUILD_PATH)/$(NAME).zip 13 | 14 | .DEFAULT_GOAL = release 15 | 16 | .PHONY: clean 17 | clean: 18 | git clean -dfX 19 | 20 | .PHONY: reset_accessibility_access 21 | reset_accessibility_access: 22 | tccutil reset Accessibility $(BUNDLE_ID) 23 | 24 | $(ARCHIVE_PRODUCT_BUNDLE_PATH): 25 | xcodebuild \ 26 | -project "$(PROJECT_PATH)" \ 27 | -configuration Release \ 28 | -scheme "$(NAME)" \ 29 | -derivedDataPath "$(BUILD_PATH)" \ 30 | -archivePath "$(ARCHIVE_PATH)" \ 31 | archive 32 | 33 | .PHONY: archive 34 | archive: $(ARCHIVE_PRODUCT_BUNDLE_PATH) 35 | 36 | $(RELEASE_ARCHIVE_PATH): $(ARCHIVE_PRODUCT_BUNDLE_PATH) 37 | ditto -c -k --sequesterRsrc --keepParent "$<" "$@" 38 | 39 | .PHONY: release 40 | release: $(RELEASE_ARCHIVE_PATH) 41 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "WindowAlignment", 8 | platforms: [ 9 | .macOS(.v13) 10 | ], 11 | products: [ 12 | .library( 13 | name: "HotKey", 14 | targets: [ 15 | "HotKey" 16 | ] 17 | ), 18 | .library( 19 | name: "WindowManager", 20 | targets: [ 21 | "WindowManager" 22 | ] 23 | ), 24 | .library( 25 | name: "WindowManagerExtension", 26 | targets: [ 27 | "WindowManagerExtension" 28 | ] 29 | ), 30 | .library( 31 | name: "Scripting", 32 | targets: [ 33 | "Scripting" 34 | ] 35 | ) 36 | ], 37 | targets: [ 38 | .target( 39 | name: "HotKey" 40 | ), 41 | .target( 42 | name: "WindowManager", 43 | dependencies: [ 44 | .target( 45 | name: "WindowManagerExtern" 46 | ) 47 | ] 48 | ), 49 | .target( 50 | name: "WindowManagerExtern", 51 | linkerSettings: [ 52 | .unsafeFlags([ 53 | "-iframework", "/System/Library/PrivateFrameworks", 54 | "-framework", "SkyLight" 55 | ]) 56 | ] 57 | ), 58 | .target( 59 | name: "WindowManagerExtension", 60 | dependencies: [ 61 | .target( 62 | name: "WindowManager" 63 | ), 64 | .target( 65 | name: "WindowManagerExtern" 66 | ) 67 | ] 68 | ), 69 | .target( 70 | name: "Scripting" 71 | ), 72 | .testTarget( 73 | name: "ScriptingTests", 74 | dependencies: [ 75 | .target( 76 | name: "Scripting" 77 | ) 78 | ] 79 | ) 80 | ] 81 | ) 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WindowAlignment 2 | =============== 3 | 4 | 5 | 6 | A simple macOS application to align the active window by the keyboard 7 | shortcut in the screen. 8 | 9 | Usage 10 | ----- 11 | 12 | Download the latest pre-build application binary from [Releases](https://github.com/niw/WindowAlignment/releases) 13 | page or build it from the source code by following instruction. 14 | 15 | Note that the pre-build application binary is only ad-hoc signed. 16 | Therefore, you need to click Open Anyway to execute it on 17 | Security & Privacy settings in System Settings. 18 | 19 | The application is also need your approval to Accessibility access. 20 | Follow the instruction appears on the dialog. 21 | 22 | 23 | Configuration 24 | ------------- 25 | 26 | Currently the application has no settings user interface. 27 | Instead, it reads a configuration JSON file at `~/.window_alignment.json` 28 | (If there is no such file, the app creates it with example configurations.) 29 | The configuration file contains list of hot key (keyboard shortcut) and 30 | alignment script. 31 | 32 | To configure the behavior, manually change the configuration JSON 33 | file and select Reload Configuration in the application menu. 34 | 35 | This is an example configuration contains one alignment action that can 36 | be triggered by `Shift` + `Command` + `Up` which align the active window 37 | to the middle of screen. 38 | 39 | ```jsonc 40 | { 41 | "actions" : [ 42 | // Each action have "hot_key" and "move" and/or "resize" alignment scripts. 43 | { 44 | "hot_key" : { 45 | // Use macOS virtual key code used for Carbon API. 46 | "key_code" : 126, 47 | // Combination of "shift", "control", "option", or "command" 48 | "modifiers" : [ 49 | "shift", 50 | "command" 51 | ] 52 | }, 53 | // Alignment script to set window position. Optional. 54 | "move" : { 55 | "x" : "screen.x + screen.width * 0.125", 56 | "y" : "screen.y" 57 | }, 58 | // Alignment script to set window size. Optional. 59 | "resize" : { 60 | "width" : "screen.width - (screen.width * 0.125) * 2", 61 | "height" : "screen.height" 62 | } 63 | } 64 | ] 65 | } 66 | ``` 67 | 68 | ### Alignment Script 69 | 70 | The alignment script is a simple regular math syntax with following 71 | variables. 72 | 73 | |Name|Description| 74 | |----|-----------| 75 | |`screen.x`|Horizontal screen position.| 76 | |`screen.y`|Vertical screen position. | 77 | |`screen.width` |Screen width.| 78 | |`screen.height`|Screen height.| 79 | |`window.x`|Horizontal window position.| 80 | |`window.y`|Vertical window position. | 81 | |`window.width` |Window width.| 82 | |`window.height`|Window height.| 83 | 84 | 85 | Build 86 | ----- 87 | 88 | You need to use the latest macOS and Xcode to build the app. 89 | Open `Applications/WindowAlignment.xcodeproj` and build `WindowAlignment` 90 | scheme for running. 91 | 92 | If you have used another binary, next time when you launch the new binary, 93 | it will shows an dialog to approve Accessibility access again. 94 | However, often it doesn't work as expected for the new binary. 95 | Therefore, use following command before launching the new binary to reset 96 | Accessibility access. 97 | 98 | ```bash 99 | tccutil reset Accessibility at.niw.WindowAlignment 100 | ``` 101 | -------------------------------------------------------------------------------- /Sources/HotKey/CarbonEventHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarbonEventHandler.swift 3 | // HotKey 4 | // 5 | // Created by Yoshimasa Niwa on 7/5/23. 6 | // 7 | 8 | import Carbon 9 | import Foundation 10 | 11 | @MainActor 12 | final class CarbonEventHandler { 13 | typealias Handler = (EventRef) -> Void 14 | 15 | // Due to class initialization order, these are `var` and forcibly unwrapped. 16 | private var eventHandlerRef: EventHandlerRef! 17 | private var handler: Handler! 18 | 19 | init?( 20 | eventClass: OSType, 21 | eventKind: UInt32, 22 | handler: @escaping Handler 23 | ) { 24 | var eventTypeSpec = EventTypeSpec( 25 | eventClass: eventClass, 26 | eventKind: eventKind 27 | ) 28 | 29 | var eventHandlerRef: EventHandlerRef? 30 | if InstallEventHandler( 31 | GetEventMonitorTarget(), // inTarget 32 | { (_, eventRef: EventRef?, userData: UnsafeMutableRawPointer?) -> OSStatus in 33 | if let eventRef, let userData { 34 | let this = Unmanaged.fromOpaque(userData).takeUnretainedValue() 35 | this.handleEvent(eventRef: eventRef) 36 | } 37 | return noErr 38 | }, // inHandler 39 | 1, // inNumTypes 40 | &eventTypeSpec, // inList 41 | Unmanaged.passUnretained(self).toOpaque(), // inUserData 42 | &eventHandlerRef // outRef 43 | ) != noErr { 44 | return nil 45 | } 46 | guard let eventHandlerRef else { 47 | return nil 48 | } 49 | 50 | self.eventHandlerRef = eventHandlerRef 51 | self.handler = handler 52 | } 53 | 54 | deinit { 55 | // `eventHandlerRef` here must not be `nil`. 56 | let eventHandlerRef = eventHandlerRef 57 | Task { 58 | await MainActor.run { 59 | RemoveEventHandler(eventHandlerRef) 60 | } 61 | } 62 | } 63 | 64 | private func handleEvent(eventRef: EventRef) { 65 | handler(eventRef) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/HotKey/HotKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotKey.swift 3 | // HotKey 4 | // 5 | // Created by Yoshimasa Niwa on 7/5/23. 6 | // 7 | 8 | import AppKit 9 | import Carbon 10 | import Foundation 11 | 12 | extension FourCharCode: ExpressibleByStringLiteral { 13 | public init(stringLiteral: StringLiteralType) { 14 | if let data = stringLiteral.data(using: .macOSRoman) { 15 | self.init(data.reduce(0) { result, data in 16 | // This `Self()` is needed or truncated to UInt8. 17 | result << 8 + Self(data) 18 | }) 19 | } else { 20 | self.init(0) 21 | } 22 | } 23 | } 24 | 25 | private struct SerialIdentifier { 26 | private var value: T 27 | 28 | init(_ initialValue: T = 0) { 29 | value = initialValue 30 | } 31 | 32 | mutating func next() -> T { 33 | value = value + 1 34 | return value 35 | } 36 | } 37 | 38 | private struct Weak: Equatable where T: Equatable { 39 | weak var object: T? 40 | } 41 | 42 | @MainActor 43 | public final class HotKey { 44 | public enum KeyCode { 45 | // TODO: cover all virtual key codes, or find a better solution. 46 | case left 47 | case right 48 | case up 49 | case down 50 | case raw(UInt32) 51 | 52 | public var rawValue: UInt32 { 53 | switch self { 54 | case .left: 55 | return UInt32(kVK_LeftArrow) 56 | case .right: 57 | return UInt32(kVK_RightArrow) 58 | case .up: 59 | return UInt32(kVK_UpArrow) 60 | case .down: 61 | return UInt32(kVK_DownArrow) 62 | case .raw(let value): 63 | return value 64 | } 65 | } 66 | } 67 | 68 | public struct Modifiers: OptionSet { 69 | public var rawValue: UInt32 70 | 71 | public init(rawValue: UInt32) { 72 | self.rawValue = rawValue 73 | } 74 | 75 | public init(modifierFlags: NSEvent.ModifierFlags) { 76 | rawValue = 0 77 | if contains(.shift) { 78 | insert(.shift) 79 | } 80 | if contains(.control) { 81 | insert(.control) 82 | } 83 | if contains(.option) { 84 | insert(.option) 85 | } 86 | if contains(.command) { 87 | insert(.command) 88 | } 89 | } 90 | 91 | public static let shift = Modifiers(rawValue: UInt32(shiftKey)) 92 | public static let control = Modifiers(rawValue: UInt32(controlKey)) 93 | public static let option = Modifiers(rawValue: UInt32(optionKey)) 94 | public static let command = Modifiers(rawValue: UInt32(cmdKey)) 95 | } 96 | 97 | public typealias Handler = () -> Void 98 | private typealias Identifier = UInt32 99 | 100 | private static var sharedEventHandler: CarbonEventHandler? 101 | private static var registeredHotKeys = [Identifier: Weak]() { 102 | didSet { 103 | guard oldValue != registeredHotKeys else { 104 | return 105 | } 106 | 107 | if registeredHotKeys.isEmpty { 108 | sharedEventHandler = nil 109 | } 110 | } 111 | } 112 | 113 | private static let signature = OSType("WAhk") 114 | private static var serialIdentifier = SerialIdentifier() 115 | 116 | public static func add( 117 | keyCode: KeyCode, 118 | modifiers: Modifiers, 119 | handler: @escaping Handler 120 | ) -> HotKey? { 121 | let identifier = serialIdentifier.next() 122 | let hotKeyID = EventHotKeyID(signature: signature, id: identifier) 123 | 124 | var hotKeyRef: EventHotKeyRef? 125 | if RegisterEventHotKey( 126 | keyCode.rawValue, // inHotKeyCode 127 | modifiers.rawValue, // inHotKeyModifiers 128 | hotKeyID, // inHotKeyID 129 | GetEventMonitorTarget(), // inTarget 130 | .zero, // inOptions 131 | &hotKeyRef // outRef 132 | ) != noErr { 133 | return nil 134 | } 135 | guard let hotKeyRef else { 136 | return nil 137 | } 138 | 139 | if sharedEventHandler == nil { 140 | sharedEventHandler = CarbonEventHandler( 141 | eventClass: OSType(kEventClassKeyboard), 142 | eventKind: UInt32(kEventHotKeyPressed) 143 | ) { eventRef in 144 | var hotKeyID = EventHotKeyID() 145 | withUnsafeMutableBytes(of: &hotKeyID) { hotKeyIDBuffer in 146 | if GetEventParameter( 147 | eventRef, // inEvent 148 | EventParamName(kEventParamDirectObject), // inName 149 | EventParamType(typeEventHotKeyID), // inDesiredType 150 | nil, // outActualType 151 | hotKeyIDBuffer.count, // inBufferSize 152 | nil, // outActualSize 153 | hotKeyIDBuffer.baseAddress // outData 154 | ) != noErr { 155 | return 156 | } 157 | } 158 | 159 | guard hotKeyID.signature == signature else { 160 | return 161 | } 162 | 163 | let weakHotKey = registeredHotKeys[hotKeyID.id] 164 | guard let hotKey = weakHotKey?.object else { 165 | return 166 | } 167 | 168 | hotKey.handler() 169 | } 170 | } 171 | 172 | return HotKey( 173 | identifier: identifier, 174 | hotKeyRef: hotKeyRef, 175 | handler: handler 176 | ) 177 | } 178 | 179 | private let identifier: Identifier 180 | private let hotKeyRef: EventHotKeyRef 181 | private let handler: Handler 182 | 183 | private init( 184 | identifier: UInt32, 185 | hotKeyRef: EventHotKeyRef, 186 | handler: @escaping Handler 187 | ) { 188 | self.identifier = identifier 189 | self.hotKeyRef = hotKeyRef 190 | self.handler = handler 191 | 192 | HotKey.registeredHotKeys[identifier] = Weak(object: self) 193 | } 194 | 195 | deinit { 196 | let identifier = identifier 197 | let hotKeyRef = hotKeyRef 198 | 199 | Task { 200 | await MainActor.run { 201 | UnregisterEventHotKey(hotKeyRef) 202 | HotKey.registeredHotKeys[identifier] = nil 203 | } 204 | } 205 | } 206 | } 207 | 208 | // MARK: - Equatable 209 | 210 | extension HotKey: Equatable { 211 | public static func == (lhs: HotKey, rhs: HotKey) -> Bool { 212 | lhs.identifier == rhs.identifier 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /Sources/Scripting/Parser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Parser.swift 3 | // Scripting 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ParserError: Error { 11 | case notValueToken(Token) 12 | case notFullyParse([Token]) 13 | } 14 | 15 | private extension Token { 16 | var parser: Parser { 17 | satisfy(consume()) { result in 18 | result == self 19 | } 20 | } 21 | } 22 | 23 | enum Expression: Equatable, Codable { 24 | case symbol(String) 25 | case number(Double) 26 | indirect case add(Expression, Expression) 27 | indirect case subtract(Expression, Expression) 28 | indirect case multiply(Expression, Expression) 29 | indirect case divide(Expression, Expression) 30 | } 31 | 32 | private typealias ExpressionParser = Parser 33 | 34 | private let groupFactorPart: ExpressionParser = bind(Token.leftParenthesis.parser) { _ in 35 | bind(expression) { output in 36 | bind(Token.rightParenthesis.parser) { _ in 37 | result(output) 38 | } 39 | } 40 | } 41 | private let valueFactorPart: ExpressionParser = bind(consume()) { valueToken in 42 | switch valueToken { 43 | case .name(let name): 44 | return result(.symbol(name)) 45 | case .number(let number): 46 | return result(.number(number)) 47 | default: 48 | throw ParserError.notValueToken(valueToken) 49 | } 50 | } 51 | private let factor: ExpressionParser = or(groupFactorPart, valueFactorPart) 52 | 53 | private let termPart: ExpressionParser = bind(factor) { left in 54 | bind(or(Token.multiplyOperator.parser, Token.divideOperator.parser)) { operatorToken in 55 | bind(term) { right in 56 | switch operatorToken { 57 | case .multiplyOperator: 58 | return result(Expression.multiply(left, right)) 59 | case .divideOperator: 60 | return result(Expression.divide(left, right)) 61 | default: 62 | // Should not reach here. 63 | fatalError() 64 | } 65 | } 66 | } 67 | } 68 | private let term: ExpressionParser = or(termPart, factor) 69 | 70 | private let expressionPart: ExpressionParser = bind(term) { left in 71 | bind(or(Token.plusOperator.parser, Token.minusOperator.parser)) { operatorToken in 72 | bind(expression) { right in 73 | switch operatorToken { 74 | case .plusOperator: 75 | return result(Expression.add(left, right)) 76 | case .minusOperator: 77 | return result(Expression.subtract(left, right)) 78 | default: 79 | // Should not reach here. 80 | fatalError() 81 | } 82 | } 83 | } 84 | } 85 | private let expression: ExpressionParser = or(expressionPart, term) 86 | 87 | func parse(tokens: [Token]) throws -> Expression { 88 | let (output, remaining) = try expression(tokens) 89 | guard remaining.isEmpty else { 90 | throw ParserError.notFullyParse(remaining) 91 | } 92 | return output 93 | } 94 | -------------------------------------------------------------------------------- /Sources/Scripting/ParserCombinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParserCombinator.swift 3 | // Scripting 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ParserCombinatorError: Error { 11 | case noMoreInput 12 | case notSatisfied 13 | case noParserMatched 14 | } 15 | 16 | typealias Parser = ([Input]) throws -> (Output, [Input]) 17 | 18 | func result( 19 | _ output: Output 20 | ) -> Parser { 21 | { input in 22 | (output, input) 23 | } 24 | } 25 | 26 | func bind( 27 | _ parser: @escaping Parser, 28 | to factory: @escaping (Output) throws -> Parser 29 | ) -> Parser { 30 | { input in 31 | let (output, remaining) = try parser(input) 32 | let parser = try factory(output) 33 | return try parser(remaining) 34 | } 35 | } 36 | 37 | func consume() -> Parser { 38 | { input in 39 | guard let first = input.first else { 40 | throw ParserCombinatorError.noMoreInput 41 | } 42 | return (first, Array(input.dropFirst())) 43 | } 44 | } 45 | 46 | func satisfy( 47 | _ parser: @escaping Parser, 48 | when condition: @escaping (Output) -> Bool 49 | ) -> Parser { 50 | bind(parser) { output in 51 | guard condition(output) else { 52 | throw ParserCombinatorError.notSatisfied 53 | } 54 | return result(output) 55 | } 56 | } 57 | 58 | func or( 59 | _ parsers: Parser... 60 | ) -> Parser { 61 | { input in 62 | for parser in parsers { 63 | do { 64 | return try parser(input) 65 | } catch { 66 | } 67 | } 68 | throw ParserCombinatorError.noParserMatched 69 | } 70 | } 71 | 72 | func seq( 73 | _ parsers: Parser... 74 | ) -> Parser { 75 | { input in 76 | try parsers.reduce((result: [Output](), input: input)) { tuple, parser in 77 | let (result, remaining) = try parser(tuple.input) 78 | return (tuple.result + result, remaining) 79 | } 80 | } 81 | } 82 | 83 | func zero() -> Parser { 84 | result([]) 85 | } 86 | 87 | func one( 88 | _ parser: @escaping Parser 89 | ) -> Parser { 90 | bind(parser) { output in 91 | result([output]) 92 | } 93 | } 94 | 95 | func zeroOrOne( 96 | _ parser: @escaping Parser 97 | ) -> Parser { 98 | or(one(parser), zero()) 99 | } 100 | 101 | func zeroOrMore( 102 | _ parser: @escaping Parser 103 | ) -> Parser { 104 | { input in 105 | var result = [Output]() 106 | var input = input 107 | do { 108 | while true { 109 | let (output, remaining) = try parser(input) 110 | result.append(output) 111 | input = remaining 112 | } 113 | } catch { 114 | } 115 | return (result, input) 116 | } 117 | } 118 | 119 | func oneOrMore( 120 | _ parser: @escaping Parser 121 | ) -> Parser { 122 | bind(one(parser)) { head in 123 | bind(zeroOrMore(parser)) { tail in 124 | result(head + tail) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/Scripting/Runtime.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Runtime.swift 3 | // Scripting 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum RuntimeError: Error { 11 | case stackUnderflow 12 | case noVariableFound(String) 13 | } 14 | 15 | private extension Array { 16 | mutating func push(_ element: Element) { 17 | append(element) 18 | } 19 | 20 | mutating func pop() throws -> Element { 21 | guard let element = popLast() else { 22 | throw RuntimeError.stackUnderflow 23 | } 24 | return element 25 | } 26 | } 27 | 28 | enum Operand: Equatable, Codable { 29 | case variable(String) 30 | case value(Double) 31 | } 32 | 33 | enum Operation: Equatable, Codable { 34 | case push(Operand) 35 | case add 36 | case subtract 37 | case multiply 38 | case divide 39 | } 40 | 41 | func compile(expression: Expression) throws -> [Operation] { 42 | switch expression { 43 | case .symbol(let symbol): 44 | return [.push(.variable(symbol))] 45 | case .number(let number): 46 | return [.push(.value(number))] 47 | case .add(let left, let right): 48 | return try compile(expression: left) + compile(expression: right) + [.add] 49 | case .subtract(let left, let right): 50 | return try compile(expression: left) + compile(expression: right) + [.subtract] 51 | case .multiply(let left, let right): 52 | return try compile(expression: left) + compile(expression: right) + [.multiply] 53 | case .divide(let left, let right): 54 | return try compile(expression: left) + compile(expression: right) + [.divide] 55 | } 56 | } 57 | 58 | func evaluate( 59 | operations: [Operation], 60 | environment: (String) -> Double? = { _ in nil } 61 | ) throws -> Double? { 62 | func value(operand: Operand) throws -> Double { 63 | switch operand { 64 | case .variable(let name): 65 | guard let value = environment(name) else { 66 | throw RuntimeError.noVariableFound(name) 67 | } 68 | return value 69 | case .value(let value): 70 | return value 71 | } 72 | } 73 | 74 | var stack = [Operand]() 75 | for operation in operations { 76 | switch operation { 77 | case .push(let value): 78 | stack.push(value) 79 | case .add: 80 | let right = try value(operand: stack.pop()) 81 | let left = try value(operand: stack.pop()) 82 | stack.push(.value(left + right)) 83 | case .subtract: 84 | let right = try value(operand: stack.pop()) 85 | let left = try value(operand: stack.pop()) 86 | stack.push(.value(left - right)) 87 | case .multiply: 88 | let right = try value(operand: stack.pop()) 89 | let left = try value(operand: stack.pop()) 90 | stack.push(.value(left * right)) 91 | case .divide: 92 | let right = try value(operand: stack.pop()) 93 | let left = try value(operand: stack.pop()) 94 | stack.push(.value(left / right)) 95 | } 96 | } 97 | 98 | let result = try stack.last.map { operand in 99 | try value(operand: operand) 100 | } 101 | return result 102 | } 103 | 104 | public struct Runtime { 105 | public struct Code { 106 | var operations: [Operation] 107 | 108 | public static func compile(source: String) throws -> Self { 109 | let tokens = try tokenize(source: source) 110 | let expression = try parse(tokens: tokens) 111 | let operations = try Scripting.compile(expression: expression) 112 | return .init(operations: operations) 113 | } 114 | } 115 | 116 | public var environment: (String) -> Double? 117 | 118 | public init(environment: @escaping (String) -> Double?) { 119 | self.environment = environment 120 | } 121 | 122 | public func run(code: Code) throws -> Double? { 123 | try evaluate(operations: code.operations, environment: environment) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/Scripting/Tokenizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tokenizer.swift 3 | // Scripting 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum TokenizerError: Error { 11 | case notNumberExpression(String) 12 | case notFullyTokenize(String) 13 | } 14 | 15 | private extension CharacterSet { 16 | var parser: Parser { 17 | satisfy(consume()) { output in 18 | contains(output) 19 | } 20 | } 21 | } 22 | 23 | enum Token: Equatable, Codable { 24 | case name(String) 25 | case number(Double) 26 | case leftParenthesis 27 | case rightParenthesis 28 | case plusOperator 29 | case minusOperator 30 | case multiplyOperator 31 | case divideOperator 32 | } 33 | 34 | private typealias TokenParser = Parser 35 | 36 | private let whitespaces = CharacterSet.whitespacesAndNewlines.parser 37 | 38 | private func ignoreWhitespaces( 39 | then parser: @escaping Parser 40 | ) -> Parser { 41 | bind(zeroOrMore(whitespaces)) { _ in 42 | parser 43 | } 44 | } 45 | 46 | private let period = CharacterSet(charactersIn: ".").parser 47 | 48 | private let sign = CharacterSet(charactersIn: "+-").parser 49 | private let exponentialIndicator = CharacterSet(charactersIn: "eE").parser 50 | private let digit = CharacterSet.decimalDigits.parser 51 | private let zeroDigit = CharacterSet(charactersIn: "0").parser 52 | private let nonZeroDigit = CharacterSet(charactersIn: "123456789").parser 53 | private let negativeSign = CharacterSet(charactersIn: "-").parser 54 | private let exponentialPart = seq(one(exponentialIndicator), zeroOrOne(sign), oneOrMore(digit)) 55 | private let fractionPart = seq(one(period), oneOrMore(digit)) 56 | private let integerPart = or( 57 | seq(zeroOrOne(negativeSign), one(nonZeroDigit), zeroOrMore(digit)), 58 | seq(zeroOrOne(negativeSign), one(zeroDigit)) 59 | ) 60 | private let floatPart = or( 61 | seq(integerPart, fractionPart, exponentialPart), 62 | seq(integerPart, fractionPart), 63 | seq(integerPart, exponentialPart) 64 | ) 65 | private let number = or( 66 | floatPart, 67 | integerPart 68 | ) 69 | private let numberToken: TokenParser = bind(number) { output in 70 | let numberString = String(String.UnicodeScalarView(output)) 71 | guard let double = Double(numberString) else { 72 | throw TokenizerError.notNumberExpression(numberString) 73 | } 74 | return result(.number(double)) 75 | } 76 | 77 | private let namePartPrefixCharacter = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_").parser 78 | private let namePartCharacter = CharacterSet(charactersIn: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_").parser 79 | private let namePart = seq(one(namePartPrefixCharacter), zeroOrMore(namePartCharacter)) 80 | private let name = or( 81 | // Not using `seq` due to recursive reference. 82 | bind(namePart) { namePart in 83 | bind(one(period)) { period in 84 | bind(name) { name in 85 | result(namePart + period + name) 86 | } 87 | } 88 | }, 89 | namePart 90 | ) 91 | private let nameToken: TokenParser = bind(name) { output in 92 | result(.name(String(String.UnicodeScalarView(output)))) 93 | } 94 | 95 | private let leftParenthesisToken: TokenParser = bind(CharacterSet(charactersIn: "(").parser) { _ in 96 | result(.leftParenthesis) 97 | } 98 | private let rightParenthesisToken: TokenParser = bind(CharacterSet(charactersIn: ")").parser) { _ in 99 | result(.rightParenthesis) 100 | } 101 | private let plusOperatorToken: TokenParser = bind(CharacterSet(charactersIn: "+").parser) { _ in 102 | result(.plusOperator) 103 | } 104 | private let minusOperatorToken: TokenParser = bind(CharacterSet(charactersIn: "-").parser) { _ in 105 | result(.minusOperator) 106 | } 107 | private let multiplyOperatorToken: TokenParser = bind(CharacterSet(charactersIn: "*").parser) { _ in 108 | result(.multiplyOperator) 109 | } 110 | private let divideOperatorToken: TokenParser = bind(CharacterSet(charactersIn: "/").parser) { _ in 111 | result(.divideOperator) 112 | } 113 | 114 | private let tokenizer = oneOrMore(or( 115 | ignoreWhitespaces(then: leftParenthesisToken), 116 | ignoreWhitespaces(then: rightParenthesisToken), 117 | ignoreWhitespaces(then: numberToken), 118 | ignoreWhitespaces(then: nameToken), 119 | ignoreWhitespaces(then: plusOperatorToken), 120 | ignoreWhitespaces(then: minusOperatorToken), 121 | ignoreWhitespaces(then: multiplyOperatorToken), 122 | ignoreWhitespaces(then: divideOperatorToken) 123 | )) 124 | 125 | func tokenize(source: String) throws -> [Token] { 126 | let (output, remaining) = try tokenizer(Array(source.unicodeScalars)) 127 | guard remaining.isEmpty else { 128 | let remainingString = String(String.UnicodeScalarView(remaining)) 129 | throw TokenizerError.notFullyTokenize(remainingString) 130 | } 131 | return output 132 | } 133 | -------------------------------------------------------------------------------- /Sources/WindowManager/Accessibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Accessibility.swift 3 | // WindowManager 4 | // 5 | // Created by Yoshimasa Niwa on 7/3/23. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | 11 | extension AXValue { 12 | var cgPoint: CGPoint? { 13 | var value: CGPoint = .zero 14 | guard AXValueGetValue(self, .cgPoint, &value) else { 15 | return nil 16 | } 17 | return value 18 | } 19 | 20 | var cgSize: CGSize? { 21 | var value: CGSize = .zero 22 | guard AXValueGetValue(self, .cgSize, &value) else { 23 | return nil 24 | } 25 | return value 26 | } 27 | } 28 | 29 | extension CGPoint { 30 | var accessibilityValue: AXValue? { 31 | var value = self 32 | return AXValueCreate(.cgPoint, &value) 33 | } 34 | } 35 | 36 | extension CGSize { 37 | var accessibilityValue: AXValue? { 38 | var value = self 39 | return AXValueCreate(.cgSize, &value) 40 | } 41 | } 42 | 43 | extension AXError: Error { 44 | } 45 | 46 | @MainActor 47 | extension AXUIElement { 48 | func attribute(for key: String) throws -> T { 49 | var value: CFTypeRef? 50 | let error = AXUIElementCopyAttributeValue( 51 | self, // element 52 | key as CFString, // attribute 53 | &value // value 54 | ) 55 | guard error == .success, let value = value as? T else { 56 | throw error 57 | } 58 | return value 59 | } 60 | 61 | func setAttribute(_ value: T, for key: String) throws { 62 | let error = AXUIElementSetAttributeValue(self, key as CFString, value) 63 | if error != .success { 64 | throw error 65 | } 66 | } 67 | } 68 | 69 | public enum Accessibility { 70 | private final actor TrustedProcess { 71 | @MainActor 72 | private static func isProcessTrusted(promptToUser: Bool = false) -> Bool { 73 | let options = [ 74 | kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: promptToUser 75 | ] as CFDictionary 76 | 77 | return AXIsProcessTrustedWithOptions(options) 78 | } 79 | 80 | private var isPromptedToUser = false 81 | 82 | private var waitingTask: Task? 83 | private var waitingCount = 0 84 | 85 | func wait() async throws { 86 | let task: Task 87 | if let waitingTask { 88 | task = waitingTask 89 | } else { 90 | let promptToUser = !isPromptedToUser 91 | isPromptedToUser = true 92 | guard await !Self.isProcessTrusted(promptToUser: promptToUser) else { 93 | return 94 | } 95 | // Reentrant. 96 | if let waitingTask { 97 | task = waitingTask 98 | } else { 99 | task = Task.detached { 100 | while true { 101 | try Task.checkCancellation() 102 | try await SuspendingClock().sleep(until: .now + .seconds(3)) 103 | if await Self.isProcessTrusted() { 104 | break 105 | } 106 | } 107 | } 108 | waitingTask = task 109 | } 110 | } 111 | 112 | defer { 113 | // Reentrant. 114 | waitingCount -= 1 115 | if waitingCount == 0 { 116 | task.cancel() 117 | waitingTask = nil 118 | } 119 | } 120 | waitingCount += 1 121 | try await task.value 122 | } 123 | } 124 | 125 | private static let trustedProcess = TrustedProcess() 126 | 127 | public static func waitForBeingProcessTrusted() async throws { 128 | try await trustedProcess.wait() 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Sources/WindowManager/WindowManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowManager.swift 3 | // WindowManager 4 | // 5 | // Created by Yoshimasa Niwa on 7/6/23. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | 11 | extension NSScreen { 12 | // `NSScreen` is using the primary screen left-bottom origin coordinate, Y up. 13 | // However, Accessibility APIs for such as `kAXPositionAttribute` is using 14 | // the primary screen left-under-the-menu-bar origin coordinate, Y down. 15 | public var visibleBounds: CGRect { 16 | // The first screen has the zero `origin`. 17 | // Note that `NSScreen.main` is the screen where the key window exists, 18 | // not the one has the zero `origin`. 19 | let primaryScreenHeightWithoutMenuBar = NSScreen.screens.first?.visibleFrame.maxY ?? 0.0 20 | return CGRect( 21 | x: visibleFrame.origin.x, 22 | y: primaryScreenHeightWithoutMenuBar - visibleFrame.maxY, 23 | width: visibleFrame.width, 24 | height: visibleFrame.height 25 | ) 26 | } 27 | } 28 | 29 | private extension NSWorkspace { 30 | var runningApplicationThatOwnsMenuBar: NSRunningApplication? { 31 | NSWorkspace.shared.runningApplications.first { runningApplication in 32 | runningApplication.ownsMenuBar 33 | } 34 | } 35 | } 36 | 37 | private extension NSRunningApplication { 38 | var accessibilityElement: AXUIElement? { 39 | AXUIElementCreateApplication(processIdentifier) 40 | } 41 | } 42 | 43 | public enum WindowManager { 44 | private static var _systemWideElement: AXUIElement? 45 | 46 | private static var systemWideElement: AXUIElement { 47 | if let _systemWideElement { 48 | return _systemWideElement 49 | } 50 | let systemWideElement = AXUIElementCreateSystemWide() 51 | _systemWideElement = systemWideElement 52 | return systemWideElement 53 | } 54 | 55 | @MainActor 56 | public struct App { 57 | public static var focused: App? { 58 | if let element: AXUIElement = try? systemWideElement.attribute(for: kAXFocusedApplicationAttribute) { 59 | return App(element: element) 60 | } 61 | // Some application, such as Google Chrome can't find by `kAXFocusedApplicationAttribute`. 62 | // Fallback to iterate running applications to find the one owns the menu bar. 63 | if let element = NSWorkspace.shared.runningApplicationThatOwnsMenuBar?.accessibilityElement { 64 | return App(element: element) 65 | } 66 | return nil 67 | } 68 | 69 | var element: AXUIElement 70 | 71 | public var focusedWindow: Window? { 72 | guard let element: AXUIElement = try? element.attribute(for: kAXFocusedWindowAttribute) else { 73 | return nil 74 | } 75 | return Window(element: element) 76 | } 77 | } 78 | 79 | @MainActor 80 | public struct Window { 81 | // This is public visibility with private name for the extension. 82 | public var _element: AXUIElement 83 | 84 | private var element: AXUIElement { 85 | _element 86 | } 87 | 88 | init(element: AXUIElement) { 89 | _element = element 90 | } 91 | 92 | public var position: CGPoint? { 93 | guard let value: AXValue = try? element.attribute(for: kAXPositionAttribute) else { 94 | return nil 95 | } 96 | return value.cgPoint 97 | } 98 | 99 | public var size: CGSize? { 100 | guard let value: AXValue = try? element.attribute(for: kAXSizeAttribute) else { 101 | return nil 102 | } 103 | return value.cgSize 104 | } 105 | 106 | public func move(to position: CGPoint) { 107 | guard let value = position.accessibilityValue else { 108 | return 109 | } 110 | do { 111 | try element.setAttribute(value, for: kAXPositionAttribute) 112 | } catch { 113 | } 114 | } 115 | 116 | public func resize(to size: CGSize) { 117 | guard let value = size.accessibilityValue else { 118 | return 119 | } 120 | do { 121 | try element.setAttribute(value, for: kAXSizeAttribute) 122 | } catch { 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/WindowManagerExtension/Accessibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Accessibility.swift 3 | // WindowManagerExtension 4 | // 5 | // Created by Yoshimasa Niwa on 7/3/23. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | import WindowManagerExtern 11 | 12 | extension AXUIElement { 13 | var windowID: CGWindowID? { 14 | var windowID: CGWindowID = 0 15 | _AXUIElementGetWindow(self, &windowID) 16 | return windowID 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/WindowManagerExtension/SkyLightService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SkyLightService.swift 3 | // WindowManagerExtension 4 | // 5 | // Created by Yoshimasa Niwa on 7/6/23. 6 | // 7 | 8 | import Foundation 9 | import WindowManagerExtern 10 | 11 | @MainActor 12 | final class SkyLightService { 13 | static let main = SkyLightService(connectionID: SLSMainConnectionID()) 14 | 15 | private let connectionID: Int32 16 | 17 | init(connectionID: Int32) { 18 | self.connectionID = connectionID 19 | } 20 | 21 | func displayIdentifier(forWindowID windowID: CGWindowID) -> CGDirectDisplayID { 22 | let uuidString = SLSCopyManagedDisplayForWindow(connectionID, windowID).takeRetainedValue() 23 | let uuid = CFUUIDCreateFromString(nil, uuidString) 24 | return CGDisplayGetDisplayIDFromUUID(uuid) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/WindowManagerExtension/WindowManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowManager.swift 3 | // WindowManagerExtension 4 | // 5 | // Created by Yoshimasa Niwa on 7/6/23. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | import WindowManager 11 | import WindowManagerExtern 12 | 13 | private extension NSDeviceDescriptionKey { 14 | static let screenNumber = NSDeviceDescriptionKey("NSScreenNumber") 15 | } 16 | 17 | private extension NSScreen { 18 | static func screen(forDisplayIdentifier displayIdentifier: CGDirectDisplayID) -> NSScreen? { 19 | for screen in NSScreen.screens { 20 | if let screenNumber = screen.deviceDescription[.screenNumber] as? Int { 21 | if displayIdentifier == screenNumber { 22 | return screen 23 | } 24 | } 25 | } 26 | return nil 27 | } 28 | } 29 | 30 | extension WindowManager.Window { 31 | private var element: AXUIElement { 32 | _element 33 | } 34 | 35 | public var screen: NSScreen? { 36 | guard let windowID = element.windowID else { 37 | return nil 38 | } 39 | let displayIdentifier = SkyLightService.main.displayIdentifier(forWindowID: windowID) 40 | return NSScreen.screen(forDisplayIdentifier: displayIdentifier) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/WindowManagerExtern/WindowManagerExtern.m: -------------------------------------------------------------------------------- 1 | // 2 | // WindowManagerExtern.m 3 | // WindowManagerExtern 4 | // 5 | // Created by Yoshimasa Niwa on 7/6/23. 6 | // 7 | 8 | #import "Accessibility.h" 9 | #import "SkyLight.h" 10 | -------------------------------------------------------------------------------- /Sources/WindowManagerExtern/include/Accessibility.h: -------------------------------------------------------------------------------- 1 | // 2 | // Accessibility.h 3 | // WindowManagerExtern 4 | // 5 | // Created by Yoshimasa Niwa on 7/6/23. 6 | // 7 | 8 | @import AppKit; 9 | 10 | extern AXError _AXUIElementGetWindow(AXUIElementRef element, CGWindowID *outID); 11 | -------------------------------------------------------------------------------- /Sources/WindowManagerExtern/include/SkyLight.h: -------------------------------------------------------------------------------- 1 | // 2 | // SkyLight.h 3 | // WindowManagerExtern 4 | // 5 | // Created by Yoshimasa Niwa on 7/6/23. 6 | // 7 | 8 | // See `Package.swift` for `-framework` linker flag. 9 | //@import SkyLight; 10 | 11 | extern int SLSMainConnectionID(void); 12 | extern CFStringRef SLSCopyManagedDisplayForWindow(int connectionID, CGWindowID windowID); 13 | -------------------------------------------------------------------------------- /Tests/ScriptingTests/ParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParserTests.swift 3 | // ScriptingTest 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import XCTest 9 | @testable import Scripting 10 | 11 | final class ParserTests: XCTestCase { 12 | func testParse() throws { 13 | let expression = try parse(tokens: [ 14 | .name("cat"), 15 | .plusOperator, 16 | .number(1.0), 17 | .multiplyOperator, 18 | .number(2.0) 19 | ]) 20 | XCTAssertEqual(expression, 21 | .add( 22 | .symbol("cat"), 23 | .multiply( 24 | .number(1.0), 25 | .number(2.0) 26 | ) 27 | ) 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/ScriptingTests/RuntimeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RuntimeTests.swift 3 | // ScriptingTest 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import XCTest 9 | @testable import Scripting 10 | 11 | final class RuntimeTests: XCTestCase { 12 | func testCompile() throws { 13 | let operations = try compile(expression: 14 | .multiply( 15 | .add( 16 | .symbol("cat"), 17 | .symbol("kitten") 18 | ), 19 | .number(1.0) 20 | ) 21 | ) 22 | XCTAssertEqual(operations, [ 23 | .push(.variable("cat")), 24 | .push(.variable("kitten")), 25 | .add, 26 | .push(.value(1.0)), 27 | .multiply 28 | ]) 29 | } 30 | 31 | func testEvaluate() throws { 32 | let variables: [String : Double] = [ 33 | "cat": 1.0, 34 | "kitten": 2.0 35 | ] 36 | let result = try evaluate(operations: [ 37 | .push(.variable("cat")), 38 | .push(.variable("kitten")), 39 | .add, 40 | .push(.value(3.0)), 41 | .multiply 42 | ]) { name in 43 | variables[name] 44 | } 45 | XCTAssertEqual(result, 9.0) 46 | } 47 | 48 | func testRuntime() throws { 49 | let code = try Runtime.Code.compile(source: "1 + 2 - (cat + kitten) * 2") 50 | 51 | let variables: [String: Double] = [ 52 | "cat": 1.0, 53 | "kitten": 2.0 54 | ] 55 | let runtime = Runtime { name in 56 | variables[name] 57 | } 58 | 59 | let result = try runtime.run(code: code) 60 | XCTAssertEqual(result, -3.0) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/ScriptingTests/TokenizerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenizerTests.swift 3 | // ScriptingTest 4 | // 5 | // Created by Yoshimasa Niwa on 7/13/23. 6 | // 7 | 8 | import XCTest 9 | @testable import Scripting 10 | 11 | final class TokenizerTests: XCTestCase { 12 | func testTokenizeNames() throws { 13 | let tokens = try tokenize(source: "_cat cat.meow kitten2") 14 | XCTAssertEqual(tokens, [ 15 | .name("_cat"), 16 | .name("cat.meow"), 17 | .name("kitten2") 18 | ]) 19 | } 20 | 21 | func testTokenizeIntegerNumbers() throws { 22 | let tokens = try tokenize(source: "1 -2 30 -40") 23 | XCTAssertEqual(tokens, [ 24 | .number(1.0), 25 | .number(-2.0), 26 | .number(30.0), 27 | .number(-40.0) 28 | ]) 29 | } 30 | 31 | func testTokenizeFloatNumbers() throws { 32 | let tokens = try tokenize(source: "0.1 -2.0 3.0e10 -4.0e-10 5.0e+10") 33 | XCTAssertEqual(tokens, [ 34 | .number(0.1), 35 | .number(-2.0), 36 | .number(3e10), 37 | .number(-4e-10), 38 | .number(5e10) 39 | ]) 40 | } 41 | 42 | func testTokenizeOperators() throws { 43 | let tokens = try tokenize(source: "1 + -2 - 3 * 4 / 5") 44 | XCTAssertEqual(tokens, [ 45 | .number(1.0), 46 | .plusOperator, 47 | .number(-2.0), 48 | .minusOperator, 49 | .number(3.0), 50 | .multiplyOperator, 51 | .number(4.0), 52 | .divideOperator, 53 | .number(5.0) 54 | ]) 55 | } 56 | 57 | func testTokenizeParentheses() throws { 58 | let tokens = try tokenize(source: "(cat)") 59 | XCTAssertEqual(tokens, [ 60 | .leftParenthesis, 61 | .name("cat"), 62 | .rightParenthesis 63 | ]) 64 | } 65 | } 66 | --------------------------------------------------------------------------------