├── .gitignore ├── images ├── raycast-demo.gif └── vscode-demo.gif ├── stubs └── base.sh ├── pint.json ├── src ├── Modifier.php ├── ModifierHelpers.php ├── KeyHelpers.php ├── Automator.php └── Key.php ├── composer.json ├── LICENSE ├── composer.lock └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | demos -------------------------------------------------------------------------------- /images/raycast-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joetannenbaum/php-mac-automator/HEAD/images/raycast-demo.gif -------------------------------------------------------------------------------- /images/vscode-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joetannenbaum/php-mac-automator/HEAD/images/vscode-demo.gif -------------------------------------------------------------------------------- /stubs/base.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | 3 | const se = Application('System Events'); 4 | 5 | function type(text) { 6 | text.split('').forEach((char) => { 7 | se.keystroke(char); 8 | delay({{typingSpeed}}); 9 | }); 10 | } -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "concat_space": { 5 | "spacing": "one" 6 | }, 7 | "not_operator_with_successor_space": false, 8 | "binary_operator_spaces": { 9 | "operators": { 10 | "=": "single_space", 11 | "=>": "align" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Modifier.php: -------------------------------------------------------------------------------- 1 | value . ' down'; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joetannenbaum/php-mac-automator", 3 | "description": "Automate your Mac with PHP", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "Automator\\": "src/" 9 | } 10 | }, 11 | "authors": [ 12 | { 13 | "name": "Joe Tannenbaum", 14 | "email": "joe@joe.codes" 15 | } 16 | ], 17 | "require-dev": { 18 | "tightenco/duster": "^2.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ModifierHelpers.php: -------------------------------------------------------------------------------- 1 | type($text, Modifier::COMMAND); 10 | } 11 | 12 | public function typeWithControl(string|Key $text): static 13 | { 14 | return $this->type($text, Modifier::CONTROL); 15 | } 16 | 17 | public function typeWithShift(string|Key $text): static 18 | { 19 | return $this->type($text, Modifier::SHIFT); 20 | } 21 | 22 | public function typeWithOption(string|Key $text): static 23 | { 24 | return $this->type($text, Modifier::OPTION); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Joe Tannenbaum 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 | -------------------------------------------------------------------------------- /src/KeyHelpers.php: -------------------------------------------------------------------------------- 1 | type(Key::RETURN, $modifiers); 10 | } 11 | 12 | public function arrowDown(Modifier|array $modifiers = null): static 13 | { 14 | return $this->type(Key::ARROW_DOWN, $modifiers); 15 | } 16 | 17 | public function arrowUp(Modifier|array $modifiers = null): static 18 | { 19 | return $this->type(Key::ARROW_UP, $modifiers); 20 | } 21 | 22 | public function arrowLeft(Modifier|array $modifiers = null): static 23 | { 24 | return $this->type(Key::ARROW_LEFT, $modifiers); 25 | } 26 | 27 | public function arrowRight(Modifier|array $modifiers = null): static 28 | { 29 | return $this->type(Key::ARROW_RIGHT, $modifiers); 30 | } 31 | 32 | public function tab(Modifier|array $modifiers = null): static 33 | { 34 | return $this->type(Key::TAB, $modifiers); 35 | } 36 | 37 | public function space(Modifier|array $modifiers = null): static 38 | { 39 | return $this->type(Key::SPACE, $modifiers); 40 | } 41 | 42 | public function delete(Modifier|array $modifiers = null): static 43 | { 44 | return $this->type(Key::DELETE, $modifiers); 45 | } 46 | 47 | public function backspace(Modifier|array $modifiers = null): static 48 | { 49 | return $this->type(Key::BACKSPACE, $modifiers); 50 | } 51 | 52 | public function escape(Modifier|array $modifiers = null): static 53 | { 54 | return $this->type(Key::ESCAPE, $modifiers); 55 | } 56 | 57 | public function home(Modifier|array $modifiers = null): static 58 | { 59 | return $this->type(Key::HOME, $modifiers); 60 | } 61 | 62 | public function end(Modifier|array $modifiers = null): static 63 | { 64 | return $this->type(Key::END, $modifiers); 65 | } 66 | 67 | public function pageUp(Modifier|array $modifiers = null): static 68 | { 69 | return $this->type(Key::PAGE_UP, $modifiers); 70 | } 71 | 72 | public function pageDown(Modifier|array $modifiers = null): static 73 | { 74 | return $this->type(Key::PAGE_DOWN, $modifiers); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Automator.php: -------------------------------------------------------------------------------- 1 | commands[] = "Application('{$app}').activate();"; 23 | 24 | return $this; 25 | } 26 | 27 | public function setTypingSpeed(float $speed): static 28 | { 29 | $this->typingSpeed = $speed; 30 | 31 | return $this; 32 | } 33 | 34 | public function pause(int|float $seconds): static 35 | { 36 | $this->commands[] = "delay($seconds);"; 37 | 38 | return $this; 39 | } 40 | 41 | public function typeAndEnter(string|Key $text, Modifier|array $modifiers = null): static 42 | { 43 | return $this->type($text, $modifiers)->enter(); 44 | } 45 | 46 | public function type(string|Key $text, Modifier|array $modifiers = null): static 47 | { 48 | if ($modifiers === null) { 49 | if ($text instanceof Key) { 50 | $text = $text->keyCode(); 51 | $this->commands[] = "se.keyCode({$text});"; 52 | } else { 53 | $text = addslashes($text); 54 | $this->commands[] = "type('$text');"; 55 | } 56 | 57 | return $this; 58 | } 59 | 60 | if (!is_array($modifiers)) { 61 | $modifiers = [$modifiers]; 62 | } 63 | 64 | $modifiers = array_map(fn ($modifier) => $modifier->forScript(), $modifiers); 65 | 66 | $keyCode = $text instanceof Key ? $text : Key::fromName($text); 67 | 68 | $this->commands[] = sprintf( 69 | 'se.%s(%s, { using: %s });', 70 | $keyCode ? 'keyCode' : 'keystroke', 71 | $keyCode?->keyCode() ?? "'" . addslashes($text) . "'", 72 | json_encode($modifiers) 73 | ); 74 | 75 | return $this; 76 | } 77 | 78 | public function repeat(int $times, callable $callback): static 79 | { 80 | for ($i = 0; $i < $times; $i++) { 81 | $callback($this); 82 | } 83 | 84 | return $this; 85 | } 86 | 87 | public function run(): void 88 | { 89 | $base = dirname(__DIR__, 1); 90 | 91 | $commands = implode(PHP_EOL, $this->commands); 92 | 93 | $path = tempnam(sys_get_temp_dir(), 'php-mac-automator'); 94 | 95 | $scriptBase = file_get_contents($base . '/stubs/base.sh'); 96 | 97 | $scriptBase = str_replace('{{typingSpeed}}', $this->typingSpeed, $scriptBase); 98 | 99 | file_put_contents($path, $scriptBase . PHP_EOL . $commands); 100 | 101 | exec("chmod +x $path"); 102 | exec("$path"); 103 | 104 | unlink($path); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "d5b4f2d820c4a24504c1c3f719a61da2", 8 | "packages": [], 9 | "packages-dev": [ 10 | { 11 | "name": "tightenco/duster", 12 | "version": "v2.0.0", 13 | "source": { 14 | "type": "git", 15 | "url": "https://github.com/tighten/duster.git", 16 | "reference": "ae429dbee9e8efad32488fd4ed34d591ecedeec1" 17 | }, 18 | "dist": { 19 | "type": "zip", 20 | "url": "https://api.github.com/repos/tighten/duster/zipball/ae429dbee9e8efad32488fd4ed34d591ecedeec1", 21 | "reference": "ae429dbee9e8efad32488fd4ed34d591ecedeec1", 22 | "shasum": "" 23 | }, 24 | "require": { 25 | "php": "^8.1.0" 26 | }, 27 | "require-dev": { 28 | "friendsofphp/php-cs-fixer": "^3.16.0", 29 | "laravel-zero/framework": "^10.0.0", 30 | "laravel/pint": "^1.9", 31 | "nunomaduro/termwind": "^1.15.1", 32 | "spatie/invade": "^1.1", 33 | "squizlabs/php_codesniffer": "^3.7", 34 | "tightenco/tlint": "^8.0" 35 | }, 36 | "bin": [ 37 | "builds/duster" 38 | ], 39 | "type": "project", 40 | "autoload": { 41 | "psr-4": { 42 | "App\\": "app/", 43 | "Database\\Seeders\\": "database/seeders/", 44 | "Database\\Factories\\": "database/factories/" 45 | } 46 | }, 47 | "notification-url": "https://packagist.org/downloads/", 48 | "license": [ 49 | "MIT" 50 | ], 51 | "authors": [ 52 | { 53 | "name": "Matt Stauffer", 54 | "email": "matt@tighten.com", 55 | "homepage": "https://tighten.com", 56 | "role": "Developer" 57 | }, 58 | { 59 | "name": "Anthony Clark", 60 | "email": "anthony@tighten.com", 61 | "homepage": "https://tighten.com", 62 | "role": "Developer" 63 | } 64 | ], 65 | "description": "Automatic configuration for Laravel apps to apply Tighten's standard linting & code standards.", 66 | "homepage": "https://github.com/tighten/duster", 67 | "keywords": [ 68 | "Code style", 69 | "duster", 70 | "laravel", 71 | "php", 72 | "tightenco" 73 | ], 74 | "support": { 75 | "issues": "https://github.com/tighten/duster/issues", 76 | "source": "https://github.com/tighten/duster" 77 | }, 78 | "time": "2023-04-18T18:55:32+00:00" 79 | } 80 | ], 81 | "aliases": [], 82 | "minimum-stability": "stable", 83 | "stability-flags": [], 84 | "prefer-stable": false, 85 | "prefer-lowest": false, 86 | "platform": [], 87 | "platform-dev": [], 88 | "plugin-api-version": "2.3.0" 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Mac Automator 2 | 3 | A simple wrapper around JXA scripts to allow you to control your Mac using PHP. 4 | 5 | **👋 This is a work in progress.** I'm still finalizing the API and adding features, things may change. 6 | 7 | That being said, it's pretty fun to play with. So please do so. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | composer require joetannenbaum/php-mac-automator 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```php 18 | use Automator\Automator; 19 | 20 | $automator = new Automator(); 21 | 22 | // Open Warp terminal and list the files in the current directory 23 | $automator->open('Warp')->typeAndEnter('ls')->run(); 24 | 25 | // or 26 | Automator::make()->open('Warp')->typeAndEnter('ls')->run(); 27 | ``` 28 | 29 | ## Opening Apps 30 | 31 | ```php 32 | $automator->open('Warp'); 33 | ``` 34 | 35 | ## Typing 36 | 37 | ```php 38 | $automator->type('Hello World'); 39 | $automator->typeAndEnter('Hello World'); 40 | 41 | // With modifier keys (e.g. zoom in) 42 | $automator->withCommand('+'); 43 | $automator->withShift('+'); 44 | $automator->withOption('+'); 45 | $automator->withControl('+'); 46 | 47 | // With multiple modifier keys (e.g. re-open last tab) 48 | $automator->type('t', [Modifier::COMMAND, Modifier::SHIFT]); 49 | 50 | // Helpers 51 | $automator->enter(); 52 | $automator->tab(); 53 | $automator->backspace(); 54 | $automator->delete(); 55 | $automator->escape(); 56 | $automator->space(); 57 | $automator->arrowUp(); 58 | $automator->arrowDown(); 59 | $automator->arrowLeft(); 60 | $automator->arrowRight(); 61 | $automator->home(); 62 | $automator->end(); 63 | $automator->pageUp(); 64 | $automator->pageDown(); 65 | 66 | // Add modifer(s) to helper 67 | $automator->enter(Modifier::SHIFT); 68 | $automator->enter([Modifier::COMMAND, Modifier::SHIFT]); 69 | 70 | // Set the typing speed 71 | // 0.1 seconds between each character (default is 0.05) 72 | $automator->setTypingSpeed(0.1); 73 | ``` 74 | 75 | ## Utilities 76 | 77 | ```php 78 | // Open an app 79 | $automator->open('Warp'); 80 | 81 | // Pause (seconds) 82 | $automator->pause(1); 83 | 84 | // Repeat a block of code (e.g. zoom in five times) 85 | $automator->repeat( 86 | 5, 87 | fn (Automator $remote) => $remote->typeWithCommand('+')->pause(.05), 88 | ); 89 | ``` 90 | 91 | ## Gotchas 92 | 93 | - This script is actually sending keystrokes to your applications, but it's running at computer speed. Remember to insert reasonable `pause` statements in your scripts to allow your computer to catch up. 94 | - If you are running a script, it will keep executing until the process itself is stopped or the script finishes. Meaning: If you tab away from the app the script is running in, if the script has more typing to do, it will continue typing. Keep that in mind. 95 | 96 | ## Examples 97 | 98 | ### Demo a Raycast Extension 99 | 100 | ```php 101 | Automator::make() 102 | ->typeWithCommand(' ') 103 | ->pause(1) 104 | ->type('Warp Launch') 105 | ->pause(.5) 106 | ->enter() 107 | ->pause(.5) 108 | ->type('blog-joe-codes') 109 | ->pause(.5) 110 | ->enter() 111 | ->run(); 112 | ``` 113 | 114 | ![Code Snippet Demo](images/raycast-demo.gif) 115 | 116 | ### Demo a Code Snippet 117 | 118 | ```php 119 | Automator::make() 120 | ->setTypingSpeed(.1) 121 | ->open('Visual Studio Code') 122 | ->pause(1) 123 | ->type('n', [Modifier::SHIFT, Modifier::COMMAND]) // Open a new window 124 | ->pause(.5) 125 | ->typeWithCommand('n') // Open a new file 126 | ->pause(.5) 127 | ->type('pause(.5) 129 | ->repeat(2, fn (Automator $remote) => $remote->enter()->pause(.25)) 130 | ->type('echo "Hello World!";') 131 | ->run(); 132 | ``` 133 | 134 | ![Code Snippet Demo](images/vscode-demo.gif) 135 | 136 | ## Roadmap 137 | 138 | Right now you can basically automate anything you can do with your keyboard. I would like to add: 139 | 140 | - [ ] Mouse control 141 | - [ ] Window control 142 | - [ ] File system control 143 | - [ ] Browser control 144 | -------------------------------------------------------------------------------- /src/Key.php: -------------------------------------------------------------------------------- 1 | Key::PLUS, 91 | '-' => Key::MINUS, 92 | '_' => Key::UNDERSCORE, 93 | '[' => Key::LEFTBRACKET, 94 | ']' => Key::RIGHTBRACKET, 95 | '\\' => Key::BACKSLASH, 96 | ';' => Key::SEMICOLON, 97 | "'" => Key::APOSTROPHE, 98 | '`' => Key::GRAVE, 99 | ',' => Key::COMMA, 100 | '.' => Key::PERIOD, 101 | '/' => Key::SLASH, 102 | ' ' => Key::SPACE, 103 | PHP_EOL => Key::RETURN, 104 | default => null, 105 | }; 106 | 107 | if ($result) { 108 | return $result; 109 | } 110 | 111 | $name = strtoupper($name); 112 | 113 | if (is_numeric($name)) { 114 | $name = "NUMBER{$name}"; 115 | } 116 | 117 | foreach (self::cases() as $status) { 118 | if ($name === $status->name) { 119 | return $status; 120 | } 121 | } 122 | 123 | return null; 124 | } 125 | 126 | public function keyCode() 127 | { 128 | return match ($this) { 129 | Key::ESC => 53, 130 | Key::F1 => 122, 131 | Key::F2 => 120, 132 | Key::F3 => 99, 133 | Key::F4 => 118, 134 | Key::F5 => 96, 135 | Key::F6 => 97, 136 | Key::F7 => 98, 137 | Key::F8 => 100, 138 | Key::F9 => 101, 139 | Key::F10 => 109, 140 | Key::F11 => 103, 141 | Key::F12 => 111, 142 | Key::A => 0, 143 | Key::B => 11, 144 | Key::C => 8, 145 | Key::D => 2, 146 | Key::E => 14, 147 | Key::F => 3, 148 | Key::G => 5, 149 | Key::H => 4, 150 | Key::I => 34, 151 | Key::J => 38, 152 | Key::K => 40, 153 | Key::L => 37, 154 | Key::M => 46, 155 | Key::N => 45, 156 | Key::O => 31, 157 | Key::P => 35, 158 | Key::Q => 12, 159 | Key::R => 15, 160 | Key::S => 1, 161 | Key::T => 17, 162 | Key::U => 32, 163 | Key::V => 9, 164 | Key::W => 13, 165 | Key::X => 7, 166 | Key::Y => 16, 167 | Key::Z => 6, 168 | Key::NUMBER1 => 18, 169 | Key::NUMBER2 => 19, 170 | Key::NUMBER3 => 20, 171 | Key::NUMBER4 => 21, 172 | Key::NUMBER5 => 23, 173 | Key::NUMBER6 => 22, 174 | Key::NUMBER7 => 26, 175 | Key::NUMBER8 => 28, 176 | Key::NUMBER9 => 25, 177 | Key::NUMBER0 => 29, 178 | Key::RETURN => 36, 179 | Key::TAB => 48, 180 | Key::SPACE => 49, 181 | Key::UNDERSCORE => 27, 182 | Key::MINUS => 27, 183 | Key::EQUAL => 24, 184 | Key::PLUS => 24, 185 | Key::LEFTBRACKET => 33, 186 | Key::RIGHTBRACKET => 30, 187 | Key::BACKSLASH => 42, 188 | Key::SEMICOLON => 41, 189 | Key::APOSTROPHE => 39, 190 | Key::GRAVE => 50, 191 | Key::COMMA => 43, 192 | Key::PERIOD => 47, 193 | Key::SLASH => 44, 194 | Key::CAPSLOCK => 57, 195 | Key::ARROW_DOWN => 125, 196 | Key::ARROW_LEFT => 123, 197 | Key::ARROW_RIGHT => 124, 198 | Key::ARROW_UP => 126, 199 | Key::HOME => 115, 200 | Key::END => 119, 201 | Key::PAGE_UP => 116, 202 | Key::PAGE_DOWN => 121, 203 | Key::BACKSPACE => 51, 204 | Key::DELETE => 117, 205 | Key::ESCAPE => 53, 206 | }; 207 | } 208 | } 209 | --------------------------------------------------------------------------------