├── LICENSE ├── composer.json └── src ├── AutoDiscover.php ├── Browser.php ├── Browser ├── BrowserProcess.php ├── ProcessAwareBrowser.php └── ProcessKeepAlive.php ├── BrowserFactory.php ├── Clip.php ├── Communication ├── Connection.php ├── Message.php ├── Response.php ├── ResponseReader.php ├── Session.php ├── Socket │ ├── MockSocket.php │ ├── SocketInterface.php │ ├── WaitForDataInterface.php │ └── Wrench.php └── Target.php ├── Cookies ├── Cookie.php └── CookiesCollection.php ├── Dom ├── Dom.php ├── Node.php ├── NodeAttributes.php ├── NodePosition.php └── Selector │ ├── CssSelector.php │ ├── Selector.php │ └── XPathSelector.php ├── Exception ├── BrowserConnectionFailed.php ├── CommunicationException.php ├── CommunicationException │ ├── CannotReadResponse.php │ ├── CantSyncEventsException.php │ ├── InvalidResponse.php │ └── ResponseHasError.php ├── DomException.php ├── ElementNotFoundException.php ├── EvaluationFailed.php ├── FilesystemException.php ├── InvalidTimezoneId.php ├── JavascriptException.php ├── NavigationExpired.php ├── NoResponseAvailable.php ├── OperationTimedOut.php ├── PdfFailed.php ├── ScreenshotFailed.php ├── StaleElementException.php └── TargetDestroyed.php ├── Frame.php ├── FrameManager.php ├── Input ├── Key.php ├── Keyboard.php ├── KeyboardKeys.php └── Mouse.php ├── Page.php ├── PageUtils ├── AbstractBinaryInput.php ├── CookiesGetter.php ├── PageEvaluation.php ├── PageLayoutMetrics.php ├── PageNavigation.php ├── PagePdf.php ├── PageScreenshot.php └── ResponseWaiter.php └── Utils.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2020 Soufiane Ghzal 4 | Copyright (c) 2020-2024 Graham Campbell 5 | Copyright (c) 2020-2024 Enrico Dias 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-php/chrome", 3 | "description": "Instrument headless chrome/chromium instances from PHP", 4 | "keywords": ["chrome", "chromium", "crawl", "browser", "headless", "screenshot", "pdf", "puppeteer"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Graham Campbell", 9 | "email": "hello@gjcampbell.co.uk", 10 | "homepage": "https://github.com/GrahamCampbell" 11 | }, 12 | { 13 | "name": "Enrico Dias", 14 | "email": "enrico@enricodias.com", 15 | "homepage": "https://github.com/enricodias" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.4.15 || ^8.0.2", 20 | "chrome-php/wrench": "^1.7", 21 | "evenement/evenement": "^3.0.1", 22 | "monolog/monolog": "^1.27.1 || ^2.8 || ^3.2", 23 | "psr/log": "^1.1 || ^2.0 || ^3.0", 24 | "symfony/filesystem": "^4.4 || ^5.0 || ^6.0 || ^7.0", 25 | "symfony/polyfill-mbstring": "^1.26", 26 | "symfony/process": "^4.4 || ^5.0 || ^6.0 || ^7.0" 27 | }, 28 | "require-dev":{ 29 | "bamarni/composer-bin-plugin": "^1.8.2", 30 | "phpunit/phpunit": "^9.6.3 || ^10.0.12", 31 | "symfony/var-dumper": "^4.4 || ^5.0 || ^6.0 || ^7.0" 32 | }, 33 | "autoload":{ 34 | "psr-4" : { 35 | "HeadlessChromium\\": "src/" 36 | } 37 | }, 38 | "autoload-dev":{ 39 | "psr-4" : { 40 | "HeadlessChromium\\Test\\": "tests/" 41 | } 42 | }, 43 | "config": { 44 | "allow-plugins": { 45 | "bamarni/composer-bin-plugin": true 46 | }, 47 | "preferred-install": "dist" 48 | }, 49 | "extra": { 50 | "bamarni-bin": { 51 | "bin-links": true, 52 | "forward-command": false 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/AutoDiscover.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium; 13 | 14 | class AutoDiscover 15 | { 16 | /** 17 | * @var callable(): string 18 | */ 19 | private $osFamily; 20 | 21 | /** 22 | * @param (callable(): string)|null $osFamily 23 | */ 24 | public function __construct(?callable $osFamily = null) 25 | { 26 | $this->osFamily = $osFamily ?? function (): string { 27 | return \PHP_OS_FAMILY; 28 | }; 29 | } 30 | 31 | public function guessChromeBinaryPath(): string 32 | { 33 | if (\array_key_exists('CHROME_PATH', $_SERVER)) { 34 | return $_SERVER['CHROME_PATH']; 35 | } 36 | 37 | switch (($this->osFamily)()) { 38 | case 'Darwin': 39 | return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 40 | case 'Windows': 41 | return self::getFromRegistry() ?? '%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe'; 42 | default: 43 | return \rtrim(\explode("\n", (string) self::shellExec('command -v google-chrome chromium-browser chrome chromium'), 2)[0]) ?: 'chrome'; 44 | } 45 | } 46 | 47 | private static function getFromRegistry(): ?string 48 | { 49 | $registryKey = self::shellExec( 50 | 'reg query "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe" /ve' 51 | ); 52 | 53 | if (null === $registryKey) { 54 | return null; 55 | } 56 | 57 | \preg_match('/.:(?!.*:).*/', $registryKey, $matches); 58 | 59 | return $matches[0] ?? null; 60 | } 61 | 62 | private static function shellExec(string $command): ?string 63 | { 64 | try { 65 | $result = @\shell_exec($command); 66 | 67 | return \is_string($result) ? $result : null; 68 | } catch (\Throwable $e) { 69 | return null; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Browser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium; 13 | 14 | use HeadlessChromium\Communication\Connection; 15 | use HeadlessChromium\Communication\Message; 16 | use HeadlessChromium\Communication\Target; 17 | use HeadlessChromium\Exception\CommunicationException; 18 | use HeadlessChromium\Exception\CommunicationException\ResponseHasError; 19 | use HeadlessChromium\Exception\NoResponseAvailable; 20 | use HeadlessChromium\Exception\OperationTimedOut; 21 | 22 | class Browser 23 | { 24 | /** 25 | * @var Connection 26 | */ 27 | protected $connection; 28 | 29 | /** 30 | * @var array 31 | */ 32 | protected $targets = []; 33 | 34 | /** 35 | * @var array 36 | */ 37 | protected $pages = []; 38 | 39 | /** 40 | * A preScript to be automatically added on every new pages. 41 | * 42 | * @var string|null 43 | */ 44 | protected $pagePreScript; 45 | 46 | public function __construct(Connection $connection) 47 | { 48 | $this->connection = $connection; 49 | 50 | // listen for target created 51 | $this->connection->on(Connection::EVENT_TARGET_CREATED, function (array $params): void { 52 | // create and store the target 53 | $this->targets[$params['targetInfo']['targetId']] = new Target($params['targetInfo'], $this->connection); 54 | }); 55 | 56 | // listen for target info changed 57 | $this->connection->on(Connection::EVENT_TARGET_INFO_CHANGED, function (array $params): void { 58 | // get target by id 59 | $target = $this->getTarget($params['targetInfo']['targetId']); 60 | 61 | if ($target) { 62 | $target->targetInfoChanged($params['targetInfo']); 63 | } 64 | }); 65 | 66 | // listen for target destroyed 67 | $this->connection->on(Connection::EVENT_TARGET_DESTROYED, function (array $params): void { 68 | // get target by id 69 | $target = $this->getTarget($params['targetId']); 70 | 71 | if ($target) { 72 | // remove the page 73 | unset($this->pages[$params['targetId']]); 74 | // remove the target 75 | unset($this->targets[$params['targetId']]); 76 | $target->destroy(); 77 | $this->connection 78 | ->getLogger() 79 | ->debug('✘ target('.$params['targetId'].') was destroyed and unreferenced.'); 80 | } 81 | }); 82 | 83 | // enable target discovery 84 | $connection->sendMessageSync(new Message('Target.setDiscoverTargets', ['discover' => true])); 85 | } 86 | 87 | /** 88 | * @return Connection 89 | */ 90 | public function getConnection(): Connection 91 | { 92 | return $this->connection; 93 | } 94 | 95 | /** 96 | * Set a preScript to be added on every new pages. 97 | * Use null to disable it. 98 | * 99 | * @param string|null $script 100 | */ 101 | public function setPagePreScript(?string $script = null): void 102 | { 103 | $this->pagePreScript = $script; 104 | } 105 | 106 | /** 107 | * Closes the browser. 108 | * 109 | * @throws \Exception 110 | */ 111 | public function close(): void 112 | { 113 | $this->sendCloseMessage(); 114 | } 115 | 116 | /** 117 | * Send close message to the browser. 118 | * 119 | * @throws OperationTimedOut 120 | */ 121 | final public function sendCloseMessage(): void 122 | { 123 | if (!$this->connection->isConnected()) { 124 | $this->connection->getLogger()->debug('process: chrome already stopped, ignoring'); 125 | 126 | return; 127 | } 128 | $r = $this->connection->sendMessageSync(new Message('Browser.close')); 129 | if (!$r->isSuccessful()) { 130 | // log 131 | $this->connection->getLogger()->debug('process: ✗ could not close gracefully'); 132 | throw new \Exception('cannot close, Browser.close not supported'); 133 | } 134 | $this->connection->disconnect(); 135 | } 136 | 137 | /** 138 | * Creates a new page. 139 | * 140 | * @throws NoResponseAvailable 141 | * @throws CommunicationException 142 | * @throws OperationTimedOut 143 | * 144 | * @return Page 145 | */ 146 | public function createPage(): Page 147 | { 148 | // page url 149 | $params = ['url' => 'about:blank']; 150 | 151 | // create page and get target id 152 | $response = $this->connection->sendMessageSync(new Message('Target.createTarget', $params)); 153 | $targetId = $response['result']['targetId']; 154 | 155 | // todo handle error 156 | 157 | $target = $this->getTarget($targetId); 158 | if (!$target) { 159 | throw new \RuntimeException('Target could not be created for page.'); 160 | } 161 | 162 | $page = $this->getPage($targetId); 163 | 164 | return $page; 165 | } 166 | 167 | /** 168 | * @param string $targetId 169 | * 170 | * @return Target|null 171 | */ 172 | public function getTarget($targetId) 173 | { 174 | // make sure target was created (via Target.targetCreated event) 175 | if (!\array_key_exists($targetId, $this->targets)) { 176 | return null; 177 | } 178 | 179 | return $this->targets[$targetId]; 180 | } 181 | 182 | /** 183 | * @return Target[] 184 | */ 185 | public function getTargets() 186 | { 187 | return \array_values($this->targets); 188 | } 189 | 190 | /** 191 | * Find a target matching the type and title. 192 | * 193 | * @param string $type 194 | * @param string $title 195 | */ 196 | public function findTarget(string $type, string $title): ?Target 197 | { 198 | foreach ($this->targets as $target) { 199 | if ($target->getTargetInfo('type') === $type && $target->getTargetInfo('title') === $title) { 200 | return $target; 201 | } 202 | } 203 | 204 | return null; 205 | } 206 | 207 | /** 208 | * @param string $targetId 209 | * 210 | * @throws CommunicationException 211 | * 212 | * @return Page|null 213 | */ 214 | public function getPage($targetId) 215 | { 216 | if (\array_key_exists($targetId, $this->pages)) { 217 | return $this->pages[$targetId]; 218 | } 219 | 220 | $target = $this->getTarget($targetId); 221 | 222 | if ('page' !== $target->getTargetInfo('type')) { 223 | return null; 224 | } 225 | 226 | // get initial frame tree 227 | $frameTreeResponse = $target->getSession()->sendMessageSync(new Message('Page.getFrameTree')); 228 | 229 | // make sure frame tree was found 230 | if (!$frameTreeResponse->isSuccessful()) { 231 | throw new ResponseHasError('Cannot read frame tree. Please, consider upgrading chrome version.'); 232 | } 233 | 234 | // create page 235 | $page = new Page($target, $frameTreeResponse['result']['frameTree']); 236 | 237 | // Page.enable 238 | $page->getSession()->sendMessageSync(new Message('Page.enable')); 239 | 240 | // Network.enable 241 | $page->getSession()->sendMessageSync(new Message('Network.enable')); 242 | 243 | // Runtime.enable 244 | $page->getSession()->sendMessageSync(new Message('Runtime.enable')); 245 | 246 | // Page.setLifecycleEventsEnabled 247 | $page->getSession()->sendMessageSync(new Message('Page.setLifecycleEventsEnabled', ['enabled' => true])); 248 | 249 | // set up http headers 250 | $headers = $this->connection->getConnectionHttpHeaders(); 251 | 252 | if (\count($headers) > 0) { 253 | $page->setExtraHTTPHeaders($headers); 254 | } 255 | 256 | // add prescript 257 | if ($this->pagePreScript) { 258 | $page->addPreScript($this->pagePreScript); 259 | } 260 | 261 | $this->pages[$targetId] = $page; 262 | 263 | return $page; 264 | } 265 | 266 | /** 267 | * @return Page[] 268 | */ 269 | public function getPages() 270 | { 271 | $ids = \array_keys($this->targets); 272 | 273 | $pages = \array_filter(\array_map([$this, 'getPage'], $ids)); 274 | 275 | return \array_values($pages); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/Browser/BrowserProcess.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Browser; 13 | 14 | use HeadlessChromium\Communication\Connection; 15 | use HeadlessChromium\Exception\OperationTimedOut; 16 | use HeadlessChromium\Utils; 17 | use Psr\Log\LoggerAwareInterface; 18 | use Psr\Log\LoggerAwareTrait; 19 | use Psr\Log\LoggerInterface; 20 | use Psr\Log\NullLogger; 21 | use Symfony\Component\Filesystem\Filesystem; 22 | use Symfony\Component\Process\Process; 23 | use Wrench\Exception\SocketException; 24 | 25 | /** 26 | * A browser process starter. Don't use directly, use BrowserFactory instead. 27 | */ 28 | class BrowserProcess implements LoggerAwareInterface 29 | { 30 | use LoggerAwareTrait; 31 | 32 | /** 33 | * chrome instance's user data data. 34 | * 35 | * @var string 36 | */ 37 | protected $userDataDir; 38 | 39 | /** 40 | * @var Process 41 | */ 42 | protected $process; 43 | 44 | /** 45 | * True if the user data dir is temporary and should be deleted on process closes. 46 | * 47 | * @var bool 48 | */ 49 | protected $userDataDirIsTemp; 50 | 51 | /** 52 | * @var Connection 53 | */ 54 | protected $connection; 55 | 56 | /** 57 | * @var ProcessAwareBrowser 58 | */ 59 | protected $browser; 60 | 61 | /** 62 | * @var bool 63 | */ 64 | protected $wasKilled = false; 65 | 66 | /** 67 | * @var bool 68 | */ 69 | protected $wasStarted = false; 70 | 71 | /** 72 | * @var string 73 | */ 74 | protected $wsUri; 75 | 76 | /** 77 | * BrowserProcess constructor. 78 | * 79 | * @param LoggerInterface|null $logger 80 | */ 81 | public function __construct(?LoggerInterface $logger = null) 82 | { 83 | // set or create logger 84 | $this->setLogger($logger ?? new NullLogger()); 85 | } 86 | 87 | /** 88 | * Starts the browser. 89 | * 90 | * @param string $binary 91 | * @param array $options 92 | */ 93 | public function start($binary, $options): void 94 | { 95 | if ($this->wasStarted) { 96 | // cannot start twice because once started this class contains the necessary data to cleanup the browser. 97 | // starting in again would result in replacing those data. 98 | throw new \RuntimeException('This process was already started'); 99 | } 100 | 101 | $this->wasStarted = true; 102 | 103 | // log 104 | $this->logger->debug('process: initializing'); 105 | 106 | // user data dir 107 | if (!\array_key_exists('userDataDir', $options) || !$options['userDataDir']) { 108 | // if no data dir specified create it 109 | $options['userDataDir'] = $this->createTempDir(); 110 | 111 | // set user data dir to get removed on close 112 | $this->userDataDirIsTemp = true; 113 | } 114 | $this->userDataDir = $options['userDataDir']; 115 | 116 | // log 117 | $this->logger->debug('process: using directory: '.$options['userDataDir']); 118 | 119 | // get args for command line 120 | $args = $this->getArgsFromOptions($binary, $options); 121 | 122 | // setup chrome process 123 | if (!\array_key_exists('keepAlive', $options) || !$options['keepAlive']) { 124 | $process = new Process($args, null, $options['envVariables'] ?? null); 125 | } else { 126 | $process = new ProcessKeepAlive($args, null, $options['envVariables'] ?? null); 127 | } 128 | $this->process = $process; 129 | 130 | // log 131 | $this->logger->debug('process: starting process: '.$process->getCommandLine()); 132 | 133 | // and start 134 | $process->start(); 135 | 136 | // wait for start and retrieve ws uri 137 | $startupTimeout = $options['startupTimeout'] ?? 30; 138 | $this->wsUri = $this->waitForStartup($process, $startupTimeout * 1000 * 1000); 139 | 140 | // log 141 | $this->logger->debug('process: connecting using '.$this->wsUri); 142 | 143 | // connect to browser 144 | $connection = new Connection($this->wsUri, $this->logger, $options['sendSyncDefaultTimeout'] ?? 5000); 145 | $connection->connect(); 146 | 147 | // connection delay 148 | if (\array_key_exists('connectionDelay', $options)) { 149 | $connection->setConnectionDelay($options['connectionDelay']); 150 | } 151 | 152 | // connection headers 153 | if (\array_key_exists('headers', $options)) { 154 | $connection->setConnectionHttpHeaders($options['headers']); 155 | } 156 | 157 | // set connection to allow killing chrome 158 | $this->connection = $connection; 159 | 160 | // create browser instance 161 | $this->browser = new ProcessAwareBrowser($connection, $this); 162 | } 163 | 164 | /** 165 | * @return ProcessAwareBrowser 166 | */ 167 | public function getBrowser() 168 | { 169 | return $this->browser; 170 | } 171 | 172 | /** 173 | * @return string 174 | */ 175 | public function getSocketUri() 176 | { 177 | return $this->wsUri; 178 | } 179 | 180 | /** 181 | * Kills the process and clean temporary files. 182 | * 183 | * @throws OperationTimedOut 184 | */ 185 | public function kill(): void 186 | { 187 | // log 188 | $this->logger->debug('process: killing chrome'); 189 | 190 | if ($this->wasKilled) { 191 | // log 192 | $this->logger->debug('process: chrome already killed, ignoring'); 193 | 194 | return; 195 | } 196 | 197 | $this->wasKilled = true; 198 | 199 | if (isset($this->process)) { 200 | // close gracefully if connection exists 201 | if (isset($this->connection)) { 202 | // if socket connect try graceful close 203 | if ($this->connection->isConnected()) { 204 | // first try to close with Browser.close 205 | // if Browser.close is not implemented, try to kill by closing all pages 206 | try { 207 | // log 208 | $this->logger->debug('process: trying to close chrome gracefully'); 209 | $this->browser->sendCloseMessage(); 210 | } catch (\Exception $e) { 211 | // log 212 | $this->logger->debug('process: closing chrome gracefully - compatibility'); 213 | 214 | // close all pages if connected 215 | try { 216 | $this->connection->isConnected() && Utils::closeAllPage($this->connection); 217 | } catch (OperationTimedOut $e) { 218 | // log 219 | $this->logger->debug('process: failed to close all pages'); 220 | } 221 | } 222 | 223 | // disconnect socket 224 | try { 225 | $this->connection->disconnect(); 226 | } catch (SocketException $e) { 227 | // Socket might be already disconnected 228 | } 229 | 230 | // log 231 | $this->logger->debug('process: waiting for process to close'); 232 | 233 | // wait for process to close 234 | $generator = function (Process $process) { 235 | while ($process->isRunning()) { 236 | yield 2 * 1000; // wait for 2ms 237 | } 238 | }; 239 | $timeout = 8 * 1000 * 1000; // 8 seconds 240 | 241 | try { 242 | Utils::tryWithTimeout($timeout, $generator($this->process)); 243 | } catch (OperationTimedOut $e) { 244 | // log 245 | $this->logger->debug('process: process didn\'t close by itself'); 246 | } 247 | } 248 | } 249 | 250 | // stop process if running 251 | if ($this->process->isRunning()) { 252 | // log 253 | $this->logger->debug('process: stopping process'); 254 | 255 | // stop process 256 | $exitCode = $this->process->stop(); 257 | 258 | // log 259 | $this->logger->debug('process: process stopped with exit code '.$exitCode); 260 | } 261 | } 262 | 263 | // remove data dir 264 | if ($this->userDataDirIsTemp && $this->userDataDir) { 265 | try { 266 | // log 267 | $this->logger->debug('process: cleaning temporary resources:'.$this->userDataDir); 268 | 269 | // cleaning 270 | $fs = new Filesystem(); 271 | $fs->remove($this->userDataDir); 272 | } catch (\Exception $e) { 273 | // log 274 | $this->logger->debug('process: ✗ could not clean temporary resources'); 275 | } 276 | } 277 | } 278 | 279 | /** 280 | * Get args for creating chrome's startup command. 281 | * 282 | * @param array $options 283 | * 284 | * @return array 285 | */ 286 | private function getArgsFromOptions($binary, array $options) 287 | { 288 | // command line args to add to start chrome (inspired by puppeteer configs) 289 | // see https://peter.sh/experiments/chromium-command-line-switches/ 290 | $args = [ 291 | $binary, 292 | 293 | // auto debug port 294 | '--remote-debugging-port=0', 295 | 296 | // allow remote access 297 | '--remote-allow-origins=*', 298 | 299 | // disable undesired features 300 | '--disable-background-networking', 301 | '--disable-background-timer-throttling', 302 | '--disable-client-side-phishing-detection', 303 | '--disable-hang-monitor', 304 | '--disable-popup-blocking', 305 | '--disable-prompt-on-repost', 306 | '--disable-sync', 307 | '--disable-translate', 308 | '--disable-features=ChromeWhatsNewUI', 309 | '--metrics-recording-only', 310 | '--no-first-run', 311 | '--safebrowsing-disable-auto-update', 312 | 313 | // automation mode 314 | '--enable-automation', 315 | 316 | // password settings 317 | '--password-store=basic', 318 | '--use-mock-keychain', // osX only 319 | ]; 320 | 321 | // disable browser notifications 322 | if (\array_key_exists('disableNotifications', $options) && (true === $options['disableNotifications'])) { 323 | $args[] = '--disable-notifications'; 324 | } 325 | 326 | // enable headless mode 327 | if (!\array_key_exists('headless', $options) || $options['headless']) { 328 | $args[] = '--headless'; 329 | $args[] = '--disable-gpu'; 330 | $args[] = '--font-render-hinting=none'; 331 | $args[] = '--hide-scrollbars'; 332 | $args[] = '--mute-audio'; 333 | } 334 | 335 | // disable loading of images (currently can't be done via devtools, only CLI) 336 | if (\array_key_exists('enableImages', $options) && (false === $options['enableImages'])) { 337 | $args[] = '--blink-settings=imagesEnabled=false'; 338 | } 339 | 340 | // window's size 341 | if (\array_key_exists('windowSize', $options) && $options['windowSize']) { 342 | if ( 343 | !\is_array($options['windowSize']) || 344 | 2 !== \count($options['windowSize']) || 345 | !\is_numeric($options['windowSize'][0]) || 346 | !\is_numeric($options['windowSize'][1]) 347 | ) { 348 | throw new \InvalidArgumentException('Option "windowSize" must be an array of dimensions (eg: [1000, 1200])'); 349 | } 350 | 351 | $args[] = '--window-size='.\implode(',', $options['windowSize']); 352 | } 353 | 354 | if (\array_key_exists('userCrashDumpsDir', $options)) { 355 | $args[] = '--enable-crash-reporter'; 356 | $args[] = '--crash-dumps-dir='.$options['userCrashDumpsDir']; 357 | } 358 | 359 | // sandbox mode - useful if you want to use chrome headless inside docker 360 | if (\array_key_exists('noSandbox', $options) && $options['noSandbox']) { 361 | $args[] = '--no-sandbox'; 362 | } 363 | 364 | // user agent 365 | if (\array_key_exists('userAgent', $options)) { 366 | $args[] = '--user-agent='.$options['userAgent']; 367 | } 368 | 369 | // ignore certificate errors 370 | if (\array_key_exists('ignoreCertificateErrors', $options) && $options['ignoreCertificateErrors']) { 371 | $args[] = '--ignore-certificate-errors'; 372 | } 373 | 374 | // proxy server 375 | if (\array_key_exists('proxyServer', $options)) { 376 | $args[] = '--proxy-server='.$options['proxyServer']; 377 | } 378 | if (\array_key_exists('noProxyServer', $options) && $options['noProxyServer']) { 379 | $args[] = '--no-proxy-server'; 380 | } 381 | if (\array_key_exists('proxyBypassList', $options)) { 382 | $args[] = '--proxy-bypass-list='.$options['proxyBypassList']; 383 | } 384 | 385 | // add custom flags 386 | if (\array_key_exists('customFlags', $options) && \is_array($options['customFlags'])) { 387 | $args = \array_merge($args, $options['customFlags']); 388 | } 389 | 390 | // add user data dir to args 391 | $args[] = '--user-data-dir='.$options['userDataDir']; 392 | 393 | // remove some arguments 394 | if (\array_key_exists('excludedSwitches', $options) && \is_array($options['excludedSwitches'])) { 395 | $args = \array_diff($args, $options['excludedSwitches']); 396 | } 397 | 398 | return $args; 399 | } 400 | 401 | /** 402 | * Wait for chrome to startup (given a process) and return the ws uri to connect to. 403 | * 404 | * @param Process $process 405 | * @param int $timeout 406 | * 407 | * @return mixed 408 | */ 409 | private function waitForStartup(Process $process, int $timeout) 410 | { 411 | // log 412 | $this->logger->debug('process: waiting for '.$timeout / 1000000 .' seconds for startup'); 413 | 414 | try { 415 | $generator = function (Process $process) { 416 | while (true) { 417 | if (!$process->isRunning()) { 418 | // log 419 | $this->logger->debug('process: ✗ chrome process stopped'); 420 | 421 | // exception 422 | $message = 'Chrome process stopped before startup completed.'; 423 | $error = \trim($process->getErrorOutput()); 424 | if (!empty($error)) { 425 | $message .= ' Additional info: '.$error; 426 | } 427 | throw new \RuntimeException($message); 428 | } 429 | 430 | $output = \trim($process->getIncrementalErrorOutput()); 431 | 432 | if ($output) { 433 | // log 434 | $this->logger->debug('process: chrome output:'.$output); 435 | 436 | $outputs = \explode(\PHP_EOL, $output); 437 | 438 | foreach ($outputs as $output) { 439 | $output = \trim($output); 440 | 441 | // ignore empty line 442 | if (empty($output)) { 443 | continue; 444 | } 445 | 446 | // find socket uri 447 | if (\preg_match('/DevTools listening on (ws:\/\/.*)/', $output, $matches)) { 448 | // log 449 | $this->logger->debug('process: ✓ accepted output'); 450 | 451 | return $matches[1]; 452 | } elseif (\preg_match('/Cannot start http server for devtools\./', $output, $matches)) { 453 | $process->stop(); 454 | throw new \RuntimeException('Devtools could not start'); 455 | } else { 456 | // log 457 | $this->logger->debug('process: ignoring output:'.\trim($output)); 458 | } 459 | } 460 | } 461 | 462 | // wait for 10ms 463 | yield 10 * 1000; 464 | } 465 | }; 466 | 467 | return Utils::tryWithTimeout($timeout, $generator($process)); 468 | } catch (OperationTimedOut $e) { 469 | $process->stop(); 470 | throw new \RuntimeException('Cannot start browser', 0, $e); 471 | } 472 | } 473 | 474 | /** 475 | * Creates a temp directory for the app. 476 | * 477 | * @return string path to the new temp directory 478 | */ 479 | private function createTempDir() 480 | { 481 | $tmpFile = \tempnam(\sys_get_temp_dir(), 'chromium-php-'); 482 | 483 | \unlink($tmpFile); 484 | \mkdir($tmpFile); 485 | 486 | return $tmpFile; 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /src/Browser/ProcessAwareBrowser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Browser; 13 | 14 | use HeadlessChromium\Browser; 15 | use HeadlessChromium\Communication\Connection; 16 | 17 | class ProcessAwareBrowser extends Browser 18 | { 19 | /** 20 | * @var BrowserProcess 21 | */ 22 | protected $browserProcess; 23 | 24 | public function __construct(Connection $connection, BrowserProcess $browserProcess) 25 | { 26 | parent::__construct($connection); 27 | 28 | $this->browserProcess = $browserProcess; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function close(): void 35 | { 36 | $this->browserProcess->kill(); 37 | } 38 | 39 | /** 40 | * @return string 41 | */ 42 | public function getSocketUri() 43 | { 44 | return $this->browserProcess->getSocketUri(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Browser/ProcessKeepAlive.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Browser; 13 | 14 | use Symfony\Component\Process\Process; 15 | 16 | class ProcessKeepAlive extends Process 17 | { 18 | public function __destruct() 19 | { 20 | // Do nothing because we are in mode keep alive, default behavior is to kill the process 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/BrowserFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium; 13 | 14 | use HeadlessChromium\Browser\BrowserProcess; 15 | use HeadlessChromium\Browser\ProcessAwareBrowser; 16 | use HeadlessChromium\Communication\Connection; 17 | use HeadlessChromium\Exception\BrowserConnectionFailed; 18 | use Monolog\Handler\StreamHandler; 19 | use Monolog\Logger; 20 | use Psr\Log\LoggerInterface; 21 | use Psr\Log\NullLogger; 22 | use Wrench\Exception\HandshakeException; 23 | 24 | class BrowserFactory 25 | { 26 | protected $chromeBinary; 27 | 28 | /** 29 | * Options for browser creation. 30 | * 31 | * - connectionDelay: Delay to apply between each operation for debugging purposes (default: none) 32 | * - customFlags: An array of flags to pass to the command line. 33 | * - debugLogger: A string (e.g "php://stdout"), or resource, or PSR-3 logger instance to print debug messages (default: none) 34 | * - disableNotifications: Disable browser notifications (default: false) 35 | * - enableImages: Toggles loading of images (default: true) 36 | * - envVariables: An array of environment variables to pass to the process (example DISPLAY variable) 37 | * - headers: An array of custom HTTP headers 38 | * - headless: Enable or disable headless mode (default: true) 39 | * - ignoreCertificateErrors: Set Chrome to ignore SSL errors 40 | * - keepAlive: Set to `true` to keep alive the Chrome instance when the script terminates (default: false) 41 | * - noSandbox: Enable no sandbox mode, useful to run in a docker container (default: false) 42 | * - proxyServer: Proxy server to use. ex: `127.0.0.1:8080` (default: none) 43 | * - sendSyncDefaultTimeout: Default timeout (ms) for sending sync messages (default 5000 ms) 44 | * - startupTimeout: Maximum time in seconds to wait for Chrome to start (default: 30 sec) 45 | * - userAgent: User agent to use for the whole browser 46 | * - userDataDir: Chrome user data dir (default: a new empty dir is generated temporarily) 47 | * - userCrashDumpsDir: The directory crashpad should store dumps in (crash reporter will be enabled automatically) 48 | * - windowSize: Size of the window. ex: `[1920, 1080]` (default: none) 49 | * - excludedSwitches: An array of Chrome flags that should be removed from the default set (example --enable-automation) 50 | */ 51 | protected $options = []; 52 | 53 | public function __construct(?string $chromeBinary = null) 54 | { 55 | $this->chromeBinary = $chromeBinary ?? (new AutoDiscover())->guessChromeBinaryPath(); 56 | } 57 | 58 | /** 59 | * Start a chrome process and allows to interact with it. 60 | * 61 | * @see BrowserFactory::$options 62 | * 63 | * @param array|null $options overwrite options for browser creation 64 | * 65 | * @return ProcessAwareBrowser a Browser instance to interact with the new chrome process 66 | */ 67 | public function createBrowser(?array $options = null): ProcessAwareBrowser 68 | { 69 | $options ??= $this->options; 70 | 71 | // create logger from options 72 | $logger = self::createLogger($options); 73 | 74 | // create browser process 75 | $browserProcess = new BrowserProcess($logger); 76 | 77 | // instruct the runtime to kill chrome and clean temp files on exit 78 | if (!\array_key_exists('keepAlive', $options) || !$options['keepAlive']) { 79 | \register_shutdown_function([$browserProcess, 'kill']); 80 | } 81 | 82 | // start the browser and connect to it 83 | $browserProcess->start($this->chromeBinary, $options); 84 | 85 | return $browserProcess->getBrowser(); 86 | } 87 | 88 | public function addHeader(string $name, string $value): void 89 | { 90 | $this->options['headers'][$name] = $value; 91 | } 92 | 93 | /** 94 | * @param array $headers 95 | */ 96 | public function addHeaders(array $headers): void 97 | { 98 | foreach ($headers as $name => $value) { 99 | $this->addHeader($name, $value); 100 | } 101 | } 102 | 103 | /** 104 | * Connects to an existing browser using it's web socket uri. 105 | * 106 | * usage: 107 | * 108 | * ``` 109 | * $browserFactory = new BrowserFactory(); 110 | * $browser = $browserFactory->createBrowser(); 111 | * 112 | * $uri = $browser->getSocketUri(); 113 | * 114 | * $existingBrowser = BrowserFactory::connectToBrowser($uri); 115 | * ``` 116 | * 117 | * @param string $uri 118 | * @param array $options options when creating the connection to the browser: 119 | * - connectionDelay: amount of time in seconds to slows down connection for debugging purposes (default: none) 120 | * - debugLogger: resource string ("php://stdout"), resource or psr-3 logger instance (default: none) 121 | * - sendSyncDefaultTimeout: maximum time in ms to wait for synchronous messages to send (default 5000 ms) 122 | * 123 | * @throws BrowserConnectionFailed 124 | * 125 | * @return Browser 126 | */ 127 | public static function connectToBrowser(string $uri, array $options = []): Browser 128 | { 129 | $logger = self::createLogger($options); 130 | 131 | if ($logger) { 132 | $logger->debug('Browser Factory: connecting using '.$uri); 133 | } 134 | 135 | // connect to browser 136 | $connection = new Connection($uri, $logger, $options['sendSyncDefaultTimeout'] ?? 5000); 137 | 138 | // try to connect 139 | try { 140 | $connection->connect(); 141 | } catch (HandshakeException $e) { 142 | throw new BrowserConnectionFailed('Invalid socket uri', 0, $e); 143 | } 144 | 145 | // make sure it is connected 146 | if (!$connection->isConnected()) { 147 | throw new BrowserConnectionFailed('Cannot connect to the browser, make sure it was not closed'); 148 | } 149 | 150 | // connection delay 151 | if (\array_key_exists('connectionDelay', $options)) { 152 | $connection->setConnectionDelay($options['connectionDelay']); 153 | } 154 | 155 | return new Browser($connection); 156 | } 157 | 158 | /** 159 | * Set default options to be used in all browser instances. 160 | * 161 | * @see BrowserFactory::$options 162 | */ 163 | public function setOptions(array $options): void 164 | { 165 | $this->options = $options; 166 | } 167 | 168 | /** 169 | * Add or overwrite options to the default options list. 170 | * 171 | * @see BrowserFactory::$options 172 | */ 173 | public function addOptions(array $options): void 174 | { 175 | $this->options = \array_merge($this->options, $options); 176 | } 177 | 178 | public function getOptions(): array 179 | { 180 | return $this->options; 181 | } 182 | 183 | /** 184 | * Create a logger instance from given options. 185 | */ 186 | private static function createLogger(array $options): LoggerInterface 187 | { 188 | $logger = $options['debugLogger'] ?? null; 189 | 190 | if ($logger instanceof LoggerInterface) { 191 | return $logger; 192 | } 193 | 194 | if (\is_string($logger) || \is_resource($logger)) { 195 | $log = new Logger('chrome'); 196 | $log->pushHandler(new StreamHandler($logger)); 197 | 198 | return $log; 199 | } 200 | 201 | return new NullLogger(); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Clip.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium; 13 | 14 | class Clip 15 | { 16 | /** @var int|float */ 17 | protected $x; 18 | /** @var int|float */ 19 | protected $y; 20 | /** @var int|float */ 21 | protected $height; 22 | /** @var int|float */ 23 | protected $width; 24 | /** @var float */ 25 | protected $scale; 26 | 27 | /** 28 | * @param int|float $x 29 | * @param int|float $y 30 | * @param int|float $height 31 | * @param int|float $width 32 | * @param float $scale 33 | */ 34 | public function __construct($x, $y, $width, $height, $scale = 1.0) 35 | { 36 | $this->x = $x; 37 | $this->y = $y; 38 | $this->height = $height; 39 | $this->width = $width; 40 | $this->scale = $scale; 41 | } 42 | 43 | /** 44 | * @return int|float 45 | */ 46 | public function getX() 47 | { 48 | return $this->x; 49 | } 50 | 51 | /** 52 | * @return int|float 53 | */ 54 | public function getY() 55 | { 56 | return $this->y; 57 | } 58 | 59 | /** 60 | * @return int|float 61 | */ 62 | public function getHeight() 63 | { 64 | return $this->height; 65 | } 66 | 67 | /** 68 | * @return int|float 69 | */ 70 | public function getWidth() 71 | { 72 | return $this->width; 73 | } 74 | 75 | /** 76 | * @return float 77 | */ 78 | public function getScale() 79 | { 80 | return $this->scale; 81 | } 82 | 83 | /** 84 | * @param int|float $x 85 | */ 86 | public function setX($x): void 87 | { 88 | $this->x = $x; 89 | } 90 | 91 | /** 92 | * @param int|float $y 93 | */ 94 | public function setY($y): void 95 | { 96 | $this->y = $y; 97 | } 98 | 99 | /** 100 | * @param int $height 101 | */ 102 | public function setHeight($height): void 103 | { 104 | $this->height = $height; 105 | } 106 | 107 | /** 108 | * @param int $width 109 | */ 110 | public function setWidth($width): void 111 | { 112 | $this->width = $width; 113 | } 114 | 115 | /** 116 | * @param float $scale 117 | */ 118 | public function setScale($scale): void 119 | { 120 | $this->scale = $scale; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Communication/Connection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication; 13 | 14 | use Evenement\EventEmitter; 15 | use HeadlessChromium\Communication\Socket\SocketInterface; 16 | use HeadlessChromium\Communication\Socket\WaitForDataInterface; 17 | use HeadlessChromium\Communication\Socket\Wrench; 18 | use HeadlessChromium\Exception\CommunicationException; 19 | use HeadlessChromium\Exception\CommunicationException\CannotReadResponse; 20 | use HeadlessChromium\Exception\CommunicationException\CantSyncEventsException; 21 | use HeadlessChromium\Exception\CommunicationException\InvalidResponse; 22 | use HeadlessChromium\Exception\OperationTimedOut; 23 | use HeadlessChromium\Exception\TargetDestroyed; 24 | use Psr\Log\LoggerAwareInterface; 25 | use Psr\Log\LoggerAwareTrait; 26 | use Psr\Log\LoggerInterface; 27 | use Psr\Log\NullLogger; 28 | use Wrench\Client as WrenchBaseClient; 29 | 30 | class Connection extends EventEmitter implements LoggerAwareInterface 31 | { 32 | use LoggerAwareTrait; 33 | 34 | public const EVENT_TARGET_CREATED = 'method:Target.targetCreated'; 35 | public const EVENT_TARGET_INFO_CHANGED = 'method:Target.targetInfoChanged'; 36 | public const EVENT_TARGET_DESTROYED = 'method:Target.targetDestroyed'; 37 | 38 | /** 39 | * When strict mode is enabled communication error will result in exceptions. 40 | * 41 | * @var bool 42 | */ 43 | protected $strict = true; 44 | 45 | /** 46 | * time in ms to wait between each message to be sent 47 | * That helps to see what is happening when debugging. 48 | * 49 | * @var int 50 | */ 51 | protected $delay; 52 | 53 | /** 54 | * time in ms when the previous message was sent. Used to know how long to wait for before send next message 55 | * (only when $delay is set). 56 | * 57 | * @var int 58 | */ 59 | private $lastMessageSentTime; 60 | 61 | /** 62 | * @var SocketInterface 63 | */ 64 | protected $wsClient; 65 | 66 | /** 67 | * List of response sent from the remote host and that are waiting to be read. 68 | * 69 | * @var array 70 | */ 71 | protected $responseBuffer = []; 72 | 73 | /** 74 | * Default timeout for send sync in ms. 75 | * 76 | * @var int 77 | */ 78 | protected $sendSyncDefaultTimeout; 79 | 80 | /** 81 | * @var Session[] 82 | */ 83 | protected $sessions = []; 84 | 85 | /** 86 | * @var array array of data received and waiting to be read 87 | */ 88 | protected $receivedData = []; 89 | 90 | /** 91 | * @var array 92 | */ 93 | protected $httpHeaders = []; 94 | 95 | /** 96 | * CommunicationChannel constructor. 97 | * 98 | * @param SocketInterface|string $socketClient 99 | * @param int|null $sendSyncDefaultTimeout 100 | */ 101 | public function __construct($socketClient, ?LoggerInterface $logger = null, ?int $sendSyncDefaultTimeout = null) 102 | { 103 | // set or create logger 104 | $this->setLogger($logger ?? new NullLogger()); 105 | 106 | // set timeout 107 | $this->sendSyncDefaultTimeout = $sendSyncDefaultTimeout ?? 5000; 108 | 109 | // create socket client 110 | if (\is_string($socketClient)) { 111 | $socketClient = new Wrench(new WrenchBaseClient($socketClient, 'http://127.0.0.1'), $this->logger); 112 | } elseif (!\is_object($socketClient) && !$socketClient instanceof SocketInterface) { 113 | throw new \InvalidArgumentException('$socketClient param should be either a SockInterface instance or a web socket uri string'); 114 | } 115 | 116 | $this->wsClient = $socketClient; 117 | } 118 | 119 | /** 120 | * @return LoggerInterface 121 | */ 122 | public function getLogger(): LoggerInterface 123 | { 124 | return $this->logger; 125 | } 126 | 127 | /** 128 | * Set the delay to apply everytime before data are sent. 129 | * 130 | * @param int $delay 131 | */ 132 | public function setConnectionDelay(int $delay): void 133 | { 134 | $this->delay = $delay; 135 | } 136 | 137 | /** 138 | * @param array $headers 139 | * 140 | * @return void 141 | */ 142 | public function setConnectionHttpHeaders(array $headers): void 143 | { 144 | $this->httpHeaders = $headers; 145 | } 146 | 147 | /** 148 | * @return array 149 | */ 150 | public function getConnectionHttpHeaders(): array 151 | { 152 | return $this->httpHeaders; 153 | } 154 | 155 | /** 156 | * Gets the default timeout used when sending a message synchronously. 157 | * 158 | * @return int 159 | */ 160 | public function getSendSyncDefaultTimeout(): int 161 | { 162 | return $this->sendSyncDefaultTimeout; 163 | } 164 | 165 | /** 166 | * @return bool 167 | */ 168 | public function isStrict(): bool 169 | { 170 | return $this->strict; 171 | } 172 | 173 | /** 174 | * @param bool $strict 175 | */ 176 | public function setStrict(bool $strict): void 177 | { 178 | $this->strict = $strict; 179 | } 180 | 181 | /** 182 | * Connects to the server. 183 | * 184 | * @return bool Whether a new connection was made 185 | */ 186 | public function connect() 187 | { 188 | return $this->wsClient->connect(); 189 | } 190 | 191 | /** 192 | * Disconnects the underlying socket, and marks the client as disconnected. 193 | * 194 | * @return bool 195 | */ 196 | public function disconnect() 197 | { 198 | return $this->wsClient->disconnect(); 199 | } 200 | 201 | /** 202 | * Returns whether the client is currently connected. 203 | * 204 | * @return bool true if connected 205 | */ 206 | public function isConnected() 207 | { 208 | return $this->wsClient->isConnected(); 209 | } 210 | 211 | /** 212 | * Wait before sending next message. 213 | */ 214 | private function waitForDelay(): void 215 | { 216 | if ($this->lastMessageSentTime) { 217 | $currentTime = (int) (\hrtime(true) / 1000 / 1000); 218 | // if not enough time was spent until last message was sent, wait 219 | if ($this->lastMessageSentTime + $this->delay > $currentTime) { 220 | $timeToWait = ($this->lastMessageSentTime + $this->delay) - $currentTime; 221 | \usleep($timeToWait * 1000); 222 | } 223 | } 224 | 225 | $this->lastMessageSentTime = (int) (\hrtime(true) / 1000 / 1000); 226 | } 227 | 228 | /** 229 | * Sends the given message and returns a response reader. 230 | * 231 | * @param Message $message 232 | * 233 | * @throws CommunicationException 234 | * 235 | * @return ResponseReader 236 | */ 237 | public function sendMessage(Message $message): ResponseReader 238 | { 239 | // if delay enabled wait before sending message 240 | if ($this->delay > 0) { 241 | $this->waitForDelay(); 242 | } 243 | 244 | $sent = $this->wsClient->sendData((string) $message); 245 | 246 | if (!$sent) { 247 | $message = 'Message could not be sent.'; 248 | 249 | if (!$this->isConnected()) { 250 | $message .= ' Reason: the connection is closed.'; 251 | } else { 252 | $message .= ' Reason: unknown.'; 253 | } 254 | 255 | throw new CommunicationException($message); 256 | } 257 | 258 | return new ResponseReader($message, $this); 259 | } 260 | 261 | /** 262 | * @param Message $message 263 | * @param int|null $timeout 264 | * 265 | * @throws OperationTimedOut 266 | * 267 | * @return Response 268 | */ 269 | public function sendMessageSync(Message $message, ?int $timeout = null): Response 270 | { 271 | $responseReader = $this->sendMessage($message); 272 | $response = $responseReader->waitForResponse($timeout); 273 | 274 | return $response; 275 | } 276 | 277 | /** 278 | * Create a session for the given target id. 279 | * 280 | * @param string $targetId 281 | * @param ?string $sessionId 282 | * 283 | * @return Session 284 | */ 285 | public function createSession($targetId, $sessionId = null): Session 286 | { 287 | if (null === $sessionId) { 288 | $response = $this->sendMessageSync( 289 | new Message('Target.attachToTarget', ['targetId' => $targetId, 'flatten' => true]) 290 | ); 291 | if (empty($response['result'])) { 292 | throw new TargetDestroyed('The target was destroyed.'); 293 | } 294 | $sessionId = $response['result']['sessionId']; 295 | } 296 | $session = new Session($targetId, $sessionId, $this); 297 | 298 | $this->sessions[$sessionId] = $session; 299 | 300 | $session->on('destroyed', function () use ($sessionId): void { 301 | $this->logger->debug('✘ session('.$sessionId.') was destroyed and unreferenced.'); 302 | unset($this->sessions[$sessionId]); 303 | }); 304 | 305 | return $session; 306 | } 307 | 308 | /** 309 | * Receive and stack data from the socket. 310 | */ 311 | private function receiveData(): void 312 | { 313 | $this->receivedData = \array_merge($this->receivedData, $this->wsClient->receiveData()); 314 | } 315 | 316 | /** 317 | * Read data from CRI and store messages. 318 | * 319 | * @throws CannotReadResponse 320 | * @throws InvalidResponse 321 | * 322 | * @return bool true if data were received 323 | */ 324 | public function readData() 325 | { 326 | $hasData = false; 327 | 328 | while ($this->readLine()) { 329 | $hasData = true; 330 | } 331 | 332 | return $hasData; 333 | } 334 | 335 | public function readLine() 336 | { 337 | // if buffer empty, then read from input 338 | if (empty($this->receivedData)) { 339 | $this->receiveData(); 340 | } 341 | 342 | // dispatch first line of buffer 343 | $datum = \array_shift($this->receivedData); 344 | if ($datum) { 345 | return $this->dispatchMessage($datum); 346 | } 347 | 348 | return false; 349 | } 350 | 351 | public function processAllEvents(): void 352 | { 353 | if (false === $this->wsClient instanceof WaitForDataInterface) { 354 | throw new CantSyncEventsException(); 355 | } 356 | 357 | $hasData = $this->wsClient->waitForData(0); 358 | 359 | if ($hasData) { 360 | $this->receiveData(); 361 | } 362 | } 363 | 364 | /** 365 | * Dispatches the message and either stores the response or emits an event. 366 | * 367 | * @throws InvalidResponse 368 | * 369 | * @return bool 370 | * 371 | * @internal 372 | */ 373 | private function dispatchMessage(string $message, ?Session $session = null) 374 | { 375 | // responses come as json string 376 | $response = \json_decode($message, true); 377 | 378 | // if json not valid throw exception 379 | $jsonError = \json_last_error(); 380 | if (\JSON_ERROR_NONE !== $jsonError) { 381 | if ($this->isStrict()) { 382 | throw new CannotReadResponse(\sprintf('Response from chrome remote interface is not a valid json response. JSON error: %s', $jsonError)); 383 | } 384 | 385 | return false; 386 | } 387 | 388 | // response must be array 389 | if (!\is_array($response)) { 390 | if ($this->isStrict()) { 391 | throw new CannotReadResponse('Response from chrome remote interface was not a valid array'); 392 | } 393 | 394 | return false; 395 | } 396 | 397 | // id is required to identify the response 398 | if (!isset($response['id'])) { 399 | if (isset($response['method'])) { 400 | if ('Target.receivedMessageFromTarget' == $response['method']) { 401 | $session = $this->sessions[$response['params']['sessionId']]; 402 | 403 | return $this->dispatchMessage($response['params']['message'], $session); 404 | } else { 405 | if (!$session && isset($response['sessionId'])) { 406 | $session = $this->sessions[$response['sessionId']] ?? null; 407 | } 408 | if ($session) { 409 | $this->logger->debug( 410 | 'session('.$session->getSessionId().'): ⇶ dispatching method:'.$response['method'] 411 | ); 412 | $session->emit('method:'.$response['method'], [$response['params']]); 413 | } else { 414 | $this->logger->debug('connection: ⇶ dispatching method:'.$response['method']); 415 | $this->emit('method:'.$response['method'], [$response['params']]); 416 | } 417 | } 418 | 419 | return false; 420 | } 421 | 422 | if ($this->isStrict()) { 423 | throw new InvalidResponse('Response from chrome remote interface did not provide a valid message id'); 424 | } 425 | 426 | return false; 427 | } 428 | 429 | // store response 430 | $this->responseBuffer[$response['id']] = $response; 431 | 432 | return true; 433 | } 434 | 435 | /** 436 | * True if a response for the given id exists. 437 | * 438 | * @param string $id 439 | * 440 | * @return bool 441 | */ 442 | public function hasResponseForId($id) 443 | { 444 | return \array_key_exists($id, $this->responseBuffer); 445 | } 446 | 447 | /** 448 | * @param string $id 449 | * 450 | * @return array|null 451 | */ 452 | public function getResponseForId($id) 453 | { 454 | if (\array_key_exists($id, $this->responseBuffer)) { 455 | $data = $this->responseBuffer[$id]; 456 | unset($this->responseBuffer[$id]); 457 | 458 | return $data; 459 | } 460 | 461 | return null; 462 | } 463 | 464 | /** 465 | * @param string $sessionId 466 | * 467 | * @return bool 468 | */ 469 | public function isSessionDestroyed($sessionId) 470 | { 471 | return !isset($this->sessions[$sessionId]); 472 | } 473 | } 474 | -------------------------------------------------------------------------------- /src/Communication/Message.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication; 13 | 14 | class Message 15 | { 16 | /** 17 | * global message id auto incremented for each message sent. 18 | * 19 | * @var int 20 | */ 21 | private static $messageId = 0; 22 | 23 | /** 24 | * @var int 25 | */ 26 | protected $id; 27 | 28 | /** 29 | * @var string 30 | */ 31 | protected $method; 32 | 33 | /** 34 | * @var array 35 | */ 36 | protected $params; 37 | 38 | /** 39 | * @var ?string 40 | */ 41 | protected $sessionId; 42 | 43 | /** 44 | * get the last generated message id. 45 | * 46 | * @return int 47 | */ 48 | public static function getLastMessageId() 49 | { 50 | return self::$messageId; 51 | } 52 | 53 | /** 54 | * @param string $method 55 | * @param array $params 56 | */ 57 | public function __construct(string $method, array $params = [], ?string $sessionId = null) 58 | { 59 | $this->id = ++self::$messageId; 60 | $this->method = $method; 61 | $this->params = $params; 62 | $this->sessionId = $sessionId; 63 | } 64 | 65 | /** 66 | * @return int 67 | */ 68 | public function getId(): int 69 | { 70 | return $this->id; 71 | } 72 | 73 | /** 74 | * @return string 75 | */ 76 | public function getMethod(): string 77 | { 78 | return $this->method; 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function getParams(): array 85 | { 86 | return $this->params; 87 | } 88 | 89 | public function __toString(): string 90 | { 91 | $message = [ 92 | 'id' => $this->getId(), 93 | 'method' => $this->getMethod(), 94 | 'params' => (object) $this->getParams(), 95 | ]; 96 | if (null !== $this->sessionId) { 97 | $message['sessionId'] = $this->sessionId; 98 | } 99 | 100 | return \json_encode($message); 101 | } 102 | 103 | public function getSessionId(): ?string 104 | { 105 | return $this->sessionId; 106 | } 107 | 108 | public function setSessionId(string $sessionId): void 109 | { 110 | $this->sessionId = $sessionId; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Communication/Response.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication; 13 | 14 | class Response implements \ArrayAccess 15 | { 16 | protected $message; 17 | 18 | protected $data; 19 | 20 | /** 21 | * Response constructor. 22 | */ 23 | public function __construct(array $data, Message $message) 24 | { 25 | $this->data = $data; 26 | $this->message = $message; 27 | } 28 | 29 | /** 30 | * True if the response is error free. 31 | * 32 | * @return bool 33 | */ 34 | public function isSuccessful() 35 | { 36 | return !\array_key_exists('error', $this->data); 37 | } 38 | 39 | /** 40 | * Get the error message if set. 41 | * 42 | * @return string|null 43 | */ 44 | public function getErrorMessage(bool $extended = true) 45 | { 46 | $message = []; 47 | 48 | if ($extended && isset($this->data['error']['code'])) { 49 | $message[] = $this->data['error']['code']; 50 | } 51 | 52 | if (isset($this->data['error']['message'])) { 53 | $message[] = $this->data['error']['message']; 54 | } 55 | 56 | if ($extended && isset($this->data['error']['data']) && \is_string($this->data['error']['data'])) { 57 | $message[] = $this->data['error']['data']; 58 | } 59 | 60 | return \implode(' - ', $message); 61 | } 62 | 63 | /** 64 | * Get the error code if set. 65 | * 66 | * @return string|null 67 | */ 68 | public function getErrorCode() 69 | { 70 | return $this->data['error']['code'] ?? null; 71 | } 72 | 73 | /** 74 | * @param string $name 75 | * 76 | * @return mixed 77 | */ 78 | public function getResultData($name) 79 | { 80 | return $this->data['result'][$name] ?? null; 81 | } 82 | 83 | /** 84 | * @return Message 85 | */ 86 | public function getMessage(): Message 87 | { 88 | return $this->message; 89 | } 90 | 91 | /** 92 | * The data returned by chrome dev tools. 93 | * 94 | * @return array 95 | */ 96 | public function getData(): array 97 | { 98 | return $this->data; 99 | } 100 | 101 | /** 102 | * {@inheritdoc} 103 | */ 104 | #[\ReturnTypeWillChange] 105 | public function offsetExists($offset) 106 | { 107 | return \array_key_exists($offset, $this->data); 108 | } 109 | 110 | /** 111 | * {@inheritdoc} 112 | */ 113 | #[\ReturnTypeWillChange] 114 | public function offsetGet($offset) 115 | { 116 | return $this->data[$offset]; 117 | } 118 | 119 | /** 120 | * {@inheritdoc} 121 | */ 122 | public function offsetSet($offset, $value): void 123 | { 124 | throw new \Exception('Responses are immutable'); 125 | } 126 | 127 | /** 128 | * {@inheritdoc} 129 | */ 130 | public function offsetUnset($offset): void 131 | { 132 | throw new \Exception('Responses are immutable'); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Communication/ResponseReader.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication; 13 | 14 | use HeadlessChromium\Exception\NoResponseAvailable; 15 | use HeadlessChromium\Exception\OperationTimedOut; 16 | use HeadlessChromium\Utils; 17 | 18 | class ResponseReader 19 | { 20 | /** 21 | * @var Message 22 | */ 23 | protected $message; 24 | 25 | /** 26 | * @var Connection 27 | */ 28 | protected $connection; 29 | 30 | /** 31 | * @var Response|null 32 | */ 33 | protected $response = null; 34 | 35 | /** 36 | * Response constructor. 37 | * 38 | * @param Message $message 39 | * @param Connection $connection 40 | */ 41 | public function __construct(Message $message, Connection $connection) 42 | { 43 | $this->message = $message; 44 | $this->connection = $connection; 45 | } 46 | 47 | /** 48 | * True if a response is available. 49 | * 50 | * @return bool 51 | */ 52 | public function hasResponse() 53 | { 54 | return null !== $this->response; 55 | } 56 | 57 | /** 58 | * the message to get a response for. 59 | * 60 | * @return Message 61 | */ 62 | public function getMessage(): Message 63 | { 64 | return $this->message; 65 | } 66 | 67 | /** 68 | * The connection to check messages for. 69 | * 70 | * @return Connection 71 | */ 72 | public function getConnection(): Connection 73 | { 74 | return $this->connection; 75 | } 76 | 77 | /** 78 | * Get the response. 79 | * 80 | * Note: response will always be missing until checkForResponse is called 81 | * and the response is available in the buffer 82 | * 83 | * @throws NoResponseAvailable 84 | * 85 | * @return Response 86 | */ 87 | public function getResponse(): Response 88 | { 89 | if (!$this->response) { 90 | throw new NoResponseAvailable('Response is not available. Try to use the method waitForResponse instead.'); 91 | } 92 | 93 | return $this->response; 94 | } 95 | 96 | /** 97 | * Wait for a response. 98 | * 99 | * @param int $timeout time to wait for a response (milliseconds) 100 | * 101 | * @throws NoResponseAvailable 102 | * @throws OperationTimedOut 103 | * 104 | * @return Response 105 | */ 106 | public function waitForResponse(?int $timeout = null): Response 107 | { 108 | if ($this->hasResponse()) { 109 | return $this->getResponse(); 110 | } 111 | 112 | $timeout ??= $this->connection->getSendSyncDefaultTimeout(); 113 | 114 | return Utils::tryWithTimeout($timeout * 1000, $this->waitForResponseGenerator()); 115 | } 116 | 117 | /** 118 | * To be used in waitForResponse method. 119 | * 120 | * @throws NoResponseAvailable 121 | * 122 | * @return \Generator|Response 123 | * 124 | * @internal 125 | */ 126 | private function waitForResponseGenerator() 127 | { 128 | while (true) { 129 | // 50 microseconds between each iteration 130 | $tryDelay = 50; 131 | 132 | // read available response 133 | $hasResponse = $this->checkForResponse(); 134 | 135 | // if found return it 136 | if ($hasResponse) { 137 | return $this->getResponse(); 138 | } 139 | 140 | // wait before next check 141 | yield $tryDelay; 142 | } 143 | } 144 | 145 | /** 146 | * Check in the connection if a response exists for the message and store it if the response exists. 147 | * 148 | * @return bool 149 | */ 150 | public function checkForResponse() 151 | { 152 | // if response is already read, ignore 153 | if ($this->hasResponse()) { 154 | return true; 155 | } 156 | 157 | $id = $this->message->getId(); 158 | 159 | // if response exists store it 160 | if ($this->connection->hasResponseForId($id)) { 161 | $this->response = new Response($this->connection->getResponseForId($id), $this->message); 162 | 163 | return true; 164 | } 165 | 166 | // read data 167 | while (!$this->connection->hasResponseForId($id)) { 168 | if (!$this->connection->readLine()) { 169 | break; 170 | } 171 | } 172 | 173 | // if response store it 174 | if ($this->connection->hasResponseForId($id)) { 175 | $this->response = new Response($this->connection->getResponseForId($id), $this->message); 176 | 177 | return true; 178 | } 179 | 180 | // check if the session was destroyed in the mean time 181 | if (null !== $this->message->getSessionId() && $this->connection->isSessionDestroyed($this->message->getSessionId())) { 182 | throw new \HeadlessChromium\Exception\TargetDestroyed('The session is destroyed.'); 183 | } 184 | 185 | return false; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Communication/Session.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication; 13 | 14 | use Evenement\EventEmitter; 15 | use HeadlessChromium\Exception\CommunicationException; 16 | use HeadlessChromium\Exception\NoResponseAvailable; 17 | use HeadlessChromium\Exception\TargetDestroyed; 18 | 19 | class Session extends EventEmitter 20 | { 21 | /** 22 | * @var string 23 | */ 24 | protected $sessionId; 25 | 26 | /** 27 | * @var string 28 | */ 29 | protected $targetId; 30 | 31 | /** 32 | * @var Connection|null 33 | */ 34 | protected $connection; 35 | 36 | /** 37 | * @var bool 38 | */ 39 | protected $destroyed = false; 40 | 41 | /** 42 | * Session constructor. 43 | * 44 | * @param string $targetId 45 | * @param string $sessionId 46 | * @param Connection $connection 47 | */ 48 | public function __construct(string $targetId, string $sessionId, Connection $connection) 49 | { 50 | $this->sessionId = $sessionId; 51 | $this->targetId = $targetId; 52 | $this->connection = $connection; 53 | } 54 | 55 | /** 56 | * @param Message $message 57 | * 58 | * @throws CommunicationException 59 | * 60 | * @return ResponseReader 61 | */ 62 | public function sendMessage(Message $message): ResponseReader 63 | { 64 | if ($this->destroyed) { 65 | throw new TargetDestroyed('The session was destroyed.'); 66 | } 67 | 68 | if (null === $message->getSessionId()) { 69 | $message->setSessionId($this->getSessionId()); 70 | } 71 | $topResponse = $this->getConnection()->sendMessage($message); 72 | 73 | return $topResponse; 74 | } 75 | 76 | /** 77 | * @param Message $message 78 | * @param int $timeout 79 | * 80 | * @throws NoResponseAvailable 81 | * @throws CommunicationException 82 | * 83 | * @return Response 84 | */ 85 | public function sendMessageSync(Message $message, ?int $timeout = null): Response 86 | { 87 | $responseReader = $this->sendMessage($message); 88 | 89 | $response = $responseReader->waitForResponse($timeout); 90 | 91 | if (!$response) { 92 | throw new NoResponseAvailable('No response was sent in the given timeout'); 93 | } 94 | 95 | return $response; 96 | } 97 | 98 | /** 99 | * @return string 100 | */ 101 | public function getSessionId() 102 | { 103 | return $this->sessionId; 104 | } 105 | 106 | /** 107 | * @return string 108 | */ 109 | public function getTargetId() 110 | { 111 | return $this->targetId; 112 | } 113 | 114 | /** 115 | * @return Connection 116 | */ 117 | public function getConnection() 118 | { 119 | if ($this->destroyed) { 120 | throw new TargetDestroyed('The session was destroyed.'); 121 | } 122 | 123 | return $this->connection; 124 | } 125 | 126 | /** 127 | * Marks the session as destroyed. 128 | * 129 | * @internal 130 | */ 131 | public function destroy(): void 132 | { 133 | if ($this->destroyed) { 134 | throw new TargetDestroyed('The session was already destroyed.'); 135 | } 136 | $this->emit('destroyed'); 137 | $this->connection = null; 138 | $this->destroyed = true; 139 | $this->removeAllListeners(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Communication/Socket/MockSocket.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication\Socket; 13 | 14 | /** 15 | * A mock adapter for unit tests. 16 | */ 17 | class MockSocket implements SocketInterface 18 | { 19 | protected $sentData = []; 20 | 21 | protected $receivedData = []; 22 | protected $receivedDataForNextMessage = []; 23 | 24 | protected $isConnected = false; 25 | 26 | protected $shouldConnect = true; 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function sendData($data) 32 | { 33 | if (!$this->isConnected()) { 34 | return false; 35 | } 36 | 37 | $this->sentData[] = $data; 38 | 39 | if (!empty($this->receivedDataForNextMessage)) { 40 | $data = \json_decode($data, true); 41 | 42 | if ($data['id']) { 43 | $next = \array_shift($this->receivedDataForNextMessage); 44 | $next = \json_decode($next, true); 45 | $next['id'] = $data['id']; 46 | $this->receivedData[] = \json_encode($next); 47 | 48 | if (isset($data['method']) && 'Target.sendMessageToTarget' == $data['method']) { 49 | --$next['id']; 50 | $this->receivedData[] = \json_encode($next); 51 | } 52 | } 53 | } 54 | 55 | return true; 56 | } 57 | 58 | /** 59 | * resets the data stored with sendData. 60 | */ 61 | public function flushData(): void 62 | { 63 | $this->sentData = []; 64 | } 65 | 66 | /** 67 | * gets the data stored with sendData. 68 | */ 69 | public function getSentData() 70 | { 71 | return $this->sentData; 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | public function receiveData(): array 78 | { 79 | $data = $this->receivedData; 80 | $this->receivedData = []; 81 | 82 | return $data; 83 | } 84 | 85 | /** 86 | * Add data to be returned with receiveData. 87 | * 88 | * @param bool $forNextMessage true to set the response id automatically 89 | * for next message (can stack for multiple messages 90 | */ 91 | public function addReceivedData($data, $forNextMessage = false): void 92 | { 93 | if ($forNextMessage) { 94 | $this->receivedDataForNextMessage[] = $data; 95 | } else { 96 | $this->receivedData[] = $data; 97 | } 98 | } 99 | 100 | /** 101 | * {@inheritdoc} 102 | */ 103 | public function connect() 104 | { 105 | $this->isConnected = $this->shouldConnect; 106 | 107 | return $this->isConnected; 108 | } 109 | 110 | /** 111 | * {@inheritdoc} 112 | */ 113 | public function isConnected() 114 | { 115 | return $this->isConnected; 116 | } 117 | 118 | /** 119 | * {@inheritdoc} 120 | */ 121 | public function disconnect($reason = 1000) 122 | { 123 | $this->isConnected = false; 124 | 125 | return true; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Communication/Socket/SocketInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication\Socket; 13 | 14 | /** 15 | * A simplified interface to wrap a socket client. 16 | */ 17 | interface SocketInterface 18 | { 19 | /** 20 | * Sends data to the socket. 21 | * 22 | * @return bool whether the data were sent 23 | */ 24 | public function sendData($data); 25 | 26 | /** 27 | * Receives data sent by the server. 28 | * 29 | * @return array Payload received since the last call to receive() 30 | */ 31 | public function receiveData(): array; 32 | 33 | /** 34 | * Connect to the server. 35 | * 36 | * @return bool Whether a new connection was made 37 | */ 38 | public function connect(); 39 | 40 | /** 41 | * Whether the client is currently connected. 42 | * 43 | * @return bool 44 | */ 45 | public function isConnected(); 46 | 47 | /** 48 | * Disconnects the underlying socket, and marks the client as disconnected. 49 | * 50 | * @param int $reason see http://tools.ietf.org/html/rfc6455#section-7.4 51 | * 52 | * @return bool 53 | */ 54 | public function disconnect($reason = 1000); 55 | } 56 | -------------------------------------------------------------------------------- /src/Communication/Socket/WaitForDataInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication\Socket; 13 | 14 | use Psr\Log\LoggerAwareInterface; 15 | use Psr\Log\LoggerAwareTrait; 16 | use Psr\Log\LoggerInterface; 17 | use Psr\Log\NullLogger; 18 | use Wrench\Client as WrenchClient; 19 | use Wrench\Payload\Payload; 20 | 21 | class Wrench implements SocketInterface, LoggerAwareInterface, WaitForDataInterface 22 | { 23 | use LoggerAwareTrait; 24 | 25 | /** 26 | * An auto incremented counter to uniquely identify each socket instance. 27 | * 28 | * @var int 29 | */ 30 | private static $socketIdCounter = 0; 31 | 32 | /** 33 | * @var WrenchClient 34 | */ 35 | protected $client; 36 | 37 | /** 38 | * Id of this socket generated from self::$socketIdCounter. 39 | * 40 | * @var int 41 | */ 42 | protected $socketId = 0; 43 | 44 | /** 45 | * @param WrenchClient $client 46 | */ 47 | public function __construct(WrenchClient $client, ?LoggerInterface $logger = null) 48 | { 49 | $this->client = $client; 50 | 51 | $this->setLogger($logger ?? new NullLogger()); 52 | 53 | $this->socketId = ++self::$socketIdCounter; 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function sendData($data) 60 | { 61 | // log 62 | $this->logger->debug('socket('.$this->socketId.'): → sending data:'.$data); 63 | 64 | // send data 65 | return $this->client->sendData($data); 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function receiveData(): array 72 | { 73 | $playloads = $this->client->receive(); 74 | 75 | $data = []; 76 | 77 | if ($playloads) { 78 | foreach ($playloads as $playload) { 79 | /** @var Payload */ 80 | $dataString = $playload->getPayload(); 81 | $data[] = $dataString; 82 | 83 | // log 84 | $this->logger->debug('socket('.$this->socketId.'): ← receiving data:'.$dataString); 85 | } 86 | } 87 | 88 | return $data; 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public function connect() 95 | { 96 | // log 97 | $this->logger->debug('socket('.$this->socketId.'): connecting'); 98 | 99 | $connected = $this->client->connect(); 100 | 101 | if ($connected) { 102 | // log 103 | $this->logger->debug('socket('.$this->socketId.'): ✓ connected'); 104 | } else { 105 | // log 106 | $this->logger->debug('socket('.$this->socketId.'): ✗ could not connect'); 107 | } 108 | 109 | return $connected; 110 | } 111 | 112 | /** 113 | * {@inheritdoc} 114 | */ 115 | public function isConnected() 116 | { 117 | return $this->client->isConnected(); 118 | } 119 | 120 | /** 121 | * {@inheritdoc} 122 | */ 123 | public function disconnect($reason = 1000) 124 | { 125 | // log 126 | $this->logger->debug('socket('.$this->socketId.'): disconnecting'); 127 | 128 | $disconnected = $this->client->disconnect($reason); 129 | 130 | if ($disconnected) { 131 | // log 132 | $this->logger->debug('socket('.$this->socketId.'): ✓ disconnected'); 133 | } else { 134 | // log 135 | $this->logger->debug('socket('.$this->socketId.'): ✗ could not disconnect'); 136 | } 137 | 138 | return $disconnected; 139 | } 140 | 141 | public function waitForData(float $maxSeconds): bool 142 | { 143 | return $this->client->waitForData($maxSeconds); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Communication/Target.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Communication; 13 | 14 | use HeadlessChromium\Exception\TargetDestroyed; 15 | 16 | class Target 17 | { 18 | /** 19 | * @var array 20 | */ 21 | protected $targetInfo; 22 | 23 | /** 24 | * @var Session|null 25 | */ 26 | protected $session; 27 | 28 | /** 29 | * @var Connection 30 | */ 31 | private $connection; 32 | 33 | /** 34 | * @var bool 35 | */ 36 | protected $destroyed = false; 37 | 38 | /** 39 | * Target constructor. 40 | */ 41 | public function __construct(array $targetInfo, Connection $connection) 42 | { 43 | $this->targetInfo = $targetInfo; 44 | $this->connection = $connection; 45 | } 46 | 47 | /** 48 | * @param ?string $sessionId 49 | * 50 | * @return Session 51 | */ 52 | public function getSession(?string $sessionId = null): Session 53 | { 54 | if ($this->destroyed) { 55 | throw new TargetDestroyed('The target was destroyed.'); 56 | } 57 | 58 | // if not already done, create a session for the target 59 | if (!$this->session) { 60 | $this->session = $this->connection->createSession($this->getTargetInfo('targetId'), $sessionId); 61 | } 62 | 63 | return $this->session; 64 | } 65 | 66 | /** 67 | * Marks the target as destroyed. 68 | * 69 | * @internal 70 | */ 71 | public function destroy(): void 72 | { 73 | if ($this->destroyed) { 74 | throw new TargetDestroyed('The target was already destroyed.'); 75 | } 76 | 77 | if ($this->session) { 78 | $this->session->destroy(); 79 | $this->session = null; 80 | } 81 | 82 | $this->destroyed = true; 83 | } 84 | 85 | /** 86 | * @return bool 87 | */ 88 | public function isDestroyed(): bool 89 | { 90 | return $this->destroyed; 91 | } 92 | 93 | /** 94 | * Get target info value by it's name or null if it does not exist. 95 | * 96 | * @param string $infoName 97 | * 98 | * @return mixed 99 | */ 100 | public function getTargetInfo($infoName) 101 | { 102 | return $this->targetInfo[$infoName] ?? null; 103 | } 104 | 105 | /** 106 | * To be called when Target.targetInfoChanged is triggered. 107 | * 108 | * @param array $targetInfo 109 | * 110 | * @internal 111 | */ 112 | public function targetInfoChanged($targetInfo): void 113 | { 114 | $this->targetInfo = $targetInfo; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Cookies/Cookie.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Cookies; 13 | 14 | class Cookie implements \ArrayAccess, \IteratorAggregate 15 | { 16 | /** 17 | * @var array 18 | */ 19 | protected $data; 20 | 21 | /** 22 | * Cookie constructor. 23 | */ 24 | public function __construct(array $data) 25 | { 26 | if (isset($data['expires']) && \is_string($data['expires']) && !\is_numeric($data['expires'])) { 27 | $data['expires'] = \strtotime($data['expires']); 28 | } 29 | 30 | $this->data = $data; 31 | } 32 | 33 | /** 34 | * @return mixed 35 | */ 36 | public function getValue() 37 | { 38 | return $this->offsetGet('value'); 39 | } 40 | 41 | /** 42 | * @return mixed 43 | */ 44 | public function getName() 45 | { 46 | return $this->offsetGet('name'); 47 | } 48 | 49 | /** 50 | * @return mixed 51 | */ 52 | public function getDomain() 53 | { 54 | return $this->offsetGet('domain'); 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | #[\ReturnTypeWillChange] 61 | public function offsetExists($offset) 62 | { 63 | return \array_key_exists($offset, $this->data); 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | #[\ReturnTypeWillChange] 70 | public function offsetGet($offset) 71 | { 72 | return $this->data[$offset] ?? null; 73 | } 74 | 75 | /** 76 | * {@inheritdoc} 77 | */ 78 | public function offsetSet($offset, $value): void 79 | { 80 | throw new \RuntimeException('Cannot set cookie values'); 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function offsetUnset($offset): void 87 | { 88 | throw new \RuntimeException('Cannot unset cookie values'); 89 | } 90 | 91 | /** 92 | * @param string $name 93 | * @param string $value 94 | * @param array $params 95 | * 96 | * @return Cookie 97 | */ 98 | public static function create($name, $value, array $params = []) 99 | { 100 | $params['name'] = $name; 101 | $params['value'] = $value; 102 | 103 | return new self($params); 104 | } 105 | 106 | public function getIterator(): \ArrayIterator 107 | { 108 | return new \ArrayIterator($this->data); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Cookies/CookiesCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Cookies; 13 | 14 | class CookiesCollection implements \IteratorAggregate, \Countable 15 | { 16 | /** 17 | * @var Cookie[] 18 | */ 19 | protected $cookies = []; 20 | 21 | /** 22 | * CookiesCollection constructor. 23 | */ 24 | public function __construct(?array $cookies = null) 25 | { 26 | if ($cookies) { 27 | foreach ($cookies as $cookie) { 28 | if (\is_array($cookie)) { 29 | $cookie = new Cookie($cookie); 30 | } 31 | $this->addCookie($cookie); 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * Adds a cookie. 38 | */ 39 | public function addCookie(Cookie $cookie): void 40 | { 41 | $this->cookies[] = $cookie; 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | #[\ReturnTypeWillChange] 48 | public function getIterator() 49 | { 50 | return new \ArrayIterator($this->cookies); 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | #[\ReturnTypeWillChange] 57 | public function count() 58 | { 59 | return \count($this->cookies); 60 | } 61 | 62 | /** 63 | * Get the cookie at the given index. 64 | * 65 | * @param int $i 66 | * 67 | * @return Cookie 68 | */ 69 | public function getAt($i): Cookie 70 | { 71 | if (!isset($this->cookies[$i])) { 72 | throw new \RuntimeException(\sprintf('No cookie at index %s', $i)); 73 | } 74 | 75 | return $this->cookies[$i]; 76 | } 77 | 78 | /** 79 | * Find cookies with matching values. 80 | * 81 | * usage: 82 | * 83 | * ``` 84 | * // find cookies having name == 'foo' 85 | * $newCookies = $cookies->filterBy('name', 'foo'); 86 | * 87 | * // find cookies having domain == 'example.com' 88 | * $newCookies = $cookies->filterBy('domain', 'example.com'); 89 | * ``` 90 | * 91 | * @param string $param 92 | * @param string $value 93 | * 94 | * @return CookiesCollection 95 | */ 96 | public function filterBy(string $param, string $value) 97 | { 98 | return new self(\array_filter($this->cookies, function (Cookie $cookie) use ($param, $value) { 99 | return $cookie[$param] == $value; 100 | })); 101 | } 102 | 103 | /** 104 | * Find first cookies with matching value. 105 | * 106 | * usage: 107 | * 108 | * ``` 109 | * // find first cookie having name == 'foo' 110 | * $cookie = $cookies->findOneBy('name', 'foo'); 111 | * 112 | * if ($cookie) { 113 | * // do something 114 | * } 115 | * ``` 116 | * 117 | * @param string $param 118 | * @param string $value 119 | * 120 | * @return Cookie|null 121 | */ 122 | public function findOneBy(string $param, string $value) 123 | { 124 | foreach ($this->cookies as $cookie) { 125 | if ($cookie[$param] == $value) { 126 | return $cookie; 127 | } 128 | } 129 | 130 | return null; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Dom/Dom.php: -------------------------------------------------------------------------------- 1 | getRootNodeId($page); 15 | 16 | parent::__construct($page, $rootNodeId); 17 | } 18 | 19 | /** 20 | * @return Node[] 21 | */ 22 | public function search(string $selector): array 23 | { 24 | $this->prepareForRequest(); 25 | 26 | $message = new Message('DOM.performSearch', [ 27 | 'query' => $selector, 28 | ]); 29 | $response = $this->page->getSession()->sendMessageSync($message); 30 | 31 | $this->assertNotError($response); 32 | 33 | $searchId = $response->getResultData('searchId'); 34 | $count = $response->getResultData('resultCount'); 35 | 36 | if (0 === $count) { 37 | return []; 38 | } 39 | $message = new Message('DOM.getSearchResults', [ 40 | 'searchId' => $searchId, 41 | 'fromIndex' => 0, 42 | 'toIndex' => $count, 43 | ]); 44 | 45 | $response = $this->page->getSession()->sendMessageSync($message); 46 | 47 | $this->assertNotError($response); 48 | 49 | $nodes = []; 50 | $nodeIds = $response->getResultData('nodeIds'); 51 | foreach ($nodeIds as $nodeId) { 52 | $nodes[] = new Node($this->page, $nodeId); 53 | } 54 | 55 | return $nodes; 56 | } 57 | 58 | public function prepareForRequest(bool $throw = true): void 59 | { 60 | $this->page->assertNotClosed(); 61 | 62 | $this->page->getSession()->getConnection()->processAllEvents(); 63 | 64 | if ($this->isStale) { 65 | $this->nodeId = $this->getRootNodeId($this->page); 66 | } 67 | } 68 | 69 | public function getRootNodeId(Page $page) 70 | { 71 | $message = new Message('DOM.getDocument'); 72 | $response = $page->getSession()->sendMessageSync($message); 73 | 74 | return $response->getResultData('root')['nodeId']; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Dom/Node.php: -------------------------------------------------------------------------------- 1 | page = $page; 34 | $this->nodeId = $nodeId; 35 | 36 | $page->getSession()->on('method:DOM.documentUpdated', function (...$event): void { 37 | $this->isStale = true; 38 | }); 39 | } 40 | 41 | public function getNodeId(): int 42 | { 43 | return $this->nodeId; 44 | } 45 | 46 | public function getNodeIdForRequest(): int 47 | { 48 | $this->prepareForRequest(); 49 | 50 | return $this->getNodeId(); 51 | } 52 | 53 | public function getAttributes(): NodeAttributes 54 | { 55 | $message = new Message('DOM.getAttributes', [ 56 | 'nodeId' => $this->getNodeIdForRequest(), 57 | ]); 58 | $response = $this->page->getSession()->sendMessageSync($message); 59 | 60 | $this->assertNotError($response); 61 | 62 | $attributes = $response->getResultData('attributes'); 63 | 64 | return new NodeAttributes($attributes); 65 | } 66 | 67 | public function setAttributeValue(string $name, string $value): void 68 | { 69 | $message = new Message('DOM.setAttributeValue', [ 70 | 'nodeId' => $this->getNodeIdForRequest(), 71 | 'name' => $name, 72 | 'value' => $value, 73 | ]); 74 | $response = $this->page->getSession()->sendMessageSync($message); 75 | 76 | $this->assertNotError($response); 77 | } 78 | 79 | public function querySelector(string $selector): ?self 80 | { 81 | $message = new Message('DOM.querySelector', [ 82 | 'nodeId' => $this->getNodeIdForRequest(), 83 | 'selector' => $selector, 84 | ]); 85 | $response = $this->page->getSession()->sendMessageSync($message); 86 | $this->assertNotError($response); 87 | 88 | $nodeId = $response->getResultData('nodeId'); 89 | 90 | if (null !== $nodeId && 0 !== $nodeId) { 91 | return new self($this->page, $nodeId); 92 | } 93 | 94 | return null; 95 | } 96 | 97 | public function querySelectorAll(string $selector): array 98 | { 99 | $message = new Message('DOM.querySelectorAll', [ 100 | 'nodeId' => $this->getNodeIdForRequest(), 101 | 'selector' => $selector, 102 | ]); 103 | $response = $this->page->getSession()->sendMessageSync($message); 104 | 105 | $this->assertNotError($response); 106 | 107 | $nodes = []; 108 | $nodeIds = $response->getResultData('nodeIds'); 109 | foreach ($nodeIds as $nodeId) { 110 | $nodes[] = new self($this->page, $nodeId); 111 | } 112 | 113 | return $nodes; 114 | } 115 | 116 | public function focus(): void 117 | { 118 | $message = new Message('DOM.focus', [ 119 | 'nodeId' => $this->getNodeIdForRequest(), 120 | ]); 121 | $response = $this->page->getSession()->sendMessageSync($message); 122 | 123 | $this->assertNotError($response); 124 | } 125 | 126 | public function getAttribute(string $name): ?string 127 | { 128 | return $this->getAttributes()->get($name); 129 | } 130 | 131 | public function getPosition(): ?NodePosition 132 | { 133 | $message = new Message('DOM.getBoxModel', [ 134 | 'nodeId' => $this->getNodeIdForRequest(), 135 | ]); 136 | $response = $this->page->getSession()->sendMessageSync($message); 137 | 138 | $this->assertNotError($response); 139 | 140 | $points = $response->getResultData('model')['content']; 141 | 142 | if (null !== $points) { 143 | return new NodePosition($points); 144 | } else { 145 | return null; 146 | } 147 | } 148 | 149 | public function hasPosition(): bool 150 | { 151 | return null !== $this->getPosition(); 152 | } 153 | 154 | public function getHTML(): string 155 | { 156 | $message = new Message('DOM.getOuterHTML', [ 157 | 'nodeId' => $this->getNodeIdForRequest(), 158 | ]); 159 | $response = $this->page->getSession()->sendMessageSync($message); 160 | 161 | $this->assertNotError($response); 162 | 163 | return $response->getResultData('outerHTML'); 164 | } 165 | 166 | public function setHTML(string $outerHTML): void 167 | { 168 | $message = new Message('DOM.setOuterHTML', [ 169 | 'nodeId' => $this->getNodeIdForRequest(), 170 | 'outerHTML' => $outerHTML, 171 | ]); 172 | $response = $this->page->getSession()->sendMessageSync($message); 173 | 174 | $this->assertNotError($response); 175 | } 176 | 177 | public function getText(): string 178 | { 179 | return \strip_tags($this->getHTML()); 180 | } 181 | 182 | public function scrollIntoView(): void 183 | { 184 | $message = new Message('DOM.scrollIntoViewIfNeeded', [ 185 | 'nodeId' => $this->getNodeIdForRequest(), 186 | ]); 187 | $response = $this->page->getSession()->sendMessageSync($message); 188 | 189 | $this->assertNotError($response); 190 | } 191 | 192 | /** 193 | * @throws DomException 194 | */ 195 | public function click(): void 196 | { 197 | if (false === $this->hasPosition()) { 198 | throw new DomException('Failed to click element without position'); 199 | } 200 | $this->scrollIntoView(); 201 | $position = $this->getPosition(); 202 | $this->page->mouse() 203 | ->move((int) $position->getCenterX(), (int) $position->getCenterY()) 204 | ->click(); 205 | } 206 | 207 | public function sendKeys(string $text): void 208 | { 209 | $this->scrollIntoView(); 210 | $this->focus(); 211 | $this->page->keyboard() 212 | ->typeText($text); 213 | } 214 | 215 | public function sendFile(string $filePath): void 216 | { 217 | $this->sendFiles([$filePath]); 218 | } 219 | 220 | public function sendFiles(array $filePaths): void 221 | { 222 | $message = new Message('DOM.setFileInputFiles', [ 223 | 'files' => $filePaths, 224 | 'nodeId' => $this->getNodeIdForRequest(), 225 | ]); 226 | $response = $this->page->getSession()->sendMessageSync($message); 227 | 228 | $this->assertNotError($response); 229 | } 230 | 231 | /** 232 | * @throws DomException 233 | */ 234 | public function assertNotError(Response $response): void 235 | { 236 | if (!$response->isSuccessful()) { 237 | throw new DomException($response->getErrorMessage()); 238 | } 239 | } 240 | 241 | public function getClip(): ?Clip 242 | { 243 | $position = $this->getPosition(); 244 | 245 | if (!$position) { 246 | return null; 247 | } 248 | 249 | return new Clip( 250 | $position->getX(), 251 | $position->getY(), 252 | $position->getWidth(), 253 | $position->getHeight(), 254 | ); 255 | } 256 | 257 | protected function prepareForRequest(): void 258 | { 259 | $this->page->assertNotClosed(); 260 | 261 | $this->page->getSession()->getConnection()->processAllEvents(); 262 | 263 | if ($this->isStale) { 264 | throw new StaleElementException(); 265 | } 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/Dom/NodeAttributes.php: -------------------------------------------------------------------------------- 1 | attributes[$attrs[$i]] = $attrs[$i + 1]; 18 | } 19 | } 20 | 21 | public function toArray(): array 22 | { 23 | return $this->attributes; 24 | } 25 | 26 | public function has(string $name): bool 27 | { 28 | return isset($this->attributes[$name]); 29 | } 30 | 31 | public function get(string $name): ?string 32 | { 33 | return $this->attributes[$name] ?? null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Dom/NodePosition.php: -------------------------------------------------------------------------------- 1 | x = (float) $leftTopX; 41 | $this->y = (float) $leftTopY; 42 | 43 | $this->height = (float) ($leftBottomY - $leftTopY); 44 | $this->width = (float) ($rightBottomX - $leftBottomX); 45 | } 46 | 47 | public function getX(): float 48 | { 49 | return $this->x; 50 | } 51 | 52 | public function getY(): float 53 | { 54 | return $this->y; 55 | } 56 | 57 | public function getWidth(): float 58 | { 59 | return $this->width; 60 | } 61 | 62 | public function getHeight(): float 63 | { 64 | return $this->height; 65 | } 66 | 67 | public function getCenterX(): float 68 | { 69 | return $this->x + ($this->width / 2); 70 | } 71 | 72 | public function getCenterY(): float 73 | { 74 | return $this->y + ($this->height / 2); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Dom/Selector/CssSelector.php: -------------------------------------------------------------------------------- 1 | expressionEncoded = \json_encode( 18 | $expression, 19 | \JSON_UNESCAPED_SLASHES 20 | | \JSON_UNESCAPED_UNICODE 21 | | \JSON_THROW_ON_ERROR 22 | ); 23 | } 24 | 25 | public function expressionCount(): string 26 | { 27 | return \sprintf( 28 | 'document.querySelectorAll(%s).length', 29 | $this->expressionEncoded 30 | ); 31 | } 32 | 33 | public function expressionFindOne(int $position): string 34 | { 35 | return \sprintf( 36 | 'document.querySelectorAll(%s)[%d]', 37 | $this->expressionEncoded, 38 | $position - 1 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Dom/Selector/Selector.php: -------------------------------------------------------------------------------- 1 | expression = $expression; 18 | } 19 | 20 | public function expressionCount(): string 21 | { 22 | return 'document.evaluate('.\json_encode($this->expression, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE).', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength'; 23 | } 24 | 25 | public function expressionFindOne(int $position): string 26 | { 27 | return 'document.evaluate('.\json_encode($this->expression."[{$position}]", \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE).', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue'; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exception/BrowserConnectionFailed.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class BrowserConnectionFailed extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/CommunicationException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class CommunicationException extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/CommunicationException/CannotReadResponse.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception\CommunicationException; 13 | 14 | use HeadlessChromium\Exception\CommunicationException; 15 | 16 | class CannotReadResponse extends CommunicationException 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/CommunicationException/CantSyncEventsException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception\CommunicationException; 13 | 14 | use HeadlessChromium\Exception\CommunicationException; 15 | 16 | class InvalidResponse extends CommunicationException 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/CommunicationException/ResponseHasError.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception\CommunicationException; 13 | 14 | use HeadlessChromium\Exception\CommunicationException; 15 | 16 | class ResponseHasError extends CommunicationException 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/DomException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class ElementNotFoundException extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/EvaluationFailed.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class EvaluationFailed extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/FilesystemException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class FilesystemException extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/InvalidTimezoneId.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class InvalidTimezoneId extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/JavascriptException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class JavascriptException extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/NavigationExpired.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class NavigationExpired extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/NoResponseAvailable.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class NoResponseAvailable extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/OperationTimedOut.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class OperationTimedOut extends \Exception 15 | { 16 | public static function createFromTimeout(int $timeoutMicroSec): self 17 | { 18 | return new self(\sprintf('Operation timed out after %s.', self::getTimeoutPhrase($timeoutMicroSec))); 19 | } 20 | 21 | private static function getTimeoutPhrase(int $timeoutMicroSec): string 22 | { 23 | if ($timeoutMicroSec > 1000 * 1000) { 24 | return \sprintf('%ds', (int) ($timeoutMicroSec / (1000 * 1000))); 25 | } 26 | 27 | if ($timeoutMicroSec > 1000) { 28 | return \sprintf('%dms', (int) ($timeoutMicroSec / 1000)); 29 | } 30 | 31 | return \sprintf('%dμs', (int) $timeoutMicroSec); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Exception/PdfFailed.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class PdfFailed extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/ScreenshotFailed.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class ScreenshotFailed extends \Exception 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/StaleElementException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Exception; 13 | 14 | class TargetDestroyed extends \RuntimeException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Frame.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium; 13 | 14 | class Frame 15 | { 16 | public const LIFECYCLE_INIT = 'init'; 17 | 18 | /** 19 | * @var array 20 | */ 21 | protected $frameData; 22 | 23 | /** 24 | * @var array 25 | */ 26 | protected $lifeCycleEvents = []; 27 | 28 | /** 29 | * @var string 30 | */ 31 | protected $latestLoaderId; 32 | 33 | /** 34 | * @var string 35 | */ 36 | protected $frameId; 37 | 38 | /** 39 | * @var int 40 | */ 41 | protected $executionContextId; 42 | 43 | /** 44 | * Frame constructor. 45 | * 46 | * @param array $frameData 47 | */ 48 | public function __construct(array $frameData) 49 | { 50 | $this->frameData = $frameData; 51 | $this->latestLoaderId = $frameData['loaderId']; 52 | $this->frameId = $frameData['id']; 53 | } 54 | 55 | /** 56 | * @internal 57 | */ 58 | public function onLifecycleEvent(array $params): void 59 | { 60 | if (self::LIFECYCLE_INIT === $params['name']) { 61 | $this->lifeCycleEvents = []; 62 | $this->latestLoaderId = $params['loaderId']; 63 | $this->frameId = $params['frameId']; 64 | } 65 | 66 | $this->lifeCycleEvents[$params['name']] = $params['timestamp']; 67 | } 68 | 69 | /** 70 | * @return int 71 | */ 72 | public function getExecutionContextId(): int 73 | { 74 | return $this->executionContextId; 75 | } 76 | 77 | /** 78 | * @param int $executionContextId 79 | */ 80 | public function setExecutionContextId(int $executionContextId): void 81 | { 82 | $this->executionContextId = $executionContextId; 83 | } 84 | 85 | /** 86 | * @return string 87 | */ 88 | public function getFrameId(): string 89 | { 90 | return $this->frameId; 91 | } 92 | 93 | /** 94 | * @return string 95 | */ 96 | public function getLatestLoaderId(): string 97 | { 98 | return $this->latestLoaderId; 99 | } 100 | 101 | /** 102 | * Gets the life cycle events of the frame with the time they occurred at. 103 | * 104 | * @return array 105 | */ 106 | public function getLifeCycle(): array 107 | { 108 | return $this->lifeCycleEvents; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/FrameManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium; 13 | 14 | class FrameManager 15 | { 16 | /** 17 | * @var Page 18 | */ 19 | protected $page; 20 | 21 | /** 22 | * @var Frame[] 23 | */ 24 | protected $frames = []; 25 | 26 | /** 27 | * @var Frame 28 | */ 29 | protected $mainFrame; 30 | 31 | /** 32 | * FrameManager constructor. 33 | */ 34 | public function __construct(Page $page, array $frameTree) 35 | { 36 | $this->page = $page; 37 | 38 | if (isset($frameTree['frame'])) { 39 | // TODO parse children frames 40 | $this->frames[$frameTree['frame']['id']] = new Frame($frameTree['frame']); 41 | 42 | // associate main frame 43 | $this->mainFrame = $this->frames[$frameTree['frame']['id']]; 44 | } 45 | 46 | // TODO listen for frame events 47 | 48 | // update frame on init 49 | $this->page->getSession()->on('method:Page.lifecycleEvent', function (array $params): void { 50 | if (isset($this->frames[$params['frameId']])) { 51 | $frame = $this->frames[$params['frameId']]; 52 | $frame->onLifecycleEvent($params); 53 | } 54 | }); 55 | 56 | // attach context id to frame 57 | $this->page->getSession()->on('method:Runtime.executionContextCreated', function (array $params): void { 58 | if (isset($params['context']['auxData']['frameId']) && $params['context']['auxData']['isDefault']) { 59 | if ($this->hasFrame($params['context']['auxData']['frameId'])) { 60 | $frame = $this->getFrame($params['context']['auxData']['frameId']); 61 | $frame->setExecutionContextId($params['context']['id']); 62 | } 63 | } 64 | }); 65 | 66 | // TODO maybe implement Runtime.executionContextDestroyed and Runtime.executionContextsCleared 67 | } 68 | 69 | /** 70 | * Checks if the given frame exists. 71 | * 72 | * @param string $frameId 73 | * 74 | * @return bool 75 | */ 76 | public function hasFrame($frameId): bool 77 | { 78 | return \array_key_exists($frameId, $this->frames); 79 | } 80 | 81 | /** 82 | * Get a frame given its id. 83 | * 84 | * @param string $frameId 85 | * 86 | * @return Frame 87 | */ 88 | public function getFrame($frameId): Frame 89 | { 90 | if (!isset($this->frames[$frameId])) { 91 | throw new \RuntimeException(\sprintf('No such frame "%s"', $frameId)); 92 | } 93 | 94 | return $this->frames[$frameId]; 95 | } 96 | 97 | /** 98 | * Gets the main frame. 99 | * 100 | * @return Frame 101 | */ 102 | public function getMainFrame(): Frame 103 | { 104 | return $this->mainFrame; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Input/Key.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Input; 13 | 14 | /** 15 | * Holds key constants and their respective bit values. 16 | * 17 | * @see https://chromedevtools.github.io/devtools-protocol/1-2/Input/ 18 | */ 19 | abstract class Key 20 | { 21 | public const ALT = 1; 22 | public const CONTROL = 2; 23 | public const META = 4; 24 | public const SHIFT = 8; 25 | public const COMMAND = self::META; 26 | } 27 | -------------------------------------------------------------------------------- /src/Input/Keyboard.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Input; 13 | 14 | use HeadlessChromium\Communication\Message; 15 | use HeadlessChromium\Page; 16 | 17 | class Keyboard 18 | { 19 | use KeyboardKeys; 20 | 21 | /** 22 | * @var Page 23 | */ 24 | protected $page; 25 | 26 | /** 27 | * @var int 28 | */ 29 | protected $sleep = 0; 30 | 31 | /** 32 | * @param Page $page 33 | */ 34 | public function __construct(Page $page) 35 | { 36 | $this->page = $page; 37 | } 38 | 39 | /** 40 | * Type a text string, char by char, without applying modifiers. 41 | * 42 | * @param string $text text string to be typed 43 | * 44 | * @throws \HeadlessChromium\Exception\CommunicationException 45 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 46 | * 47 | * @return $this 48 | */ 49 | public function typeText(string $text) 50 | { 51 | $this->page->assertNotClosed(); 52 | 53 | $length = \mb_strlen($text); 54 | 55 | for ($i = 0; $i < $length; ++$i) { 56 | $char = \mb_substr($text, $i, 1); 57 | 58 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchKeyEvent', [ 59 | 'type' => 'char', 60 | 'modifiers' => $this->getModifiers(), 61 | 'text' => \ctype_space($char) ? $char.\mb_substr($text, ++$i, 1) : $char, 62 | ])); 63 | 64 | \usleep($this->sleep); 65 | } 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Type a raw key using the rawKeyDown event, without sending any codes or modifiers. 72 | * 73 | * Example: 74 | * 75 | * ```php 76 | * $page->keyboard()->typeRawKey('Tab'); 77 | * ``` 78 | * 79 | * @param string $key single raw key to be typed 80 | * 81 | * @throws \HeadlessChromium\Exception\CommunicationException 82 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 83 | * 84 | * @return $this 85 | */ 86 | public function typeRawKey(string $key): self 87 | { 88 | $this->page->assertNotClosed(); 89 | 90 | $this->onKeyPress($key); 91 | 92 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchKeyEvent', [ 93 | 'type' => 'rawKeyDown', 94 | 'key' => $key, 95 | ])); 96 | 97 | \usleep($this->sleep); 98 | 99 | $this->release($key); 100 | 101 | return $this; 102 | } 103 | 104 | /** 105 | * Press and release a single key. 106 | * 107 | * Example: 108 | * 109 | * ```php 110 | * $page->keyboard()->type('a'); 111 | * ``` 112 | * 113 | * @param string $key single key to be typed 114 | * 115 | * @throws \HeadlessChromium\Exception\CommunicationException 116 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 117 | * 118 | * @return $this 119 | */ 120 | public function type(string $key): self 121 | { 122 | return $this->press($key)->release($key); 123 | } 124 | 125 | /** 126 | * Press a single key with key codes and modifiers. 127 | * 128 | * A key can be pressed multiple times sequentially. This is what happens 129 | * in a real browser when the user presses and holds down hown the key. 130 | * 131 | * Example: 132 | * 133 | * ```php 134 | * $page->keyboard()->press('Control')->press('c'); // press ctrl + c 135 | * ``` 136 | * 137 | * @param string $key single key to be pressed 138 | * 139 | * @throws \HeadlessChromium\Exception\CommunicationException 140 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 141 | * 142 | * @return $this 143 | */ 144 | public function press(string $key): self 145 | { 146 | $this->page->assertNotClosed(); 147 | 148 | $this->onKeyPress($key); 149 | 150 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchKeyEvent', [ 151 | 'type' => 'keyDown', 152 | 'modifiers' => $this->getModifiers(), 153 | 'text' => $key, 154 | 'key' => $this->getCurrentKey(), 155 | 'windowsVirtualKeyCode' => $this->getKeyCode(), 156 | ])); 157 | 158 | \usleep($this->sleep); 159 | 160 | return $this; 161 | } 162 | 163 | /** 164 | * Release a single key. 165 | * 166 | * A key is released only once, even if it was pressed multiple times. 167 | * If no key is given, all pressed keys will be released. 168 | * 169 | * Example: 170 | * 171 | * ```php 172 | * $page->keyboard()->release('Control'); // release Control 173 | * $page->keyboard()->release(); // release all 174 | * ``` 175 | * 176 | * @param string $key (optional) single key to be released 177 | * 178 | * @throws \HeadlessChromium\Exception\CommunicationException 179 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 180 | * 181 | * @return $this 182 | */ 183 | public function release(?string $key = null): self 184 | { 185 | $this->page->assertNotClosed(); 186 | 187 | if (null === $key) { 188 | $this->releaseAll(); 189 | 190 | return $this; 191 | } 192 | 193 | $this->onKeyRelease($key); 194 | 195 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchKeyEvent', [ 196 | 'type' => 'keyUp', 197 | 'key' => $this->getCurrentKey(), 198 | ])); 199 | 200 | \usleep($this->sleep); 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Release all pressed keys. 207 | * 208 | * @return self 209 | */ 210 | private function releaseAll(): self 211 | { 212 | foreach ($this->pressedKeys as $key => $value) { 213 | if (true === $value) { 214 | $this->release($key); 215 | } 216 | } 217 | 218 | return $this; 219 | } 220 | 221 | /** 222 | * Set the time interval between key strokes in milliseconds. 223 | * 224 | * @param int $milliseconds 225 | * 226 | * @return $this 227 | */ 228 | public function setKeyInterval(int $milliseconds) 229 | { 230 | if ($milliseconds < 0) { 231 | $milliseconds = 0; 232 | } 233 | 234 | $this->sleep = $milliseconds * 1000; 235 | 236 | return $this; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/Input/KeyboardKeys.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Input; 13 | 14 | /** 15 | * Translates typed keys to their respective codes. 16 | * 17 | * @see https://chromedevtools.github.io/devtools-protocol/1-2/Input/ 18 | */ 19 | trait KeyboardKeys 20 | { 21 | /** 22 | * Array of currently pressed keys (keyDown events). 23 | * 24 | * The elements of this array should be unique. A real keyboard can create several keyDown events 25 | * by holding down a key, but only one keyUp event will be sent when the key is released. 26 | */ 27 | protected $pressedKeys = []; 28 | 29 | /** 30 | * Current key as a sanitized string. 31 | * 32 | * Single letters like "v" must be in uppercase, otherwise key combinations like ctrl + v won't work. 33 | */ 34 | protected $currentKey = ''; 35 | 36 | /** 37 | * Bit field representing pressed modifier keys. 38 | */ 39 | protected $modifiers = 0; 40 | 41 | /** 42 | * Aliases for modifier keys, in lowercase. 43 | */ 44 | protected $keyAliases = [ 45 | Key::ALT => [ 46 | 'alt', 47 | 'altgr', 48 | 'alt gr', 49 | ], 50 | Key::CONTROL => [ 51 | 'control', 52 | 'ctrl', 53 | 'ctr', 54 | ], 55 | Key::META => [ 56 | 'meta', 57 | 'command', 58 | 'cmd', 59 | ], 60 | Key::SHIFT => [ 61 | 'shift', 62 | ], 63 | ]; 64 | 65 | /** 66 | * Register a pressed key and apply modifiers. 67 | * 68 | * @param string $key pressed key 69 | * 70 | * @return void 71 | */ 72 | protected function onKeyPress(string $key): void 73 | { 74 | $this->setCurrentKey($key); 75 | 76 | if (true === $this->isKeyPressed()) { 77 | return; 78 | } 79 | 80 | $this->pressedKeys[$this->currentKey] = true; 81 | 82 | $this->toggleModifierFromKey(); 83 | } 84 | 85 | /** 86 | * Register a released key and remove modifiers. 87 | * 88 | * @param string $key released key 89 | * 90 | * @return void 91 | */ 92 | protected function onKeyRelease(string $key): void 93 | { 94 | $this->setCurrentKey($key); 95 | 96 | if (false === $this->isKeyPressed()) { 97 | return; 98 | } 99 | 100 | unset($this->pressedKeys[$this->currentKey]); 101 | 102 | $this->toggleModifierFromKey(); 103 | } 104 | 105 | /** 106 | * Check the current key against the list of aliases. 107 | * If it match, try to add or remove its bits to the modifier. 108 | * 109 | * @see self::$keyAliases 110 | * @see self::$modifiers 111 | * 112 | * @return void 113 | */ 114 | protected function toggleModifierFromKey(): void 115 | { 116 | $key = \strtolower($this->currentKey); 117 | 118 | foreach ($this->keyAliases as $modifier => $aliases) { 119 | if (true === \in_array($key, $aliases)) { 120 | $this->toggleModifier($modifier); 121 | break; 122 | } 123 | } 124 | } 125 | 126 | /** 127 | * Perform bit operations to add or remove bits from the modifier. 128 | * 129 | * Examples: 130 | * 131 | * 0001 132 | * | 0100 133 | * = 0101 134 | * 135 | * 0101 136 | * & 0100 137 | * = 0100 138 | * 139 | * 0101 140 | * & 0010 141 | * = 0000 142 | * 143 | * @see self::$modifiers 144 | * 145 | * @return void 146 | */ 147 | protected function toggleModifier(int $bit): void 148 | { 149 | if (($this->modifiers & $bit) === $bit) { 150 | $this->modifiers &= ~$bit; 151 | 152 | return; 153 | } 154 | 155 | $this->modifiers |= $bit; 156 | } 157 | 158 | /** 159 | * Check if the current key was pressed and not released yet. 160 | * 161 | * @return bool true if they key is listed as pressed 162 | */ 163 | protected function isKeyPressed(): bool 164 | { 165 | return \array_key_exists($this->currentKey, $this->pressedKeys); 166 | } 167 | 168 | /** 169 | * Return the current key code. 170 | * 171 | * @return int the key code 172 | */ 173 | public function getKeyCode(): int 174 | { 175 | return \ord($this->currentKey); 176 | } 177 | 178 | /** 179 | * Return the current bit modifier. 180 | * The browser expects to receive this value as int. 181 | * 182 | * @return int current bit modifier 183 | */ 184 | public function getModifiers(): int 185 | { 186 | return $this->modifiers; 187 | } 188 | 189 | /** 190 | * Return the current key being processed. 191 | * 192 | * @return string the current key 193 | */ 194 | public function getCurrentKey(): string 195 | { 196 | return $this->currentKey; 197 | } 198 | 199 | /** 200 | * Return the list of unique pressed keys that were not released yet. 201 | * 202 | * @return array list of pressed keys 203 | */ 204 | public function getPressedKeys(): array 205 | { 206 | return $this->pressedKeys; 207 | } 208 | 209 | /** 210 | * Set a key as the current key. 211 | * 212 | * Single character keys must be in uppercase, otherwhie things like ctrl + v won't work. 213 | * Triming the string will also prevent future mistakes during normal usage. 214 | * 215 | * @param string $key key to be set as current 216 | * 217 | * @return void 218 | */ 219 | protected function setCurrentKey(string $key): void 220 | { 221 | $this->currentKey = \ucfirst(\trim($key)); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Input/Mouse.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium\Input; 13 | 14 | use HeadlessChromium\Communication\Message; 15 | use HeadlessChromium\Dom\Selector\CssSelector; 16 | use HeadlessChromium\Dom\Selector\Selector; 17 | use HeadlessChromium\Exception\ElementNotFoundException; 18 | use HeadlessChromium\Exception\JavascriptException; 19 | use HeadlessChromium\Page; 20 | use HeadlessChromium\Utils; 21 | 22 | class Mouse 23 | { 24 | public const BUTTON_LEFT = 'left'; 25 | public const BUTTON_NONE = 'none'; 26 | public const BUTTON_RIGHT = 'right'; 27 | public const BUTTON_MIDDLE = 'middle'; 28 | 29 | /** 30 | * @var Page 31 | */ 32 | protected $page; 33 | 34 | protected $x = 0; 35 | protected $y = 0; 36 | 37 | protected $button = self::BUTTON_NONE; 38 | 39 | /** 40 | * @param Page $page 41 | */ 42 | public function __construct(Page $page) 43 | { 44 | $this->page = $page; 45 | } 46 | 47 | /** 48 | * @param int $x 49 | * @param int $y 50 | * @param array|null $options 51 | * 52 | * @throws \HeadlessChromium\Exception\CommunicationException 53 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 54 | * 55 | * @return $this 56 | */ 57 | public function move(int $x, int $y, ?array $options = null) 58 | { 59 | $this->page->assertNotClosed(); 60 | 61 | // get origin of the move 62 | $originX = $this->x; 63 | $originY = $this->y; 64 | 65 | // set new position after move 66 | $this->x = $x; 67 | $this->y = $y; 68 | 69 | // number of steps to achieve the move 70 | $steps = $options['steps'] ?? 1; 71 | if ($steps <= 0) { 72 | throw new \InvalidArgumentException('options "steps" for mouse move must be a positive integer'); 73 | } 74 | 75 | // move 76 | for ($i = 1; $i <= $steps; ++$i) { 77 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchMouseEvent', [ 78 | 'x' => $originX + ($this->x - $originX) * ($i / $steps), 79 | 'y' => $originY + ($this->y - $originY) * ($i / $steps), 80 | 'type' => 'mouseMoved', 81 | ])); 82 | } 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @throws \HeadlessChromium\Exception\CommunicationException 89 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 90 | */ 91 | public function press(?array $options = null) 92 | { 93 | $this->page->assertNotClosed(); 94 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchMouseEvent', [ 95 | 'x' => $this->x, 96 | 'y' => $this->y, 97 | 'type' => 'mousePressed', 98 | 'button' => $options['button'] ?? self::BUTTON_LEFT, 99 | 'clickCount' => 1, 100 | ])); 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * @throws \HeadlessChromium\Exception\CommunicationException 107 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 108 | */ 109 | public function release(?array $options = null) 110 | { 111 | $this->page->assertNotClosed(); 112 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchMouseEvent', [ 113 | 'x' => $this->x, 114 | 'y' => $this->y, 115 | 'type' => 'mouseReleased', 116 | 'button' => $options['button'] ?? self::BUTTON_LEFT, 117 | 'clickCount' => 1, 118 | ])); 119 | 120 | return $this; 121 | } 122 | 123 | /** 124 | * @param array|null $options 125 | * 126 | * @throws \HeadlessChromium\Exception\CommunicationException 127 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 128 | */ 129 | public function click(?array $options = null) 130 | { 131 | $this->press($options); 132 | $this->release($options); 133 | 134 | return $this; 135 | } 136 | 137 | /** 138 | * Scroll up using the mouse wheel. 139 | * 140 | * @param int $distance Distance in pixels 141 | * 142 | * @throws \HeadlessChromium\Exception\CommunicationException 143 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 144 | * 145 | * @return $this 146 | */ 147 | public function scrollUp(int $distance) 148 | { 149 | return $this->scroll(-1 * \abs($distance)); 150 | } 151 | 152 | /** 153 | * Scroll down using the mouse wheel. 154 | * 155 | * @param int $distance Distance in pixels 156 | * 157 | * @throws \HeadlessChromium\Exception\CommunicationException 158 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 159 | * 160 | * @return $this 161 | */ 162 | public function scrollDown(int $distance) 163 | { 164 | return $this->scroll(\abs($distance)); 165 | } 166 | 167 | /** 168 | * Scroll a positive or negative distance using the mouseWheel event type. 169 | * 170 | * @param int $distanceY Distance in pixels for the Y axis 171 | * @param int $distanceX (optional) Distance in pixels for the X axis 172 | * 173 | * @throws \HeadlessChromium\Exception\CommunicationException 174 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 175 | * @throws \HeadlessChromium\Exception\OperationTimedOut 176 | * 177 | * @return $this 178 | */ 179 | private function scroll(int $distanceY, int $distanceX = 0): self 180 | { 181 | $this->page->assertNotClosed(); 182 | 183 | $scrollableArea = $this->page->getLayoutMetrics()->getCssContentSize(); 184 | $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); 185 | 186 | $maximumX = $scrollableArea['width'] - $visibleArea['clientWidth']; 187 | $maximumY = $scrollableArea['height'] - $visibleArea['clientHeight']; 188 | 189 | $distanceX = $this->getMaximumDistance($distanceX, $visibleArea['pageX'], $maximumX); 190 | $distanceY = $this->getMaximumDistance($distanceY, $visibleArea['pageY'], $maximumY); 191 | 192 | $targetX = $visibleArea['pageX'] + $distanceX; 193 | $targetY = $visibleArea['pageY'] + $distanceY; 194 | 195 | // make sure the mouse is on the screen 196 | $this->move($this->x, $this->y); 197 | 198 | // scroll 199 | $this->page->getSession()->sendMessageSync(new Message('Input.dispatchMouseEvent', [ 200 | 'type' => 'mouseWheel', 201 | 'x' => $this->x, 202 | 'y' => $this->y, 203 | 'deltaX' => $distanceX, 204 | 'deltaY' => $distanceY, 205 | ])); 206 | 207 | // wait until the scroll is done 208 | Utils::tryWithTimeout(30000 * 1000, $this->waitForScroll($targetX, $targetY)); 209 | 210 | // set new position after move 211 | $this->x += $distanceX; 212 | $this->y += $distanceY; 213 | 214 | return $this; 215 | } 216 | 217 | /** 218 | * Scroll in both X and Y axis until the given boundaries fit in the screen. 219 | * 220 | * This method currently scrolls only to right and bottom. If the desired element is outside the visible screen 221 | * to the left or top, thie method will not work. Its visibility will stay private until it works for both cases. 222 | * 223 | * @param int $right The element right boundary 224 | * @param int $bottom The element bottom boundary 225 | * 226 | * @return $this 227 | */ 228 | private function scrollToBoundary(int $right, int $bottom): self 229 | { 230 | $visibleArea = $this->page->getLayoutMetrics()->getCssLayoutViewport(); 231 | 232 | $distanceX = $distanceY = 0; 233 | 234 | if ($right > $visibleArea['clientWidth']) { 235 | $distanceX = $right - $visibleArea['clientWidth']; 236 | } 237 | 238 | if ($bottom > $visibleArea['clientHeight']) { 239 | $distanceY = $bottom - $visibleArea['clientHeight']; 240 | } 241 | 242 | return $this->scroll($distanceY, $distanceX); 243 | } 244 | 245 | /** 246 | * Find an element and move the mouse to a random position over it. 247 | * 248 | * The search could result in several elements. The $position param can be used to select a specific element. 249 | * The given position can only be between 1 and the maximum number or elements. It will be adjusted to the 250 | * minimum and maximum values if needed. 251 | * 252 | * Example: 253 | * $page->mouse()->find('#a'): 254 | * $page->mouse()->find('.a', 2); 255 | * 256 | * @see https://developer.mozilla.org/docs/Web/API/Document/querySelector 257 | * 258 | * @param string $selectors selectors to use with document.querySelector 259 | * @param int $position (optional) which element of the result set should be used 260 | * 261 | * @throws \HeadlessChromium\Exception\CommunicationException 262 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 263 | * @throws \HeadlessChromium\Exception\ElementNotFoundException 264 | * 265 | * @return $this 266 | */ 267 | public function find(string $selectors, int $position = 1): self 268 | { 269 | $this->findElement(new CssSelector($selectors), $position); 270 | 271 | return $this; 272 | } 273 | 274 | /** 275 | * Find an element and move the mouse to a random position over it. 276 | * 277 | * The search could result in several elements. The $position param can be used to select a specific element. 278 | * The given position can only be between 1 and the maximum number or elements. It will be adjusted to the 279 | * minimum and maximum values if needed. 280 | * 281 | * Example: 282 | * $page->mouse()->findElement(new CssSelector('#a')): 283 | * $page->mouse()->findElement(new CssSelector('.a'), 2); 284 | * $page->mouse()->findElement(new XPathSelector('//*[@id="a"]'), 2); 285 | * 286 | * @param Selector $selector selector to use 287 | * @param int $position (optional) which element of the result set should be used 288 | * 289 | * @throws \HeadlessChromium\Exception\CommunicationException 290 | * @throws \HeadlessChromium\Exception\NoResponseAvailable 291 | * @throws \HeadlessChromium\Exception\ElementNotFoundException 292 | * 293 | * @return $this 294 | */ 295 | public function findElement(Selector $selector, int $position = 1): self 296 | { 297 | $this->page->assertNotClosed(); 298 | 299 | try { 300 | $element = Utils::getElementPositionFromPage($this->page, $selector, $position); 301 | } catch (JavascriptException $exception) { 302 | throw new ElementNotFoundException('The search for "'.$selector->expressionCount().'" returned no result.'); 303 | } 304 | 305 | if (false === \array_key_exists('x', $element)) { 306 | throw new ElementNotFoundException('The search for "'.$selector->expressionFindOne($position).'" returned an element with no position.'); 307 | } 308 | 309 | $rightBoundary = \floor($element['right']); 310 | $bottomBoundary = \floor($element['bottom']); 311 | 312 | $this->scrollToBoundary($rightBoundary, $bottomBoundary); 313 | 314 | $visibleArea = $this->page->getLayoutMetrics()->getLayoutViewport(); 315 | 316 | $offsetX = $visibleArea['pageX']; 317 | $offsetY = $visibleArea['pageY']; 318 | $minX = $element['left'] - $offsetX; 319 | $minY = $element['top'] - $offsetY; 320 | 321 | $positionX = \floor($minX + (($rightBoundary - $offsetX) - $minX) / 2); 322 | $positionY = \ceil($minY + (($bottomBoundary - $offsetY) - $minY) / 2); 323 | 324 | $this->move($positionX, $positionY); 325 | 326 | return $this; 327 | } 328 | 329 | /** 330 | * Get the maximum distance to scroll a page. 331 | * 332 | * @param int $distance Distance to scroll, positive or negative 333 | * @param int $current Current position 334 | * @param int $maximum Maximum possible distance 335 | * 336 | * @return int allowed distance to scroll 337 | */ 338 | private function getMaximumDistance(int $distance, int $current, int $maximum): int 339 | { 340 | $result = $current + $distance; 341 | 342 | if ($result < 0) { 343 | return $distance + \abs($result); 344 | } 345 | 346 | if ($result > $maximum) { 347 | return $maximum - $current; 348 | } 349 | 350 | return $distance; 351 | } 352 | 353 | /** 354 | * Wait for the browser to process the scroll command. 355 | * 356 | * Return the number of microseconds to wait before trying again or true in case of success. 357 | * 358 | * @see \HeadlessChromium\Utils::tryWithTimeout 359 | * 360 | * @param int $targetX 361 | * @param int $targetY 362 | * 363 | * @throws \HeadlessChromium\Exception\OperationTimedOut 364 | * 365 | * @return bool|\Generator 366 | */ 367 | private function waitForScroll(int $targetX, int $targetY) 368 | { 369 | while (true) { 370 | $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); 371 | 372 | if ($visibleArea['pageX'] === $targetX && $visibleArea['pageY'] === $targetY) { 373 | return true; 374 | } 375 | 376 | yield 1000; 377 | } 378 | } 379 | 380 | /** 381 | * Get the current mouse position. 382 | * 383 | * @return array [x, y] 384 | */ 385 | public function getPosition(): array 386 | { 387 | return [ 388 | 'x' => $this->x, 389 | 'y' => $this->y, 390 | ]; 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/Page.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace HeadlessChromium; 13 | 14 | use HeadlessChromium\Communication\Message; 15 | use HeadlessChromium\Communication\Session; 16 | use HeadlessChromium\Communication\Target; 17 | use HeadlessChromium\Cookies\Cookie; 18 | use HeadlessChromium\Cookies\CookiesCollection; 19 | use HeadlessChromium\Dom\Dom; 20 | use HeadlessChromium\Dom\Node; 21 | use HeadlessChromium\Dom\Selector\CssSelector; 22 | use HeadlessChromium\Dom\Selector\Selector; 23 | use HeadlessChromium\Exception\CommunicationException; 24 | use HeadlessChromium\Exception\EvaluationFailed; 25 | use HeadlessChromium\Exception\InvalidTimezoneId; 26 | use HeadlessChromium\Exception\JavascriptException; 27 | use HeadlessChromium\Exception\NoResponseAvailable; 28 | use HeadlessChromium\Exception\OperationTimedOut; 29 | use HeadlessChromium\Exception\TargetDestroyed; 30 | use HeadlessChromium\Input\Keyboard; 31 | use HeadlessChromium\Input\Mouse; 32 | use HeadlessChromium\PageUtils\CookiesGetter; 33 | use HeadlessChromium\PageUtils\PageEvaluation; 34 | use HeadlessChromium\PageUtils\PageLayoutMetrics; 35 | use HeadlessChromium\PageUtils\PageNavigation; 36 | use HeadlessChromium\PageUtils\PagePdf; 37 | use HeadlessChromium\PageUtils\PageScreenshot; 38 | use HeadlessChromium\PageUtils\ResponseWaiter; 39 | 40 | class Page 41 | { 42 | public const DOM_CONTENT_LOADED = 'DOMContentLoaded'; 43 | public const FIRST_CONTENTFUL_PAINT = 'firstContentfulPaint'; 44 | public const FIRST_IMAGE_PAINT = 'firstImagePaint'; 45 | public const FIRST_MEANINGFUL_PAINT = 'firstMeaningfulPaint'; 46 | public const FIRST_PAINT = 'firstPaint'; 47 | public const INIT = 'init'; 48 | public const INTERACTIVE_TIME = 'InteractiveTime'; 49 | public const LOAD = 'load'; 50 | public const NETWORK_IDLE = 'networkIdle'; 51 | 52 | private const MAX_COOKIE_AGE = 60 * 60 * 24 * 365; 53 | 54 | /** 55 | * @var Target 56 | */ 57 | protected $target; 58 | 59 | /** 60 | * @var FrameManager 61 | */ 62 | protected $frameManager; 63 | 64 | /** 65 | * @var Mouse|null 66 | */ 67 | protected $mouse; 68 | 69 | /** 70 | * @var Keyboard|null 71 | */ 72 | protected $keyboard; 73 | 74 | /** 75 | * @var Dom|null 76 | */ 77 | protected $dom = null; 78 | 79 | /** 80 | * Page constructor. 81 | * 82 | * @param Target $target 83 | * @param array $frameTree 84 | */ 85 | public function __construct(Target $target, array $frameTree) 86 | { 87 | $this->target = $target; 88 | $this->frameManager = new FrameManager($this, $frameTree); 89 | } 90 | 91 | /** 92 | * Adds a script to be evaluated upon page navigation. 93 | * 94 | * @param string $script 95 | * @param array $options 96 | * - onLoad: defer script execution after page has loaded (useful for scripts that require the dom to be populated) 97 | * 98 | * @throws CommunicationException 99 | * @throws NoResponseAvailable 100 | */ 101 | public function addPreScript(string $script, array $options = []): void 102 | { 103 | // defer script execution 104 | if (isset($options['onLoad']) && $options['onLoad']) { 105 | $script = 'window.onload = () => {'.$script.'}'; 106 | } 107 | 108 | // add script 109 | $this->getSession()->sendMessageSync( 110 | new Message('Page.addScriptToEvaluateOnNewDocument', ['source' => $script]) 111 | ); 112 | } 113 | 114 | /** 115 | * Retrieves layout metrics of the page. 116 | * 117 | * Example: 118 | * 119 | * ```php 120 | * $metrics = $page->getLayoutMetrics(); 121 | * $contentSize = $metrics->getContentSize(); 122 | * ``` 123 | * 124 | * @throws CommunicationException 125 | * 126 | * @return PageLayoutMetrics 127 | */ 128 | public function getLayoutMetrics() 129 | { 130 | $this->assertNotClosed(); 131 | 132 | $reader = $this->getSession()->sendMessage( 133 | new Message('Page.getLayoutMetrics') 134 | ); 135 | 136 | return new PageLayoutMetrics($reader); 137 | } 138 | 139 | /** 140 | * @return FrameManager 141 | */ 142 | public function getFrameManager(): FrameManager 143 | { 144 | $this->assertNotClosed(); 145 | 146 | return $this->frameManager; 147 | } 148 | 149 | /** 150 | * Get the session this page is attached to. 151 | * 152 | * @return Session 153 | */ 154 | public function getSession(): Session 155 | { 156 | $this->assertNotClosed(); 157 | 158 | return $this->target->getSession(); 159 | } 160 | 161 | /** 162 | * Sets the HTTP header necessary for basic authentication. 163 | * 164 | * @param string $username 165 | * @param string $password 166 | */ 167 | public function setBasicAuthHeader(string $username, string $password): void 168 | { 169 | $header = \base64_encode($username.':'.$password); 170 | $this->setExtraHTTPHeaders([ 171 | 'Authorization' => 'Basic '.$header, 172 | ]); 173 | } 174 | 175 | /** 176 | * Sets the path to save downloaded files. 177 | * 178 | * @param string $path 179 | */ 180 | public function setDownloadPath(string $path): void 181 | { 182 | $this->getSession()->sendMessage(new Message( 183 | 'Page.setDownloadBehavior', 184 | ['behavior' => 'allow', 'downloadPath' => $path] 185 | )); 186 | } 187 | 188 | /** 189 | * Set extra http headers. 190 | * 191 | * If headers are not passed, all instances of Page::class will use global settings from the BrowserFactory::class 192 | * 193 | * @see https://chromedevtools.github.io/devtools-protocol/1-2/Network/#method-setExtraHTTPHeaders 194 | * 195 | * @param array $headers 196 | * 197 | * @throws CommunicationException 198 | */ 199 | public function setExtraHTTPHeaders(array $headers = []): void 200 | { 201 | $response = $this->getSession()->sendMessage(new Message( 202 | 'Network.setExtraHTTPHeaders', 203 | ['headers' => $headers] 204 | ))->waitForResponse(); 205 | 206 | if (false === $response->isSuccessful()) { 207 | throw new CommunicationException($response->getErrorMessage()); 208 | } 209 | } 210 | 211 | /** 212 | * @param string $url 213 | * @param array $options 214 | * - strict: make waitForNAvigation to fail if a new navigation is initiated. Default: false 215 | * 216 | * @throws CommunicationException 217 | * 218 | * @return PageNavigation 219 | */ 220 | public function navigate(string $url, array $options = []) 221 | { 222 | $this->assertNotClosed(); 223 | 224 | return new PageNavigation($this, $url, $options['strict'] ?? false); 225 | } 226 | 227 | /** 228 | * Evaluates the given string in the page context. 229 | * 230 | * Example: 231 | * 232 | * ```php 233 | * $evaluation = $page->evaluate('document.querySelector("title").innerHTML'); 234 | * $response = $evaluation->getReturnValue(); 235 | * ``` 236 | * 237 | * @param string $expression 238 | * 239 | * @throws CommunicationException 240 | * 241 | * @return PageEvaluation 242 | */ 243 | public function evaluate(string $expression) 244 | { 245 | $this->assertNotClosed(); 246 | 247 | $currentLoaderId = $this->frameManager->getMainFrame()->getLatestLoaderId(); 248 | $reader = $this->getSession()->sendMessage( 249 | new Message( 250 | 'Runtime.evaluate', 251 | [ 252 | 'awaitPromise' => true, 253 | 'returnByValue' => true, 254 | 'expression' => $expression, 255 | 'userGesture' => true, 256 | ] 257 | ) 258 | ); 259 | 260 | return new PageEvaluation($reader, $currentLoaderId, $this); 261 | } 262 | 263 | /** 264 | * Call a js function with the given argument in the page context. 265 | * 266 | * Example: 267 | * 268 | * ```php 269 | * $evaluation = $page->callFunction('function(a, b) {return a + b}', [1, 2]); 270 | * 271 | * echo $evaluation->getReturnValue(); 272 | * // 3 273 | * ``` 274 | * 275 | * @param string $functionDeclaration 276 | * @param array $arguments 277 | * 278 | * @throws CommunicationException 279 | * 280 | * @return PageEvaluation 281 | */ 282 | public function callFunction(string $functionDeclaration, array $arguments = []): PageEvaluation 283 | { 284 | $this->assertNotClosed(); 285 | 286 | $currentLoaderId = $this->frameManager->getMainFrame()->getLatestLoaderId(); 287 | $executionContextId = $this->frameManager->getMainFrame()->getExecutionContextId(); 288 | $reader = $this->getSession()->sendMessage( 289 | new Message( 290 | 'Runtime.callFunctionOn', 291 | [ 292 | 'functionDeclaration' => $functionDeclaration, 293 | 'arguments' => \array_map(function ($arg) { 294 | return [ 295 | 'value' => $arg, 296 | ]; 297 | }, $arguments), 298 | 'executionContextId' => $executionContextId, 299 | 'awaitPromise' => true, 300 | 'returnByValue' => true, 301 | 'userGesture' => true, 302 | ] 303 | ) 304 | ); 305 | 306 | return new PageEvaluation($reader, $currentLoaderId, $this); 307 | } 308 | 309 | /** 310 | * Add a script tag to the page (ie.