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
[](https://www.npmjs.com/package/terminalizer)
[](https://github.com/faressoft/terminalizer/blob/master/LICENSE)
[](https://gitter.im/terminalizer/Lobby)
[](https://www.youtube.com/watch?v=QH2-TGUlwu4)
[](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:  `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`
## 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: 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' }],
},
],
},
};