├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── API.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples ├── photobooth │ ├── README.md │ ├── app_icon.png │ ├── main.js │ ├── package.json │ └── www │ │ ├── camera.svg │ │ ├── favicon.ico │ │ └── index.html ├── systeminfo │ ├── .gitignore │ ├── README.md │ ├── app.js │ ├── app_icon.png │ ├── main.js │ ├── package.json │ ├── test.js │ └── www │ │ ├── favicon.ico │ │ ├── fonts │ │ └── roboto-v18-latin-regular.woff2 │ │ └── index.html ├── terminal │ ├── .gitignore │ ├── README.md │ ├── app_icon.png │ ├── main.js │ ├── package.json │ ├── worker.js │ └── www │ │ ├── favicon.ico │ │ └── index.html └── windows │ ├── README.md │ ├── main.html │ ├── main.js │ └── package.json ├── index.js ├── lib ├── carlo.js ├── color.js ├── features │ ├── file_info.js │ └── shortcuts.js ├── find_chrome.js └── http_request.js ├── package.json ├── rpc ├── index.js ├── rpc.js ├── rpc.md ├── rpc_process.js └── test.js └── test ├── app.spec.js ├── color.spec.js ├── folder ├── index.html └── redirect.html ├── headful.js ├── http └── index.html └── test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | examples 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | 4 | "env": { 5 | "node": true, 6 | "es6": true 7 | }, 8 | 9 | "parserOptions": { 10 | "ecmaVersion": 8 11 | }, 12 | 13 | /** 14 | * ESLint rules 15 | * 16 | * All available rules: http://eslint.org/docs/rules/ 17 | * 18 | * Rules take the following form: 19 | * "rule-name", [severity, { opts }] 20 | * Severity: 2 == error, 1 == warning, 0 == off. 21 | */ 22 | "rules": { 23 | /** 24 | * Enforced rules 25 | */ 26 | 27 | 28 | // syntax preferences 29 | "quotes": [2, "single", { 30 | "avoidEscape": true, 31 | "allowTemplateLiterals": true 32 | }], 33 | "semi": 2, 34 | "no-extra-semi": 2, 35 | "comma-style": [2, "last"], 36 | "wrap-iife": [2, "inside"], 37 | "spaced-comment": [2, "always", { 38 | "markers": ["*"] 39 | }], 40 | "eqeqeq": [2], 41 | "arrow-body-style": [2, "as-needed"], 42 | "accessor-pairs": [2, { 43 | "getWithoutSet": false, 44 | "setWithoutGet": false 45 | }], 46 | "brace-style": [2, "1tbs", {"allowSingleLine": true}], 47 | "curly": [2, "multi-or-nest", "consistent"], 48 | "new-parens": 2, 49 | "func-call-spacing": 2, 50 | "arrow-parens": [2, "as-needed"], 51 | "prefer-const": 2, 52 | "quote-props": [2, "consistent"], 53 | 54 | // anti-patterns 55 | "no-var": 2, 56 | "no-with": 2, 57 | "no-multi-str": 2, 58 | "no-caller": 2, 59 | "no-implied-eval": 2, 60 | "no-labels": 2, 61 | "no-new-object": 2, 62 | "no-octal-escape": 2, 63 | "no-self-compare": 2, 64 | "no-shadow-restricted-names": 2, 65 | "no-cond-assign": 2, 66 | "no-debugger": 2, 67 | "no-dupe-keys": 2, 68 | "no-duplicate-case": 2, 69 | "no-empty-character-class": 2, 70 | "no-unreachable": 2, 71 | "no-unsafe-negation": 2, 72 | "radix": 2, 73 | "valid-typeof": 2, 74 | "no-unused-vars": [2, { "args": "none", "vars": "local", "varsIgnorePattern": "([fx]?describe|[fx]?it|beforeAll|beforeEach|afterAll|afterEach)" }], 75 | "no-implicit-globals": [2], 76 | 77 | // es2015 features 78 | "require-yield": 2, 79 | "template-curly-spacing": [2, "never"], 80 | 81 | // spacing details 82 | "space-infix-ops": 2, 83 | "space-in-parens": [2, "never"], 84 | "space-before-function-paren": [2, "never"], 85 | "no-whitespace-before-property": 2, 86 | "keyword-spacing": [2, { 87 | "overrides": { 88 | "if": {"after": true}, 89 | "else": {"after": true}, 90 | "for": {"after": true}, 91 | "while": {"after": true}, 92 | "do": {"after": true}, 93 | "switch": {"after": true}, 94 | "return": {"after": true} 95 | } 96 | }], 97 | "arrow-spacing": [2, { 98 | "after": true, 99 | "before": true 100 | }], 101 | 102 | // file whitespace 103 | "no-multiple-empty-lines": [2, {"max": 2}], 104 | "no-mixed-spaces-and-tabs": 2, 105 | "no-trailing-spaces": 2, 106 | "linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ], 107 | "indent": [2, 2, { "SwitchCase": 1, "CallExpression": {"arguments": 2}, "MemberExpression": 2 }], 108 | "key-spacing": [2, { 109 | "beforeColon": false 110 | }] 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .eslintrc.js 3 | .eslintignore 4 | .profile 5 | .vscode 6 | node_modules 7 | package-lock.json 8 | rpc/node_modules 9 | rpc/package-lock.json 10 | lib/.local-data -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # repeats from .gitignore 2 | .DS_Store 3 | .eslintrc.js 4 | .eslintignore 5 | .profile 6 | .vscode 7 | node_modules 8 | package-lock.json 9 | rpc/node_modules 10 | rpc/package-lock.json 11 | lib/.local-data 12 | 13 | .npmignore 14 | examples 15 | CONTRIBUTING.md 16 | API.md 17 | rpc/test.js 18 | test 19 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | ## API v0.9 2 | 3 | > This is a pre-release API, so it is a subject to change. Please use it at your own risk. Once API is validated, it will be bumped to v1.0 and preserved for backwards compatibility. 4 | 5 | ##### Node side API 6 | 7 | - [carlo.enterTestMode()](#carloentertestmode) 8 | - [carlo.launch([options])](#carlolaunchoptions) 9 | - [class: App](#class-app) 10 | * [event: 'exit'](#event-exit) 11 | * [event: 'window'](#event-window) 12 | * [App.browserForTest()](#appbrowserfortest) 13 | * [App.createWindow(options)](#appcreatewindowoptions) 14 | * [App.evaluate(pageFunction[, ...args])](#appevaluatepagefunction-args) 15 | * [App.exit()](#appexit) 16 | * [App.exposeFunction(name, carloFunction)](#appexposefunctionname-carlofunction) 17 | * [App.load(uri[, ...params])](#apploaduri-params) 18 | * [App.mainWindow()](#appmainwindow) 19 | * [App.serveFolder(folder[, prefix])](#appservefolderfolder-prefix) 20 | * [App.serveHandler(handler)](#appservehandlerhandler) 21 | * [App.serveOrigin(base[, prefix])](#appserveoriginbase-prefix) 22 | * [App.setIcon(image)](#appseticonimage) 23 | * [App.windows()](#appwindows) 24 | - [class: HttpRequest](#class-httprequest) 25 | * [HttpRequest.abort()](#httprequestabort) 26 | * [HttpRequest.continue()](#httprequestcontinue) 27 | * [HttpRequest.fail()](#httprequestfail) 28 | * [HttpRequest.fulfill(options)](#httprequestfulfilloptions) 29 | * [HttpRequest.headers()](#httprequestheaders) 30 | * [HttpRequest.method()](#httprequestmethod) 31 | * [HttpRequest.url()](#httprequesturl) 32 | - [class: Window](#class-window) 33 | * [event: 'close'](#event-close) 34 | * [Window.bounds()](#windowbounds) 35 | * [Window.bringToFront()](#windowbringtofront) 36 | * [Window.close()](#windowclose) 37 | * [Window.evaluate(pageFunction[, ...args])](#windowevaluatepagefunction-args) 38 | * [Window.exposeFunction(name, carloFunction)](#windowexposefunctionname-carlofunction) 39 | * [Window.fullscreen()](#windowfullscreen) 40 | * [Window.load(uri[, ...params])](#windowloaduri-params) 41 | * [Window.maximize()](#windowmaximize) 42 | * [Window.minimize()](#windowminimize) 43 | * [Window.pageForTest()](#windowpagefortest) 44 | * [Window.paramsForReuse()](#windowparamsforreuse) 45 | * [Window.serveFolder(folder[, prefix])](#windowservefolderfolder-prefix) 46 | * [Window.serveHandler(handler)](#windowservehandlerhandler) 47 | * [Window.serveOrigin(base[, prefix])](#windowserveoriginbase-prefix) 48 | * [Window.setBounds(bounds)](#windowsetboundsbounds) 49 | 50 | ##### Web side API 51 | 52 | - [carlo.fileInfo(file)](#carlofileinfofile) 53 | - [carlo.loadParams()](#carloloadparams) 54 | 55 | #### carlo.enterTestMode() 56 | 57 | Enters headless test mode. In the test mode, Puppeteer browser and pages are available via 58 | [App.browserForTest()](#appbrowserfortest) and [Window.pageForTest()](#windowpagefortest) respectively. 59 | Please refer to the Puppeteer [documentation](https://pptr.dev) for details on headless testing. 60 | 61 | #### carlo.launch([options]) 62 | - `options` <[Object]> Set of configurable options to set on the app. Can have the following fields: 63 | - `width` <[number]> App window width in pixels. 64 | - `height` <[number]> App window height in pixels. 65 | - `top`: <[number]> App window top offset in pixels. 66 | - `left` <[number]> App window left offset in pixels. 67 | - `bgcolor` <[string]> Background color using hex notation, defaults to `'#ffffff'`. 68 | - `channel` <[Array]<[string]>> Browser to be used, defaults to `['stable']`: 69 | - `'stable'` only uses locally installed stable channel Chrome. 70 | - `'canary'` only uses Chrome SxS aka Canary. 71 | - `'chromium'` downloads local version of Chromium compatible with the Puppeteer used. 72 | - `'rXXXXXX'` a specific Chromium revision is used. 73 | - `icon` <[Buffer]|[string]> Application icon to be used in the system dock. Either buffer containing PNG or a path to the PNG file on the file system. This feature is only available in Chrome M72+. One can use `'canary'` channel to see it in action before M72 hits stable. 74 | - `paramsForReuse` <\*> Optional parameters to share between Carlo instances. See [Window.paramsForReuse](#windowparamsforreuse) for details. 75 | - `title` <[string]> Application title. 76 | - `userDataDir` <[string]> Path to a [User Data Directory](https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md). This folder is created upon the first app launch and contains user settings and Web storage data. Defaults to `'.profile'`. 77 | - `executablePath` <[string]> Path to a Chromium or Chrome executable to run instead of the automatically located Chrome. If `executablePath` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). Carlo is only guaranteed to work with the latest Chrome stable version. 78 | - `args` <[Array]<[string]>> Additional arguments to pass to the browser instance. The list of Chromium flags can be found [here](https://peter.sh/experiments/chromium-command-line-switches/). 79 | - `return`: <[Promise]<[App]>> Promise which resolves to the app instance. 80 | 81 | Launches the browser. 82 | 83 | ### class: App 84 | 85 | #### event: 'exit' 86 | Emitted when the last window closes. 87 | 88 | #### event: 'window' 89 | - <[Window]> 90 | 91 | Emitted when the new window opens. This can happen in the following situations: 92 | - [App.createWindow](#appcreatewindowoptions) was called. 93 | - [carlo.launch](#carlolaunchoptions) was called from the same or another instance of the Node app. 94 | - [window.open](https://developer.mozilla.org/en-US/docs/Web/API/Window/open) was called from within the web page. 95 | 96 | #### App.browserForTest() 97 | - `return`: <[Browser]> Puppeteer browser object for testing. 98 | 99 | #### App.createWindow([options]) 100 | - `options` <[Object]> Set of configurable options to set on the app. Can have the following fields: 101 | - `width` <[number]> Window width in pixels, defaults to app width. 102 | - `height` <[number]> Window height in pixels, defaults to app height. 103 | - `top` <[number]> Window top in pixels, defaults to app top. 104 | - `left` <[number]> Window left in pixels, defaults to app left. 105 | - `bgcolor` <[string]> Background color using hex notation, defaults to app `bgcolor`. 106 | - `return`: <[Promise]<[Window]>> Promise which resolves to the window instance. 107 | 108 | Creates a new app window. 109 | 110 | #### App.evaluate(pageFunction[, ...args]) 111 | 112 | Shortcut to the main window's [Window.evaluate(pageFunction[, ...args])](#windowevaluatepagefunction-args). 113 | 114 | #### App.exit() 115 | - `return`: <[Promise]> 116 | 117 | Closes the browser window. 118 | 119 | #### App.exposeFunction(name, carloFunction) 120 | - `name` <[string]> Name of the function on the window object. 121 | - `carloFunction` <[function]> Callback function which will be called in Carlo's context. 122 | - `return`: <[Promise]> 123 | 124 | The method adds a function called `name` on the pages' `window` object. 125 | When called, the function executes `carloFunction` in Node.js and returns a [Promise] which resolves to the return value of `carloFunction`. 126 | 127 | If the `carloFunction` returns a [Promise], it will be awaited. 128 | 129 | > **NOTE** Functions installed via `App.exposeFunction` survive navigations. 130 | 131 | An example of adding an `md5` function into the page: 132 | 133 | `main.js` 134 | ```js 135 | const carlo = require('carlo'); 136 | const crypto = require('crypto'); 137 | 138 | carlo.launch().then(async app => { 139 | app.on('exit', () => process.exit()); 140 | app.serveFolder(__dirname); 141 | await app.exposeFunction('md5', text => // <-- expose function 142 | crypto.createHash('md5').update(text).digest('hex') 143 | ); 144 | await app.load('index.html'); 145 | }); 146 | ``` 147 | 148 | `index.html` 149 | ```html 150 | 153 | ``` 154 | 155 | #### App.load(uri[, ...params]) 156 | 157 | Shortcut to the main window's [Window.load(uri[, ...params])](#windowloaduri-params). 158 | 159 | #### App.mainWindow() 160 | - `return`: <[Window]> Returns main window. 161 | 162 | Running app guarantees to have main window. If current main window closes, a next open window 163 | becomes the main one. 164 | 165 | #### App.serveFolder(folder[, prefix]) 166 | - `folder` <[string]> Folder with web content to make available to Chrome. 167 | - `prefix` <[string]> Prefix of the URL path to serve from the given folder. 168 | 169 | Makes the content of the given folder available to the Chrome web app. 170 | 171 | An example of adding a local `www` folder along with the `node_modules`: 172 | 173 | `main.js` 174 | ```js 175 | const carlo = require('carlo'); 176 | 177 | carlo.launch().then(async app => { 178 | app.on('exit', () => process.exit()); 179 | app.serveFolder(`${__dirname}/www`); 180 | app.serveFolder(`${__dirname}/node_modules`, 'node_modules'); 181 | await app.load('index.html'); 182 | }); 183 | ``` 184 | ***www***/`index.html` 185 | ```html 186 | 187 | 192 | ``` 193 | 194 | #### App.serveHandler(handler) 195 | - `handler` <[Function]> Network handler callback accepting the [HttpRequest](#class-httprequest) parameter. 196 | 197 | An example serving primitive `index.html`: 198 | ```js 199 | const carlo = require('carlo'); 200 | 201 | carlo.launch().then(async app => { 202 | app.on('exit', () => process.exit()); 203 | app.serveHandler(request => { 204 | if (request.url().endsWith('/index.html')) 205 | request.fulfill({body: Buffer.from('Hello World')}); 206 | else 207 | request.continue(); // <-- user needs to resolve each request, otherwise it'll time out. 208 | }); 209 | await app.load('index.html'); // <-- loads index.html served above. 210 | }); 211 | ``` 212 | 213 | Handler function is called with every network request in this app. It can abort, continue or fulfill each request. Only single app-wide handler can be registered. 214 | 215 | #### App.serveOrigin(base[, prefix]) 216 | - `base` <[string]> Base to serve web content from. 217 | - `prefix` <[string]> Prefix of the URL path to serve from the given folder. 218 | 219 | Fetches Carlo content from the specified origin instead of reading it from the file system, eg `http://localhost:8080`. This mode can be used for the fast development mode available in web frameworks. 220 | 221 | An example of adding the local `http://localhost:8080` origin: 222 | 223 | ```js 224 | const carlo = require('carlo'); 225 | 226 | carlo.launch().then(async app => { 227 | app.on('exit', () => process.exit()); 228 | app.serveOrigin('http://localhost:8080'); // <-- fetch from the local server 229 | app.serveFolder(__dirname); // <-- won't be used 230 | await app.load('index.html'); 231 | }); 232 | ``` 233 | 234 | #### App.setIcon(image) 235 | - `image`: <[Buffer]|[string]> Either buffer containing PNG or a path to the PNG file on the file system. 236 | 237 | Specifies image to be used as an app icon in the system dock. 238 | 239 | > This feature is only available in Chrome M72+. One can use `'canary'` channel to see it in action before M72 hits stable. 240 | 241 | #### App.windows() 242 | - `return`: <[Array]<[Window]>> Returns all currently opened windows. 243 | 244 | Running app guarantees to have at least one open window. 245 | 246 | ### class: HttpRequest 247 | 248 | Handlers registered via [App.serveHandler](#appservehandlerhandler) and [Window.serveHandler](#windowservehandlerhandler) receive parameter of this upon every network request. 249 | 250 | #### HttpRequest.abort() 251 | - `return`: <[Promise]> 252 | 253 | Aborts request. If request is a navigation request, navigation is aborted as well. 254 | 255 | #### HttpRequest.continue() 256 | 257 | Proceeds with the default behavior for this request. Either serves it from the filesystem or defers to the browser. 258 | 259 | #### HttpRequest.fail() 260 | - `return`: <[Promise]> 261 | 262 | Marks the request as failed. If request is a navigation request, navigation is still committed, but to a location that fails to be fetched. 263 | 264 | #### HttpRequest.fulfill(options) 265 | - `options`: <[Object]> 266 | - `status` <[number]> HTTP status code (200, 304, etc), defaults to 200. 267 | - `headers` <[Object]> HTTP response headers. 268 | - `body` <[Buffer]> Response body. 269 | - `return`: <[Promise]> 270 | 271 | Fulfills the network request with the given data. `'Content-Length'` header is generated in case it is not listed in the headers. 272 | 273 | #### HttpRequest.headers() 274 | - `return`: <[Object]> HTTP headers 275 | 276 | Network request headers. 277 | 278 | #### HttpRequest.method() 279 | - `return`: <[string]> HTTP method 280 | 281 | HTTP method of this network request (GET, POST, etc). 282 | 283 | #### HttpRequest.url() 284 | - `return`: <[string]> HTTP URL 285 | 286 | Network request URL. 287 | 288 | ### class: Window 289 | 290 | #### event: 'close' 291 | Emitted when the window closes. 292 | 293 | #### Window.bounds() 294 | - `return`: <[Promise]<[Object]>> 295 | - `top` <[number]> Top offset in pixels. 296 | - `left` <[number]> Left offset in pixels. 297 | - `width` <[number]> Width in pixels. 298 | - `height` <[number]> Height in pixels. 299 | 300 | Returns window bounds. 301 | 302 | #### Window.bringToFront() 303 | - `return`: <[Promise]> 304 | 305 | Brings this window to front. 306 | 307 | #### Window.close() 308 | - `return`: <[Promise]> 309 | 310 | Closes this window. 311 | 312 | #### Window.evaluate(pageFunction[, ...args]) 313 | - `pageFunction` <[function]|[string]> Function to be evaluated in the page context. 314 | - `...args` <...[Serializable]> Arguments to pass to `pageFunction`. 315 | - `return`: <[Promise]<[Serializable]>> Promise which resolves to the return value of `pageFunction`. 316 | 317 | If the function passed to the `Window.evaluate` returns a [Promise], then `Window.evaluate` would wait for the promise to resolve and return its value. 318 | 319 | If the function passed to the `Window.evaluate` returns a non-[Serializable] value, then `Window.evaluate` resolves to `undefined`. 320 | 321 | ```js 322 | const result = await window.evaluate(() => navigator.userAgent); 323 | console.log(result); // prints "" in Node console 324 | ``` 325 | 326 | Passing arguments to `pageFunction`: 327 | ```js 328 | const result = await window.evaluate(x => { 329 | return Promise.resolve(8 * x); 330 | }, 7); 331 | console.log(result); // prints "56" in Node console 332 | ``` 333 | 334 | A string can also be passed in instead of a function: 335 | ```js 336 | console.log(await window.evaluate('1 + 2')); // prints "3" 337 | const x = 10; 338 | console.log(await window.evaluate(`1 + ${x}`)); // prints "11" 339 | ``` 340 | 341 | #### Window.exposeFunction(name, carloFunction) 342 | - `name` <[string]> Name of the function on the window object. 343 | - `carloFunction` <[function]> Callback function which will be called in Carlo's context. 344 | - `return`: <[Promise]> 345 | 346 | Same as [App.exposeFunction](#appexposefunctionname-carlofunction), but only applies to 347 | the current window. 348 | 349 | > **NOTE** Functions installed via `Window.exposeFunction` survive navigations. 350 | 351 | #### Window.fullscreen() 352 | - `return`: <[Promise]> 353 | 354 | Turns the window into the full screen mode. Behavior is platform-specific. 355 | 356 | #### Window.load(uri[, ...params]) 357 | - `uri` <[string]> Path to the resource relative to the folder passed into [`serveFolder()`]. 358 | - `params` <\*> Optional parameters to pass to the web application. Parameters can be 359 | primitive types, <[Array]>, <[Object]> or <[rpc]> `handles`. 360 | - `return`: <[Promise]> Resolves upon DOMContentLoaded event in the web page. 361 | 362 | Navigates the corresponding web page to the given `uri`, makes given `params` available in the web page via [carlo.loadParams()](#carloloadparams). 363 | 364 | `main.js` 365 | ```js 366 | const carlo = require('carlo'); 367 | const { rpc } = require('carlo/rpc'); 368 | 369 | carlo.launch().then(async app => { 370 | app.serveFolder(__dirname); 371 | app.on('exit', () => process.exit()); 372 | await app.load('index.html', rpc.handle(new Backend)); 373 | }); 374 | 375 | class Backend { 376 | hello(name) { 377 | console.log(`Hello ${name}`); 378 | return 'Backend is happy'; 379 | } 380 | 381 | setFrontend(frontend) { 382 | // Node world can now use frontend RPC handle. 383 | this.frontend_ = frontend; 384 | } 385 | } 386 | ``` 387 | 388 | `index.html` 389 | ```html 390 | 399 | Open console 400 | ``` 401 | 402 | #### Window.maximize() 403 | - `return`: <[Promise]> 404 | 405 | Maximizes the window. Behavior is platform-specific. 406 | 407 | #### Window.minimize() 408 | - `return`: <[Promise]> 409 | 410 | Minimizes the window. Behavior is platform-specific. 411 | 412 | #### Window.pageForTest() 413 | - `return`: <[Page]> Puppeteer page object for testing. 414 | 415 | #### Window.paramsForReuse() 416 | - `return`: <\*> parameters. 417 | 418 | Returns the `options.paramsForReuse` value passed into the [carlo.launch](#carlolaunchoptions). 419 | 420 | These parameters are useful when Carlo app is started multiple times: 421 | - First time the Carlo app is started, it successfully calls `carlo.launch` and opens the main window. 422 | - Second time the Carlo app is started, `carlo.launch` fails with the 'browser is already running' exception. 423 | - Despite the fact that second call to `carlo.launch` failed, a new window is created in the first Carlo app. This window contains `paramsForReuse` value that was specified in the second (failed) `carlo.launch` call. 424 | 425 | This way app can pass initialization parameters such as command line, etc. to the singleton Carlo that owns the browser. 426 | 427 | #### Window.serveFolder(folder[, prefix]) 428 | - `folder` <[string]> Folder with web content to make available to Chrome. 429 | - `prefix` <[string]> Prefix of the URL path to serve from the given folder. 430 | 431 | Same as [App.serveFolder(folder[, prefix])](#appservefolderfolder-prefix), but 432 | only applies to current window. 433 | 434 | #### Window.serveHandler(handler) 435 | - `handler` <[Function]> Network handler callback accepting the [HttpRequest](#class-httprequest) parameter. 436 | 437 | Same as [App.serveHandler(handler)](#appservehandlerhandler), but only applies to the current window requests. 438 | Only single window-level handler can be installed in window. 439 | 440 | #### Window.serveOrigin(base[, prefix]) 441 | - `base` <[string]> Base to serve web content from. 442 | - `prefix` <[string]> Prefix of the URL path to serve from the given folder. 443 | 444 | Same as [App.serveOrigin(base[, prefix])](#appserveoriginbase-prefix), but 445 | only applies to current window. 446 | 447 | #### Window.setBounds(bounds) 448 | - `bounds` <[Object]> Window bounds: 449 | - `top` <[number]> Top offset in pixels. 450 | - `left` <[number]> Left offset in pixels. 451 | - `width` <[number]> Width in pixels. 452 | - `height` <[number]> Height in pixels. 453 | - `return`: <[Promise]> 454 | 455 | Sets window bounds. Parameters `top`, `left`, `width` and `height` are all optional. Dimension or 456 | the offset is only applied when specified. 457 | 458 | #### carlo.fileInfo(file) 459 | - `file` <[File]> to get additional information for. 460 | - `return`: <[Promise]<[Object]>> 461 | - `path` absolute path to the given file. 462 | 463 | > Available in Chrome M73+. 464 | 465 | Returns additional information about the file, otherwise not available to the web. 466 | 467 | 468 | #### carlo.loadParams() 469 | - `return`: <[Promise]<[Array]>> parameters passed into [window.load()](#windowloaduri-params). 470 | 471 | This method is available in the Web world and returns parameters passed into the [window.load()](#windowloaduri-params). This is how Carlo passes initial set of <[rpc]> handles to Node objects into the web world. 472 | 473 | [`serveFolder()`]: #windowservefolderfolder-prefix 474 | [App]: #class-app 475 | [Array]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array "Array" 476 | [Browser]: https://pptr.dev/#?show=api-class-browser "Browser" 477 | [Buffer]: https://nodejs.org/api/buffer.html#buffer_class_buffer "Buffer" 478 | [File]: https://developer.mozilla.org/en-US/docs/Web/API/File "File" 479 | [Object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object "Object" 480 | [Page]: https://pptr.dev/#?show=api-class-page "Page" 481 | [Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise "Promise" 482 | [Serializable]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Description "Serializable" 483 | [Window]: #class-window 484 | [boolean]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type "Boolean" 485 | [function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function "Function" 486 | [number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type "Number" 487 | [origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin "Origin" 488 | [rpc]: https://github.com/GoogleChromeLabs/carlo/blob/master/rpc/rpc.md "rpc" 489 | [string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String" 490 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Carlo - headful Node app framework 2 | 3 | ### ❗Carlo is [no longer maintained](https://github.com/GoogleChromeLabs/carlo/issues/163#issuecomment-592238093). 4 | 5 | ----------------------- 6 | 7 | 8 | > Carlo provides Node applications with [Google Chrome](https://www.google.com/chrome/) rendering capabilities, communicates with the locally-installed browser instance using the [Puppeteer](https://github.com/GoogleChrome/puppeteer/) project, and implements a remote call infrastructure for communication between Node and the browser. 9 | 10 | ###### [API](https://github.com/GoogleChromeLabs/carlo/blob/master/API.md) | [FAQ](#faq) | [Contributing](https://github.com/GoogleChromeLabs/carlo/blob/master/CONTRIBUTING.md) 11 | 12 | ![image](https://user-images.githubusercontent.com/883973/47826256-0531fc80-dd34-11e8-9c8d-c1b93a6ba631.png) 13 | 14 | 15 | ###### What can I do? 16 | 17 | With Carlo, users can create hybrid applications that use Web stack for rendering and Node for capabilities: 18 | - For Node applications, the web rendering stack lets users visualize the dynamic state of the app. 19 | - For Web applications, additional system capabilities are accessible from Node. 20 | - The application can be bundled into a single executable using [pkg](https://github.com/zeit/pkg). 21 | 22 | ###### How does it work? 23 | 24 | - Carlo locates Google Chrome installed locally. 25 | - Launches Chrome and establishes a connection over the process pipe. 26 | - Exposes a high-level API for rendering in Chrome with the Node environment. 27 | 28 | 29 | 30 | 31 | 32 | ## Usage 33 | 34 | Install Carlo 35 | 36 | #### npm 37 | ```bash 38 | npm i carlo 39 | # yarn add carlo 40 | ``` 41 | 42 | > Carlo requires at least Node v7.6.0. 43 | 44 | **Example** - Display local environment 45 | 46 | Save file as **example.js** 47 | 48 | ```js 49 | const carlo = require('carlo'); 50 | 51 | (async () => { 52 | // Launch the browser. 53 | const app = await carlo.launch(); 54 | 55 | // Terminate Node.js process on app window closing. 56 | app.on('exit', () => process.exit()); 57 | 58 | // Tell carlo where your web files are located. 59 | app.serveFolder(__dirname); 60 | 61 | // Expose 'env' function in the web environment. 62 | await app.exposeFunction('env', _ => process.env); 63 | 64 | // Navigate to the main page of your app. 65 | await app.load('example.html'); 66 | })(); 67 | ``` 68 | 69 | Save file as **example.html** 70 | 71 | ```html 72 | 83 | 84 | ``` 85 | 86 | Run your application: 87 | 88 | ```bash 89 | node example.js 90 | ``` 91 | 92 | Check out [systeminfo](https://github.com/GoogleChromeLabs/carlo/tree/master/examples/systeminfo) and [terminal](https://github.com/GoogleChromeLabs/carlo/tree/master/examples/terminal) examples with richer UI and RPC-based communication between the Web and Node in the [examples](https://github.com/GoogleChromeLabs/carlo/tree/master/examples) folder. 93 | 94 | 95 | 96 | ## API 97 | 98 | Check out the [API](https://github.com/GoogleChromeLabs/carlo/blob/master/API.md) to get familiar with Carlo. 99 | 100 | 101 | ## Testing 102 | 103 | Carlo uses [Puppeteer](https://pptr.dev/) project for testing. Carlo application and all Carlo windows have 104 | corresponding Puppeteer objects exposed for testing. Please refer to the [API](https://github.com/GoogleChromeLabs/carlo/blob/master/API.md) and the [systeminfo](https://github.com/GoogleChromeLabs/carlo/tree/master/examples/systeminfo) project for more details. 105 | 106 | ## Contributing to Carlo 107 | 108 | Look at the [contributing guide](https://github.com/GoogleChromeLabs/carlo/blob/master/CONTRIBUTING.md) to get an overview of Carlo's development. 109 | 110 | 111 | 112 | ## FAQ 113 | 114 | #### Q: What was the motivation behind this project when we already have Electron and NW.js? How does this project differ from these platforms, how does it achieve something that is not possible/harder with Electron or NW.js? 115 | 116 | - One of the motivations of this project is to demonstrate how browsers that are installed locally can be used with Node out of the box. 117 | - Node v8 and Chrome v8 engines are decoupled in Carlo, providing a maintainable model with the ability to independently update underlying components. Carlo gives the user control over bundling and is more about productivity than branding. 118 | 119 | #### Q: Can a Node app using Carlo be packaged as a Desktop app? 120 | 121 | The [pkg](https://github.com/zeit/pkg) project can be used to package a Node app as a Desktop app. Carlo does not provide branding configurability such as application icons or customizable menus, instead, Carlo focuses on productivity and Web/Node interoperability. Check out the [systeminfo](https://github.com/GoogleChromeLabs/carlo/tree/master/examples/systeminfo) example and call `pkg package.json` to see how it works. 122 | 123 | #### Q: What happens if the user does not have Chrome installed? 124 | 125 | Carlo prints an error message when Chrome can not be located. 126 | 127 | #### Q: What is the minimum Chrome version that Carlo supports? 128 | 129 | Chrome Stable channel, versions 70.* are supported. 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /examples/photobooth/README.md: -------------------------------------------------------------------------------- 1 | ### Usage 2 | 3 | > This example requires Chrome 72 (Chrome Canary) to function. 4 | 5 | Install dependencies 6 | 7 | ```bash 8 | npm i 9 | ``` 10 | 11 | Run application 12 | 13 | ```bash 14 | npm start 15 | ``` 16 | 17 | Optionally package as executable 18 | 19 | ```bash 20 | pkg package.json 21 | ``` 22 | -------------------------------------------------------------------------------- /examples/photobooth/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/carlo/8f2cbfedf381818792017fe53651fe07f270bb96/examples/photobooth/app_icon.png -------------------------------------------------------------------------------- /examples/photobooth/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | const carlo = require('carlo'); 20 | const fs = require('fs'); 21 | const path = require('path'); 22 | const os = require('os'); 23 | 24 | (async () => { 25 | let app; 26 | try { 27 | app = await carlo.launch( 28 | { 29 | bgcolor: '#e6e8ec', 30 | width: 800, 31 | height: 648 + 24, 32 | icon: path.join(__dirname, '/app_icon.png'), 33 | channel: ['canary', 'stable'], 34 | localDataDir: path.join(os.homedir(), '.carlophotobooth'), 35 | }); 36 | } catch(e) { 37 | // New window is opened in the running instance. 38 | console.log('Reusing the running instance'); 39 | return; 40 | } 41 | app.on('exit', () => process.exit()); 42 | // New windows are opened when this app is started again from command line. 43 | app.on('window', window => window.load('index.html')); 44 | app.serveFolder(path.join(__dirname, '/www')); 45 | await app.exposeFunction('saveImage', saveImage); 46 | await app.load('index.html'); 47 | })(); 48 | 49 | function saveImage(base64) { 50 | var buffer = Buffer.from(base64, 'base64') 51 | if (!fs.existsSync('pictures')) 52 | fs.mkdirSync('pictures'); 53 | const fileName = path.join('pictures', new Date().toISOString().replace(/:/g,'-') + '.jpeg'); 54 | fs.writeFileSync(fileName, buffer); 55 | } 56 | -------------------------------------------------------------------------------- /examples/photobooth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "photobooth-app", 3 | "version": "0.9.0", 4 | "description": "Photo Booth App", 5 | "main": "main.js", 6 | "scripts": { 7 | "bundle": "pkg package.json", 8 | "start": "node main.js" 9 | }, 10 | "bin": { 11 | "photobooth-app": "./main.js" 12 | }, 13 | "pkg": { 14 | "scripts": "*.js", 15 | "assets": "www/**/*" 16 | }, 17 | "keywords": [], 18 | "author": "The Chromium Authors", 19 | "license": "Apache-2.0", 20 | "dependencies": { 21 | "carlo": "^0.9.0", 22 | "systeminformation": "^3.45.9" 23 | }, 24 | "devDependencies": { 25 | "pkg": "^4.3.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/photobooth/www/camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/photobooth/www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/carlo/8f2cbfedf381818792017fe53651fe07f270bb96/examples/photobooth/www/favicon.ico -------------------------------------------------------------------------------- /examples/photobooth/www/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | PhotoBooth App 19 | 72 | 73 | 93 | 94 | 95 | 96 | 97 |
98 |
99 |
100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /examples/systeminfo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .profile 4 | .DS_Store 5 | .vscode 6 | .idea -------------------------------------------------------------------------------- /examples/systeminfo/README.md: -------------------------------------------------------------------------------- 1 | ### Usage 2 | 3 | Install dependencies 4 | 5 | ```bash 6 | npm i 7 | ``` 8 | 9 | Run application 10 | 11 | ```bash 12 | npm start 13 | ``` 14 | 15 | Optionally package as executable 16 | 17 | ```bash 18 | pkg package.json 19 | ``` 20 | -------------------------------------------------------------------------------- /examples/systeminfo/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc., PhantomJS Authors All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | const carlo = require('carlo'); 20 | const os = require('os'); 21 | const path = require('path'); 22 | const si = require('systeminformation'); 23 | 24 | async function run() { 25 | let app; 26 | try { 27 | app = await carlo.launch( 28 | { 29 | bgcolor: '#2b2e3b', 30 | title: 'Systeminfo App', 31 | width: 1000, 32 | height: 500, 33 | channel: ['canary', 'stable'], 34 | icon: path.join(__dirname, '/app_icon.png'), 35 | args: process.env.DEV === 'true' ? ['--auto-open-devtools-for-tabs'] : [], 36 | localDataDir: path.join(os.homedir(), '.carlosysteminfo'), 37 | }); 38 | } catch(e) { 39 | // New window is opened in the running instance. 40 | console.log('Reusing the running instance'); 41 | return; 42 | } 43 | app.on('exit', () => process.exit()); 44 | // New windows are opened when this app is started again from command line. 45 | app.on('window', window => window.load('index.html')); 46 | app.serveFolder(path.join(__dirname, 'www')); 47 | await app.exposeFunction('systeminfo', systeminfo); 48 | await app.load('index.html'); 49 | return app; 50 | } 51 | 52 | async function systeminfo() { 53 | const info = {}; 54 | await Promise.all([ 55 | si.battery().then(r => info.battery = r), 56 | si.cpu().then(r => info.cpu = r), 57 | si.osInfo().then(r => info.osInfo = r), 58 | ]); 59 | return info; 60 | } 61 | 62 | module.exports = { run }; 63 | -------------------------------------------------------------------------------- /examples/systeminfo/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/carlo/8f2cbfedf381818792017fe53651fe07f270bb96/examples/systeminfo/app_icon.png -------------------------------------------------------------------------------- /examples/systeminfo/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc., PhantomJS Authors All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | require('./app.js').run(); 20 | -------------------------------------------------------------------------------- /examples/systeminfo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "systeminfo-app", 3 | "version": "0.9.0", 4 | "description": "System info example", 5 | "main": "main.js", 6 | "scripts": { 7 | "bundle": "pkg package.json", 8 | "start": "node main.js", 9 | "test": "node test.js" 10 | }, 11 | "bin": { 12 | "systeminfo-app": "./main.js" 13 | }, 14 | "pkg": { 15 | "scripts": "*.js", 16 | "assets": "www/**/*" 17 | }, 18 | "keywords": [], 19 | "author": "The Chromium Authors", 20 | "license": "Apache-2.0", 21 | "dependencies": { 22 | "carlo": "^0.9.0", 23 | "systeminformation": "^3.45.9" 24 | }, 25 | "devDependencies": { 26 | "pkg": "^4.3.4", 27 | "@pptr/testrunner": "^0.5.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/systeminfo/test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const {TestRunner, Reporter, Matchers} = require('@pptr/testrunner'); 18 | 19 | require('carlo').enterTestMode(); 20 | const { run } = require('./app'); 21 | 22 | // Runner holds and runs all the tests 23 | const runner = new TestRunner({ 24 | parallel: 1, // run 2 parallel threads 25 | timeout: 3000, // setup timeout of 1 second per test 26 | }); 27 | // Simple expect-like matchers 28 | const {expect} = new Matchers(); 29 | 30 | // Extract jasmine-like DSL into the global namespace 31 | const {describe, xdescribe, fdescribe} = runner; 32 | const {it, fit, xit} = runner; 33 | const {beforeAll, beforeEach, afterAll, afterEach} = runner; 34 | 35 | describe('test', () => { 36 | it('test columns', async(state, test) => { 37 | const app = await run(); 38 | const page = app.mainWindow().pageForTest(); 39 | await page.waitForSelector('.header'); 40 | const columns = await page.$$eval('.header', nodes => nodes.map(n => n.textContent)); 41 | expect(columns.sort().join(',')).toBe('battery,cpu,osInfo'); 42 | }); 43 | }); 44 | 45 | // Reporter subscribes to TestRunner events and displays information in terminal 46 | new Reporter(runner); 47 | 48 | // Run all tests. 49 | runner.run(); 50 | -------------------------------------------------------------------------------- /examples/systeminfo/www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/carlo/8f2cbfedf381818792017fe53651fe07f270bb96/examples/systeminfo/www/favicon.ico -------------------------------------------------------------------------------- /examples/systeminfo/www/fonts/roboto-v18-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/carlo/8f2cbfedf381818792017fe53651fe07f270bb96/examples/systeminfo/www/fonts/roboto-v18-latin-regular.woff2 -------------------------------------------------------------------------------- /examples/systeminfo/www/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 92 | 93 | Systeminfo App 94 | 127 | 128 |
129 |
Welcome to Carlo!
130 |
131 |
132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /examples/terminal/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .profile 4 | .DS_Store 5 | .vscode 6 | .idea -------------------------------------------------------------------------------- /examples/terminal/README.md: -------------------------------------------------------------------------------- 1 | ### Usage 2 | 3 | Install dependencies 4 | 5 | ```bash 6 | npm i 7 | ``` 8 | 9 | Run application 10 | 11 | ```bash 12 | npm start 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/terminal/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/carlo/8f2cbfedf381818792017fe53651fe07f270bb96/examples/terminal/app_icon.png -------------------------------------------------------------------------------- /examples/terminal/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copyright 2018 Google Inc., PhantomJS Authors All rights reserved. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | 'use strict'; 20 | 21 | const carlo = require('carlo'); 22 | const path = require('path'); 23 | const { rpc, rpc_process } = require('carlo/rpc'); 24 | 25 | class TerminalApp { 26 | constructor() { 27 | this.lastTop_ = 50; 28 | this.lastLeft_ = 50; 29 | this.launch_(); 30 | this.handle_ = rpc.handle(this); 31 | } 32 | 33 | async launch_() { 34 | try { 35 | this.app_ = await carlo.launch({ 36 | bgcolor: '#2b2e3b', 37 | title: 'Terminal App', 38 | width: 800, 39 | height: 800, 40 | channel: ['canary', 'stable'], 41 | icon: path.join(__dirname, '/app_icon.png'), 42 | top: this.lastTop_, 43 | left: this.lastLeft_ }); 44 | } catch (e) { 45 | console.log('Reusing the running instance'); 46 | return; 47 | } 48 | this.app_.on('exit', () => process.exit()); 49 | this.app_.serveFolder(path.join(__dirname, 'www')); 50 | this.app_.serveFolder(path.join(__dirname, 'node_modules'), 'node_modules'); 51 | this.app_.on('window', win => this.initUI_(win)); 52 | this.initUI_(this.app_.mainWindow()); 53 | } 54 | 55 | async newWindow() { 56 | this.lastTop_ = (this.lastTop_ + 50) % 200; 57 | this.lastLeft_ += 50; 58 | const options = { top: this.lastTop_, left: this.lastLeft_ }; 59 | this.app_.createWindow(options); 60 | } 61 | 62 | async initUI_(win) { 63 | const term = await rpc_process.spawn('worker.js'); 64 | win.load('index.html', this.handle_, term); 65 | } 66 | } 67 | 68 | new TerminalApp(); 69 | -------------------------------------------------------------------------------- /examples/terminal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xterm-app", 3 | "version": "0.9.0", 4 | "description": "Terminal example", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "node main.js" 8 | }, 9 | "bin": { 10 | "xterm-app": "./main.js" 11 | }, 12 | "keywords": [], 13 | "author": "The Chromium Authors", 14 | "license": "Apache-2.0", 15 | "dependencies": { 16 | "carlo": "^0.9.0", 17 | "ndb-node-pty-prebuilt": "^0.8.0", 18 | "xterm": "~3.8.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/terminal/worker.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copyright 2018 Google Inc., PhantomJS Authors All rights reserved. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | 'use strict'; 20 | 21 | const EventEmitter = require('events'); 22 | const os = require('os'); 23 | const pty = require('ndb-node-pty-prebuilt'); 24 | const { rpc, rpc_process } = require('carlo/rpc'); 25 | 26 | class Terminal extends EventEmitter { 27 | constructor() { 28 | super(); 29 | const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; 30 | this.term_ = pty.spawn(shell, [], { 31 | name: 'xterm-color', 32 | cwd: process.env.PWD, 33 | env: process.env 34 | }); 35 | this.term_.on('data', data => this.emit('data', data)); 36 | } 37 | 38 | on(event, func) { 39 | // EventEmitter returns heavy object that we don't want to 40 | // send over the wire. 41 | super.on(event, func); 42 | } 43 | 44 | resize(cols, rows) { 45 | this.term_.resize(cols, rows); 46 | } 47 | 48 | write(data) { 49 | this.term_.write(data); 50 | } 51 | 52 | dispose() { 53 | process.kill(this._term.pid); 54 | } 55 | } 56 | 57 | rpc_process.init(() => rpc.handle(new Terminal)); 58 | -------------------------------------------------------------------------------- /examples/terminal/www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/carlo/8f2cbfedf381818792017fe53651fe07f270bb96/examples/terminal/www/favicon.ico -------------------------------------------------------------------------------- /examples/terminal/www/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | Terminal App 21 | 27 | 28 | 29 | 30 | 56 | 57 | 58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /examples/windows/README.md: -------------------------------------------------------------------------------- 1 | ### Usage 2 | 3 | Install dependencies 4 | 5 | ```bash 6 | npm i 7 | ``` 8 | 9 | Run application 10 | 11 | ```bash 12 | npm start 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/windows/main.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | Main 19 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /examples/windows/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copyright 2018 Google Inc., PhantomJS Authors All rights reserved. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | 'use strict'; 20 | 21 | const carlo = require('carlo'); 22 | const { rpc } = require('carlo/rpc'); 23 | 24 | class Backend { 25 | constructor(app) { 26 | this.app_ = app; 27 | this.windows_ = new Map(); 28 | } 29 | 30 | showMyWindow(url) { 31 | let windowPromise = this.windows_.get(url); 32 | if (!windowPromise) { 33 | windowPromise = this.createWindow_(url); 34 | this.windows_.set(url, windowPromise); 35 | } 36 | windowPromise.then(w => w.bringToFront()); 37 | } 38 | 39 | async createWindow_(url) { 40 | const window = await this.app_.createWindow({width: 800, height: 600, top: 200, left: 10}); 41 | window.on('close', () => this.windows_.delete(url)); 42 | window.load(url); 43 | return window; 44 | } 45 | } 46 | 47 | (async() => { 48 | const app = await carlo.launch( 49 | {title: 'Main', width: 300, height: 100, top: 10, left: 10 }); 50 | app.on('exit', () => process.exit()); 51 | const mainWindow = app.mainWindow(); 52 | mainWindow.on('close', () => process.exit()); 53 | mainWindow.serveFolder(__dirname); 54 | mainWindow.load('main.html', rpc.handle(new Backend(app))); 55 | })(); 56 | -------------------------------------------------------------------------------- /examples/windows/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "windows-app", 3 | "version": "0.9.0", 4 | "description": "Multiple windows example", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "node main.js" 8 | }, 9 | "bin": { 10 | "windows-app": "./main.js" 11 | }, 12 | "keywords": [], 13 | "author": "The Chromium Authors", 14 | "license": "Apache-2.0", 15 | "dependencies": { 16 | "carlo": "^0.9.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | module.exports = require('./lib/carlo'); 20 | -------------------------------------------------------------------------------- /lib/carlo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | const path = require('path'); 20 | const puppeteer = require('puppeteer-core'); 21 | const findChrome = require('./find_chrome'); 22 | const {rpc} = require('../rpc'); 23 | const debugApp = require('debug')('carlo:app'); 24 | const debugServer = require('debug')('carlo:server'); 25 | const {Color} = require('./color'); 26 | const {HttpRequest} = require('./http_request'); 27 | 28 | const fs = require('fs'); 29 | const util = require('util'); 30 | const {URL} = require('url'); 31 | const EventEmitter = require('events'); 32 | const fsReadFile = util.promisify(fs.readFile); 33 | 34 | let testMode = false; 35 | 36 | class App extends EventEmitter { 37 | /** 38 | * @param {!Puppeteer.Browser} browser Puppeteer browser 39 | * @param {!Object} options 40 | */ 41 | constructor(browser, options) { 42 | super(); 43 | this.browser_ = browser; 44 | this.options_ = options; 45 | this.windows_ = new Map(); 46 | this.exposedFunctions_ = []; 47 | this.pendingWindows_ = new Map(); 48 | this.windowSeq_ = 0; 49 | this.www_ = []; 50 | } 51 | 52 | async init_() { 53 | debugApp('Configuring browser'); 54 | let page; 55 | await Promise.all([ 56 | this.browser_.target().createCDPSession().then(session => { 57 | this.session_ = session; 58 | if (this.options_.icon) 59 | this.setIcon(this.options_.icon); 60 | }), 61 | this.browser_.defaultBrowserContext(). 62 | overridePermissions('https://domain', [ 63 | 'geolocation', 64 | 'midi', 65 | 'notifications', 66 | 'camera', 67 | 'microphone', 68 | 'clipboard-read', 69 | 'clipboard-write']), 70 | this.browser_.pages().then(pages => page = pages[0]) 71 | ]); 72 | 73 | this.browser_.on('targetcreated', this.targetCreated_.bind(this)); 74 | 75 | // Simulate the pageCreated sequence. 76 | let callback; 77 | const result = new Promise(f => callback = f); 78 | this.pendingWindows_.set('', { options: this.options_, callback }); 79 | this.pageCreated_(page); 80 | return result; 81 | } 82 | 83 | /** 84 | * Close the app windows. 85 | */ 86 | async exit() { 87 | debugApp('app.exit...'); 88 | if (this.exited_) 89 | return; 90 | this.exited_ = true; 91 | await this.browser_.close(); 92 | this.emit(App.Events.Exit); 93 | } 94 | 95 | /** 96 | * @return {!} main window. 97 | */ 98 | mainWindow() { 99 | for (const window of this.windows_.values()) 100 | return window; 101 | } 102 | 103 | /** 104 | * @param {!Object=} options 105 | * @return {!Promise} 106 | */ 107 | async createWindow(options = {}) { 108 | options = Object.assign({}, this.options_, options); 109 | const seq = String(++this.windowSeq_); 110 | if (!this.windows_.size) 111 | throw new Error('Needs at least one window to create more.'); 112 | 113 | const params = []; 114 | for (const prop of ['top', 'left', 'width', 'height']) { 115 | if (typeof options[prop] === 'number') 116 | params.push(`${prop}=${options[prop]}`); 117 | } 118 | 119 | for (const page of this.windows_.keys()) { 120 | page.evaluate(`window.open('about:blank?seq=${seq}', '', '${params.join(',')}')`); 121 | break; 122 | } 123 | 124 | return new Promise(callback => { 125 | this.pendingWindows_.set(seq, { options, callback }); 126 | }); 127 | } 128 | 129 | /** 130 | * @return {!Array} 131 | */ 132 | windows() { 133 | return Array.from(this.windows_.values()); 134 | } 135 | 136 | /** 137 | * @param {string} name 138 | * @param {function} func 139 | * @return {!Promise} 140 | */ 141 | exposeFunction(name, func) { 142 | this.exposedFunctions_.push({name, func}); 143 | return Promise.all(this.windows().map(window => window.exposeFunction(name, func))); 144 | } 145 | 146 | /** 147 | * @param {function()|string} pageFunction 148 | * @param {!Array<*>} args 149 | * @return {!Promise<*>} 150 | */ 151 | evaluate(pageFunction, ...args) { 152 | return this.mainWindow().evaluate(pageFunction, ...args); 153 | } 154 | 155 | /** 156 | * @param {string=} folder Folder with the web content. 157 | * @param {string=} prefix Only serve folder for requests with given prefix. 158 | */ 159 | serveFolder(folder = '', prefix = '') { 160 | this.www_.push({folder, prefix: wrapPrefix(prefix)}); 161 | } 162 | 163 | /** 164 | * Serves pages from given origin, eg `http://localhost:8080`. 165 | * This can be used for the fast development mode available in web frameworks. 166 | * 167 | * @param {string} base 168 | * @param {string=} prefix Only serve folder for requests with given prefix. 169 | */ 170 | serveOrigin(base, prefix = '') { 171 | this.www_.push({baseURL: new URL(base + '/'), prefix: wrapPrefix(prefix)}); 172 | } 173 | 174 | /** 175 | * Calls given handler for each request and allows called to handle it. 176 | * 177 | * @param {function(!Request)} handler to be used for each request. 178 | */ 179 | serveHandler(handler) { 180 | this.httpHandler_ = handler; 181 | } 182 | 183 | /** 184 | * @param {string=} uri 185 | * @param {...*} params 186 | * @return {!Promise<*>} 187 | */ 188 | async load(uri = '', ...params) { 189 | return this.mainWindow().load(uri, ...params); 190 | } 191 | 192 | /** 193 | * Set the application icon shown in the OS dock / task swicher. 194 | * @param {string|!Buffer} dockIcon 195 | */ 196 | async setIcon(icon) { 197 | const buffer = typeof icon === 'string' ? await fsReadFile(icon) : icon; 198 | this.session_.send('Browser.setDockTile', 199 | { image: buffer.toString('base64') }).catch(e => {}); 200 | } 201 | 202 | /** 203 | * Puppeteer browser object for test. 204 | * @return {!Puppeteer.Browser} 205 | */ 206 | browserForTest() { 207 | return this.browser_; 208 | } 209 | 210 | async targetCreated_(target) { 211 | const page = await target.page(); 212 | if (!page) 213 | return; 214 | this.pageCreated_(page); 215 | } 216 | 217 | /** 218 | * @param {!Puppeteer.Page} page 219 | */ 220 | async pageCreated_(page) { 221 | const url = page.url(); 222 | debugApp('Page created at', url); 223 | const seq = url.startsWith('about:blank?seq=') ? url.substr('about:blank?seq='.length) : ''; 224 | const params = this.pendingWindows_.get(seq); 225 | const { callback, options } = params || { options: this.options_ }; 226 | this.pendingWindows_.delete(seq); 227 | const window = new Window(this, page, options); 228 | await window.init_(); 229 | this.windows_.set(page, window); 230 | if (callback) 231 | callback(window); 232 | this.emit(App.Events.Window, window); 233 | } 234 | 235 | /** 236 | * @param {!Window} 237 | */ 238 | windowClosed_(window) { 239 | debugApp('window closed', window.loadURI_); 240 | this.windows_.delete(window.page_); 241 | if (!this.windows_.size) 242 | this.exit(); 243 | } 244 | } 245 | 246 | App.Events = { 247 | Exit: 'exit', 248 | Window: 'window' 249 | }; 250 | 251 | class Window extends EventEmitter { 252 | /** 253 | * @param {!App} app 254 | * @param {!Puppeteer.Page} page Puppeteer page 255 | * @param {!Object} options 256 | */ 257 | constructor(app, page, options) { 258 | super(); 259 | this.app_ = app; 260 | this.options_ = Object.assign({}, app.options_, options); 261 | this.www_ = []; 262 | this.page_ = page; 263 | this.page_.on('close', this.closed_.bind(this)); 264 | this.page_.on('domcontentloaded', this.domContentLoaded_.bind(this)); 265 | this.hostHandle_ = rpc.handle(new HostWindow(this)); 266 | } 267 | 268 | async init_() { 269 | debugApp('Configuring window'); 270 | const targetId = this.page_.target()._targetInfo.targetId; 271 | const bgcolor = Color.parse(this.options_.bgcolor); 272 | const bgcolorRGBA = bgcolor.canonicalRGBA(); 273 | this.session_ = await this.page_.target().createCDPSession(); 274 | 275 | await Promise.all([ 276 | this.session_.send('Runtime.evaluate', { expression: 'self.paramsForReuse', returnByValue: true }). 277 | then(response => { this.paramsForReuse_ = response.result.value; }), 278 | this.session_.send('Emulation.setDefaultBackgroundColorOverride', 279 | {color: {r: bgcolorRGBA[0], g: bgcolorRGBA[1], 280 | b: bgcolorRGBA[2], a: bgcolorRGBA[3] * 255}}), 281 | this.app_.session_.send('Browser.getWindowForTarget', { targetId }) 282 | .then(this.initBounds_.bind(this)), 283 | this.configureRpcOnce_(), 284 | ...this.app_.exposedFunctions_.map(({name, func}) => this.exposeFunction(name, func)) 285 | ]); 286 | } 287 | 288 | /** 289 | * @param {string} name 290 | * @param {function} func 291 | * @return {!Promise} 292 | */ 293 | exposeFunction(name, func) { 294 | debugApp('Exposing function', name); 295 | return this.page_.exposeFunction(name, func); 296 | } 297 | 298 | /** 299 | * @param {function()|string} pageFunction 300 | * @param {!Array<*>} args 301 | * @return {!Promise<*>} 302 | */ 303 | evaluate(pageFunction, ...args) { 304 | return this.page_.evaluate(pageFunction, ...args); 305 | } 306 | 307 | /** 308 | * @param {string=} www Folder with the web content. 309 | * @param {string=} prefix Only serve folder for requests with given prefix. 310 | */ 311 | serveFolder(folder = '', prefix = '') { 312 | this.www_.push({folder, prefix: wrapPrefix(prefix)}); 313 | } 314 | 315 | /** 316 | * Serves pages from given origin, eg `http://localhost:8080`. 317 | * This can be used for the fast development mode available in web frameworks. 318 | * 319 | * @param {string} base 320 | * @param {string=} prefix Only serve folder for requests with given prefix. 321 | */ 322 | serveOrigin(base, prefix = '') { 323 | this.www_.push({baseURL: new URL(base + '/'), prefix: wrapPrefix(prefix)}); 324 | } 325 | 326 | /** 327 | * Calls given handler for each request and allows called to handle it. 328 | * 329 | * @param {function(!Request)} handler to be used for each request. 330 | */ 331 | serveHandler(handler) { 332 | this.httpHandler_ = handler; 333 | } 334 | 335 | /** 336 | * @param {string=} uri 337 | * @param {...*} params 338 | * @return {!Promise} 339 | */ 340 | async load(uri = '', ...params) { 341 | debugApp('Load page', uri); 342 | this.loadURI_ = uri; 343 | this.loadParams_ = params; 344 | await this.initializeInterception_(); 345 | debugApp('Navigating the page to', this.loadURI_); 346 | 347 | const result = new Promise(f => this.domContentLoadedCallback_ = f); 348 | // Await here to process exceptions. 349 | await this.page_.goto(new URL(this.loadURI_, 'https://domain/').toString(), {timeout: 0, waitFor: 'domcontentloaded'}); 350 | // Available in Chrome M73+. 351 | this.session_.send('Page.resetNavigationHistory').catch(e => {}); 352 | // Make sure domContentLoaded callback is processed before we return. 353 | // That indirection is here to handle debug-related reloads we did not call for. 354 | return result; 355 | } 356 | 357 | initBounds_(result) { 358 | this.windowId_ = result.windowId; 359 | return this.setBounds({ top: this.options_.top, 360 | left: this.options_.left, 361 | width: this.options_.width, 362 | height: this.options_.height }); 363 | } 364 | 365 | /** 366 | * Puppeteer page object for test. 367 | * @return {!Puppeteer.Page} 368 | */ 369 | pageForTest() { 370 | return this.page_; 371 | } 372 | 373 | /** 374 | * Returns value specified in the carlo.launch(options.paramsForReuse). This is handy 375 | * when Carlo is reused across app runs. First Carlo app successfully starts the browser. 376 | * Second carlo attempts to start the browser, but browser profile is already in use. 377 | * Yet, new window is being opened in the first Carlo app. This new window returns 378 | * options.paramsForReuse passed into the second Carlo. This was single app knows what to 379 | * do with the additional windows. 380 | * 381 | * @return {*} 382 | */ 383 | paramsForReuse() { 384 | return this.paramsForReuse_; 385 | } 386 | 387 | async configureRpcOnce_() { 388 | await this.page_.exposeFunction('receivedFromChild', data => this.receivedFromChild_(data)); 389 | 390 | const rpcFile = (await fsReadFile(__dirname + '/../rpc/rpc.js')).toString(); 391 | const features = [ require('./features/shortcuts.js'), 392 | require('./features/file_info.js') ]; 393 | 394 | await this.page_.evaluateOnNewDocument((rpcFile, features) => { 395 | const module = { exports: {} }; 396 | eval(rpcFile); 397 | self.rpc = module.exports; 398 | self.carlo = {}; 399 | let argvCallback; 400 | const argvPromise = new Promise(f => argvCallback = f); 401 | self.carlo.loadParams = () => argvPromise; 402 | 403 | function transport(receivedFromParent) { 404 | self.receivedFromParent = receivedFromParent; 405 | return receivedFromChild; 406 | } 407 | 408 | self.rpc.initWorld(transport, async(loadParams, win) => { 409 | argvCallback(loadParams); 410 | 411 | if (document.readyState === 'loading') 412 | await new Promise(f => document.addEventListener('DOMContentLoaded', f)); 413 | 414 | for (const feature of features) 415 | eval(`(${feature})`)(win); 416 | }); 417 | }, rpcFile, features.map(f => f.toString())); 418 | } 419 | 420 | async domContentLoaded_() { 421 | debugApp('Creating rpc world for page...'); 422 | const transport = receivedFromChild => { 423 | this.receivedFromChild_ = receivedFromChild; 424 | return data => { 425 | const json = JSON.stringify(data); 426 | if (this.session_._connection) 427 | this.session_.send('Runtime.evaluate', {expression: `self.receivedFromParent(${json})`}); 428 | }; 429 | }; 430 | if (this._lastWebWorldId) 431 | rpc.disposeWorld(this._lastWebWorldId); 432 | const { worldId } = await rpc.createWorld(transport, this.loadParams_, this.hostHandle_); 433 | debugApp('World created', worldId); 434 | this._lastWebWorldId = worldId; 435 | 436 | this.domContentLoadedCallback_(); 437 | } 438 | 439 | async initializeInterception_() { 440 | debugApp('Initializing network interception...'); 441 | if (this.interceptionInitialized_) 442 | return; 443 | if (this.www_.length + this.app_.www_.length === 0 && !this.httpHandler_ && !this.app_.httpHandler_) 444 | return; 445 | this.interceptionInitialized_ = true; 446 | this.session_.on('Network.requestIntercepted', this.requestIntercepted_.bind(this)); 447 | return this.session_.send('Network.setRequestInterception', {patterns: [{urlPattern: '*'}]}); 448 | } 449 | 450 | /** 451 | * @param {!Object} request Intercepted request. 452 | */ 453 | async requestIntercepted_(payload) { 454 | debugServer('intercepted:', payload.request.url); 455 | const handlers = []; 456 | if (this.httpHandler_) 457 | handlers.push(this.httpHandler_); 458 | if (this.app_.httpHandler_) 459 | handlers.push(this.app_.httpHandler_); 460 | handlers.push(this.handleRequest_.bind(this)); 461 | new HttpRequest(this.session_, payload, handlers); 462 | } 463 | 464 | /** 465 | * @param {!HttpRequest} request Intercepted request. 466 | */ 467 | async handleRequest_(request) { 468 | const url = new URL(request.url()); 469 | debugServer('request url:', url.toString()); 470 | 471 | if (url.hostname !== 'domain') { 472 | request.deferToBrowser(); 473 | return; 474 | } 475 | 476 | const urlpathname = url.pathname; 477 | for (const {prefix, folder, baseURL} of this.app_.www_.concat(this.www_)) { 478 | debugServer('prefix:', prefix); 479 | if (!urlpathname.startsWith(prefix)) 480 | continue; 481 | 482 | const pathname = urlpathname.substr(prefix.length); 483 | debugServer('pathname:', pathname); 484 | if (baseURL) { 485 | request.deferToBrowser({ url: String(new URL(pathname, baseURL)) }); 486 | return; 487 | } 488 | const fileName = path.join(folder, pathname); 489 | if (!fs.existsSync(fileName)) 490 | continue; 491 | 492 | const headers = { 'content-type': contentType(request, fileName) }; 493 | const body = await fsReadFile(fileName); 494 | request.fulfill({ headers, body}); 495 | return; 496 | } 497 | request.deferToBrowser(); 498 | } 499 | 500 | /** 501 | * @return {{left: number, top: number, width: number, height: number}} 502 | */ 503 | async bounds() { 504 | const { bounds } = await this.app_.session_.send('Browser.getWindowBounds', { windowId: this.windowId_ }); 505 | return { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height }; 506 | } 507 | 508 | /** 509 | * @param {{left: (number|undefined), top: (number|undefined), width: (number|undefined), height: (number|undefined)}} bounds 510 | */ 511 | async setBounds(bounds) { 512 | await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds }); 513 | } 514 | 515 | async fullscreen() { 516 | const bounds = { windowState: 'fullscreen' }; 517 | await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds }); 518 | } 519 | 520 | async minimize() { 521 | const bounds = { windowState: 'minimized' }; 522 | await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds }); 523 | } 524 | 525 | async maximize() { 526 | const bounds = { windowState: 'maximized' }; 527 | await this.app_.session_.send('Browser.setWindowBounds', { windowId: this.windowId_, bounds }); 528 | } 529 | 530 | bringToFront() { 531 | return this.page_.bringToFront(); 532 | } 533 | 534 | close() { 535 | return this.page_.close(); 536 | } 537 | 538 | closed_() { 539 | rpc.dispose(this.hostHandle_); 540 | this.app_.windowClosed_(this); 541 | this.emit(Window.Events.Close); 542 | } 543 | 544 | /** 545 | * @return {boolean} 546 | */ 547 | isClosed() { 548 | return this.page_.isClosed(); 549 | } 550 | } 551 | 552 | Window.Events = { 553 | Close: 'close', 554 | }; 555 | 556 | const imageContentTypes = new Map([ 557 | ['jpeg', 'image/jpeg'], ['jpg', 'image/jpeg'], ['svg', 'image/svg+xml'], ['gif', 'image/gif'], ['webp', 'image/webp'], 558 | ['png', 'image/png'], ['ico', 'image/ico'], ['tiff', 'image/tiff'], ['tif', 'image/tiff'], ['bmp', 'image/bmp'] 559 | ]); 560 | 561 | const fontContentTypes = new Map([ 562 | ['ttf', 'font/opentype'], ['otf', 'font/opentype'], ['ttc', 'font/opentype'], ['woff', 'application/font-woff'] 563 | ]); 564 | 565 | /** 566 | * @param {!HttpRequest} request 567 | * @param {!string} fileName 568 | */ 569 | function contentType(request, fileName) { 570 | const dotIndex = fileName.lastIndexOf('.'); 571 | const extension = fileName.substr(dotIndex + 1); 572 | switch (request.resourceType()) { 573 | case 'Document': return 'text/html'; 574 | case 'Script': return 'text/javascript'; 575 | case 'Stylesheet': return 'text/css'; 576 | case 'Image': 577 | return imageContentTypes.get(extension) || 'image/png'; 578 | case 'Font': 579 | return fontContentTypes.get(extension) || 'application/font-woff'; 580 | } 581 | } 582 | 583 | /** 584 | * @param {!Object=} options 585 | * @return {!App} 586 | */ 587 | async function launch(options = {}) { 588 | debugApp('Launching Carlo', options); 589 | options = Object.assign(options); 590 | if (!options.bgcolor) 591 | options.bgcolor = '#ffffff'; 592 | options.localDataDir = options.localDataDir || path.join(__dirname, '.local-data'); 593 | 594 | const { executablePath, type } = await findChrome(options); 595 | if (!executablePath) { 596 | console.error('Could not find Chrome installation, please make sure Chrome browser is installed from https://www.google.com/chrome/.'); 597 | process.exit(0); 598 | return; 599 | } 600 | 601 | const targetPage = ` 602 | ${encodeURIComponent(options.title || '')} 603 | 604 | `; 605 | 606 | const args = [ 607 | `--app=data:text/html,${targetPage}`, 608 | `--enable-features=NetworkService,NetworkServiceInProcess`, 609 | ]; 610 | 611 | if (options.args) 612 | args.push(...options.args); 613 | if (typeof options.width === 'number' && typeof options.height === 'number') 614 | args.push(`--window-size=${options.width},${options.height}`); 615 | if (typeof options.left === 'number' && typeof options.top === 'number') 616 | args.push(`--window-position=${options.left},${options.top}`); 617 | 618 | try { 619 | const browser = await puppeteer.launch({ 620 | executablePath, 621 | pipe: true, 622 | defaultViewport: null, 623 | headless: testMode, 624 | userDataDir: options.userDataDir || path.join(options.localDataDir, `profile-${type}`), 625 | args }); 626 | const app = new App(browser, options); 627 | await app.init_(); 628 | return app; 629 | } catch (e) { 630 | if (e.toString().includes('Target closed')) 631 | throw new Error('Could not start the browser or the browser was already running with the given profile.'); 632 | else 633 | throw e; 634 | } 635 | } 636 | 637 | class HostWindow { 638 | /** 639 | * @param {!Window} win 640 | */ 641 | constructor(win) { 642 | this.window_ = win; 643 | } 644 | 645 | closeBrowser() { 646 | // Allow rpc response to land. 647 | setTimeout(() => this.window_.app_.exit(), 0); 648 | } 649 | 650 | async fileInfo(expression) { 651 | const { result } = await this.window_.session_.send('Runtime.evaluate', { expression }); 652 | return this.window_.session_.send('DOM.getFileInfo', { objectId: result.objectId }); 653 | } 654 | } 655 | 656 | function enterTestMode() { 657 | testMode = true; 658 | } 659 | 660 | function wrapPrefix(prefix) { 661 | if (!prefix.startsWith('/')) prefix = '/' + prefix; 662 | if (!prefix.endsWith('/')) prefix += '/'; 663 | return prefix; 664 | } 665 | 666 | module.exports = { launch, enterTestMode }; 667 | -------------------------------------------------------------------------------- /lib/color.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | class Color { 20 | /** 21 | * @param {!Array.} rgba 22 | * @param {!Color.Format} format 23 | * @param {string=} originalText 24 | */ 25 | constructor(rgba, format, originalText) { 26 | this._rgba = rgba; 27 | this._originalText = originalText || null; 28 | this._originalTextIsValid = !!this._originalText; 29 | this._format = format; 30 | if (typeof this._rgba[3] === 'undefined') 31 | this._rgba[3] = 1; 32 | 33 | for (let i = 0; i < 4; ++i) { 34 | if (this._rgba[i] < 0) { 35 | this._rgba[i] = 0; 36 | this._originalTextIsValid = false; 37 | } 38 | if (this._rgba[i] > 1) { 39 | this._rgba[i] = 1; 40 | this._originalTextIsValid = false; 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * @param {string} text 47 | * @return {?Color} 48 | */ 49 | static parse(text) { 50 | const value = text.toLowerCase().replace(/\s+/g, ''); 51 | const simple = /^(?:#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))$/i; 52 | let match = value.match(simple); 53 | if (match) { 54 | if (match[1]) { // hex 55 | let hex = match[1].toLowerCase(); 56 | let format; 57 | if (hex.length === 3) { 58 | format = Color.Format.ShortHEX; 59 | hex = hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2); 60 | } else if (hex.length === 4) { 61 | format = Color.Format.ShortHEXA; 62 | hex = hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2) + 63 | hex.charAt(3) + hex.charAt(3); 64 | } else if (hex.length === 6) { 65 | format = Color.Format.HEX; 66 | } else { 67 | format = Color.Format.HEXA; 68 | } 69 | const r = parseInt(hex.substring(0, 2), 16); 70 | const g = parseInt(hex.substring(2, 4), 16); 71 | const b = parseInt(hex.substring(4, 6), 16); 72 | let a = 1; 73 | if (hex.length === 8) 74 | a = parseInt(hex.substring(6, 8), 16) / 255; 75 | return new Color([r / 255, g / 255, b / 255, a], format, text); 76 | } 77 | 78 | return null; 79 | } 80 | 81 | // rgb/rgba(), hsl/hsla() 82 | match = text.toLowerCase().match(/^\s*(?:(rgba?)|(hsla?))\((.*)\)\s*$/); 83 | 84 | if (match) { 85 | const components = match[3].trim(); 86 | let values = components.split(/\s*,\s*/); 87 | if (values.length === 1) { 88 | values = components.split(/\s+/); 89 | if (values[3] === '/') { 90 | values.splice(3, 1); 91 | if (values.length !== 4) 92 | return null; 93 | } else if ((values.length > 2 && values[2].indexOf('/') !== -1) || (values.length > 3 && values[3].indexOf('/') !== -1)) { 94 | const alpha = values.slice(2, 4).join(''); 95 | values = values.slice(0, 2).concat(alpha.split(/\//)).concat(values.slice(4)); 96 | } else if (values.length >= 4) { 97 | return null; 98 | } 99 | } 100 | if (values.length !== 3 && values.length !== 4 || values.indexOf('') > -1) 101 | return null; 102 | const hasAlpha = (values[3] !== undefined); 103 | 104 | if (match[1]) { // rgb/rgba 105 | const rgba = [ 106 | Color._parseRgbNumeric(values[0]), Color._parseRgbNumeric(values[1]), 107 | Color._parseRgbNumeric(values[2]), hasAlpha ? Color._parseAlphaNumeric(values[3]) : 1 108 | ]; 109 | if (rgba.indexOf(null) > -1) 110 | return null; 111 | return new Color(rgba, hasAlpha ? Color.Format.RGBA : Color.Format.RGB, text); 112 | } 113 | 114 | if (match[2]) { // hsl/hsla 115 | const hsla = [ 116 | Color._parseHueNumeric(values[0]), Color._parseSatLightNumeric(values[1]), 117 | Color._parseSatLightNumeric(values[2]), hasAlpha ? Color._parseAlphaNumeric(values[3]) : 1 118 | ]; 119 | if (hsla.indexOf(null) > -1) 120 | return null; 121 | const rgba = []; 122 | Color.hsl2rgb(hsla, rgba); 123 | return new Color(rgba, hasAlpha ? Color.Format.HSLA : Color.Format.HSL, text); 124 | } 125 | } 126 | 127 | return null; 128 | } 129 | 130 | /** 131 | * @param {string} value 132 | * return {number} 133 | */ 134 | static _parsePercentOrNumber(value) { 135 | if (isNaN(value.replace('%', ''))) 136 | return null; 137 | const parsed = parseFloat(value); 138 | 139 | if (value.indexOf('%') !== -1) { 140 | if (value.indexOf('%') !== value.length - 1) 141 | return null; 142 | return parsed / 100; 143 | } 144 | return parsed; 145 | } 146 | 147 | /** 148 | * @param {string} value 149 | * return {number} 150 | */ 151 | static _parseRgbNumeric(value) { 152 | const parsed = Color._parsePercentOrNumber(value); 153 | if (parsed === null) 154 | return null; 155 | 156 | if (value.indexOf('%') !== -1) 157 | return parsed; 158 | return parsed / 255; 159 | } 160 | 161 | /** 162 | * @param {string} value 163 | * return {number} 164 | */ 165 | static _parseHueNumeric(value) { 166 | const angle = value.replace(/(deg|g?rad|turn)$/, ''); 167 | if (isNaN(angle) || value.match(/\s+(deg|g?rad|turn)/)) 168 | return null; 169 | const number = parseFloat(angle); 170 | 171 | if (value.indexOf('turn') !== -1) 172 | return number % 1; 173 | else if (value.indexOf('grad') !== -1) 174 | return (number / 400) % 1; 175 | else if (value.indexOf('rad') !== -1) 176 | return (number / (2 * Math.PI)) % 1; 177 | return (number / 360) % 1; 178 | } 179 | 180 | /** 181 | * @param {string} value 182 | * return {number} 183 | */ 184 | static _parseSatLightNumeric(value) { 185 | if (value.indexOf('%') !== value.length - 1 || isNaN(value.replace('%', ''))) 186 | return null; 187 | const parsed = parseFloat(value); 188 | return Math.min(1, parsed / 100); 189 | } 190 | 191 | /** 192 | * @param {string} value 193 | * return {number} 194 | */ 195 | static _parseAlphaNumeric(value) { 196 | return Color._parsePercentOrNumber(value); 197 | } 198 | 199 | /** 200 | * @param {!Array.} hsl 201 | * @param {!Array.} out_rgb 202 | */ 203 | static hsl2rgb(hsl, out_rgb) { 204 | const h = hsl[0]; 205 | let s = hsl[1]; 206 | const l = hsl[2]; 207 | 208 | function hue2rgb(p, q, h) { 209 | if (h < 0) 210 | h += 1; 211 | else if (h > 1) 212 | h -= 1; 213 | 214 | if ((h * 6) < 1) 215 | return p + (q - p) * h * 6; 216 | else if ((h * 2) < 1) 217 | return q; 218 | else if ((h * 3) < 2) 219 | return p + (q - p) * ((2 / 3) - h) * 6; 220 | else 221 | return p; 222 | } 223 | 224 | if (s < 0) 225 | s = 0; 226 | 227 | let q; 228 | if (l <= 0.5) 229 | q = l * (1 + s); 230 | else 231 | q = l + s - (l * s); 232 | 233 | const p = 2 * l - q; 234 | 235 | const tr = h + (1 / 3); 236 | const tg = h; 237 | const tb = h - (1 / 3); 238 | 239 | out_rgb[0] = hue2rgb(p, q, tr); 240 | out_rgb[1] = hue2rgb(p, q, tg); 241 | out_rgb[2] = hue2rgb(p, q, tb); 242 | out_rgb[3] = hsl[3]; 243 | } 244 | 245 | /** 246 | * @return {!Color.Format} 247 | */ 248 | format() { 249 | return this._format; 250 | } 251 | 252 | /** 253 | * @return {!Array.} HSLA with components within [0..1] 254 | */ 255 | hsla() { 256 | if (this._hsla) 257 | return this._hsla; 258 | const r = this._rgba[0]; 259 | const g = this._rgba[1]; 260 | const b = this._rgba[2]; 261 | const max = Math.max(r, g, b); 262 | const min = Math.min(r, g, b); 263 | const diff = max - min; 264 | const add = max + min; 265 | 266 | let h; 267 | if (min === max) 268 | h = 0; 269 | else if (r === max) 270 | h = ((1 / 6 * (g - b) / diff) + 1) % 1; 271 | else if (g === max) 272 | h = (1 / 6 * (b - r) / diff) + 1 / 3; 273 | else 274 | h = (1 / 6 * (r - g) / diff) + 2 / 3; 275 | 276 | const l = 0.5 * add; 277 | 278 | let s; 279 | if (l === 0) 280 | s = 0; 281 | else if (l === 1) 282 | s = 0; 283 | else if (l <= 0.5) 284 | s = diff / add; 285 | else 286 | s = diff / (2 - add); 287 | 288 | this._hsla = [h, s, l, this._rgba[3]]; 289 | return this._hsla; 290 | } 291 | 292 | /** 293 | * @return {boolean} 294 | */ 295 | hasAlpha() { 296 | return this._rgba[3] !== 1; 297 | } 298 | 299 | /** 300 | * @return {!Color.Format} 301 | */ 302 | detectHEXFormat() { 303 | let canBeShort = true; 304 | for (let i = 0; i < 4; ++i) { 305 | const c = Math.round(this._rgba[i] * 255); 306 | if (c % 17) { 307 | canBeShort = false; 308 | break; 309 | } 310 | } 311 | 312 | const hasAlpha = this.hasAlpha(); 313 | const cf = Color.Format; 314 | if (canBeShort) 315 | return hasAlpha ? cf.ShortHEXA : cf.ShortHEX; 316 | return hasAlpha ? cf.HEXA : cf.HEX; 317 | } 318 | 319 | /** 320 | * @return {?string} 321 | */ 322 | asString(format) { 323 | if (format === this._format && this._originalTextIsValid) 324 | return this._originalText; 325 | 326 | if (!format) 327 | format = this._format; 328 | 329 | /** 330 | * @param {number} value 331 | * @return {number} 332 | */ 333 | function toRgbValue(value) { 334 | return Math.round(value * 255); 335 | } 336 | 337 | /** 338 | * @param {number} value 339 | * @return {string} 340 | */ 341 | function toHexValue(value) { 342 | const hex = Math.round(value * 255).toString(16); 343 | return hex.length === 1 ? '0' + hex : hex; 344 | } 345 | 346 | /** 347 | * @param {number} value 348 | * @return {string} 349 | */ 350 | function toShortHexValue(value) { 351 | return (Math.round(value * 255) / 17).toString(16); 352 | } 353 | 354 | switch (format) { 355 | case Color.Format.Original: 356 | return this._originalText; 357 | case Color.Format.RGB: 358 | if (this.hasAlpha()) 359 | return null; 360 | return `rgb(${toRgbValue(this._rgba[0])}, ${toRgbValue(this._rgba[1])}, ${toRgbValue(this._rgba[2])})`; 361 | case Color.Format.RGBA: 362 | return `rgba(${toRgbValue(this._rgba[0])}, ${toRgbValue(this._rgba[1])}, ${toRgbValue(this._rgba[2])}, ${this._rgba[3]})`; 363 | case Color.Format.HSL: 364 | if (this.hasAlpha()) 365 | return null; 366 | const hsl = this.hsla(); 367 | return `hsl(${Math.round(hsl[0] * 360)}, ${Math.round(hsl[1] * 100)}%, ${Math.round(hsl[2] * 100)}%)`; 368 | case Color.Format.HSLA: 369 | const hsla = this.hsla(); 370 | return `hsla(${Math.round(hsla[0] * 360)}, ${Math.round(hsla[1] * 100)}%, ${Math.round(hsla[2] * 100)}%, ${hsla[3]})`; 371 | case Color.Format.HEXA: 372 | return `#${toHexValue(this._rgba[0])}${toHexValue(this._rgba[1])}${toHexValue(this._rgba[2])}${toHexValue(this._rgba[3])}`.toLowerCase(); 373 | case Color.Format.HEX: 374 | if (this.hasAlpha()) 375 | return null; 376 | return `#${toHexValue(this._rgba[0])}${toHexValue(this._rgba[1])}${toHexValue(this._rgba[2])}`.toLowerCase(); 377 | case Color.Format.ShortHEXA: 378 | const hexFormat = this.detectHEXFormat(); 379 | if (hexFormat !== Color.Format.ShortHEXA && hexFormat !== Color.Format.ShortHEX) 380 | return null; 381 | return `#${toShortHexValue(this._rgba[0])}${toShortHexValue(this._rgba[1])}${toShortHexValue(this._rgba[2])}${toShortHexValue(this._rgba[3])}`.toLowerCase(); 382 | case Color.Format.ShortHEX: 383 | if (this.hasAlpha()) 384 | return null; 385 | if (this.detectHEXFormat() !== Color.Format.ShortHEX) 386 | return null; 387 | return `#${toShortHexValue(this._rgba[0])}${toShortHexValue(this._rgba[1])}${toShortHexValue(this._rgba[2])}`.toLowerCase(); 388 | } 389 | 390 | return this._originalText; 391 | } 392 | 393 | /** 394 | * @return {!Array} 395 | */ 396 | rgba() { 397 | return this._rgba.slice(); 398 | } 399 | 400 | /** 401 | * @return {!Array.} 402 | */ 403 | canonicalRGBA() { 404 | const rgba = new Array(4); 405 | for (let i = 0; i < 3; ++i) 406 | rgba[i] = Math.round(this._rgba[i] * 255); 407 | rgba[3] = this._rgba[3]; 408 | return rgba; 409 | } 410 | } 411 | 412 | /** @type {!RegExp} */ 413 | Color.Regex = /((?:rgb|hsl)a?\([^)]+\)|#[0-9a-fA-F]{8}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3,4}|\b[a-zA-Z]+\b(?!-))/g; 414 | 415 | /** 416 | * @enum {string} 417 | */ 418 | Color.Format = { 419 | Original: 'original', 420 | HEX: 'hex', 421 | ShortHEX: 'shorthex', 422 | HEXA: 'hexa', 423 | ShortHEXA: 'shorthexa', 424 | RGB: 'rgb', 425 | RGBA: 'rgba', 426 | HSL: 'hsl', 427 | HSLA: 'hsla' 428 | }; 429 | 430 | module.exports = { Color }; 431 | -------------------------------------------------------------------------------- /lib/features/file_info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports = function install(hostWindow) { 18 | let lastFileId = 0; 19 | self.carlo.fileInfo = async(file) => { 20 | const fileId = ++lastFileId; 21 | self.carlo.fileInfo.files_.set(fileId, file); 22 | const result = await hostWindow.fileInfo(`self.carlo.fileInfo.files_.get(${fileId})`); 23 | self.carlo.fileInfo.files_.delete(fileId); 24 | return result; 25 | }; 26 | 27 | self.carlo.fileInfo.files_ = new Map(); 28 | }; 29 | -------------------------------------------------------------------------------- /lib/features/shortcuts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports = function install(hostWindow) { 18 | const ctrlOrCmdCodes = new Set( 19 | ['KeyD', 'KeyE', 'KeyD', 'KeyG', 'KeyN', 'KeyO', 'KeyP', 'KeyQ', 'KeyR', 'KeyS', 20 | 'KeyT', 'KeyW', 'KeyY', 'Tab', 'PageUp', 'PageDown', 'F4']); 21 | const cmdCodes = new Set(['BracketLeft', 'BracketRight', 'Comma']); 22 | const cmdOptionCodes = new Set(['ArrowLeft', 'ArrowRight', 'KeyB']); 23 | const ctrlShiftCodes = new Set(['KeyQ', 'KeyW']); 24 | const altCodes = new Set(['Home', 'ArrowLeft', 'ArrowRight', 'F4']); 25 | 26 | function preventDefaultShortcuts(event) { 27 | let prevent = false; 28 | if (navigator.userAgent.match(/Mac OS X/)) { 29 | if (event.metaKey) { 30 | if (event.keyCode > 48 && event.keyCode <= 57) // 1-9 31 | prevent = true; 32 | if (ctrlOrCmdCodes.has(event.code) || cmdCodes.has(event.code)) 33 | prevent = true; 34 | if (event.shiftKey && cmdOptionCodes.has(event.code)) 35 | prevent = true; 36 | if (event.code === 'ArrowLeft' || event.code === 'ArrowRight') { 37 | if (!event.contentEditable && event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA') 38 | prevent = true; 39 | } 40 | } 41 | } else { 42 | if (event.code === 'F4') 43 | prevent = true; 44 | if (event.ctrlKey) { 45 | if (event.keyCode > 48 && event.keyCode <= 57) // 1-9 46 | prevent = true; 47 | if (ctrlOrCmdCodes.has(event.code)) 48 | prevent = true; 49 | if (event.shiftKey && ctrlShiftCodes.has(event.code)) 50 | prevent = true; 51 | } 52 | if (event.altKey && altCodes.has(event.code)) 53 | prevent = true; 54 | } 55 | 56 | if (prevent) 57 | event.preventDefault(); 58 | } 59 | 60 | document.addEventListener('keydown', preventDefaultShortcuts, false); 61 | document.addEventListener('keydown', event => { 62 | if ((event.key === 'q' || event.key === 'Q') && (event.metaKey || event.ctrlKey)) { 63 | hostWindow.closeBrowser(); 64 | event.preventDefault(); 65 | } 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /lib/find_chrome.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | const fs = require('fs'); 20 | const path = require('path'); 21 | const execSync = require('child_process').execSync; 22 | const execFileSync = require('child_process').execFileSync; 23 | const puppeteer = require('puppeteer-core'); 24 | 25 | const newLineRegex = /\r?\n/; 26 | 27 | function darwin(canary) { 28 | const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework' + 29 | '/Versions/A/Frameworks/LaunchServices.framework' + 30 | '/Versions/A/Support/lsregister'; 31 | const grepexpr = canary ? 'google chrome canary' : 'google chrome'; 32 | const result = 33 | execSync(`${LSREGISTER} -dump | grep -i \'${grepexpr}\\?.app$\' | awk \'{$1=""; print $0}\'`); 34 | 35 | const installations = new Set(); 36 | const paths = result.toString().split(newLineRegex).filter(a => a).map(a => a.trim()); 37 | paths.unshift(canary ? '/Applications/Google Chrome Canary.app' : '/Applications/Google Chrome.app'); 38 | for (const p of paths) { 39 | if (p.startsWith('/Volumes')) 40 | continue; 41 | const inst = path.join(p, canary ? '/Contents/MacOS/Google Chrome Canary' : '/Contents/MacOS/Google Chrome'); 42 | if (canAccess(inst)) 43 | return inst; 44 | } 45 | } 46 | 47 | /** 48 | * Look for linux executables in 3 ways 49 | * 1. Look into CHROME_PATH env variable 50 | * 2. Look into the directories where .desktop are saved on gnome based distro's 51 | * 3. Look for google-chrome-stable & google-chrome executables by using the which command 52 | */ 53 | function linux(canary) { 54 | let installations = []; 55 | 56 | // Look into the directories where .desktop are saved on gnome based distro's 57 | const desktopInstallationFolders = [ 58 | path.join(require('os').homedir(), '.local/share/applications/'), 59 | '/usr/share/applications/', 60 | ]; 61 | desktopInstallationFolders.forEach(folder => { 62 | installations = installations.concat(findChromeExecutables(folder)); 63 | }); 64 | 65 | // Look for google-chrome(-stable) & chromium(-browser) executables by using the which command 66 | const executables = [ 67 | 'google-chrome-stable', 68 | 'google-chrome', 69 | 'chromium-browser', 70 | 'chromium', 71 | ]; 72 | executables.forEach(executable => { 73 | try { 74 | const chromePath = 75 | execFileSync('which', [executable], {stdio: 'pipe'}).toString().split(newLineRegex)[0]; 76 | if (canAccess(chromePath)) 77 | installations.push(chromePath); 78 | } catch (e) { 79 | // Not installed. 80 | } 81 | }); 82 | 83 | if (!installations.length) 84 | throw new Error('The environment variable CHROME_PATH must be set to executable of a build of Chromium version 54.0 or later.'); 85 | 86 | const priorities = [ 87 | {regex: /chrome-wrapper$/, weight: 51}, 88 | {regex: /google-chrome-stable$/, weight: 50}, 89 | {regex: /google-chrome$/, weight: 49}, 90 | {regex: /chromium-browser$/, weight: 48}, 91 | {regex: /chromium$/, weight: 47}, 92 | ]; 93 | 94 | if (process.env.CHROME_PATH) 95 | priorities.unshift({regex: new RegExp(`${process.env.CHROME_PATH}`), weight: 101}); 96 | 97 | return sort(uniq(installations.filter(Boolean)), priorities)[0]; 98 | } 99 | 100 | function win32(canary) { 101 | const suffix = canary ? 102 | `${path.sep}Google${path.sep}Chrome SxS${path.sep}Application${path.sep}chrome.exe` : 103 | `${path.sep}Google${path.sep}Chrome${path.sep}Application${path.sep}chrome.exe`; 104 | const prefixes = [ 105 | process.env.LOCALAPPDATA, process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'] 106 | ].filter(Boolean); 107 | 108 | let result; 109 | prefixes.forEach(prefix => { 110 | const chromePath = path.join(prefix, suffix); 111 | if (canAccess(chromePath)) 112 | result = chromePath; 113 | }); 114 | return result; 115 | } 116 | 117 | function sort(installations, priorities) { 118 | const defaultPriority = 10; 119 | return installations 120 | // assign priorities 121 | .map(inst => { 122 | for (const pair of priorities) { 123 | if (pair.regex.test(inst)) 124 | return {path: inst, weight: pair.weight}; 125 | } 126 | return {path: inst, weight: defaultPriority}; 127 | }) 128 | // sort based on priorities 129 | .sort((a, b) => (b.weight - a.weight)) 130 | // remove priority flag 131 | .map(pair => pair.path); 132 | } 133 | 134 | function canAccess(file) { 135 | if (!file) 136 | return false; 137 | 138 | try { 139 | fs.accessSync(file); 140 | return true; 141 | } catch (e) { 142 | return false; 143 | } 144 | } 145 | 146 | function uniq(arr) { 147 | return Array.from(new Set(arr)); 148 | } 149 | 150 | function findChromeExecutables(folder) { 151 | const argumentsRegex = /(^[^ ]+).*/; // Take everything up to the first space 152 | const chromeExecRegex = '^Exec=\/.*\/(google-chrome|chrome|chromium)-.*'; 153 | 154 | const installations = []; 155 | if (canAccess(folder)) { 156 | // Output of the grep & print looks like: 157 | // /opt/google/chrome/google-chrome --profile-directory 158 | // /home/user/Downloads/chrome-linux/chrome-wrapper %U 159 | let execPaths; 160 | 161 | // Some systems do not support grep -R so fallback to -r. 162 | // See https://github.com/GoogleChrome/chrome-launcher/issues/46 for more context. 163 | try { 164 | execPaths = execSync(`grep -ER "${chromeExecRegex}" ${folder} | awk -F '=' '{print $2}'`); 165 | } catch (e) { 166 | execPaths = execSync(`grep -Er "${chromeExecRegex}" ${folder} | awk -F '=' '{print $2}'`); 167 | } 168 | 169 | execPaths = execPaths.toString() 170 | .split(newLineRegex) 171 | .map(execPath => execPath.replace(argumentsRegex, '$1')); 172 | 173 | execPaths.forEach(execPath => canAccess(execPath) && installations.push(execPath)); 174 | } 175 | 176 | return installations; 177 | } 178 | 179 | /** 180 | * @return {!Promise} 181 | */ 182 | async function downloadChromium(options, targetRevision) { 183 | const browserFetcher = puppeteer.createBrowserFetcher({ path: options.localDataDir }); 184 | const revision = targetRevision || require('puppeteer-core/package.json').puppeteer.chromium_revision; 185 | const revisionInfo = browserFetcher.revisionInfo(revision); 186 | 187 | // Do nothing if the revision is already downloaded. 188 | if (revisionInfo.local) 189 | return revisionInfo; 190 | 191 | // Override current environment proxy settings with npm configuration, if any. 192 | try { 193 | console.log(`Downloading Chromium r${revision}...`); 194 | const newRevisionInfo = await browserFetcher.download(revisionInfo.revision); 195 | console.log('Chromium downloaded to ' + newRevisionInfo.folderPath); 196 | let localRevisions = await browserFetcher.localRevisions(); 197 | localRevisions = localRevisions.filter(revision => revision !== revisionInfo.revision); 198 | // Remove previous chromium revisions. 199 | const cleanupOldVersions = localRevisions.map(revision => browserFetcher.remove(revision)); 200 | await Promise.all(cleanupOldVersions); 201 | return newRevisionInfo; 202 | } catch (error) { 203 | console.error(`ERROR: Failed to download Chromium r${revision}!`); 204 | console.error(error); 205 | return null; 206 | } 207 | } 208 | 209 | async function findChrome(options) { 210 | if (options.executablePath) 211 | return { executablePath: options.executablePath, type: 'user' }; 212 | 213 | const config = new Set(options.channel || ['stable']); 214 | let executablePath; 215 | // Always prefer canary. 216 | if (config.has('canary') || config.has('*')) { 217 | if (process.platform === 'linux') 218 | executablePath = linux(true); 219 | else if (process.platform === 'win32') 220 | executablePath = win32(true); 221 | else if (process.platform === 'darwin') 222 | executablePath = darwin(true); 223 | if (executablePath) 224 | return { executablePath, type: 'canary' }; 225 | } 226 | 227 | // Then pick stable. 228 | if (config.has('stable') || config.has('*')) { 229 | if (process.platform === 'linux') 230 | executablePath = linux(); 231 | else if (process.platform === 'win32') 232 | executablePath = win32(); 233 | else if (process.platform === 'darwin') 234 | executablePath = darwin(); 235 | if (executablePath) 236 | return { executablePath, type: 'stable' }; 237 | } 238 | 239 | // always prefer puppeteer revision of chromium 240 | if (config.has('chromium') || config.has('*')) { 241 | const revisionInfo = await downloadChromium(options); 242 | return { executablePath: revisionInfo.executablePath, type: revisionInfo.revision }; 243 | } 244 | 245 | for (const item of config) { 246 | if (!item.startsWith('r')) 247 | continue; 248 | const revisionInfo = await downloadChromium(options, item.substring(1)); 249 | return { executablePath: revisionInfo.executablePath, type: revisionInfo.revision }; 250 | } 251 | 252 | return {}; 253 | } 254 | 255 | module.exports = findChrome; 256 | -------------------------------------------------------------------------------- /lib/http_request.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | const debugServer = require('debug')('carlo:server'); 20 | 21 | const statusTexts = { 22 | '100': 'Continue', 23 | '101': 'Switching Protocols', 24 | '102': 'Processing', 25 | '200': 'OK', 26 | '201': 'Created', 27 | '202': 'Accepted', 28 | '203': 'Non-Authoritative Information', 29 | '204': 'No Content', 30 | '206': 'Partial Content', 31 | '207': 'Multi-Status', 32 | '208': 'Already Reported', 33 | '209': 'IM Used', 34 | '300': 'Multiple Choices', 35 | '301': 'Moved Permanently', 36 | '302': 'Found', 37 | '303': 'See Other', 38 | '304': 'Not Modified', 39 | '305': 'Use Proxy', 40 | '306': 'Switch Proxy', 41 | '307': 'Temporary Redirect', 42 | '308': 'Permanent Redirect', 43 | '400': 'Bad Request', 44 | '401': 'Unauthorized', 45 | '402': 'Payment Required', 46 | '403': 'Forbidden', 47 | '404': 'Not Found', 48 | '405': 'Method Not Allowed', 49 | '406': 'Not Acceptable', 50 | '407': 'Proxy Authentication Required', 51 | '408': 'Request Timeout', 52 | '409': 'Conflict', 53 | '410': 'Gone', 54 | '411': 'Length Required', 55 | '412': 'Precondition Failed', 56 | '413': 'Payload Too Large', 57 | '414': 'URI Too Long', 58 | '415': 'Unsupported Media Type', 59 | '416': 'Range Not Satisfiable', 60 | '417': 'Expectation Failed', 61 | '418': 'I\'m a teapot', 62 | '421': 'Misdirected Request', 63 | '422': 'Unprocessable Entity', 64 | '423': 'Locked', 65 | '424': 'Failed Dependency', 66 | '426': 'Upgrade Required', 67 | '428': 'Precondition Required', 68 | '429': 'Too Many Requests', 69 | '431': 'Request Header Fields Too Large', 70 | '451': 'Unavailable For Legal Reasons', 71 | '500': 'Internal Server Error', 72 | '501': 'Not Implemented', 73 | '502': 'Bad Gateway', 74 | '503': 'Service Unavailable', 75 | '504': 'Gateway Timeout', 76 | '505': 'HTTP Version Not Supported', 77 | '506': 'Variant Also Negotiates', 78 | '507': 'Insufficient Storage', 79 | '508': 'Loop Detected', 80 | '510': 'Not Extended', 81 | '511': 'Network Authentication Required', 82 | }; 83 | 84 | /** 85 | * Intercepted request instance that can be resolved to the client's liking. 86 | */ 87 | class HttpRequest { 88 | /** 89 | * @param {!CDPSession} session 90 | * @param {!Object} params 91 | */ 92 | constructor(session, params, handlers) { 93 | this.session_ = session; 94 | this.params_ = params; 95 | this.handlers_ = handlers; 96 | this.done_ = false; 97 | this.callNextHandler_(); 98 | } 99 | 100 | /** 101 | * @return {string} 102 | */ 103 | url() { 104 | return this.params_.request.url; 105 | } 106 | 107 | /** 108 | * @return {string} 109 | */ 110 | method() { 111 | return this.params_.request.method; 112 | } 113 | 114 | /** 115 | * @return {!Object} HTTP request headers. 116 | */ 117 | headers() { 118 | return this.params_.request.headers || {}; 119 | } 120 | 121 | /** 122 | * @return {string} 123 | */ 124 | resourceType() { 125 | return this.params_.resourceType; 126 | } 127 | 128 | /** 129 | * Aborts the request. 130 | */ 131 | abort() { 132 | debugServer('abort', this.url()); 133 | return this.resolve_({errorReason: 'Aborted'}); 134 | } 135 | 136 | 137 | /** 138 | * Fails the request. 139 | */ 140 | fail() { 141 | debugServer('fail', this.url()); 142 | return this.resolve_({errorReason: 'Failed'}); 143 | } 144 | 145 | /** 146 | * Falls through to the next handler. 147 | */ 148 | continue() { 149 | debugServer('continue', this.url()); 150 | return this.callNextHandler_(); 151 | } 152 | 153 | /** 154 | * Continues the request with the provided overrides to the url, method or 155 | * headers. 156 | * 157 | * @param {{url: (string|undefined), method: (string|undefined), 158 | * headers: (!Object|undefined)}|undefined} overrides 159 | * Overrides to apply to the request before it hits network. 160 | */ 161 | deferToBrowser(overrides) { 162 | debugServer('deferToBrowser', this.url()); 163 | const params = {}; 164 | if (overrides && overrides.url) params.url = overrides.url; 165 | if (overrides && overrides.method) params.method = overrides.method; 166 | if (overrides && overrides.headers) params.headers = overrides.headers; 167 | return this.resolve_(params); 168 | } 169 | 170 | /** 171 | * Fulfills the request with the given data. 172 | * 173 | * @param {{status: number|undefined, 174 | * headers: !Object|undefined, 175 | * body: !Buffer|undefined}} options 176 | */ 177 | fulfill({status, headers, body}) { 178 | debugServer('fulfill', this.url()); 179 | status = status || 200; 180 | const responseHeaders = {}; 181 | if (headers) { 182 | for (const header of Object.keys(headers)) 183 | responseHeaders[header.toLowerCase()] = headers[header]; 184 | } 185 | if (body && !('content-length' in responseHeaders)) 186 | responseHeaders['content-length'] = Buffer.byteLength(body); 187 | 188 | const statusText = statusTexts[status] || ''; 189 | const statusLine = `HTTP/1.1 ${status} ${statusText}`; 190 | 191 | const CRLF = '\r\n'; 192 | let text = statusLine + CRLF; 193 | for (const header of Object.keys(responseHeaders)) 194 | text += header + ': ' + responseHeaders[header] + CRLF; 195 | text += CRLF; 196 | let responseBuffer = Buffer.from(text, 'utf8'); 197 | if (body) 198 | responseBuffer = Buffer.concat([responseBuffer, body]); 199 | 200 | return this.resolve_({ 201 | interceptionId: this.interceptionId_, 202 | rawResponse: responseBuffer.toString('base64') 203 | }); 204 | } 205 | 206 | callNextHandler_() { 207 | debugServer('next handler', this.url()); 208 | const handler = this.handlers_.shift(); 209 | if (handler) { 210 | handler(this); 211 | return; 212 | } 213 | this.resolve_({}); 214 | } 215 | 216 | /** 217 | * Aborts the request. 218 | * @param {!Object} params 219 | */ 220 | async resolve_(params) { 221 | debugServer('resolve', this.url()); 222 | if (this.done_) throw new Error('Already resolved given request'); 223 | params.interceptionId = this.params_.interceptionId; 224 | this.done_ = true; 225 | return this.session_.send('Network.continueInterceptedRequest', params); 226 | } 227 | } 228 | 229 | module.exports = { HttpRequest }; 230 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "carlo", 3 | "version": "0.9.46", 4 | "description": "Carlo is a framework for rendering Node data structures using Chrome browser.", 5 | "repository": "github:GoogleChromeLabs/carlo", 6 | "engines": { 7 | "node": ">=7.6.0" 8 | }, 9 | "main": "index.js", 10 | "directories": { 11 | "lib": "lib" 12 | }, 13 | "scripts": { 14 | "lint": "([ \"$CI\" = true ] && eslint --quiet -f codeframe . || eslint .)", 15 | "test": "node rpc/test.js && node test/test.js", 16 | "headful-test": "node test/headful.js" 17 | }, 18 | "keywords": [], 19 | "author": "The Chromium Authors", 20 | "license": "Apache-2.0", 21 | "dependencies": { 22 | "debug": "^4.1.0", 23 | "puppeteer-core": "~1.12.0" 24 | }, 25 | "devDependencies": { 26 | "eslint": "^5.8.0", 27 | "@pptr/testrunner": "^0.5.0", 28 | "@pptr/testserver": "^0.5.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /rpc/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | module.exports = { 20 | rpc: require('./rpc'), 21 | rpc_process: require('./rpc_process') 22 | }; 23 | -------------------------------------------------------------------------------- /rpc/rpc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | /** @typedef { !Array } Address */ 20 | /** @typedef {{ name: string, isFunc: boolean }} Descriptor */ 21 | /** @typedef {function(function(data)): function(data)} Transport */ 22 | 23 | const handleSymbol = Symbol('handle'); 24 | 25 | /** 26 | * Handle to the object. This handle has methods matching the methods of the 27 | * target object. Calling these methods calls them remotely over the low level 28 | * messaging transprot. Return values are delivered to the caller. 29 | */ 30 | class Handle { 31 | /** 32 | * @param {string} localAddress Address of this handle. 33 | * @param {string} address Address of the primary handle this handle refers 34 | * to. Primary handle is the one that lives in the same world 35 | * as the actual object it refers to. 36 | * @param {!Object} descriptor Target object spec descriptor (list of methods, etc.) 37 | * @param {!Rpc} rpc 38 | */ 39 | constructor(localAddress, address, descriptor, rpc) { 40 | this.localAddress_ = localAddress; 41 | this.address_ = address; 42 | this.descriptor_ = descriptor; 43 | this.rpc_ = rpc; 44 | this.object_ = null; 45 | 46 | const target = {}; 47 | target[handleSymbol] = this; 48 | this.proxy_ = new Proxy(target, { get: Handle.proxyHandler_ }); 49 | } 50 | 51 | /** 52 | * We always return proxies to the user to encapsulate handle and marshall 53 | * calls automatically. 54 | */ 55 | static proxyHandler_(target, methodName, receiver) { 56 | const handle = target[handleSymbol]; 57 | if (methodName === handleSymbol) 58 | return handle; 59 | if (typeof methodName !== 'string') 60 | return; 61 | if (methodName === 'then') 62 | return target[methodName]; 63 | return handle.callMethod_.bind(handle, methodName); 64 | } 65 | 66 | /** 67 | * Calls method on the target object. 68 | * 69 | * @param {string} method Method to call on the target object. 70 | * @param {!Array<*>} args Call arguments. These can be either primitive 71 | * types, other handles or JSON structures. 72 | * @return {!Promise<*>} result, also primitive, JSON or handle. 73 | */ 74 | async callMethod_(method, ...args) { 75 | const message = { 76 | m: method, 77 | p: this.rpc_.wrap_(args) 78 | }; 79 | const response = await this.rpc_.sendCommand_(this.address_, this.localAddress_, message); 80 | return this.rpc_.unwrap_(response); 81 | } 82 | 83 | /** 84 | * Dispatches external message on this handle. 85 | * @param {string} message 86 | * @return {!Promise<*>} result, also primitive, JSON or handle. 87 | */ 88 | async dispatchMessage_(message) { 89 | if (this.descriptor_.isFunc) { 90 | const result = await this.object_(...this.rpc_.unwrap_(message.p)); 91 | return this.rpc_.wrap_(result); 92 | } 93 | if (message.m.startsWith('_') || message.m.endsWith('_')) 94 | throw new Error(`Private members are not exposed over RPC: '${message.m}'`); 95 | 96 | if (!(message.m in this.object_)) 97 | throw new Error(`There is no member '${message.m}' in '${this.descriptor_.name}'`); 98 | const value = this.object_[message.m]; 99 | if (typeof value !== 'function') { 100 | if (message.p.length) 101 | throw new Error(`'${message.m}' is not a function, can't pass args '${message.p}'`); 102 | return this.rpc_.wrap_(value); 103 | } 104 | 105 | const result = await this.object_[message.m](...this.rpc_.unwrap_(message.p)); 106 | return this.rpc_.wrap_(result); 107 | } 108 | 109 | /** 110 | * Returns the proxy to this handle that is passed to the userland. 111 | */ 112 | proxy() { 113 | return this.proxy_; 114 | } 115 | } 116 | 117 | /** 118 | * Main Rpc object. Keeps all the book keeping and performs message routing 119 | * between handles beloning to different worlds. Each 'world' has a singleton 120 | * 'rpc' instance. 121 | */ 122 | class Rpc { 123 | constructor() { 124 | this.lastHandleId_ = 0; 125 | this.lastWorldId_ = 0; 126 | this.worlds_ = new Map(); 127 | this.idToHandle_ = new Map(); 128 | this.lastMessageId_ = 0; 129 | this.callbacks_ = new Map(); 130 | 131 | this.worldId_ = '.'; 132 | this.sendToParent_ = null; 133 | this.cookieResponseCallbacks_ = new Map(); 134 | this.debug_ = false; 135 | } 136 | 137 | /** 138 | * Each singleton rpc object has the world's parameters that parent world sent 139 | * to them. 140 | * 141 | * @return {*} 142 | */ 143 | params() { 144 | return this.worldParams_; 145 | } 146 | 147 | /** 148 | * Called in the parent world. 149 | * Creates a child world with the given root handle. 150 | * 151 | * @param {!Transport} transport 152 | * - receives function that should be called upon messages from 153 | * the world and 154 | * - returns function that should be used to send messages to the 155 | * world 156 | * @param {...*} args Params to pass to the child world. 157 | * @return {!Promise<{worldId:string, *}>} returns the handles / parameters that child 158 | * world returned during the initialization. 159 | */ 160 | createWorld(transport, ...args) { 161 | const worldId = this.worldId_ + '/' + (++this.lastWorldId_); 162 | const sendToChild = transport(this.routeMessage_.bind(this, false)); 163 | this.worlds_.set(worldId, sendToChild); 164 | sendToChild({cookie: true, args: this.wrap_(args), worldId }); 165 | return new Promise(f => this.cookieResponseCallbacks_.set(worldId, f)); 166 | } 167 | 168 | /** 169 | * Called in the parent world. 170 | * Disposes a child world with the given id. 171 | * 172 | * @param {string} worldId The world to dispose. 173 | */ 174 | disposeWorld(worldId) { 175 | if (!this.worlds_.has(worldId)) 176 | throw new Error('No world with given id exists'); 177 | this.worlds_.delete(worldId); 178 | } 179 | 180 | /** 181 | * Called in the child world to initialize it. 182 | * @param {!Transport} transport. 183 | * @param {function(...*):!Promise<*>} initializer 184 | */ 185 | initWorld(transport, initializer) { 186 | this.sendToParent_ = transport(this.routeMessage_.bind(this, true)); 187 | return new Promise(f => this.cookieCallback_ = f) 188 | .then(args => initializer ? initializer(...args) : undefined) 189 | .then(response => this.sendToParent_( 190 | {cookieResponse: true, worldId: this.worldId_, r: this.wrap_(response)})); 191 | } 192 | 193 | /** 194 | * Creates a handle to the object. 195 | * @param {!Object} object Object to create handle for 196 | * @return {!Object} 197 | */ 198 | handle(object) { 199 | if (!object) 200 | throw new Error('Can only create handles for objects'); 201 | if (typeof object === 'object' && handleSymbol in object) 202 | throw new Error('Can not return handle to handle.'); 203 | const descriptor = this.describe_(object); 204 | const address = [this.worldId_, descriptor.name + '#' + (++this.lastHandleId_)]; 205 | const handle = new Handle(address, address, descriptor, this); 206 | handle.object_ = object; 207 | this.idToHandle_.set(address[1], handle); 208 | return handle.proxy(); 209 | } 210 | 211 | /** 212 | * Returns the object this handle points to. Only works on the local 213 | * handles, otherwise returns null. 214 | * 215 | * @param {*} handle Primary object handle. 216 | * @return {?Object} 217 | */ 218 | object(proxy) { 219 | return proxy[handleSymbol].object_ || null; 220 | } 221 | 222 | /** 223 | * Disposes a handle to the object. 224 | * @param {*} handle Primary object handle. 225 | */ 226 | dispose(proxy) { 227 | const handle = proxy[handleSymbol]; 228 | if (!handle.object_) 229 | throw new Error('Can only dipose handle that was explicitly created with rpc.handle()'); 230 | this.idToHandle_.delete(handle.address_[1]); 231 | } 232 | 233 | /** 234 | * Builds object descriptor. 235 | * @return {!Descriptor} 236 | */ 237 | describe_(o) { 238 | if (typeof o === 'function') 239 | return { isFunc: true }; 240 | return { name: o.constructor.name }; 241 | } 242 | 243 | /** 244 | * Wraps call argument as a protocol structures. 245 | * @param {*} param 246 | * @param {number=} maxDepth 247 | * @return {*} 248 | */ 249 | wrap_(param, maxDepth = 1000) { 250 | if (!maxDepth) 251 | throw new Error('Object reference chain is too long'); 252 | maxDepth--; 253 | if (!param) 254 | return param; 255 | 256 | if (param[handleSymbol]) { 257 | const handle = param[handleSymbol]; 258 | return { __rpc_a__: handle.address_, descriptor: handle.descriptor_ }; 259 | } 260 | 261 | if (param instanceof Array) 262 | return param.map(item => this.wrap_(item, maxDepth)); 263 | 264 | if (typeof param === 'object') { 265 | const result = {}; 266 | for (const key in param) 267 | result[key] = this.wrap_(param[key], maxDepth); 268 | return result; 269 | } 270 | 271 | return param; 272 | } 273 | 274 | /** 275 | * Unwraps call argument from the protocol structures. 276 | * @param {!Object} param 277 | * @return {*} 278 | */ 279 | unwrap_(param) { 280 | if (!param) 281 | return param; 282 | if (param.__rpc_a__) { 283 | const handle = this.createHandle_(param.__rpc_a__, param.descriptor); 284 | if (handle.descriptor_.isFunc) 285 | return (...args) => handle.callMethod_('call', ...args); 286 | return handle.proxy(); 287 | } 288 | 289 | if (param instanceof Array) 290 | return param.map(item => this.unwrap_(item)); 291 | 292 | if (typeof param === 'object') { 293 | const result = {}; 294 | for (const key in param) 295 | result[key] = this.unwrap_(param[key]); 296 | return result; 297 | } 298 | 299 | return param; 300 | } 301 | 302 | /** 303 | * Unwraps descriptor and creates a local world handle that will be associated 304 | * with the primary handle at given address. 305 | * 306 | * @param {!Address} address Address of the primary wrapper. 307 | * @param {!Descriptor} address Address of the primary wrapper. 308 | * @return {!Handle} 309 | */ 310 | createHandle_(address, descriptor) { 311 | if (address[0] === this.worldId_) { 312 | const existing = this.idToHandle_.get(address[1]); 313 | if (existing) 314 | return existing; 315 | } 316 | 317 | const localAddress = [this.worldId_, descriptor.name + '#' + (++this.lastHandleId_)]; 318 | return new Handle(localAddress, address, descriptor, this); 319 | } 320 | 321 | /** 322 | * Sends message to the target handle and receive the response. 323 | * 324 | * @param {!Object} payload 325 | * @return {!Promise} 326 | */ 327 | sendCommand_(to, from, message) { 328 | const payload = { to, from, message, id: ++this.lastMessageId_ }; 329 | if (this.debug_) 330 | console.log('\nSEND', payload); 331 | const result = new Promise((fulfill, reject) => 332 | this.callbacks_.set(payload.id, {fulfill, reject})); 333 | this.routeMessage_(false, payload); 334 | return result; 335 | } 336 | 337 | /** 338 | * Routes message between the worlds. 339 | * 340 | * @param {!Object} payload 341 | */ 342 | routeMessage_(fromParent, payload) { 343 | if (this.debug_) 344 | console.log(`\nROUTE[${this.worldId_}]`, payload); 345 | 346 | if (payload.cookie) { 347 | this.worldId_ = payload.worldId; 348 | this.cookieCallback_(this.unwrap_(payload.args)); 349 | this.cookieCallback_ = null; 350 | return; 351 | } 352 | 353 | // If this is a cookie request, the world is being initialized. 354 | if (payload.cookieResponse) { 355 | const callback = this.cookieResponseCallbacks_.get(payload.worldId); 356 | this.cookieResponseCallbacks_.delete(payload.worldId); 357 | callback({ result: this.unwrap_(payload.r), worldId: payload.worldId }); 358 | return; 359 | } 360 | 361 | if (!fromParent && !this.isActiveWorld_(payload.from[0])) { 362 | // Dispatching from the disposed world. 363 | if (this.debug_) 364 | console.log(`DROP ON THE FLOOR`); 365 | return; 366 | } 367 | 368 | if (payload.to[0] === this.worldId_) { 369 | if (this.debug_) 370 | console.log(`ROUTED TO SELF`); 371 | this.dispatchMessageLocally_(payload); 372 | return; 373 | } 374 | 375 | for (const [worldId, worldSend] of this.worlds_) { 376 | if (payload.to[0].startsWith(worldId)) { 377 | if (this.debug_) 378 | console.log(`ROUTED TO CHILD ${worldId}`); 379 | worldSend(payload); 380 | return; 381 | } 382 | } 383 | 384 | if (payload.to[0].startsWith(this.worldId_)) { 385 | // Sending to the disposed world. 386 | if (this.debug_) 387 | console.log(`DROP ON THE FLOOR`); 388 | return; 389 | } 390 | 391 | if (this.debug_) 392 | console.log(`ROUTED TO PARENT`); 393 | this.sendToParent_(payload); 394 | } 395 | 396 | /** 397 | * @param {!Address} address 398 | * @return {boolean} 399 | */ 400 | isActiveWorld_(worldId) { 401 | if (this.worldId_ === worldId) 402 | return true; 403 | for (const wid of this.worlds_.keys()) { 404 | if (worldId.startsWith(wid)) 405 | return true; 406 | } 407 | return false; 408 | } 409 | 410 | /** 411 | * Message is routed from other worlds and hits rpc here. 412 | * 413 | * @param {!Object} payload 414 | */ 415 | async dispatchMessageLocally_(payload) { 416 | if (this.debug_) 417 | console.log('\nDISPATCH', payload); 418 | // Dispatch the response. 419 | if (typeof payload.rid === 'number') { 420 | const {fulfill, reject} = this.callbacks_.get(payload.rid); 421 | this.callbacks_.delete(payload.rid); 422 | if (payload.e) 423 | reject(new Error(payload.e)); 424 | else 425 | fulfill(payload.r); 426 | return; 427 | } 428 | 429 | const message = { from: payload.to, rid: payload.id, to: payload.from }; 430 | const handle = this.idToHandle_.get(payload.to[1]); 431 | if (!handle) { 432 | message.e = 'Object has been diposed.'; 433 | } else { 434 | try { 435 | message.r = await handle.dispatchMessage_(payload.message); 436 | } catch (e) { 437 | message.e = e.toString() + '\n' + e.stack; 438 | } 439 | } 440 | this.routeMessage_(false, message); 441 | } 442 | } 443 | 444 | module.exports = new Rpc(); 445 | -------------------------------------------------------------------------------- /rpc/rpc.md: -------------------------------------------------------------------------------- 1 | ## RPC API 2 | 3 | > This is a pre-release API, so it is a subject to change. Please use it at your own risk. Once API is validated, it will be bumped to v1.0 and preserved for backwards compatibility. 4 | 5 | ### Handles 6 | 7 | In Carlo's RPC system one can obtain a `handle` to a local `object` and pass it between the execution 8 | contexts. Execution contexts can be Chrome, Node, child processes or any other JavaScript 9 | execution environment, local or remote. 10 | 11 | ![rpc](https://user-images.githubusercontent.com/883973/48327354-0d6f1f00-e5f3-11e8-99dc-fef5f4ad53dc.png) 12 | 13 | Calling a method on the `handle` results in calling it on the actual `object`: 14 | 15 | ```js 16 | class Foo { 17 | hello(name) { console.log(`hello ${name}`); } 18 | } 19 | const foo = rpc.handle(new Foo()); // <-- Obtained handle to object. 20 | await foo.hello('world'); // <-- Prints 'hello world'. 21 | ``` 22 | 23 | > By default, `handle` has access to all the *public* methods of the object. 24 | Public methods are the ones not starting or ending with `_`. 25 | 26 | All handle operations are async, notice how synchronous `hello` method became async when accessed 27 | via the handle. The world where `handle` is created can access the actual `object`. When handle is no longer needed, the world that created it can dispose it: 28 | 29 | ```js 30 | const handle = rpc.handle(object); 31 | const object = rpc.object(handle); 32 | rpc.dispose(handle); 33 | ``` 34 | 35 | Properties of the target object are similarly accessible via the handle: 36 | 37 | ```js 38 | const foo = rpc.handle({ myValue: 'value' }); // <-- Obtained handle to object. 39 | await foo.myValue(); // <-- Returns 'value'. 40 | ``` 41 | 42 | Handles are passed between the worlds as arguments of the calls on other handles: 43 | 44 | `World 1` 45 | ```js 46 | class Parent { 47 | constructor() { 48 | this.children = []; 49 | } 50 | addChild(child) { 51 | this.children.push(child); 52 | return this.children.length - 1; 53 | } 54 | } 55 | ``` 56 | 57 | `World 2` 58 | ```js 59 | class Child {} 60 | 61 | async function main(parent) { // parent is a handle to the object from World 1. 62 | const child = rpc.handle(new Child); 63 | // Call method on parent remotely, pass handle to child into it. 64 | const ordinal = await parent.addChild(child); 65 | console.log(`Added child #${ordinal}`); 66 | } 67 | ``` 68 | 69 | ### Example 70 | Following is an end-to-end example of the RPC application that demonstrates the variety of remote 71 | operations that can be performed on handles: 72 | 73 | `family.js` 74 | 75 | ```js 76 | const rpc = require('rpc'); 77 | 78 | class Parent { 79 | constructor() { 80 | this.children = []; 81 | } 82 | 83 | addChild(child) { 84 | const ordinal = this.children.length; 85 | console.log(`Adding child #${ordinal}`); 86 | child.setOrdinal(ordinal); 87 | 88 | // Go over the children and make siblings aware of each other. 89 | for (const c of this.children) { 90 | c.setSibling(child); 91 | child.setSibling(c); 92 | } 93 | this.children.push(child); 94 | return ordinal; 95 | } 96 | } 97 | 98 | class Child { 99 | constructor() { 100 | // Obtain handle to self that is used in RPC. 101 | this.handle_ = rpc.handle(this); 102 | } 103 | 104 | setOrdinal(ordinal) { this.ordinal_ = ordinal; } 105 | ordinal() { return this.ordinal_; } 106 | 107 | async setSibling(sibling) { 108 | // Say hello to another sibling when it is reported. 109 | const o = await sibling.ordinal(); 110 | console.log(`I am #${this.ordinal_} and I have a sibling #${o}`); 111 | await sibling.hiSibling(this.handle_); 112 | } 113 | 114 | async hiSibling(sibling) { 115 | const o = await sibling.ordinal(); 116 | console.log(`I am #${this.ordinal_} and my sibling #${o} is saying hello`); 117 | } 118 | 119 | dispose() { 120 | rpc.dispose(this.handle_); 121 | } 122 | } 123 | 124 | module.exports = { Parent, Child }; 125 | ``` 126 | 127 | `main.js` runs in the main process. 128 | ```js 129 | const rpc = require('rpc'); 130 | const rpc_process = require('rpc_process'); 131 | const { Parent } = require('./family'); 132 | 133 | (async () => { 134 | // Create parent object in the main process, obtain the handle to it. 135 | const parent = rpc.handle(new Parent()); 136 | 137 | // Create a child process and load worker.js there. Pass parent object 138 | // into that new child world, assign return value to a child. 139 | const child1 = await rpc_process.spawn(__dirname + '/worker.js', parent); 140 | parent.addChild(child1); 141 | 142 | // Do it again. 143 | const child2 = await rpc_process.spawn(__dirname + '/worker.js', parent); 144 | parent.addChild(child2); 145 | })(); 146 | ``` 147 | 148 | `worker.js` runs in a child process. 149 | ```js 150 | const rpc = require('rpc'); 151 | const rpc_process = require('rpc_process'); 152 | const { Child } = require('./family'); 153 | 154 | rpc_process.init(parent => { 155 | // Note that parent is available in this context and we can call 156 | // parent.addChild(rpc.handle(new Child)) here. 157 | 158 | // But we prefer to simply return the handle to the newly created child 159 | // into the parent world for the sake of this demo. 160 | return rpc.handle(new Child()); 161 | }); 162 | ``` 163 | -------------------------------------------------------------------------------- /rpc/rpc_process.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | const child_process = require('child_process'); 20 | const rpc = require('./rpc'); 21 | 22 | async function spawn(fileName, ...args) { 23 | const child = child_process.fork(fileName, [], { 24 | detached: true, stdio: [0, 1, 2, 'ipc'] 25 | }); 26 | 27 | const transport = receivedFromChild => { 28 | child.on('message', receivedFromChild); 29 | return child.send.bind(child); 30 | }; 31 | const { result } = await rpc.createWorld(transport, ...args); 32 | return result; 33 | } 34 | 35 | function init(initializer) { 36 | const transport = receivedFromParent => { 37 | process.on('message', receivedFromParent); 38 | return process.send.bind(process); 39 | }; 40 | rpc.initWorld(transport, initializer); 41 | } 42 | 43 | module.exports = { spawn, init }; 44 | -------------------------------------------------------------------------------- /rpc/test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const {TestRunner, Reporter, Matchers} = require('@pptr/testrunner'); 18 | const rpc = require('./rpc'); 19 | 20 | // Runner holds and runs all the tests 21 | const runner = new TestRunner({ 22 | parallel: 1, // run 2 parallel threads 23 | timeout: 1000, // setup timeout of 1 second per test 24 | }); 25 | // Simple expect-like matchers 26 | const {expect} = new Matchers(); 27 | 28 | // Extract jasmine-like DSL into the global namespace 29 | const {describe, xdescribe, fdescribe} = runner; 30 | const {it, fit, xit} = runner; 31 | const {beforeAll, beforeEach, afterAll, afterEach} = runner; 32 | 33 | async function createChildWorld(rpc, initializer, ...args) { 34 | let sendToParent; 35 | let sendToChild; 36 | function transport1(receivedFromChild) { 37 | sendToParent = receivedFromChild; 38 | return data => setTimeout(() => sendToChild(data), 0); 39 | } 40 | function transport2(receivedFromParent) { 41 | sendToChild = receivedFromParent; 42 | return data => setTimeout(() => sendToParent(data), 0); 43 | } 44 | const childRpc = new rpc.constructor(); 45 | childRpc.initWorld(transport2, p => initializer(p, childRpc)); 46 | await rpc.createWorld(transport1, ...args); 47 | return childRpc; 48 | } 49 | 50 | describe('rpc', () => { 51 | it('call method', async(state, test) => { 52 | class Foo { 53 | sum(a, b) { return a + b; } 54 | } 55 | const foo = rpc.handle(new Foo()); 56 | expect(await foo.sum(1, 3)).toBe(4); 57 | }); 58 | it('call method with object', async(state, test) => { 59 | class Foo { 60 | sum(a, b) { return { value: a.value + b.value }; } 61 | } 62 | const foo = rpc.handle(new Foo()); 63 | const result = await foo.sum({value: 1}, {value: 3}); 64 | expect(result.value).toBe(4); 65 | }); 66 | it('call method with array', async(state, test) => { 67 | class Foo { 68 | sum(arr) { return arr.reduce((a, c) => a + c, 0); } 69 | } 70 | const foo = rpc.handle(new Foo()); 71 | const result = await foo.sum([1, 2, 3, 4, 5]); 72 | expect(result).toBe(15); 73 | }); 74 | it('call method with objects with handles', async(state, test) => { 75 | class Foo { 76 | async call(val) { return await val.a[0].name(); } 77 | name() { return 'name'; } 78 | } 79 | const foo = rpc.handle(new Foo()); 80 | const result = await foo.call({a: [foo]}); 81 | expect(result).toBe('name'); 82 | }); 83 | it('call method with object with recursive link', async(state, test) => { 84 | class Foo { 85 | async call(val) { return await val.a[0].name(); } 86 | name() { return 'name'; } 87 | } 88 | const foo = rpc.handle(new Foo()); 89 | const a = {}; 90 | a.a = a; 91 | try { 92 | await foo.call({a}); 93 | } catch (e) { 94 | expect(e.message).toBe('Object reference chain is too long'); 95 | } 96 | }); 97 | it('call method that does not exist', async(state, test) => { 98 | class Foo { 99 | } 100 | const foo = rpc.handle(new Foo()); 101 | try { 102 | await foo.sum(1, 3); 103 | expect(true).toBeFalsy(); 104 | } catch (e) { 105 | expect(e.toString()).toContain('There is no member'); 106 | } 107 | }); 108 | it('call private method', async(state, test) => { 109 | const foo = rpc.handle({}); 110 | try { 111 | await foo._sum(1, 3); 112 | expect(true).toBeFalsy(); 113 | } catch (e) { 114 | expect(e.toString()).toContain('Private members are not exposed over RPC'); 115 | } 116 | }); 117 | it('call method exception', async(state, test) => { 118 | class Foo { 119 | sum(a, b) { return b + c; } 120 | } 121 | const foo = rpc.handle(new Foo()); 122 | try { 123 | await foo.sum(1, 3); 124 | expect(true).toBeFalsy(); 125 | } catch (e) { 126 | expect(e.toString()).toContain('c is not defined'); 127 | } 128 | }); 129 | it('call nested exception', async(state, test) => { 130 | class Foo { 131 | sum(a, b) { return rpc.handle(this).doSum(a, b); } 132 | doSum(a, b) { return b + c; } 133 | } 134 | const foo = rpc.handle(new Foo()); 135 | try { 136 | await foo.sum(1, 3); 137 | expect(true).toBeFalsy(); 138 | } catch (e) { 139 | expect(e.toString()).toContain('c is not defined'); 140 | } 141 | }); 142 | it('handle to function', async(state, test) => { 143 | class Foo { 144 | call(callback) { return callback(); } 145 | } 146 | const foo = rpc.handle(new Foo()); 147 | let calls = 0; 148 | await foo.call(rpc.handle(() => ++calls)); 149 | expect(calls).toBe(1); 150 | }); 151 | it('handle to function exception', async(state, test) => { 152 | class Foo { 153 | call(callback) { return callback(); } 154 | } 155 | const foo = rpc.handle(new Foo()); 156 | const calls = 0; 157 | try { 158 | await foo.call(rpc.handle(() => ++calls)); 159 | expect(true).toBeFalsy(); 160 | } catch (e) { 161 | expect(e.toString()).toContain('Assignment to constant'); 162 | } 163 | }); 164 | it('access property', async(state, test) => { 165 | const foo = rpc.handle({ value: 'Hello wold' }); 166 | expect(await foo.value()).toBe('Hello wold'); 167 | }); 168 | it('access property with params', async(state, test) => { 169 | const foo = rpc.handle({ value: 'Hello wold' }); 170 | try { 171 | expect(await foo.value(1)).toBe('Hello wold'); 172 | expect(true).toBeFalsy(); 173 | } catch (e) { 174 | expect(e.toString()).toContain('is not a function'); 175 | } 176 | }); 177 | it('materialize handle', async(state, test) => { 178 | const object = {}; 179 | const handle = rpc.handle(object); 180 | expect(rpc.object(handle) === object).toBeTruthy(); 181 | }); 182 | it('access disposed handle', async(state, test) => { 183 | class Foo { 184 | sum(a, b) { return b + c; } 185 | } 186 | const foo = rpc.handle(new Foo()); 187 | rpc.dispose(foo); 188 | try { 189 | await foo.sum(1, 2); 190 | expect(true).toBeFalsy(); 191 | } catch (e) { 192 | expect(e.toString()).toContain('Object has been diposed'); 193 | } 194 | }); 195 | it('dedupe implicit handles in the same world', async(state, test) => { 196 | let foo2; 197 | class Foo { foo(f) { foo2 = f; }} 198 | const foo = rpc.handle(new Foo()); 199 | await foo.foo(foo); 200 | expect(foo === foo2).toBeTruthy(); 201 | }); 202 | it('handle to handle should throw', async(state, test) => { 203 | const handle = rpc.handle({}); 204 | try { 205 | rpc.handle(handle); 206 | expect(true).toBeFalsy(); 207 | } catch (e) { 208 | expect(e.toString()).toContain('Can not return handle to handle'); 209 | } 210 | }); 211 | it('parent / child communication', async(state, test) => { 212 | const messages = []; 213 | class Root { hello(message) { messages.push(message); } } 214 | const root = rpc.handle(new Root()); 215 | await createChildWorld(rpc, p => p.hello('one'), root); 216 | await createChildWorld(rpc, p => p.hello('two'), root); 217 | expect(messages.join(',')).toBe('one,two'); 218 | }); 219 | it('parent / grand child communication', async(state, test) => { 220 | const messages = []; 221 | class Root { hello(message) { messages.push(message); } } 222 | const root = rpc.handle(new Root()); 223 | await createChildWorld(rpc, async(p, r) => { 224 | await createChildWorld(r, p => p.hello('one'), p); 225 | }, root); 226 | expect(messages.join(',')).toBe('one'); 227 | }); 228 | it('child / child communication', async(state, test) => { 229 | const messages = []; 230 | class Parent { 231 | constructor() { this.children_ = []; } 232 | addChild(child) { 233 | this.children_.forEach(c => { c.setSibling(child); child.setSibling(c); }); 234 | this.children_.push(child); 235 | } 236 | } 237 | class Child { 238 | constructor() {} 239 | setSibling(sibling) { 240 | sibling.helloSibling('hello'); 241 | } 242 | helloSibling(message) { 243 | messages.push(message); 244 | } 245 | } 246 | const parent = rpc.handle(new Parent()); 247 | await createChildWorld(rpc, (p, r) => p.addChild(r.handle(new Child())), parent); 248 | await createChildWorld(rpc, (p, r) => p.addChild(r.handle(new Child())), parent); 249 | await new Promise(f => setTimeout(f, 0)); 250 | await new Promise(f => setTimeout(f, 0)); 251 | expect(messages.join(',')).toBe('hello,hello'); 252 | }); 253 | it('dispose world', async(state, test) => { 254 | const messages = []; 255 | class Root { hello(message) { messages.push(message); } } 256 | const root = rpc.handle(new Root()); 257 | let childRoot; 258 | const childRpc = await createChildWorld(rpc, r => childRoot = r, root); 259 | childRoot.hello('hello'); 260 | 261 | await new Promise(f => setTimeout(f, 0)); 262 | rpc.disposeWorld(childRpc.worldId_); 263 | 264 | childRoot.hello('hello'); 265 | await new Promise(f => setTimeout(f, 0)); 266 | 267 | expect(messages.join(',')).toBe('hello'); 268 | }); 269 | it('dispose world half way', async(state, test) => { 270 | const messages = []; 271 | let go; 272 | class Root { 273 | hello(message) { messages.push(message); return new Promise(f => go = f); } 274 | } 275 | const root = rpc.handle(new Root()); 276 | let childRoot; 277 | const childRpc = await createChildWorld(rpc, r => childRoot = r, root); 278 | childRoot.hello('hello').then(() => messages.push('should-not-happen')); 279 | await new Promise(f => setTimeout(f, 0)); 280 | rpc.disposeWorld(childRpc.worldId_); 281 | go(); 282 | await new Promise(f => setTimeout(f, 0)); 283 | await new Promise(f => setTimeout(f, 0)); 284 | expect(messages.join(',')).toBe('hello'); 285 | }); 286 | }); 287 | 288 | // Reporter subscribes to TestRunner events and displays information in terminal 289 | new Reporter(runner); 290 | 291 | // Run all tests. 292 | runner.run(); 293 | -------------------------------------------------------------------------------- /test/app.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const path = require('path'); 18 | 19 | module.exports.addTests = function({testRunner, expect}) { 20 | 21 | const {describe, xdescribe, fdescribe} = testRunner; 22 | const {it, fit, xit} = testRunner; 23 | const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; 24 | const carlo = require('../lib/carlo'); 25 | const {rpc} = require('../rpc'); 26 | 27 | let app; 28 | 29 | function staticHandler(data) { 30 | return request => { 31 | for (const entry of data) { 32 | const url = new URL(request.url()); 33 | if (url.pathname === entry[0]) { 34 | request.fulfill({ body: Buffer.from(entry[1]), headers: entry[2]}); 35 | return; 36 | } 37 | } 38 | request.continue(); 39 | }; 40 | } 41 | 42 | afterEach(async({server, httpsServer}) => { 43 | try { await app.exit(); } catch (e) {} 44 | }); 45 | 46 | describe('app basics', () => { 47 | it('evaluate', async() => { 48 | app = await carlo.launch(); 49 | const ua = await app.evaluate('navigator.userAgent'); 50 | expect(ua).toContain('HeadlessChrome'); 51 | }); 52 | it('exposeFunction', async() => { 53 | app = await carlo.launch(); 54 | await app.exposeFunction('foobar', () => 42); 55 | const result = await app.evaluate('foobar()'); 56 | expect(result).toBe(42); 57 | }); 58 | it('app load', async() => { 59 | app = await carlo.launch(); 60 | await app.load('data:text/plain,hello'); 61 | const result = await app.evaluate('document.body.textContent'); 62 | expect(result).toBe('hello'); 63 | }); 64 | it('mainWindow accessor', async() => { 65 | app = await carlo.launch(); 66 | app.serveFolder(path.join(__dirname, 'folder')); 67 | await app.load('index.html'); 68 | expect(app.mainWindow().pageForTest().url()).toBe('https://domain/index.html'); 69 | }); 70 | it('createWindow creates window', async() => { 71 | app = await carlo.launch(); 72 | let window = await app.createWindow(); 73 | expect(window.pageForTest().url()).toBe('about:blank?seq=1'); 74 | window = await app.createWindow(); 75 | expect(window.pageForTest().url()).toBe('about:blank?seq=2'); 76 | }); 77 | it('exit event is emitted', async() => { 78 | app = await carlo.launch(); 79 | let callback; 80 | const onexit = new Promise(f => callback = f); 81 | app.on('exit', callback); 82 | await app.mainWindow().close(); 83 | await onexit; 84 | }); 85 | it('window event is emitted', async() => { 86 | app = await carlo.launch(); 87 | const windows = []; 88 | app.on('window', window => windows.push(window)); 89 | const window1 = await app.createWindow(); 90 | const window2 = await app.createWindow(); 91 | expect(window1).toBe(windows[0]); 92 | expect(window2).toBe(windows[1]); 93 | }); 94 | it('window exposeFunction', async() => { 95 | app = await carlo.launch(); 96 | await app.exposeFunction('appFunc', () => 'app'); 97 | const w1 = await app.createWindow(); 98 | await w1.exposeFunction('windowFunc', () => 'window'); 99 | const result1 = await w1.evaluate(async() => (await appFunc()) + (await windowFunc())); 100 | expect(result1).toBe('appwindow'); 101 | 102 | const w2 = await app.createWindow(); 103 | const result2 = await w2.evaluate(async() => (await appFunc()) + self.windowFunc); 104 | expect(result2).toBe('appundefined'); 105 | }); 106 | }); 107 | 108 | describe('http serve', () => { 109 | it('serveFolder works', async() => { 110 | app = await carlo.launch(); 111 | app.serveFolder(path.join(__dirname, 'folder')); 112 | await app.load('index.html'); 113 | const result = await app.evaluate('document.body.textContent'); 114 | expect(result).toBe('hello file'); 115 | }); 116 | it('serveFolder prefix is respected works', async() => { 117 | app = await carlo.launch(); 118 | app.serveFolder(path.join(__dirname, 'folder'), 'prefix'); 119 | await app.load('prefix/index.html'); 120 | const result = await app.evaluate('document.body.textContent'); 121 | expect(result).toBe('hello file'); 122 | }); 123 | it('serveOrigin works', async({server}) => { 124 | app = await carlo.launch(); 125 | app.serveOrigin(server.PREFIX); 126 | await app.load('index.html'); 127 | const result = await app.evaluate('document.body.textContent'); 128 | expect(result).toBe('hello http'); 129 | }); 130 | it('serveOrigin prefix is respected', async({server}) => { 131 | app = await carlo.launch(); 132 | app.serveOrigin(server.PREFIX, 'prefix'); 133 | await app.load('prefix/index.html'); 134 | const result = await app.evaluate('document.body.textContent'); 135 | expect(result).toBe('hello http'); 136 | }); 137 | it('HttpRequest params', async() => { 138 | app = await carlo.launch(); 139 | app.serveFolder(path.join(__dirname, 'folder')); 140 | const log = []; 141 | app.serveHandler(request => { 142 | log.push({url: request.url(), method: request.method(), ua: ('User-Agent' in request.headers()) }); 143 | request.continue(); 144 | }); 145 | await app.load('index.html'); 146 | expect(JSON.stringify(log)).toBe('[{"url":"https://domain/index.html","method":"GET","ua":true}]'); 147 | }); 148 | it('serveHandler can fulfill', async() => { 149 | app = await carlo.launch(); 150 | app.serveHandler(request => { 151 | if (!request.url().endsWith('index.html')) { 152 | request.continue(); 153 | return; 154 | } 155 | request.fulfill({ body: Buffer.from('hello handler') }); 156 | }); 157 | await app.load('index.html'); 158 | const result = await app.evaluate('document.body.textContent'); 159 | expect(result).toBe('hello handler'); 160 | }); 161 | it('serveHandler can continue to file', async() => { 162 | app = await carlo.launch(); 163 | app.serveHandler(request => request.continue()); 164 | app.serveFolder(path.join(__dirname, 'folder')); 165 | await app.load('index.html'); 166 | const result = await app.evaluate('document.body.textContent'); 167 | expect(result).toBe('hello file'); 168 | }); 169 | it('serveHandler can continue to http', async({server}) => { 170 | app = await carlo.launch(); 171 | app.serveOrigin(server.PREFIX); 172 | app.serveHandler(request => request.continue()); 173 | await app.load('index.html'); 174 | const result = await app.evaluate('document.body.textContent'); 175 | expect(result).toBe('hello http'); 176 | }); 177 | xit('serveHandler can abort', async() => { 178 | app = await carlo.launch(); 179 | app.serveHandler(request => request.abort()); 180 | try { 181 | await app.load('index.html'); 182 | expect(false).toBeTruthy(); 183 | } catch (e) { 184 | expect(e.toString()).toContain('domain/index.html'); 185 | } 186 | }); 187 | it('window serveFolder', async() => { 188 | app = await carlo.launch(); 189 | 190 | const w1 = await app.createWindow(); 191 | await w1.serveFolder(path.join(__dirname, 'folder')); 192 | await w1.load('index.html'); 193 | const result1 = await w1.evaluate('document.body.textContent'); 194 | expect(result1).toBe('hello file'); 195 | 196 | const w2 = await app.createWindow(); 197 | try { 198 | await w2.load('index.html'); 199 | expect(false).toBeTruthy(); 200 | } catch (e) { 201 | expect(e.toString()).toContain('domain/index.html'); 202 | } 203 | }); 204 | it('navigation history is empty', async() => { 205 | app = await carlo.launch({ channel: ['canary'] }); 206 | app.serveFolder(path.join(__dirname, 'folder')); 207 | await app.load('index.html?1'); 208 | await app.load('index.html?2'); 209 | await app.load('index.html?3'); 210 | expect(await app.evaluate('history.length')).toBe(1); 211 | }); 212 | it('fail navigation', async() => { 213 | app = await carlo.launch(); 214 | app.serveFolder(path.join(__dirname, 'folder')); 215 | app.serveHandler(async request => { 216 | request.url() === 'https://domain/index.html' ? request.fail() : request.continue(); 217 | }); 218 | await app.load('redirect.html'); 219 | expect(await app.evaluate(`window.location.href`)).toBe('chrome-error://chromewebdata/'); 220 | }); 221 | it('abort navigation', async() => { 222 | app = await carlo.launch(); 223 | app.serveFolder(path.join(__dirname, 'folder')); 224 | app.serveHandler(async request => { 225 | request.url() === 'https://domain/index.html' ? request.abort() : request.continue(); 226 | }); 227 | await app.load('redirect.html'); 228 | expect(await app.evaluate(`window.location.href`)).toBe('https://domain/redirect.html'); 229 | }); 230 | }); 231 | 232 | describe('features', () => { 233 | it('carlo.fileInfo', async() => { 234 | const files = [[ 235 | '/index.html', ` 236 | 243 | ` 244 | ]]; 245 | app = await carlo.launch(); 246 | app.serveHandler(staticHandler(files)); 247 | 248 | let callback; 249 | const result = new Promise(f => callback = f); 250 | app.exposeFunction('checkFileInfo', callback); 251 | 252 | await app.load('index.html'); 253 | const page = app.mainWindow().pageForTest(); 254 | const element = await page.evaluateHandle(`document.getElementById('file')`); 255 | await element.uploadFile(__filename); 256 | app.evaluate('check()'); 257 | const info = await result; 258 | expect(info.path).toBe(__filename); 259 | }); 260 | }); 261 | 262 | describe('rpc', () => { 263 | it('load params are accessible', async() => { 264 | const files = [[ 265 | '/index.html', 266 | ` 271 | ` 272 | ]]; 273 | app = await carlo.launch(); 274 | app.serveHandler(staticHandler(files)); 275 | let callback; 276 | const result = new Promise(f => callback = f); 277 | await app.load('index.html', 278 | rpc.handle({ val: 42 }), 279 | rpc.handle({ print: v => callback(v) })); 280 | expect(await result).toBe(42); 281 | // Allow b.print to dispatch. 282 | await new Promise(f => setTimeout(f, 0)); 283 | }); 284 | it('load params are accessible after reload', async() => { 285 | const files = [[ 286 | '/index.html', 287 | ` 298 | ` 299 | ]]; 300 | app = await carlo.launch(); 301 | app.serveHandler(staticHandler(files)); 302 | let callback; 303 | const result = new Promise(f => callback = f); 304 | await app.load('index.html', 305 | rpc.handle({ val: 42 }), 306 | rpc.handle({ print: v => callback(v) })); 307 | expect(await result).toBe(42); 308 | // Allow b.print to dispatch. 309 | await new Promise(f => setTimeout(f, 0)); 310 | }); 311 | }); 312 | 313 | }; 314 | -------------------------------------------------------------------------------- /test/color.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports.addTests = function({testRunner, expect}) { 18 | 19 | const {describe, xdescribe, fdescribe} = testRunner; 20 | const {it, fit, xit} = testRunner; 21 | const {Color} = require('../lib/color'); 22 | 23 | describe('color', () => { 24 | it('rgb1', async(state, test) => { 25 | color = Color.parse('rgb(94, 126, 91)'); 26 | expect(color.asString(Color.Format.RGB)).toBe('rgb(94, 126, 91)'); 27 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(94, 126, 91, 1)'); 28 | expect(color.asString(Color.Format.HSL)).toBe('hsl(115, 16%, 43%)'); 29 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(115, 16%, 43%, 1)'); 30 | expect(color.asString(Color.Format.HEXA)).toBe('#5e7e5bff'); 31 | expect(color.asString(Color.Format.HEX)).toBe('#5e7e5b'); 32 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 33 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 34 | expect(color.asString()).toBe('rgb(94, 126, 91)'); 35 | }); 36 | it('rgb2', async(state, test) => { 37 | color = Color.parse('rgba(94 126 91)'); 38 | expect(color.asString(Color.Format.RGB)).toBe('rgba(94 126 91)'); 39 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(94, 126, 91, 1)'); 40 | expect(color.asString(Color.Format.HSL)).toBe('hsl(115, 16%, 43%)'); 41 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(115, 16%, 43%, 1)'); 42 | expect(color.asString(Color.Format.HEXA)).toBe('#5e7e5bff'); 43 | expect(color.asString(Color.Format.HEX)).toBe('#5e7e5b'); 44 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 45 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 46 | expect(color.asString()).toBe('rgb(94, 126, 91)'); 47 | }); 48 | it('rgb3', async(state, test) => { 49 | color = Color.parse('rgba(94, 126, 91, 0.5)'); 50 | expect(color.asString(Color.Format.RGB)).toBe(null); 51 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(94, 126, 91, 0.5)'); 52 | expect(color.asString(Color.Format.HSL)).toBe(null); 53 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(115, 16%, 43%, 0.5)'); 54 | expect(color.asString(Color.Format.HEXA)).toBe('#5e7e5b80'); 55 | expect(color.asString(Color.Format.HEX)).toBe(null); 56 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 57 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 58 | expect(color.asString()).toBe('rgba(94, 126, 91, 0.5)'); 59 | }); 60 | it('rgb4', async(state, test) => { 61 | color = Color.parse('rgb(94 126 91 / 50%)'); 62 | expect(color.asString(Color.Format.RGB)).toBe(null); 63 | expect(color.asString(Color.Format.RGBA)).toBe('rgb(94 126 91 / 50%)'); 64 | expect(color.asString(Color.Format.HSL)).toBe(null); 65 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(115, 16%, 43%, 0.5)'); 66 | expect(color.asString(Color.Format.HEXA)).toBe('#5e7e5b80'); 67 | expect(color.asString(Color.Format.HEX)).toBe(null); 68 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 69 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 70 | expect(color.asString()).toBe('rgba(94, 126, 91, 0.5)'); 71 | }); 72 | it('hsl1', async(state, test) => { 73 | color = Color.parse('hsl(212, 55%, 32%)'); 74 | expect(color.asString(Color.Format.RGB)).toBe('rgb(37, 79, 126)'); 75 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(37, 79, 126, 1)'); 76 | expect(color.asString(Color.Format.HSL)).toBe('hsl(212, 55%, 32%)'); 77 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(212, 55%, 32%, 1)'); 78 | expect(color.asString(Color.Format.HEXA)).toBe('#254f7eff'); 79 | expect(color.asString(Color.Format.HEX)).toBe('#254f7e'); 80 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 81 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 82 | expect(color.asString()).toBe('hsl(212, 55%, 32%)'); 83 | }); 84 | it('hsl2', async(state, test) => { 85 | color = Color.parse('hsla(212 55% 32%)'); 86 | expect(color.asString(Color.Format.RGB)).toBe('rgb(37, 79, 126)'); 87 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(37, 79, 126, 1)'); 88 | expect(color.asString(Color.Format.HSL)).toBe('hsla(212 55% 32%)'); 89 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(212, 55%, 32%, 1)'); 90 | expect(color.asString(Color.Format.HEXA)).toBe('#254f7eff'); 91 | expect(color.asString(Color.Format.HEX)).toBe('#254f7e'); 92 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 93 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 94 | expect(color.asString()).toBe('hsl(212, 55%, 32%)'); 95 | }); 96 | it('hsl3', async(state, test) => { 97 | color = Color.parse('hsla(212, 55%, 32%, 0.5)'); 98 | expect(color.asString(Color.Format.RGB)).toBe(null); 99 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(37, 79, 126, 0.5)'); 100 | expect(color.asString(Color.Format.HSL)).toBe(null); 101 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(212, 55%, 32%, 0.5)'); 102 | expect(color.asString(Color.Format.HEXA)).toBe('#254f7e80'); 103 | expect(color.asString(Color.Format.HEX)).toBe(null); 104 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 105 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 106 | expect(color.asString()).toBe('hsla(212, 55%, 32%, 0.5)'); 107 | }); 108 | it('hsl4', async(state, test) => { 109 | color = Color.parse('hsla(212 55% 32% / 50%)'); 110 | expect(color.asString(Color.Format.RGB)).toBe(null); 111 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(37, 79, 126, 0.5)'); 112 | expect(color.asString(Color.Format.HSL)).toBe(null); 113 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(212 55% 32% / 50%)'); 114 | expect(color.asString(Color.Format.HEXA)).toBe('#254f7e80'); 115 | expect(color.asString(Color.Format.HEX)).toBe(null); 116 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 117 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 118 | expect(color.asString()).toBe('hsla(212, 55%, 32%, 0.5)'); 119 | }); 120 | it('hsl5', async(state, test) => { 121 | color = Color.parse('hsla(212deg 55% 32% / 50%)'); 122 | expect(color.asString(Color.Format.RGB)).toBe(null); 123 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(37, 79, 126, 0.5)'); 124 | expect(color.asString(Color.Format.HSL)).toBe(null); 125 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(212deg 55% 32% / 50%)'); 126 | expect(color.asString(Color.Format.HEXA)).toBe('#254f7e80'); 127 | expect(color.asString(Color.Format.HEX)).toBe(null); 128 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 129 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 130 | expect(color.asString()).toBe('hsla(212, 55%, 32%, 0.5)'); 131 | }); 132 | it('hex1', async(state, test) => { 133 | color = Color.parse('#12345678'); 134 | expect(color.asString(Color.Format.RGB)).toBe(null); 135 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(18, 52, 86, 0.47058823529411764)'); 136 | expect(color.asString(Color.Format.HSL)).toBe(null); 137 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(210, 65%, 20%, 0.47058823529411764)'); 138 | expect(color.asString(Color.Format.HEXA)).toBe('#12345678'); 139 | expect(color.asString(Color.Format.HEX)).toBe(null); 140 | expect(color.asString(Color.Format.ShortHEXA)).toBe(null); 141 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 142 | expect(color.asString()).toBe('#12345678'); 143 | }); 144 | it('hex2', async(state, test) => { 145 | color = Color.parse('#00FFFF'); 146 | expect(color.asString(Color.Format.RGB)).toBe('rgb(0, 255, 255)'); 147 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(0, 255, 255, 1)'); 148 | expect(color.asString(Color.Format.HSL)).toBe('hsl(180, 100%, 50%)'); 149 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(180, 100%, 50%, 1)'); 150 | expect(color.asString(Color.Format.HEXA)).toBe('#00ffffff'); 151 | expect(color.asString(Color.Format.HEX)).toBe('#00FFFF'); 152 | expect(color.asString(Color.Format.ShortHEXA)).toBe('#0fff'); 153 | expect(color.asString(Color.Format.ShortHEX)).toBe('#0ff'); 154 | expect(color.asString()).toBe('#00ffff'); 155 | }); 156 | it('hex3', async(state, test) => { 157 | color = Color.parse('#1234'); 158 | expect(color.asString(Color.Format.RGB)).toBe(null); 159 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(17, 34, 51, 0.26666666666666666)'); 160 | expect(color.asString(Color.Format.HSL)).toBe(null); 161 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(210, 50%, 13%, 0.26666666666666666)'); 162 | expect(color.asString(Color.Format.HEXA)).toBe('#11223344'); 163 | expect(color.asString(Color.Format.HEX)).toBe(null); 164 | expect(color.asString(Color.Format.ShortHEXA)).toBe('#1234'); 165 | expect(color.asString(Color.Format.ShortHEX)).toBe(null); 166 | expect(color.asString()).toBe('#1234'); 167 | }); 168 | it('hex4', async(state, test) => { 169 | color = Color.parse('#0FF'); 170 | expect(color.asString(Color.Format.RGB)).toBe('rgb(0, 255, 255)'); 171 | expect(color.asString(Color.Format.RGBA)).toBe('rgba(0, 255, 255, 1)'); 172 | expect(color.asString(Color.Format.HSL)).toBe('hsl(180, 100%, 50%)'); 173 | expect(color.asString(Color.Format.HSLA)).toBe('hsla(180, 100%, 50%, 1)'); 174 | expect(color.asString(Color.Format.HEXA)).toBe('#00ffffff'); 175 | expect(color.asString(Color.Format.HEX)).toBe('#00ffff'); 176 | expect(color.asString(Color.Format.ShortHEXA)).toBe('#0fff'); 177 | expect(color.asString(Color.Format.ShortHEX)).toBe('#0FF'); 178 | expect(color.asString()).toBe('#0ff'); 179 | }); 180 | }); 181 | 182 | }; 183 | -------------------------------------------------------------------------------- /test/folder/index.html: -------------------------------------------------------------------------------- 1 | hello file -------------------------------------------------------------------------------- /test/folder/redirect.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /test/headful.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const {TestRunner, Reporter, Matchers} = require('@pptr/testrunner'); 18 | 19 | const path = require('path'); 20 | const carlo = require('../lib/carlo'); 21 | 22 | // Runner holds and runs all the tests 23 | const testRunner = new TestRunner({ 24 | parallel: 1, // run 2 parallel threads 25 | timeout: 3000, // setup timeout of 1 second per test 26 | }); 27 | const {expect} = new Matchers(); 28 | const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; 29 | const {describe, xdescribe, fdescribe} = testRunner; 30 | const {it, fit, xit} = testRunner; 31 | 32 | describe('app reuse', () => { 33 | fit('load returns value', async() => { 34 | app = await carlo.launch(); 35 | let callback; 36 | const windowPromise = new Promise(f => callback = f); 37 | app.on('window', callback); 38 | 39 | try { 40 | await carlo.launch({paramsForReuse: {val: 42}}); 41 | expect(false).toBeTruthy(); 42 | } catch (e) { 43 | expect(e.toString()).toContain('already running'); 44 | } 45 | 46 | const window = await windowPromise; 47 | expect(JSON.stringify(window.paramsForReuse())).toBe('{"val":42}'); 48 | }); 49 | }); 50 | 51 | // Reporter subscribes to TestRunner events and displays information in terminal 52 | new Reporter(testRunner); 53 | 54 | // Run all tests. 55 | testRunner.run(); 56 | -------------------------------------------------------------------------------- /test/http/index.html: -------------------------------------------------------------------------------- 1 | hello http -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const {TestRunner, Reporter, Matchers} = require('@pptr/testrunner'); 18 | const {TestServer} = require('@pptr/testserver'); 19 | 20 | const path = require('path'); 21 | const carlo = require('../lib/carlo'); 22 | carlo.enterTestMode(); 23 | 24 | // Runner holds and runs all the tests 25 | const testRunner = new TestRunner({ 26 | parallel: 1, // run 2 parallel threads 27 | timeout: 3000, // setup timeout of 1 second per test 28 | }); 29 | const {expect} = new Matchers(); 30 | const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; 31 | 32 | beforeAll(async state => { 33 | const assetsPath = path.join(__dirname, 'http'); 34 | 35 | const port = 8907 + state.parallelIndex * 2; 36 | state.server = await TestServer.create(assetsPath, port); 37 | state.server.PORT = port; 38 | state.server.PREFIX = `http://localhost:${port}`; 39 | 40 | const httpsPort = port + 1; 41 | state.httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort); 42 | state.httpsServer.PORT = httpsPort; 43 | state.httpsServer.PREFIX = `https://localhost:${httpsPort}`; 44 | }); 45 | 46 | afterAll(async({server, httpsServer}) => { 47 | await Promise.all([ 48 | server.stop(), 49 | httpsServer.stop(), 50 | ]); 51 | }); 52 | 53 | beforeEach(async({server, httpsServer}) => { 54 | server.reset(); 55 | httpsServer.reset(); 56 | }); 57 | 58 | require('./app.spec.js').addTests({testRunner, expect}); 59 | require('./color.spec.js').addTests({testRunner, expect}); 60 | 61 | // Reporter subscribes to TestRunner events and displays information in terminal 62 | new Reporter(testRunner); 63 | 64 | // Run all tests. 65 | testRunner.run(); 66 | --------------------------------------------------------------------------------