Repository: faressoft/terminalizer Branch: master Commit: 7b7da2913987 Files: 25 Total size: 70.7 KB Directory structure: gitextract__ztfea2a/ ├── .gitignore ├── .jscsrc ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── app.js ├── bin/ │ └── app.js ├── commands/ │ ├── config.js │ ├── generate.js │ ├── init.js │ ├── play.js │ ├── record.js │ ├── render.js │ └── share.js ├── config.yml ├── di.js ├── package.json ├── render/ │ ├── index.html │ ├── index.js │ ├── preload.js │ └── src/ │ ├── css/ │ │ └── app.css │ └── js/ │ └── app.js ├── utility.js └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Rendering data render/frames/* render/data.json # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed # Dependency directories node_modules # Optional npm cache directory .npm # Build render/dist # Private assets demo logo # Others .DS_Store ================================================ FILE: .jscsrc ================================================ { "requireCurlyBraces": [ "if", "else", "for", "while", "do", "try", "catch" ], "requireSpaceAfterKeywords": [ "if", "else", "for", "while", "do", "switch", "return", "try", "catch" ], "requireSemicolons": true, "requireSpacesInForStatement": true, "requireSpaceBeforeBlockStatements": true, "requireParenthesesAroundIIFE": true, "requireSpacesInConditionalExpression": true, "requireSpacesInAnonymousFunctionExpression": { "beforeOpeningCurlyBrace": true }, "requireSpacesInNamedFunctionExpression": { "beforeOpeningCurlyBrace": true }, "requireBlocksOnNewline": true, "disallowEmptyBlocks": false, "disallowSpacesInsideObjectBrackets": true, "disallowSpacesInsideArrayBrackets": true, "disallowSpacesInsideParentheses": true, "requireSpaceAfterComma": true, "disallowSpaceAfterPrefixUnaryOperators": [ "++", "--", "+", "-", "~", "!" ], "disallowSpaceBeforePostfixUnaryOperators": [ "++", "--" ], "requireSpaceBeforeBinaryOperators": [ "=", "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", ">>>=", "&=", "|=", "^=", "+", "-", "*", "/", "%", "<<", ">>", ">>>", "&", "|", "^", "&&", "||", "===", "==", ">=", "<=", "<", ">", "!=", "!==" ], "requireSpaceAfterBinaryOperators": true, "requireCamelCaseOrUpperCaseIdentifiers": { "ignoreProperties": true }, "disallowKeywords": [ "with" ], "disallowMultipleLineStrings": true, "validateLineBreaks": "LF", "validateIndentation": 2, "disallowTrailingComma": true, "requireLineFeedAtFileEnd": true, "validateQuoteMarks": { "mark": "'", "escape": true }, "requireCapitalizedComments": true, "requireSpaceAfterLineComment": { "allExcept": [ "//////////////////////////////////////////////////" ] }, "jsDoc": { "checkAnnotations": true, "checkRedundantAccess": true, "checkTypes": "capitalizedNativeCase", "requireNewlineAfterDescription": true, "checkParamExistence": true, "checkParamNames": true, "requireParamTypes": true, "checkRedundantParams": true, "requireReturnTypes": true } } ================================================ FILE: .npmignore ================================================ # Rendering data render/frames/* render/data.json # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed # Dependency directories node_modules # Optional npm cache directory .npm # Whitelist build !dist ================================================ FILE: .prettierrc ================================================ { "semi": true, "singleQuote": true, "printWidth": 120 } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Mohammad Fares Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

# Terminalizer [![npm](https://img.shields.io/npm/v/terminalizer.svg)](https://www.npmjs.com/package/terminalizer) [![npm](https://img.shields.io/npm/l/terminalizer.svg)](https://github.com/faressoft/terminalizer/blob/master/LICENSE) [![Gitter](https://badges.gitter.im/join_chat.svg)](https://gitter.im/terminalizer/Lobby) [![Unicorn](https://img.shields.io/badge/nyancat-approved-ff69b4.svg)](https://www.youtube.com/watch?v=QH2-TGUlwu4) [![Tweet](https://img.shields.io/badge/twitter-share-76abec.svg)](https://goo.gl/QJzJu1) > Record your terminal and generate animated gif images or share a web player link [www.terminalizer.com](https://www.terminalizer.com)

Built to be jusT cOol 👌🦄 ! > If you think so, support me with a `star` and a `follow` 😘 ---

--- # Table of Contents - [Terminalizer](#terminalizer) - [Table of Contents](#table-of-contents) - [Features](#features) - [What's Next](#whats-next) - [Installation](#installation) - [Getting Started](#getting-started) - [Compression](#compression) - [Usage](#usage) - [Init](#init) - [Config](#config) - [Record](#record) - [Play](#play) - [Render](#render) - [Share](#share) - [Generate](#generate) - [Configurations](#configurations) - [Recording](#recording) - [Delays](#delays) - [GIF](#gif) - [Terminal](#terminal) - [Theme](#theme) - [Watermark](#watermark) - [Frame Box](#frame-box) - [Null Frame](#null-frame) - [Window Frame](#window-frame) - [Floating Frame](#floating-frame) - [Solid Frame](#solid-frame) - [Solid Frame Without Title](#solid-frame-without-title) - [Styling Hint](#styling-hint) - [FAQ](#faq) - [How to support ZSH](#how-to-support-zsh) - [Issues](#issues) - [License](#license) # Features - Highly customizable. - Cross platform (Linux, Windows, MacOS). - Custom `window frames`. - Custom `font`. - Custom `colors`. - Custom `styles` with `CSS`. - Watermark. - Edit frames and adjust delays before rendering. - Skipping frames by a step value to reduce the number of rendered frames. - Render images with texts on them instead of capturing your screen for better quality. - The ability to configure: - The command to capture (bash, powershell.exe, yourOwnCommand, etc) - The current working directory. - Explicit values for the number of cols and rows. - GIF quality and repeating. - Frames delays. - The max idle time between frames. - Cursor style. - Font. - Font size. - Line height. - Letter spacing. - Theme. # What's Next - The `Generate` command to generate a web player for a recording file. - Support `apt-get`, `yum`, `brew` installation. # Installation You need to install [Node.js](https://nodejs.org/en/download/) first, then install the tool globally using this command: ```bash yarn global add terminalizer ```

> Still facing an issue? Check the [Issues](#issues) section or open a new issue. The installation should be very smooth with Node.js v4-v16. For newer versions, if the installation is failed, you may need to install the development tools to build the `C++` add-ons. Check [node-gyp](https://github.com/nodejs/node-gyp#installation). # Getting Started Start recording your terminal using the `record` command. ```bash terminalizer record demo ``` A file called `demo.yml` will be created in the current directory. You can open it using any editor to edit the configurations and the recorded frames. You can replay your recording using the `play` command. ```bash terminalizer play demo ``` Now let's render our recording as an animated gif. ```bash terminalizer render demo ``` ## Compression GIF compression is not implemented yet. For now we recommend [https://gifcompressor.com](https://gifcompressor.com). # Usage > You can use the `--help` option to get more details about the commands and their options ```bash terminalizer [options] ``` ## Init > Create a global config directory ```bash terminalizer init ``` ## Config > Generate a config file in the current directory ```bash terminalizer config ``` ## Record > Record your terminal and create a recording file ```bash terminalizer record ``` Options ``` -c, --config Overwrite the default configurations [string] -d, --command The command to be executed [string] [default: null] -k, --skip-sharing Skip sharing and showing the sharing prompt message [boolean] [default: false] ``` Examples ``` terminalizer record foo Start recording and create a recording file called foo.yml terminalizer record foo --config config.yml Start recording with your own configurations ``` ## Play > Play a recording file on your terminal ```bash terminalizer play ``` Options ``` -r, --real-timing Use the actual delays between frames as recorded [boolean] [default: false] -s, --speed-factor Speed factor, multiply the frames delays by this factor [number] [default: 1] ``` ## Render > Render a recording file as an animated gif image ```bash terminalizer render ``` Options ``` -o, --output A name for the output file [string] -q, --quality The quality of the rendered image (1 - 100) [number] -s, --step To reduce the number of rendered frames (step > 1) [number] [default: 1] ``` ## Share > Upload a recording file and get a link for an online player ```bash terminalizer share ``` ## Generate > Generate a web player for a recording file ```bash terminalizer generate ``` # Configurations The default `config.yml` file is stored under the root directory of the project. Execute the below command to copy it to your current directory. > Use any editor to edit the copied `config.yml`, then use the `-c` option to override the default one. ```bash terminalizer config ``` > RECOMMENDED, use the `init` command to create a global config file to be used instead of the default one. ```bash terminalizer init ``` For Linux and MacOS, the created directory is located under the home directory `~/config/terminalizer`. For Windows, it is located under the `AppData`. ## Recording - `command`: Specify a command to be executed like `/bin/bash -l`, `ls`, or any other command. The default is `bash` for `Linux` or `powershell.exe` for `Windows`. - `cwd`: Specify the current working directory path. The default is the current working directory path. - `env`: Export additional ENV variables, to be read by your scripts when starting the recording. - `cols`: Explicitly set the number of columns or use `auto` to take the current number of columns of your shell. - `rows`: Explicitly set the number of rows or use `auto` to take the current number of rows of your shell. ## Delays - `frameDelay`: The delay between frames in ms. If the value is `auto` use the actual recording delays. - `maxIdleTime`: Maximum delay between frames in ms. Ignored if the `frameDelay` isn't set to `auto`. Set to `auto` to prevent limiting the max idle time. ## GIF - `quality`: The quality of the generated GIF image (1 - 100). - `repeat`: Amount of times to repeat GIF: - If value is `-1`, play once. - If value is `0`, loop indefinitely. - If value is a positive number, loop `n` times. ## Terminal - `cursorStyle`: Cursor style can be one of `block`, `underline`, or `bar`. - `fontFamily`: You can use any font that is installed on your machine like `Monaco` or `Lucida Console` (CSS-like list). - `fontSize`: The size of the font in pixels. - `lineHeight`: The height of lines in pixels. - `letterSpacing`: The spacing between letters in pixels. ## Theme You can set the colors of your terminal using one of the CSS formats: - Hex: `#FFFFFF`. - RGB: `rgb(255, 255, 255)`. - HSL: `hsl(0, 0%, 100%)`. - Name: `white`, `red`, `blue`. > You can use the value `transparent` too. The default colors that are assigned to the terminal colors are: - background: ![#ffffff](https://placehold.it/15/ffffff/000000?text=+) `transparent` - foreground: ![#afafaf](https://placehold.it/15/afafaf/000000?text=+) `#afafaf` - cursor: ![#c7c7c7](https://placehold.it/15/c7c7c7/000000?text=+) `#c7c7c7` - black: ![#232628](https://placehold.it/15/232628/000000?text=+) `#232628` - red: ![#fc4384](https://placehold.it/15/fc4384/000000?text=+) `#fc4384` - green: ![#b3e33b](https://placehold.it/15/b3e33b/000000?text=+) `#b3e33b` - yellow: ![#ffa727](https://placehold.it/15/ffa727/000000?text=+) `#ffa727` - blue: ![#75dff2](https://placehold.it/15/75dff2/000000?text=+) `#75dff2` - magenta: ![#ae89fe](https://placehold.it/15/ae89fe/000000?text=+) `#ae89fe` - cyan: ![#708387](https://placehold.it/15/708387/000000?text=+) `#708387` - white: ![#d5d5d0](https://placehold.it/15/d5d5d0/000000?text=+) `#d5d5d0` - brightBlack: ![#626566](https://placehold.it/15/626566/000000?text=+) `#626566` - brightRed: ![#ff7fac](https://placehold.it/15/ff7fac/000000?text=+) `#ff7fac` - brightGreen: ![#c8ed71](https://placehold.it/15/c8ed71/000000?text=+) `#c8ed71` - brightYellow: ![#ebdf86](https://placehold.it/15/ebdf86/000000?text=+) `#ebdf86` - brightBlue: ![#75dff2](https://placehold.it/15/75dff2/000000?text=+) `#75dff2` - brightMagenta: ![#ae89fe](https://placehold.it/15/ae89fe/000000?text=+) `#ae89fe` - brightCyan: ![#b1c6ca](https://placehold.it/15/b1c6ca/000000?text=+) `#b1c6ca` - brightWhite: ![#f9f9f4](https://placehold.it/15/f9f9f4/000000?text=+) `#f9f9f4` ## Watermark You can add a watermark logo to your generated GIF images.

``` watermark: imagePath: AbsolutePathOrURL style: position: absolute right: 15px bottom: 15px width: 100px opacity: 0.9 ``` - `watermark.imagePath`: An absolute path for the image on your machine or a URL. - `watermark.style`: Apply CSS styles (camelCase) to the watermark image, like resizing it. ## Frame Box Terminalizer comes with predefined frames that you can use to make your GIF images look cool. - `frameBox.type`: Can be `null`, `window`, `floating`, or `solid`. - `frameBox.title`: To display a title for the frame or `null`. - `frameBox.style`: To apply custom CSS styles or to override the current ones. ### Null Frame No frame, just your recording.

> Don't forget to add a `backgroundColor` under `style`. ``` frameBox: type: null title: null style: backgroundColor: black ``` ### Window Frame

``` frameBox: type: window title: Terminalizer style: [] ``` ### Floating Frame

``` frameBox: type: floating title: Terminalizer style: [] ``` ### Solid Frame

``` frameBox: type: solid title: Terminalizer style: [] ``` ### Solid Frame Without Title

``` frameBox: type: solid title: null style: [] ``` ### Styling Hint You can disable the default shadows and margins.

``` frameBox: type: solid title: null style: boxShadow: none margin: 0px ``` # FAQ ### How to support ZSH The default command that gets recorded for Linux is `bash -l`. You need to change the default command to `zsh`. - Generate a config file in the current directory ```bash terminalizer config ``` - Open the generated config file in your preferred editor. - Change the `command` to `zsh`: ``` command: zsh ``` - You may need to change the font, check the font that is used in your terminal: ``` fontFamily: "Meslo for Powerline, Meslo LG M for Powerline" ``` - Use the `-c` option to override the config file: ```bash terminalizer record demo -c config.yml ``` # Issues > error while loading shared libraries: libXss.so.1: cannot open shared object file: No such file or directory Solution: ```bash sudo yum install libXScrnSaver ``` > error while loading shared libraries: libgconf-2.so.4: cannot open shared object file: No such file or directory Solution: ```bash sudo apt-get install libgconf-2-4 ``` > Error: EACCES: permission denied, access '/usr/local/lib' Solution: ```bash sudo mkdir -p /usr/local/lib/node_modules && sudo chown -R $(whoami):$(whoami) /usr/local/lib/node_modules # then use the install command in the "Installation" section above yarn global add terminalizer ``` # License This project is under the MIT license. ================================================ FILE: app.js ================================================ /** * Terminalizer * * @author Mohammad Fares */ var yargs = require('yargs'), requireDir = require('require-dir'); var package = require('./package.json'), commands = requireDir('./commands'), DI = require('./di.js'); // Define the DI as a global object global.di = new DI(); // Define the the root path of the app as a global constant global.ROOT_PATH = __dirname; // The base url of the Terminalizer website // `www` is necessary due to https://github.com/faressoft/terminalizer/issues/207 global.BASEURL = 'https://www.terminalizer.com'; // Dependency Injection di.require('chalk'); di.require('async'); di.require('axios'); di.require('death'); di.require('path'); di.require('os'); di.require('electron'); di.require('deepmerge'); di.require('uuid'); di.require('tmp'); di.require('lodash', '_'); di.require('fs-extra', 'fs'); di.require('js-yaml', 'yaml'); di.require('performance-now', 'now'); di.require('async-promises', 'asyncPromises'); di.require('string-argv', 'stringArgv'); di.require('progress', 'ProgressBar'); di.require('gif-encoder', 'GIFEncoder'); di.require('inquirer'); di.set('pty', require('@homebridge/node-pty-prebuilt-multiarch')); di.set('PNG', require('pngjs').PNG); di.set('spawn', require('child_process').spawn); di.set('utility', require('./utility.js')); di.set('commands', commands); di.set('errorHandler', errorHandler); // Initialize yargs yargs.usage('Usage: $0 [options]') // Add link .epilogue('For more information, check https://www.terminalizer.com') // Set the version number .version(package.version) // Add aliases for version and help options .alias({v: 'version', h: 'help'}) // Require to pass a command .demandCommand(1, 'The command is missing') // Strict mode .strict() // Set width to 90 cols .wrap(100) // Handle failures .fail(errorHandler); // Load commands yargs.command(commands.init) .command(commands.config) .command(commands.record) .command(commands.play) .command(commands.render) .command(commands.share) .command(commands.generate) debugger; try { // Parse the command line arguments yargs.parse(); } catch (error) { // Print the error errorHandler(error); } /** * Print an error * * @param {String|Error} error */ function errorHandler(error) { error = error.toString(); console.error('Error: \n ' + error + '\n'); console.error('Hint:\n Use the ' + di.chalk.green('--help') + ' option to get help about the usage'); process.exit(1); } ================================================ FILE: bin/app.js ================================================ #!/usr/bin/env node /** * Terminalizer * * @author Mohammad Fares */ require('../app.js'); ================================================ FILE: commands/config.js ================================================ /** * Config * Generate a config file in the current directory * * @author Mohammad Fares */ /** * Executed after the command completes its task */ function done() { console.log(di.chalk.green('Successfully Saved')); console.log('The config file is saved into the file:'); console.log(di.chalk.magenta('config.yml')); } /** * The command's main function * * @param {Object} argv */ function command(argv) { // Copy the default config file di.fs.copySync(di.path.join(ROOT_PATH, 'config.yml'), 'config.yml'); done(); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = 'config'; /** * Command's description * @type {String} */ module.exports.describe = 'Generate a config file in the current directory'; /** * Command's handler function * @type {Function} */ module.exports.handler = command; ================================================ FILE: commands/generate.js ================================================ /** * Generate * Generate a web player for a recording file * * @author Mohammad Fares */ /** * Executed after the command completes its task */ function done() { // Terminate the app process.exit(); } /** * The command's main function * * @param {Object} argv */ function command(argv) { console.log('This command is not implemented yet. It will be available in the next versions'); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = 'generate '; /** * Command's description * @type {String} */ module.exports.describe = 'Generate a web player for a recording file'; /** * Command's handler function * @type {Function} */ module.exports.handler = command; /** * Builder * * @param {Object} yargs */ module.exports.builder = function(yargs) { // Define the recordingFile argument yargs.positional('recordingFile', { describe: 'the recording file', type: 'string', coerce: di.utility.loadYAML }); }; ================================================ FILE: commands/init.js ================================================ /** * Init * Create a global config directory for Terminalizer * * - Create a global config directory * - For Windows, create it under `APPDATA` * - For Linux and MacOS, create it under the home directory * - Copy the default config into it * * @author Mohammad Fares */ /** * Executed after the command completes its task */ function done() { console.log(di.chalk.green('The global config directory is created at')); console.log(di.chalk.magenta(di.utility.getGlobalDirectory())); } /** * The command's main function * * @param {Object} argv */ function command(argv) { var globalPath = di.utility.getGlobalDirectory(); // Create the global directory try { di.fs.mkdirSync(di.utility.getGlobalDirectory(), { recursive: true }); } catch (error) { // Ignore `already exists` error if (error.code != 'EEXIST') { throw error; } } // Copy the default config file di.fs.copySync(di.path.join(ROOT_PATH, 'config.yml'), di.path.join(globalPath, 'config.yml'), {overwrite: true}); done(); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = 'init'; /** * Command's description * @type {String} */ module.exports.describe = 'Create a global config directory'; /** * Command's handler function * @type {Function} */ module.exports.handler = command; ================================================ FILE: commands/play.js ================================================ /** * Play * Play a recording file on your terminal * * @author Mohammad Fares */ /** * Print the passed content * * @param {String} content * @param {Function} callback */ function playCallback(content, callback) { process.stdout.write(content); callback(); } /** * Executed after the command completes its task */ function done() { // Full reset for the terminal process.stdout.write('\033c'); process.exit(); } /** * The command's main function * * @param {Object} argv */ function command(argv) { process.stdin.pause(); // Playing options var options = { frameDelay: argv.recordingFile.json.config.frameDelay, maxIdleTime: argv.recordingFile.json.config.maxIdleTime }; // Use the actual delays between frames as recorded if (argv.realTiming) { options = { frameDelay: 'auto', maxIdleTime: 'auto' }; } // When app is closing di.death(done); // Add the speedFactor option options.speedFactor = argv.speedFactor; // Adjust frames delays adjustFramesDelays(argv.recordingFile.json.records, options); // Play the recording records play(argv.recordingFile.json.records, playCallback, null, options); } /** * Adjust frames delays * * Options: * * - frameDelay (default: auto) * - Delay between frames in ms * - If the value is `auto` use the actual recording delays * * - maxIdleTime (default: 2000) * - Maximum delay between frames in ms * - Ignored if the `frameDelay` isn't set to `auto` * - Set to `auto` to prevent limiting the max idle time * * - speedFactor (default: 1) * - Multiply the frames delays by this factor * * @param {Array} records * @param {Object} options (optional) */ function adjustFramesDelays(records, options) { // Default value for options if (typeof options === 'undefined') { options = {}; } // Default value for options.frameDelay if (typeof options.frameDelay === 'undefined') { options.frameDelay = 'auto'; } // Default value for options.maxIdleTime if (typeof options.maxIdleTime === 'undefined') { options.maxIdleTime = 2000; } // Default value for options.speedFactor if (typeof options.speedFactor === 'undefined') { options.speedFactor = 1; } // Foreach record records.forEach(function(record) { // Adjust the delay according to the options if (options.frameDelay != 'auto') { record.delay = options.frameDelay; } else if (options.maxIdleTime != 'auto' && record.delay > options.maxIdleTime) { record.delay = options.maxIdleTime; } // Apply speedFactor record.delay = record.delay * options.speedFactor; }); } /** * Play recording records * * @param {Array} records * @param {Function} playCallback * @param {Function|Null} doneCallback */ function play(records, playCallback, doneCallback) { var tasks = []; // Default value for doneCallback if (typeof doneCallback === 'undefined') { doneCallback = null; } // Foreach record records.forEach(function(record) { tasks.push(function(callback) { setTimeout(function() { playCallback(record.content, callback); }, record.delay); }); }); di.async.series(tasks, function(error, results) { if (doneCallback) { doneCallback(); } }); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = 'play '; /** * Command's description * @type {String} */ module.exports.describe = 'Play a recording file on your terminal'; /** * Command's handler function * @type {Function} */ module.exports.handler = command; /** * Builder * * @param {Object} yargs */ module.exports.builder = function(yargs) { // Define the recordingFile argument yargs.positional('recordingFile', { describe: 'The recording file', type: 'string', coerce: di.utility.loadYAML }); // Define the real-timing option yargs.option('r', { alias: 'real-timing', describe: 'Use the actual delays between frames as recorded', type: 'boolean', default: false }); // Define the speed-factor option yargs.option('s', { alias: 'speed-factor', describe: 'Speed factor, multiply the frames delays by this factor', type: 'number', default: 1.0 }); }; //////////////////////////////////////////////////// // Module ////////////////////////////////////////// //////////////////////////////////////////////////// // Play recording records module.exports.play = play; // Adjust frames delays module.exports.adjustFramesDelays = adjustFramesDelays; ================================================ FILE: commands/record.js ================================================ /** * Record * Record your terminal and create a recording file * * @author Mohammad Fares */ /** * The path of the recording file * @type {String} */ var recordingFile = null; /** * The normalized configurations * @type {Object} {json, raw} */ var config = {}; /** * To keep tracking of the timestamp * of the last inserted record * @type {Number} */ var lastRecordTimestamp = null; /** * To store the records * @type {Array} */ var records = []; /** * Normalize the config file * * - Set default values in the json and raw * - Change the formatting of the values in the json and raw * * @param {Object} config {json, raw} * @return {Object} {json, raw} */ function normalizeConfig(config) { // Default value for command if (!config.json.command) { // Windows OS if (di.os.platform() === 'win32') { di.utility.changeYAMLValue(config, 'command', 'powershell.exe'); } else { di.utility.changeYAMLValue(config, 'command', 'bash -l'); } } // Default value for cwd if (!config.json.cwd) { di.utility.changeYAMLValue(config, 'cwd', process.cwd()); } else { di.utility.changeYAMLValue(config, 'cwd', di.path.resolve(config.json.cwd)); } // Default value for cols if (isNaN(config.json.cols)) { di.utility.changeYAMLValue(config, 'cols', process.stdout.columns); } // Default value for rows if (isNaN(config.json.rows)) { di.utility.changeYAMLValue(config, 'rows', process.stdout.rows); } return config; } /** * Calculate the duration from the last inserted record in ms, * and update lastRecordTimestamp * * @return {Number} */ function getDuration() { // Calculate the duration from the last inserted record var duration = di.now().toFixed() - lastRecordTimestamp; // Update the lastRecordTimestamp lastRecordTimestamp = di.now().toFixed(); return duration; } /** * When an input or output is received from the PTY instance * * @param {Buffer} content */ function onData(content) { process.stdout.write(content); var duration = getDuration(); if (duration < 5) { var lastRecord = records[records.length - 1]; lastRecord.content += content; return; } records.push({ delay: duration, content: content }); } /** * Executed after the command completes its task * Store the output file with reserving the comments * * @param {Object} argv */ function done(argv) { var outputYAML = ''; // Add config parent element outputYAML += '# The configurations that used for the recording, feel free to edit them\n'; outputYAML += 'config:\n\n'; // Add the configurations with indentation outputYAML += config.raw.replace(/^/gm, ' '); // Add the records outputYAML += '\n# Records, feel free to edit them\n'; outputYAML += di.yaml.dump({records: records}); // Store the data into the recording file try { di.fs.writeFileSync(recordingFile, outputYAML, 'utf8'); } catch (error) { return di.errorHandler(error); } console.log(di.chalk.green('Successfully Recorded')); console.log('The recording data is saved into the file:'); console.log(di.chalk.magenta(recordingFile)); console.log('You can edit the file and even change the configurations.'); console.log( "The command " + di.chalk.magenta("`terminalizer share`") + "can be used anytime to share recordings!" ); // Reset STDIN process.stdin.setRawMode(false); process.stdin.pause(); if (argv.skipSharing) { return } di.inquirer.prompt([ { type: "confirm", name: "share", message: "Would you like to share your recording on www.terminalizer.com?", }, ]).then(function(answers) { if (!answers.share) { return; } console.log( di.chalk.green( "Let's now share your recording on https://www.terminalizer.com" ) ); // Invoke the share command di.commands.share.handler({ recordingFile: recordingFile, }); }); } /** * The command's main function * * @param {Object} argv */ function command(argv) { // Normalize the configurations config = normalizeConfig(argv.config); // Store the path of the recordingFile recordingFile = argv.recordingFile; // Overwrite the command to be executed if (argv.command) { di.utility.changeYAMLValue(config, 'command', argv.command); } // Split the command and its arguments var args = di.stringArgv(config.json.command); var command = args[0]; var commandArguments = args.slice(1); // PTY instance var ptyProcess = di.pty.spawn(command, commandArguments, { cols: config.json.cols, rows: config.json.rows, cwd: config.json.cwd, env: di.deepmerge(process.env, config.json.env) }); var onInput = ptyProcess.write.bind(ptyProcess); console.log('The recording session is started'); console.log('Press', di.chalk.green('CTRL+D'), 'to exit and save the recording'); // Input and output capturing and redirection process.stdin.on('data', onInput); ptyProcess.on('data', onData); ptyProcess.on('exit', function() { process.stdin.removeListener('data', onInput); done(argv); }); // Input and output normalization process.stdout.setDefaultEncoding('utf8'); process.stdin.setEncoding('utf8'); process.stdin.setRawMode(true); process.stdin.resume(); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = 'record '; /** * Command's description * @type {String} */ module.exports.describe = 'Record your terminal and create a recording file'; /** * Handler * * @param {Object} argv */ module.exports.handler = function(argv) { // Default value for the config option if (typeof argv.config == 'undefined') { argv.config = di.utility.getDefaultConfig(); } // Execute the command command(argv); }; /** * Builder * * @param {Object} yargs */ module.exports.builder = function(yargs) { // Define the recordingFile argument yargs.positional('recordingFile', { describe: 'A name for the recording file', type: 'string', coerce: di._.partial(di.utility.resolveFilePath, di._, 'yml') }); // Define the config option yargs.option('c', { alias: 'config', type: 'string', describe: 'Overwrite the default configurations', requiresArg: true, coerce: di.utility.loadYAML }); // Define the config option yargs.option('d', { alias: 'command', type: 'string', describe: 'The command to be executed', requiresArg: true, default: null }); // Define the config option yargs.option('k', { alias: 'skip-sharing', type: 'boolean', describe: 'Skip sharing and showing the sharing prompt message', requiresArg: false, default: false }); // Add examples yargs.example('$0 record foo', 'Start recording and create a recording file called foo.yml'); yargs.example('$0 record foo --config config.yml', 'Start recording with your own configurations'); }; ================================================ FILE: commands/render.js ================================================ /** * Render * Render a recording file as an animated gif image * * @author Mohammad Fares */ const tmp = require('tmp'); tmp.setGracefulCleanup(); /** * The directory to render the frames into */ var renderDir = tmp.dirSync({ unsafeCleanup: true }).name; /** * Create a progress bar for processing frames * * @param {String} operation a name for the operation * @param {Number} framesCount * @return {ProgressBar} */ function getProgressBar(operation, framesCount) { return new di.ProgressBar( operation + " " + di.chalk.magenta("frame :current/:total") + " :percent [:bar] :etas", { width: 30, total: framesCount, } ); } /** * Write the recording data into render/data.json * * @param {Object} recordingFile * @return {Promise} */ function writeRecordingData(recordingFile) { return new Promise(function (resolve, reject) { // Write the data into data.json file in the root path of the app di.fs.writeFile( di.path.join(ROOT_PATH, "render/data.json"), JSON.stringify(recordingFile.json), "utf8", function (error) { if (error) { return reject(error); } resolve(); } ); }); } /** * Read and parse a PNG image file * * @param {String} path the absolute path of the image * @return {Promise} resolve with the parsed PNG image */ function loadPNG(path) { return new Promise(function (resolve, reject) { di.fs.readFile(path, function (error, imageData) { if (error) { return reject(error); } new di.PNG().parse(imageData, function (error, data) { if (error) { return reject(error); } resolve(data); }); }); }); } /** * Get the dimensions of the first rendered frame * * @return {Promise} */ function getFrameDimensions() { // The path of the first rendered frame var framePath = di.path.join(renderDir, "0.png"); // Read and parse a PNG image file return loadPNG(framePath).then(function (png) { return { width: png.width, height: png.height, }; }); } /** * Render the frames into PNG images * * @param {Array} records [{delay, content}, ...] * @param {Object} options {step} * @return {Promise} */ function renderFrames(records, options) { return new Promise(function (resolve, reject) { // The number of frames var framesCount = records.length; // Track execution time var start = Date.now(); // Create a progress bar var progressBar = getProgressBar( "Rendering", Math.ceil(framesCount / options.step) ); // Execute the rendering process var render = di.spawn( di.electron, [di.path.join(ROOT_PATH, "render/index.js"), renderDir, options.step,], { detached: false } ); render.stdout.on('data', onData); render.stderr.on('data', onError); render.on('close', onClose); // Track progress of rendering through stdout function onData(data) { // Is not a recordIndex (to skip Electron's logs or new lines) if (isNaN(parseInt(data.toString()))) { return; } progressBar.tick(); } // Track rendering errors observed on stderr function onError(error) { // If error is Buffer, print it, otherwise reject if (!!error && error instanceof Buffer) { console.log(di.chalk.yellow(`[render] ${error.toString('utf8').trim()}`)); } else { render.kill(); reject(new Error("Unknown error [" + typeof error + "]: " + error)); } } // React when rendering process finishes function onClose(code) { if (code !== 0) { reject(new Error("Rendering exited with code " + code)); } else { if (progressBar.complete) { console.log(di.chalk.green('[render] Process successfully completed in ' + (Date.now() - start) + 'ms.')); } else { console.log(di.chalk.yellow('[render] Process completion unverified')); } resolve(); } }; }); } /** * Merge the rendered frames into an animated GIF image * * @param {Array} records [{delay, content}, ...] * @param {Object} options {quality, repeat, step, outputFile} * @param {Object} frameDimensions {width, height} * @return {Promise} */ function mergeFrames(records, options, frameDimensions) { return new Promise(function (resolve, reject) { // The number of frames var framesCount = records.length; // Track execution time var start = Date.now(); // Used for the step option var stepsCounter = 0; // Create a progress bar var progressBar = getProgressBar( "Merging", Math.ceil(framesCount / options.step) ); // The gif image var gif = new di.GIFEncoder(frameDimensions.width, frameDimensions.height, { highWaterMark: 5 * 1024 * 1024, }); // Pipe gif.pipe(di.fs.createWriteStream(options.outputFile)); // Quality gif.setQuality(101 - options.quality); // Repeat gif.setRepeat(options.repeat); // Write the headers gif.writeHeader(); di.async.eachOfSeries( records, function (frame, index, callback) { if (stepsCounter != 0) { stepsCounter = (stepsCounter + 1) % options.step; return callback(); } stepsCounter = (stepsCounter + 1) % options.step; // The path of the rendered frame var framePath = di.path.join(renderDir, index + ".png"); // Read and parse the rendered frame loadPNG(framePath) .then(function (png) { progressBar.tick(); // Set the duration (the delay of the next frame) // The % is used to take the delay of the first frame // as the duration of the last frame gif.setDelay(records[(index + 1) % framesCount].delay); // Add frames gif.addFrame(png.data); // Next callback(); }) .catch(function (error) { callback(error); }); }, function (error) { if (error) { return reject(error); } // Write the footer gif.finish(); // Finish console.log(di.chalk.green('[merge] Process successfully completed in ' + (Date.now() - start) + 'ms.')); resolve(); } ); }); } /** * Delete the temporary rendered PNG images * * @return {Promise} */ function cleanup() { return new Promise(function (resolve, reject) { di.fs.emptyDir(di.path.join(ROOT_PATH, "render/frames"), function (error) { if (error) { return reject(error); } resolve(); }); }); } /** * Executed after the command completes its task * * @param {String} outputFile the path of the rendered image */ function done(outputFile) { console.log("\n" + di.chalk.green("Successfully Rendered")); console.log("The animated GIF image is saved into the file:"); console.log(di.chalk.magenta(outputFile)); process.exit(); } /** * The command's main function * * @param {Object} argv */ function command(argv) { // Frames var records = argv.recordingFile.json.records; var config = argv.recordingFile.json.config; // Number of frames in the recording file var framesCount = records.length; // The path of the output file var outputFile = di.utility.resolveFilePath( "render" + Date.now(), "gif" ); // For adjusting (calculating) the frames delays var adjustFramesDelaysOptions = { frameDelay: config.frameDelay, maxIdleTime: config.maxIdleTime, }; // For rendering the frames into PNG images var renderingOptions = { step: argv.step, }; // For merging the rendered frames into an animated GIF image var mergingOptions = { quality: config.quality, repeat: config.repeat, step: argv.step, outputFile: outputFile, }; // Overwrite the quality of the rendered image if (argv.quality) { mergingOptions.quality = argv.quality; } // Overwrite the outputFile of the rendered image if (argv.output) { outputFile = argv.output; mergingOptions.outputFile = argv.output; } // Tasks di.asyncPromises .waterfall([ // Remove all previously rendered frames cleanup, // Write the recording data into render/data.json di._.partial(writeRecordingData, argv.recordingFile), // Render the frames into PNG images di._.partial(renderFrames, records, renderingOptions), // Adjust frames delays di._.partial( di.commands.play.adjustFramesDelays, records, adjustFramesDelaysOptions ), // Get the dimensions of the first rendered frame di._.partial(getFrameDimensions), // Merge the rendered frames into an animated GIF image di._.partial(mergeFrames, records, mergingOptions), // Delete the temporary rendered PNG images cleanup, ]) .then(function () { done(outputFile); }) .catch(di.errorHandler); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = "render "; /** * Command's description * @type {String} */ module.exports.describe = "Render a recording file as an animated gif image"; /** * Command's handler function * @type {Function} */ module.exports.handler = command; /** * Builder * * @param {Object} yargs */ module.exports.builder = function (yargs) { // Define the recordingFile argument yargs.positional("recordingFile", { describe: "The recording file", type: "string", coerce: di.utility.loadYAML, }); // Define the output option yargs.option("o", { alias: "output", type: "string", describe: "A name for the output file", requiresArg: true, coerce: di._.partial(di.utility.resolveFilePath, di._, "gif"), }); // Define the quality option yargs.option("q", { alias: "quality", type: "number", describe: "The quality of the rendered image (1 - 100)", requiresArg: true, }); // Define the quality option yargs.option("s", { alias: "step", type: "number", describe: "To reduce the number of rendered frames (step > 1)", requiresArg: true, default: 1, }); }; ================================================ FILE: commands/share.js ================================================ /** * Share * Upload a recording file and get a link for an online player * * @author Mohammad Fares */ /** * Executed after the command completes its task * * @param {String} url the url of the uploaded recording */ function done(url) { console.log(di.chalk.green('Successfully Uploaded')); console.log('The recording is available on the link:'); console.log(di.chalk.magenta(url)); process.exit(); } /** * Check if the value is not an empty value * * - Throw `Required field` if empty * * @param {String} input * @return {Boolean} */ function isSet(input) { if (!input) { return new Error('Required field'); } return true; } /** * Get a token for uploading recordings * * - Check if already registered * - Yes: use the token * - No: Generate a new one and ask the user to register it * * @return {Promise} */ function getToken(context) { var token = di.utility.getToken(); // Already registered if (token) { return Promise.resolve(token); } token = di.utility.generateToken(); console.log('Open the following link in your browser and login into your account'); console.log(di.chalk.dim(BASEURL + '/token?token=' + token) + '\n'); // Continue action return new Promise(function (resolve, reject) { console.log('When you do it, press any key to continue'); process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.once('data', function handler(data) { // Check if CTRL+C is pressed to exit if (data == '\u0003' || data == '\u0003') { process.exit(); } console.log(di.chalk.dim('Enjoy !') + '\n'); process.stdin.pause(); process.stdin.setRawMode(false); resolve(token); }); }); } /** * Ask the user to enter meta data about the recording * * - Skip the task if already executed before * and resolve with the last result * * @param {Object} context * @return {Promise} */ function getMeta(context) { var platform = di.utility.getOS(); // Already executed if (typeof context.getMeta != 'undefined') { return Promise.resolve(context.getMeta); } console.log('Please enter some details about your recording'); return di.inquirer .prompt([ { type: 'input', name: 'title', message: 'Title', validate: isSet, }, { type: 'input', name: 'description', message: 'Description', validate: isSet, }, { type: 'input', name: 'tags', message: 'Tags ' + di.chalk.dim('such as git,bash,game'), validate: isSet, default: platform, }, ]) .then(function (answers) { var params = Object.assign({}, answers); // Add the platform params.platform = platform; // Print a new line console.log(); return params; }); } /** * Upload the recording * * - If the token is rejected * - Delete the token * - Jump into the getToken task * * - Resolve with the url of the uploaded recording * * @param {Object} context * @return {Promise} */ function shareRecording(context) { var self = this; var token = context.getToken; var meta = context.getMeta; var recordingFile = context.recordingFile; var options = { method: 'POST', url: BASEURL + '/v1/recording', formData: { title: meta.title, description: meta.description, tags: meta.tags, platform: meta.platform, token: token, file: { value: di.fs.createReadStream(recordingFile), options: { filename: 'recording.yml', contentType: 'application/x-yaml', }, }, }, }; return di .axios(options) .then((response) => { // Internal server error if (response.status === 500) { throw new Error(response.data.errors.join('\n')); } // Invalid input if (response.status === 400) { throw new Error(response.data.errors.join('\n')); } // Invalid token if (response.status === 401) { di.utility.removeToken(); self.jump('getToken'); return; } // Unexpected error if (response.status !== 200) { throw new Error('Something went wrong, try again later'); } // Resolve with the URL from the response body return response.data.url; }) .catch((error) => { // Reject the promise with the error throw error; }); } /** * The command's main function * * @param {Object} argv */ function command(argv) { // No global config if (!di.utility.isGlobalDirectoryCreated()) { require('./init.js').handler(); } var context = { ...argv }; // Get a token for uploading recordings getToken(context) .then(function (token) { context.getToken = token; // Ask the user to enter meta data about the recording return getMeta(context); }) .then(function (meta) { context.getMeta = meta; // Upload the recording return shareRecording(context); }) .then(function (url) { done(url); }) .catch(di.errorHandler); } //////////////////////////////////////////////////// // Command Definition ////////////////////////////// //////////////////////////////////////////////////// /** * Command's usage * @type {String} */ module.exports.command = 'share '; /** * Command's description * @type {String} */ module.exports.describe = 'Upload a recording file and get a link for an online player'; /** * Command's handler function * @type {Function} */ module.exports.handler = command; /** * Builder * * @param {Object} yargs */ module.exports.builder = function (yargs) { // Define the recordingFile argument yargs.positional('recordingFile', { describe: 'the recording file', type: 'string', coerce: di._.partial(di.utility.resolveFilePath, di._, 'yml'), }); }; ================================================ FILE: config.yml ================================================ # Specify a command to be executed # like `/bin/bash -l`, `ls`, or any other commands # the default is bash for Linux # or powershell.exe for Windows command: null # Specify the current working directory path # the default is the current working directory path cwd: null # Export additional ENV variables env: recording: true # Explicitly set the number of columns # or use `auto` to take the current # number of columns of your shell cols: auto # Explicitly set the number of rows # or use `auto` to take the current # number of rows of your shell rows: auto # Amount of times to repeat GIF # If value is -1, play once # If value is 0, loop indefinitely # If value is a positive number, loop n times repeat: 0 # Quality # 1 - 100 quality: 100 # Delay between frames in ms # If the value is `auto` use the actual recording delays frameDelay: auto # Maximum delay between frames in ms # Ignored if the `frameDelay` isn't set to `auto` # Set to `auto` to prevent limiting the max idle time maxIdleTime: 2000 # The surrounding frame box # The `type` can be null, window, floating, or solid` # To hide the title use the value null # Don't forget to add a backgroundColor style with a null as type frameBox: type: floating title: Terminalizer style: border: 0px black solid # boxShadow: none # margin: 0px # Add a watermark image to the rendered gif # You need to specify an absolute path for # the image on your machine or a URL, and you can also # add your own CSS styles watermark: imagePath: null style: position: absolute right: 15px bottom: 15px width: 100px opacity: 0.9 # Cursor style can be one of # `block`, `underline`, or `bar` cursorStyle: block # Font family # You can use any font that is installed on your machine # in CSS-like syntax fontFamily: "Monaco, Lucida Console, Ubuntu Mono, Monospace" # The size of the font fontSize: 12 # The height of lines lineHeight: 1 # The spacing between letters letterSpacing: 0 # Theme theme: background: "transparent" foreground: "#afafaf" cursor: "#c7c7c7" black: "#232628" red: "#fc4384" green: "#b3e33b" yellow: "#ffa727" blue: "#75dff2" magenta: "#ae89fe" cyan: "#708387" white: "#d5d5d0" brightBlack: "#626566" brightRed: "#ff7fac" brightGreen: "#c8ed71" brightYellow: "#ebdf86" brightBlue: "#75dff2" brightMagenta: "#ae89fe" brightCyan: "#b1c6ca" brightWhite: "#f9f9f4" ================================================ FILE: di.js ================================================ /** * Dependency injection * * @author Mohammad Fares */ var path = require('path'), _ = require('lodash'); /** * Dependency injection */ function DI() { /** * Injected dependecies * @type {Object} */ this._dependencies = {}; /** * A wrapper proxy object to set traps * @type {Proxy} */ this._proxy = new Proxy(this, { get: this.getHandler, set: this.setHandler }); return this._proxy; } /** * Trap for getting a property value * * @param {Object} target * @param {String} key * @return {*} */ DI.prototype.getHandler = function(target, key) { if (key in target) { return target[key]; } if (key in target._dependencies) { return target._dependencies[key]; } }; /** * Trap for setting a property value * * @param {Object} target * @param {String} key * @param {*} value * @return {*} */ DI.prototype.setHandler = function(target, key, value) { if (key in target) { throw new Error(`It is not allowed to set '${key}'`); } target._dependencies[key] = value; return true; }; /** * Require and set a package * * - Require the module * - Add the module as depndency * - Format the key as * - Convert the moduleName to camelCase * - Remove the extension * - Resolve third party packages as the native `require`. * - Resolve our own scripts with paths relative to * the app's root path `require`. * * @param {String} moduleName * @param {String} key (Optional) (Default: the moduleName camel cased) */ DI.prototype.require = function(moduleName, key) { var parsedModuleName = path.parse(moduleName); // Default value for key if (typeof key == 'undefined') { key = _.camelCase(parsedModuleName.name); } // Is not a third party package if (parsedModuleName.dir != '') { // Resolve the path to an absolute path moduleName = path.resolve(this._getAppRootPath(), moduleName); } this._dependencies[key] = require(moduleName); }; /** * Inject a dependency * * @param {String} key * @param {*} value */ DI.prototype.set = function(key, value) { this[key] = value; }; /** * Get an injected dependency * * @param {String} key * @return {*} */ DI.prototype.get = function(key) { return this[key]; }; /** * Get the root path of the app * * - Follow the module.parent.parent... etc until null * * @return {String} */ DI.prototype._getAppRootPath = function() { var parent = module.parent; while (parent.parent) { parent = parent.parent; } return path.dirname(parent.filename); }; module.exports = DI; ================================================ FILE: package.json ================================================ { "name": "terminalizer", "version": "0.12.0", "description": "Record your terminal and generate animated gif images or share a web player", "main": "bin/app.js", "author": "Mohammad Fares ", "license": "MIT", "homepage": "https://www.terminalizer.com", "repository": { "type": "git", "url": "https://github.com/faressoft/terminalizer.git" }, "bin": { "terminalizer": "bin/app.js" }, "scripts": { "dev": "NODE_ENV=development webpack --watch", "build": "NODE_ENV=production webpack --progress", "prepublish": "npm run build" }, "keywords": [ "terminal", "record", "capture", "tty", "shot", "bash", "powershell", "gif", "animated", "generate", "theme", "colors", "font", "repeat", "command-line", "shell", "zsh", "bash-profile", "render", "pty" ], "dependencies": { "@homebridge/node-pty-prebuilt-multiarch": "^0.11.14", "async": "^2.6.3", "async-promises": "^0.2.2", "axios": "^1.7.5", "chalk": "^2.4.2", "death": "^1.1.0", "deepmerge": "^2.2.1", "electron": "^25.2.0", "fs-extra": "^5.0.0", "gif-encoder": "^0.6.1", "inquirer": "^6.5.2", "js-yaml": "^3.13.1", "lodash": "^4.17.15", "performance-now": "^2.1.0", "pngjs": "^3.4.0", "progress": "^2.0.3", "require-dir": "^1.1.0", "string-argv": "0.0.2", "tmp": "^0.2.1", "uuid": "^10.0.0", "yargs": "^17.7.2" }, "devDependencies": { "ajv": "^6.12.6", "clean-webpack-plugin": "^4.0.0", "css-loader": "^4.3.0", "jquery": "^3.4.1", "mini-css-extract-plugin": "^2.7.6", "terminalizer-player": "^0.4.1", "webpack": "^5.88.1", "webpack-cli": "^4.10.0", "xterm": "^v3.14.5" } } ================================================ FILE: render/index.html ================================================ Renderer
================================================ FILE: render/index.js ================================================ /** * Render the frames into PNG images * An electron app, takes one command line argument `step` * * @author Mohammad Fares */ const fs = require('fs'); const path = require('path'); const { app } = require('electron'); const { BrowserWindow } = require('electron'); const ipcMain = require('electron').ipcMain; const os = require('os'); let mainWindow = null; /** * The directory to render the frames into * @type {String} */ const renderDir = process.argv[2]; /** * The step option * To reduce the number of rendered frames (step > 1) * @type {Number} */ const step = process.argv[3] || 1; // Hide the Dock for macOS if (os.platform() == 'darwin') { app.dock.hide(); } // When the app is ready app.on('ready', createWindow); /** * Create a hidden browser window and load the rendering page */ function createWindow() { // Create a browser window mainWindow = new BrowserWindow({ show: false, width: 8000, height: 8000, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, }); // Load index.html mainWindow.loadURL('file://' + __dirname + '/index.html'); } /** * A callback function for the event: * getOptions to request the options that need * to be passed to the renderer * * @param {Object} event */ ipcMain.handle('getOptions', function () { return { step }; }); /** * A callback function for the event: * capturePage * * @param {Object} event */ ipcMain.handle('capturePage', async function (event, captureRect, frameIndex) { // To show the cursor for headless browser mainWindow.focusOnWebView(); const img = await mainWindow.webContents.capturePage(captureRect); const outputPath = path.join(renderDir, frameIndex + '.png'); fs.writeFileSync(outputPath, img.toPNG()); console.log(frameIndex); }); /** * A callback function for the event: * Close * * @param {Object} event * @param {String} error */ ipcMain.on('close', function (event, error) { mainWindow.close(); }); /** * A callback function for the event: * When something unexpected happened * * @param {Object} event * @param {String} error */ ipcMain.on('error', function (event, error) { process.stderr.write(error); }); ================================================ FILE: render/preload.js ================================================ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('app', { close() { return ipcRenderer.send('close'); }, getOptions() { return ipcRenderer.invoke('getOptions'); }, capturePage(captureRect, frameIndex) { return ipcRenderer.invoke('capturePage', captureRect, frameIndex); }, }); // Catch all unhandled errors window.onerror = function (error) { ipcRenderer.send('error', error); }; ================================================ FILE: render/src/css/app.css ================================================ /** * Terminalizer * * @author Mohammad Fares */ body { background-color: white; margin: 0; } #terminal { display: inline-block; font-size: 0px; } ================================================ FILE: render/src/js/app.js ================================================ /** * Terminalizer * * @author Mohammad Fares */ import async from 'async'; import 'terminalizer-player'; // Styles import '../css/app.css'; import 'terminalizer-player/dist/css/terminalizer.min.css'; import 'xterm/dist/xterm.css'; /** * Used for the step option * @type {Number} */ var stepsCounter = 0; /** * Rendering options */ var options = {}; /** * A callback function for the event: * When the document is loaded */ $(document).ready(async () => { options = await app.getOptions(); // Initialize the terminalizer plugin $('#terminal').terminalizer({ recordingFile: 'data.json', autoplay: true, controls: false, }); /** * A callback function for the event: * When the terminal playing is started */ $('#terminal').one('playingStarted', function () { var terminalizer = $('#terminal').data('terminalizer'); // Pause the playing terminalizer.pause(); }); /** * A callback function for the event: * When the terminal playing is paused */ $('#terminal').one('playingPaused', function () { var terminalizer = $('#terminal').data('terminalizer'); // Reset the terminal terminalizer._terminal.reset(); // When the terminal's reset is done $('#terminal').one('rendered', render); }); }); /** * Render each frame and capture it */ function render() { var terminalizer = $('#terminal').data('terminalizer'); var framesCount = terminalizer.getFramesCount(); // Foreach frame async.timesSeries( framesCount, function (frameIndex, next) { terminalizer._renderFrame(frameIndex, true, function () { capture(frameIndex, next); }); }, function (error) { if (error) { throw new Error(error); } app.close(); } ); } /** * Capture the current frame * * @param {Number} frameIndex * @param {Function} callback */ function capture(frameIndex, callback) { var width = $('#terminal').width(); var height = $('#terminal').height(); var captureRect = { x: 0, y: 0, width: width, height: height }; if (stepsCounter != 0) { stepsCounter = (stepsCounter + 1) % options.step; return callback(); } stepsCounter = (stepsCounter + 1) % options.step; app .capturePage(captureRect, frameIndex) .then(callback) .catch((err) => { throw err; }); } ================================================ FILE: utility.js ================================================ /** * Provide utility functions * * @author Mohammad Fares */ /** * Check if a path represents a valid path for a file * * @param {String} filePath an absolute or a relative path * @return {Boolean} */ function isFile(filePath) { // Resolve the path into an absolute path filePath = di.path.resolve(filePath); try { return di.fs.statSync(filePath).isFile(); } catch (error) { return false; } } /** * Check if a path represents a valid path for a directory * * @param {String} dirPath an absolute or a relative path * @return {Boolean} */ function isDir(dirPath) { // Resolve the path into an absolute path dirPath = di.path.resolve(dirPath); try { return di.fs.statSync(dirPath).isDirectory(); } catch (error) { return false; } } /** * Load a file's content * * - Check if the file exists, if not found check * if the file exists with appending the extension * * Throws * - The provided file doesn't exit * - Any reading errors * * @param {String} filePath an absolute or a relative path * @param {String} extension * @return {String} */ function loadFile(filePath, extension) { var content = null; // Resolve the path into an absolute path filePath = resolveFilePath(filePath, extension); // The file doesn't exist if (!isFile(filePath)) { throw new Error('The provided file doesn\'t exit'); } // Read the file try { content = di.fs.readFileSync(filePath); } catch (error) { throw new Error(error); } return content; } /** * Check, load, and parse YAML files * * - Add .yml extension when needed * * Throws * - The provided file doesn't exit * - The provided file is not a valid YAML file * - Any reading errors * * @param {String} filePath an absolute or a relative path * @return {Object} */ function loadYAML(filePath) { var file = loadFile(filePath, 'yml'); // Parse the file try { return { json: di.yaml.load(file), raw: file.toString() }; } catch (error) { throw new Error('The provided file is not a valid YAML file'); } } /** * Check, load, and parse JSON files * * - Add .json extension when needed * * Throws * - The provided file doesn't exit * - The provided file is not a valid JSON file * - Any reading errors * * @param {String} filePath an absolute or a relative path * @return {Object} */ function loadJSON(filePath) { var file = loadFile(filePath, 'json'); // Read the file try { file = di.fs.readFileSync(filePath); } catch (error) { throw new Error(error); } // Parse the file try { return JSON.parse(file); } catch (error) { throw new Error('The provided file is not a valid JSON file'); } } /** * Resolve to an absolute path * * Accepts * - FileName * - FileName.ext * - /path/to/FileName * - /path/to/FileName.ext * * - Add the extension if not already added * - Resolve to `/path/to/FileName.ext` * * @param {String} filePath an absolute or a relative path * @param {String} extension * @return {String} */ function resolveFilePath(filePath, extension) { var resolvedPath = di.path.resolve(filePath); // The extension is not added if (di.path.extname(resolvedPath) != '.' + extension) { resolvedPath += '.' + extension; } return resolvedPath; } /** * Get the default configurations * * - Check if there is a global config file * - Found: Get the global config file * - Not Found: Get the default config file * * @return {Object} {json, raw} */ function getDefaultConfig() { var defaultConfigPath = di.path.join(ROOT_PATH, 'config.yml'); var globalConfigPath = di.path.join(getGlobalDirectory(), 'config.yml'); // The global config file exists if (isFile(globalConfigPath)) { return loadYAML(globalConfigPath); } console.log('defaultConfigPath'); // Load global config file return loadYAML(defaultConfigPath); } /** * Change a value for a specific key in YAML * * - Works only with the first level keys * - Works only with keys with a single value * - Apply the changes on the json and raw * * @param {Object} data {json, raw} * @param {String} key * @param {*} value */ function changeYAMLValue(data, key, value) { data.json[key] = value; data.raw = data.raw.replace(new RegExp('^' + key + ':.+$', 'm'), key + ': ' + value); } /** * Get the path of the global config directory * * - For Windows, get the path of APPDATA * - For Linux and MacOS, get the path of the home directory * * @return {String} */ function getGlobalDirectory() { // Windows if (typeof process.env.APPDATA != 'undefined') { return di.path.join(process.env.APPDATA, 'terminalizer'); } return di.path.join(process.env.HOME, '.config/terminalizer'); } /** * Check if the global config directory is created * * @return {Boolean} */ function isGlobalDirectoryCreated() { var globalDirPath = getGlobalDirectory(); return isDir(globalDirPath); } /** * Generate and save a token to be used for uploading recordings * * @param {String} token * @return {String} */ function generateToken(token) { var token = di.uuid.v4(); var globalDirPath = getGlobalDirectory(); var tokenPath = di.path.join(globalDirPath, 'token.txt'); di.fs.writeFileSync(tokenPath, token, 'utf8'); return token; } /** * Get registered token for uploading recordings * * @return {String|Null} */ function getToken() { var globalDirPath = getGlobalDirectory(); var tokenPath = di.path.join(globalDirPath, 'token.txt'); // The file doesn't exist if (!isFile(tokenPath)) { return null; } return di.fs.readFileSync(tokenPath, 'utf8'); } /** * Remove a registered token */ function removeToken() { var globalDirPath = getGlobalDirectory(); var tokenPath = di.path.join(globalDirPath, 'token.txt'); // The file doesn't exist if (!isFile(tokenPath)) { return; } di.fs.unlinkSync(tokenPath); } /** * Get the name of the current OS * * @return {String} mac, windows, linux */ function getOS() { // MacOS if (di.os.platform() == 'darwin') { return 'mac'; // Windows } else if (di.os.platform() == 'win32') { return 'windows'; } return 'linux'; } //////////////////////////////////////////////////// // Module ////////////////////////////////////////// //////////////////////////////////////////////////// module.exports = { loadYAML: loadYAML, loadJSON: loadJSON, resolveFilePath: resolveFilePath, getDefaultConfig: getDefaultConfig, changeYAMLValue: changeYAMLValue, getGlobalDirectory: getGlobalDirectory, isGlobalDirectoryCreated: isGlobalDirectoryCreated, generateToken: generateToken, getToken: getToken, removeToken: removeToken, getOS: getOS }; ================================================ FILE: webpack.config.js ================================================ const webpack = require('webpack'); const path = require('path'); // Extract CSS into separate files const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); // Global variables const globals = { $: 'jquery', jQuery: 'jquery', Terminal: ['xterm', 'Terminal'], 'window.jQuery': 'jquery', 'window.$': 'jquery', }; module.exports = { mode: 'production', target: 'electron-renderer', entry: { app: './render/src/js/app.js', }, output: { filename: 'js/[name].js', path: path.resolve(__dirname, 'render/dist'), publicPath: '/dist/', }, plugins: [ new CleanWebpackPlugin({ cleanBeforeEveryBuildPatterns: [path.join(__dirname, 'render/dist')], }), new webpack.ProvidePlugin(globals), new MiniCssExtractPlugin({ filename: 'css/[name].css' }), new webpack.NoEmitOnErrorsPlugin(), ], module: { rules: [ // CSS { test: /\.css$/, use: [{ loader: MiniCssExtractPlugin.loader }, { loader: 'css-loader' }], }, ], }, };