├── .cargo └── config.toml ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── config.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── asciinema-player-ui.min.js ├── asciinema-player-worker.min.js ├── asciinema-player.css └── index.html ├── build.rs ├── default.nix ├── doc ├── asciicast-v1.md └── asciicast-v2.md ├── flake.lock ├── flake.nix ├── shell.nix ├── src ├── alis.rs ├── api.rs ├── asciicast.rs ├── asciicast │ ├── util.rs │ ├── v1.rs │ ├── v2.rs │ └── v3.rs ├── cli.rs ├── cmd │ ├── auth.rs │ ├── cat.rs │ ├── convert.rs │ ├── mod.rs │ ├── play.rs │ ├── session.rs │ └── upload.rs ├── config.rs ├── encoder │ ├── asciicast.rs │ ├── mod.rs │ ├── raw.rs │ └── txt.rs ├── file_writer.rs ├── forwarder.rs ├── hash.rs ├── html.rs ├── io.rs ├── leb128.rs ├── locale.rs ├── main.rs ├── notifier.rs ├── player.rs ├── pty.rs ├── server.rs ├── session.rs ├── status.rs ├── stream.rs ├── tty.rs └── util.rs └── tests ├── casts ├── demo.cast ├── demo.json ├── full-v2.cast ├── full-v3.cast ├── full.json ├── minimal-v2.cast ├── minimal-v3.cast └── minimal.json ├── distros.sh ├── distros ├── Dockerfile.alpine ├── Dockerfile.arch ├── Dockerfile.centos ├── Dockerfile.debian ├── Dockerfile.fedora └── Dockerfile.ubuntu └── integration.sh /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | RUST_TEST_THREADS = "1" 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | assets/asciinema-player.* linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve asciinema CLI 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | To make life of the project maintainers easier please submit bug reports only. 11 | 12 | This is a bug tracker for asciinema cli (aka recorder). 13 | If your issue seems to be with another component (js player, server) then open an issue in the related repository. 14 | If you're experiencing issue with asciinema server at asciinema.org, contact admin@asciinema.org. 15 | 16 | Ideas, feature requests, help requests, questions and general discussions should be discussed on the forum: https://discourse.asciinema.org 17 | 18 | If you think you've found a bug or regression, go ahead, delete this message, then fill in the details below. 19 | 20 | ----- 21 | 22 | **Describe the bug** 23 | A clear and concise description of what the bug is. 24 | 25 | **To Reproduce** 26 | Steps to reproduce the behavior: 27 | 1. ... 28 | 2. ... 29 | 3. ... 30 | 4. See error 31 | 32 | **Expected behavior** 33 | A clear and concise description of what you expected to happen. 34 | 35 | **Versions:** 36 | - OS: [e.g. macOS 12.6, Ubuntu 23.04] 37 | - asciinema cli: [e.g. 2.4.0] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Forum 4 | url: https://discourse.asciinema.org/ 5 | about: Ideas, feature requests, help requests, questions and general discussions should be posted here. 6 | - name: GitHub discussions 7 | url: https://github.com/orgs/asciinema/discussions 8 | about: Ideas, feature requests, help requests, questions and general discussions should be posted here. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["develop"] 6 | pull_request: 7 | branches: ["develop"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Build 20 | run: cargo build --verbose 21 | 22 | - name: Run tests 23 | run: cargo test --verbose 24 | 25 | - name: Check formatting 26 | run: cargo fmt --check 27 | 28 | - name: Lint with clippy 29 | run: cargo clippy 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - v[0-9]+.* 10 | 11 | jobs: 12 | create-release: 13 | name: Create GH release draft 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Create the release 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | run: gh release create ${{ github.ref_name }} --draft --verify-tag --title ${{ github.ref_name }} 23 | 24 | upload-binary: 25 | needs: create-release 26 | name: ${{ matrix.target }} 27 | runs-on: ${{ matrix.os }} 28 | 29 | strategy: 30 | matrix: 31 | include: 32 | - os: ubuntu-latest 33 | target: x86_64-unknown-linux-gnu 34 | use-cross: false 35 | 36 | - os: ubuntu-latest 37 | target: x86_64-unknown-linux-musl 38 | use-cross: false 39 | 40 | - os: ubuntu-latest 41 | target: aarch64-unknown-linux-gnu 42 | use-cross: true 43 | 44 | - os: macos-latest 45 | target: x86_64-apple-darwin 46 | use-cross: false 47 | 48 | - os: macos-latest 49 | target: aarch64-apple-darwin 50 | use-cross: false 51 | 52 | env: 53 | CARGO: cargo 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | 58 | - name: Install Rust toolchain 59 | uses: dtolnay/rust-toolchain@stable 60 | with: 61 | targets: ${{ matrix.target }} 62 | 63 | - name: Install cross 64 | if: matrix.use-cross 65 | uses: taiki-e/install-action@v2 66 | with: 67 | tool: cross 68 | 69 | - name: Overwrite build command env variable 70 | if: matrix.use-cross 71 | shell: bash 72 | run: echo "CARGO=cross" >> $GITHUB_ENV 73 | 74 | - name: Install build deps 75 | shell: bash 76 | run: | 77 | if [[ ${{ matrix.target }} == x86_64-unknown-linux-musl ]]; then 78 | sudo apt-get update 79 | sudo apt-get install -y musl-tools 80 | fi 81 | 82 | - name: Build release binary 83 | run: ${{ env.CARGO }} build --release --locked --target ${{ matrix.target }} 84 | 85 | - name: Upload the binary to the release 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | run: | 89 | mv target/${{ matrix.target }}/release/asciinema target/release/asciinema-${{ matrix.target }} 90 | gh release upload ${{ github.ref_name }} target/release/asciinema-${{ matrix.target }} 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .envrc 3 | .direnv 4 | /result 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # asciinema changelog 2 | 3 | ## 3.0.0 (wip) 4 | 5 | * Full rewrite in Rust 6 | * rec: `--append` can be used with `--raw` now 7 | * rec: use of `--append` and `--overwrite` together returns error now 8 | * rec: fixed saving of custom rec command in asciicast header 9 | * Improved error message when non-UTF-8 locale is detected 10 | 11 | ## 2.4.0 (2023-10-23) 12 | 13 | * When recording without file arg we now ask whether to save, upload or discard the recording (#576) 14 | * Added capture of terminal resize events (#565) 15 | * Fixed blocking write error when PTY master is not ready (#569) (thanks @Low-power!) 16 | * Fixed "broken pipe" errors when piping certain commands during recording (#369) (thanks @Low-power!) 17 | * Fixed crash during playback of cast files with trailing blank line (#577) 18 | 19 | ## 2.3.0 (2023-07-05) 20 | 21 | * Added official support for Python 3.11 22 | * Dropped official support for Python 3.6 23 | * Implemented markers in `rec` and `play -m` commands 24 | * Added `--loop` option for looped playback in `play` command 25 | * Added `--stream` and `--out-fmt` option for customizing output of `play` command 26 | * Improved terminal charset detection (thanks @djds) 27 | * Extended `cat` command to support multiple files (thanks @Low-power) 28 | * Improved upload error messages 29 | * Fixed direct playback from URL 30 | * Made raw output start with terminal size sequence (`\e[8;H;Wt`) 31 | * Prevented recording to stdout when it's a TTY 32 | * Added target file permission checks to avoid ugly errors 33 | * Removed named pipe re-opening, which was causing hangs in certain scenarios 34 | * Improved PTY/TTY data reading - it goes in bigger chunks now (256 kb) 35 | * Fixed deadlock in PTY writes (thanks @Low-power) 36 | * Improved input forwarding from stdin 37 | * Ignored OSC responses in recorded stdin stream 38 | 39 | ## 2.2.0 (2022-05-07) 40 | 41 | * Added official support for Python 3.8, 3.9, 3.10 42 | * Dropped official support for Python 3.5 43 | * Added `--cols` / `--rows` options for overriding size of pseudo-terminal reported to recorded program 44 | * Improved behaviour of `--append` when output file doesn't exist 45 | * Keyboard input is now explicitly read from a TTY device in addition to stdin (when stdin != TTY) 46 | * Recorded program output is now explicitly written to a TTY device instead of stdout 47 | * Dash char (`-`) can now be passed as output filename to write asciicast to stdout 48 | * Diagnostic messages are now printed to stderr (without colors when stderr != TTY) 49 | * Improved robustness of writing asciicast to named pipes 50 | * Lots of codebase modernizations (many thanks to Davis @djds Schirmer!) 51 | * Many other internal refactorings 52 | 53 | ## 2.1.0 (2021-10-02) 54 | 55 | * Ability to pause/resume terminal capture with `C-\` key shortcut 56 | * Desktop notifications - only for the above pause feature at the moment 57 | * Removed dependency on tput/ncurses (thanks @arp242 / Martin Tournoij!) 58 | * ASCIINEMA_REC env var is back (thanks @landonb / Landon Bouma!) 59 | * Terminal answerbacks (CSI 6 n) in `asciinema cat` are now hidden (thanks @djpohly / Devin J. Pohly!) 60 | * Codeset detection works on HP-UX now (thanks @michael-o / Michael Osipov!) 61 | * Attempt at recording to existing file suggests use of `--overwrite` option now 62 | * Upload for users with very long `$USER` is fixed 63 | * Added official support for Python 3.8 and 3.9 64 | * Dropped official support for EOL-ed Python 3.4 and 3.5 65 | 66 | ## 2.0.2 (2019-01-12) 67 | 68 | * Official support for Python 3.7 69 | * Recording is now possible on US-ASCII locale (thanks Jean-Philippe @jpouellet Ouellet!) 70 | * Improved Android support (thanks Fredrik @fornwall Fornwall!) 71 | * Possibility of programatic recording with `asciinema.record_asciicast` function 72 | * Uses new JSON response format added recently to asciinema-server 73 | * Tweaked message about how to stop recording (thanks Bachynin @vanyakosmos Ivan!) 74 | * Added proper description and other metadata to Python package (thanks @Crestwave!) 75 | 76 | ## 2.0.1 (2018-04-04) 77 | 78 | * Fixed example in asciicast v2 format doc (thanks Josh "@anowlcalledjosh" Holland!) 79 | * Replaced deprecated `encodestring` (since Python 3.1) with `encodebytes` (thanks @delirious-lettuce!) 80 | * Fixed location of config dir (you can `mv ~/.asciinema ~/.config/asciinema`) 81 | * Internal refactorings 82 | 83 | ## 2.0 (2018-02-10) 84 | 85 | This major release brings many new features, improvements and bugfixes. The most 86 | notable ones: 87 | 88 | * new [asciicast v2 file format](doc/asciicast-v2.md) 89 | * recording and playback of arbitrarily long session with minimal memory usage 90 | * ability to live-stream via UNIX pipe: `asciinema rec unix.pipe` + `asciinema play unix.pipe` in second terminal tab/window 91 | * optional stdin recording (`asciinema rec --stdin`) 92 | * appending to existing recording (`asciinema rec --append `) 93 | * raw recording mode, storing only stdout bytes (`asciinema rec --raw `) 94 | * environment variable white-listing (`asciinema rec --env="VAR1,VAR2..."`) 95 | * toggling pause in `asciinema play` by Space 96 | * stepping through a recording one frame at a time with . (when playback paused) 97 | * new `asciinema cat ` command to dump full output of the recording 98 | * playback from new IPFS URL scheme: `dweb:/ipfs/` (replaces `fs:/`) 99 | * lots of other bugfixes and improvements 100 | * dropped official support for Python 3.3 (although it still works on 3.3) 101 | 102 | ## 1.4.0 (2017-04-11) 103 | 104 | * Dropped distutils fallback in setup.py - setuptools required now (thanks Jakub "@jakubjedelsky" Jedelsky!) 105 | * Dropped official support for Python 3.2 (although it still works on 3.2) 106 | * New `--speed` option for `asciinema play` (thanks Bastiaan "@bastiaanb" Bakker!) 107 | * Ability to set API token via `ASCIINEMA_API_TOKEN` env variable (thanks Samantha "@samdmarshall" Marshall!) 108 | * Improved shutdown on more signals: CHLD, HUP, TERM, QUIT (thanks Richard "@typerlc"!) 109 | * Fixed stdin handling during playback via `asciinema play` 110 | 111 | ## 1.3.0 (2016-07-13) 112 | 113 | This release brings back the original Python implementation of asciinema. It's 114 | based on 0.9.8 codebase and adds all features and bug fixes that have been 115 | implemented in asciinema's Go version between 0.9.8 and 1.2.0. 116 | 117 | Other notable changes: 118 | 119 | * Zero dependencies! (other than Python 3) 120 | * Fixed crash when resizing terminal window during recording (#167) 121 | * Fixed upload from IPv6 hosts (#94) 122 | * Improved UTF-8 charset detection (#160) 123 | * `-q/--quiet` option can be saved in config file now 124 | * Final "logout" (produced by csh) is now removed from recorded stdout 125 | * `rec` command now tries to write to target path before starting recording 126 | 127 | ## 1.2.0 (2016-02-22) 128 | 129 | * Added playback from stdin: `cat demo.json | asciinema play -` 130 | * Added playback from IPFS: `asciinema play ipfs:/ipfs/QmcdXYJp6e4zNuimuGeWPwNMHQdxuqWmKx7NhZofQ1nw2V` 131 | * Added playback from asciicast page URL: `asciinema play https://asciinema.org/a/22124` 132 | * `-q/--quiet` option added to `rec` command 133 | * Fixed handling of partial UTF-8 sequences in recorded stdout 134 | * Final "exit" is now removed from recorded stdout 135 | * Longer operations like uploading/downloading show "spinner" 136 | 137 | ## 1.1.1 (2015-06-21) 138 | 139 | * Fixed putting terminal in raw mode (fixes ctrl-o in nano) 140 | 141 | ## 1.1.0 (2015-05-25) 142 | 143 | * `--max-wait` option is now also available for `play` command 144 | * Added support for compilation on FreeBSD 145 | * Improved locale/charset detection 146 | * Improved upload error messages 147 | * New config file location (with backwards compatibility) 148 | 149 | ## 1.0.0 (2015-03-12) 150 | 151 | * `--max-wait` and `--yes` options can be saved in config file 152 | * Support for displaying warning messages returned from API 153 | * Also, see changes for 1.0.0 release candidates below 154 | 155 | ## 1.0.0.rc2 (2015-03-08) 156 | 157 | * All dependencies are vendored now in Godeps dir 158 | * Help message includes all commands with their possible options 159 | * `-y` and `-t` options have longer alternatives: `--yes`, `--title` 160 | * `--max-wait` option has shorter alternative: `-w` 161 | * Import paths changed to `github.com/asciinema/asciinema` due to repository 162 | renaming 163 | * `-y` also suppresess "please resize terminal" prompt 164 | 165 | ## 1.0.0.rc1 (2015-03-02) 166 | 167 | * New [asciicast file format](doc/asciicast-v1.md) 168 | * `rec` command can now record to file 169 | * New commands: `play ` and `upload ` 170 | * UTF-8 native locale is now required 171 | * Added handling of status 413 and 422 by printing user friendly message 172 | 173 | ## 0.9.9 (2014-12-17) 174 | 175 | * Rewritten in Go 176 | * License changed to GPLv3 177 | * `--max-wait` option added to `rec` command 178 | * Recorded process has `ASCIINEMA_REC` env variable set (useful for "rec" 179 | indicator in shell's `$PROMPT/$RPROMPT`) 180 | * No more terminal resetting (via `reset` command) before and after recording 181 | * Informative messages are coloured to be distinguishable from normal output 182 | * Improved error messages 183 | 184 | ## 0.9.8 (2014-02-09) 185 | 186 | * Rename user_token to api_token 187 | * Improvements to test suite 188 | * Send User-Agent including client version number, python version and platform 189 | * Handle 503 status as server maintenance 190 | * Handle 404 response as a request for client upgrade 191 | 192 | ## 0.9.7 (2013-10-07) 193 | 194 | * Depend on requests==1.1.0, not 2.0 195 | 196 | ## 0.9.6 (2013-10-06) 197 | 198 | * Remove install script 199 | * Introduce proper python package: https://pypi.python.org/pypi/asciinema 200 | * Make the code compatible with both python 2 and 3 201 | * Use requests lib instead of urrlib(2) 202 | 203 | ## 0.9.5 (2013-10-04) 204 | 205 | * Fixed measurement of total recording time 206 | * Improvements to install script 207 | * Introduction of Homebrew formula 208 | 209 | ## 0.9.4 (2013-10-03) 210 | 211 | * Use python2.7 in shebang 212 | 213 | ## 0.9.3 (2013-10-03) 214 | 215 | * Re-enable resetting of a terminal before and after recording 216 | * Add Arch Linux source package 217 | 218 | ## 0.9.2 (2013-10-02) 219 | 220 | * Use os.uname over running the uname command 221 | * Add basic integration tests 222 | * Make PtyRecorder test stable again 223 | * Move install script out of bin dir 224 | 225 | ## 0.9.1 (2013-10-01) 226 | 227 | * Split monolithic script into separate classes/files 228 | * Remove upload queue 229 | * Use python2 in generated binary's shebang 230 | * Delay config file creation until user_token is requested 231 | * Introduce command classes for handling cli commands 232 | * Split the recorder into classes with well defined responsibilities 233 | * Drop curl dependency, use urllib(2) for http requests 234 | 235 | ## 0.9.0 (2013-09-24) 236 | 237 | * Project rename from "ascii.io" to "asciinema" 238 | 239 | ## ... limbo? ... 240 | 241 | ## 0.1 (2012-03-11) 242 | 243 | * Initial release 244 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at admin@asciinema.org. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to asciinema 2 | 3 | First, if you're opening a GitHub issue make sure it goes to the correct 4 | repository: 5 | 6 | - [asciinema/asciinema](https://github.com/asciinema/asciinema/issues) - command-line recorder 7 | - [asciinema/asciinema-server](https://github.com/asciinema/asciinema-server/issues) - public website hosting recordings 8 | - [asciinema/asciinema-player](https://github.com/asciinema/asciinema-player/issues) - player 9 | 10 | ## Reporting bugs 11 | 12 | Open an issue in GitHub issue tracker. 13 | 14 | Tell us what's the problem and include steps to reproduce it (reliably). 15 | Including your OS/browser/terminal name and version in the report would be 16 | great. 17 | 18 | ## Submitting patches with bug fixes 19 | 20 | If you found a bug and made a patch for it: 21 | 22 | 1. Make sure your changes pass the [pre-commit](https://pre-commit.com/) 23 | [hooks](.pre-commit-config.yaml). You can install the hooks in your work 24 | tree by running `pre-commit install` in your checked out copy. 25 | 1. Make sure all tests pass. If you add new functionality, add new tests. 26 | 1. Send us a pull request, including a description of the fix (referencing an 27 | existing issue if there's one). 28 | 29 | ## Requesting new features 30 | 31 | We welcome all ideas. 32 | 33 | If you believe most asciinema users would benefit from implementing your idea 34 | then feel free to open a GitHub issue. However, as this is an open-source 35 | project maintained by a small team of volunteers we simply can't implement all 36 | of them due to limited resources. Please keep that in mind. 37 | 38 | ## Proposing features/changes (pull requests) 39 | 40 | If you want to propose code change, either introducing a new feature or 41 | improving an existing one, please first discuss this with asciinema team. You 42 | can simply open a separate issue for a discussion or join #asciinema IRC 43 | channel on Libera.Chat. 44 | 45 | ## Reporting security issues 46 | 47 | If you found a security issue in asciinema please contact us at 48 | admin@asciinema.org. For the benefit of all asciinema users please **do 49 | not** publish details of the vulnerability in a GitHub issue. 50 | 51 | The PGP key below (1eb33a8760dec34b) can be used when sending encrypted email 52 | to or verifying responses from admin@asciinema.org. 53 | 54 | ```Public Key 55 | -----BEGIN PGP PUBLIC KEY BLOCK----- 56 | Version: GnuPG v2 57 | 58 | mQENBFRH/yQBCADwC8fadhrTTqCFEcQ8ex82FE24b2frRC3fvkFeKsY+v2lniYmZ 59 | wJ+qsd3cEv5uctCl+lQjrqhJrBx5DnZpCMw85vNuOhz/wjzn7efTISUF+HlnhiZd 60 | tN3FPbk4uu+1JiiZ7SEvH+I4JjM46Vx6wPZ9en79u8VPMLJ24F81Rar62oiMuL29 61 | PGV7CdG+ErUHEQfN1qLaZNQqkPCQSAouxooNqXKjs/mmz2651FrP8TKVr2f6B/2O 62 | YJ++H9SoIp7Ly+/fEjgmdaZnGqfxnBC+Pm82tZguprWeh8pdiu9ieJswr4S9tRms 63 | h2+eht8PWwkaOOhcFdZLnJFoXHOPzHilQVutABEBAAG0KUFzY2lpbmVtYSBTdXBw 64 | b3J0IDxzdXBwb3J0QGFzY2lpbmVtYS5vcmc+iQE4BBMBAgAiBQJUR/8kAhsDBgsJ 65 | CAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRAeszqHYN7DSyCeCADS9Jk7Ibl2f+2K 66 | eZ4XmYU0UxU55EtHZBd34yF+FGbl4doQhnKcRqT5lKLfYk4x3LzzPAHNSbRS05/K 67 | fw8l72GLHY01U/3slAixphIR8LwVyqPxwelTqLzkDvcK1TTTFnOM/XUT1ymNUS7i 68 | 6Bs889I4I8bPrnt1XK+W35/SqZbBAWotdidCbI/oKQgffCbVsH/Im5pnXTapvf/l 69 | sRUpB2fp7vD5+ycKDcB5CqbtnsPU9vCPL11GG3ijwQBgnPc0fKanUHb3IMElQ0ju 70 | 8IYTZjpPe7bIV3V3nYZvdO41IYLCHhRpvNt4BO2amQoGyqTqGHr/rCY1aEToDG2c 71 | cOdsEOmuuQENBFRH/yQBCACsR59NPSwGoK4zGgzDjuY7yLab2Tq1Jg1c038lA23G 72 | t3H9aOpVbeYGvDPYLHi2y1cCNv19nzs5/k/LAflhTcgPjipTHQ2ojDG+MNfO4qyH 73 | 3JFhm1WUw6zxFjBXfsZhoCKTNHZkzH+d0jeutbBq/Rd77sLjN/VVTLfzJCZhyhKD 74 | VEyO6DYaANZn1B/xx84WdxqqiQsLELOCQVUCG7HzbQAmx7lYYIUAwUoFTrBeBd+d 75 | sN7htw3j7le99EiccqMXceZd2W9cAlRfXcjHtvbtkbJTcsvANSUSU10q5uuT3f6l 76 | NftTLWOGZnu/rFU/ow5ipKft0ygfJKpMHD+AoLkiRIajABEBAAGJAR8EGAECAAkF 77 | AlRH/yQCGwwACgkQHrM6h2Dew0tG1wgAqOkkSznwF+6muK88GgrgasqnIq2t2VkN 78 | fTEKmykgSuMxiN4bsNLc4FQECZqIcL7zGuD6fFnsnO6Hg36R4rYGFSEsjjN7rXj0 79 | QLnrJJLZV0oA6Q77fUqdB0he7uJm+nlQjUv8HNJwp1oIyhhHz/r1kTHUlX+bEMO3 80 | Khc96UnE7nzwPBCbUvKuHJQY6K2ms1wgr9ELXjF1KVU9QtBtG2/XWRGDHDwQKxnW 81 | +2pRVtn2xNJ9rBipGG86ZU88vurYjgPZrXaex3M1QGD/8+9Wlp/TR7YUzjiZbtwc 82 | 6mpG4SUlwZheX9RbTRdjnLr7Qy+CddOWvGxebgk23/U90KrDyHDHig== 83 | =2M/2 84 | -----END PGP PUBLIC KEY BLOCK----- 85 | ``` 86 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "asciinema" 3 | version = "3.0.0-rc.4" 4 | edition = "2021" 5 | authors = ["Marcin Kulik "] 6 | homepage = "https://asciinema.org" 7 | repository = "https://github.com/asciinema/asciinema" 8 | description = "Terminal session recorder" 9 | license = "GPL-3.0" 10 | 11 | # MSRV 12 | rust-version = "1.75.0" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | anyhow = "1.0.98" 18 | nix = { version = "0.30", features = ["fs", "term", "process", "signal", "poll"] } 19 | termion = "3.0.0" 20 | serde = { version = "1.0.219", features = ["derive"] } 21 | serde_json = "1.0.140" 22 | clap = { version = "4.5.37", features = ["derive"] } 23 | signal-hook = { version = "0.3.17", default-features = false } 24 | uuid = { version = "1.6.1", features = ["v4"] } 25 | reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "rustls-tls-native-roots", "multipart", "gzip", "json"] } 26 | rustyline = { version = "13.0.0", default-features = false } 27 | config = { version = "0.15.11", default-features = false, features = ["toml"] } 28 | which = "6.0.3" 29 | tempfile = "3.9.0" 30 | avt = "0.16.0" 31 | axum = { version = "0.8.4", default-features = false, features = ["http1", "ws"] } 32 | tokio = { version = "1.44.2", features = ["rt-multi-thread", "net", "sync", "time"] } 33 | futures-util = { version = "0.3.31", default-features = false, features = ["sink"] } 34 | tokio-stream = { version = "0.1.17", default-features = false, features = ["sync", "time"] } 35 | rust-embed = "8.2.0" 36 | tower-http = { version = "0.6.2", features = ["trace"] } 37 | tracing = { version = "0.1.41", default-features = false } 38 | tracing-subscriber = { version = "0.3.18", default-features = false, features = ["fmt", "env-filter"] } 39 | rgb = { version = "0.8.37", default-features = false } 40 | url = "2.5.0" 41 | tokio-tungstenite = { version = "0.26.2", default-features = false, features = ["connect", "rustls-tls-native-roots"] } 42 | rustls = { version = "0.23.26", default-features = false, features = ["aws_lc_rs"] } 43 | tokio-util = "0.7.10" 44 | rand = "0.9.1" 45 | 46 | [build-dependencies] 47 | clap = { version = "4.5.37", features = ["derive"] } 48 | clap_complete = "4.5.48" 49 | clap_mangen = "0.2.26" 50 | url = "2.5.0" 51 | 52 | [profile.release] 53 | strip = true 54 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG RUST_VERSION=1.70.0 2 | FROM rust:${RUST_VERSION}-bookworm as builder 3 | WORKDIR /app 4 | 5 | RUN --mount=type=bind,source=src,target=src \ 6 | --mount=type=bind,source=Cargo.toml,target=Cargo.toml \ 7 | --mount=type=bind,source=Cargo.lock,target=Cargo.lock \ 8 | --mount=type=cache,target=/app/target/ \ 9 | --mount=type=cache,target=/usr/local/cargo/registry/ \ 10 | < [!NOTE] 99 | > Windows is currently not supported. _(See [#467](https://github.com/asciinema/asciinema/issues/467))_ 100 | 101 | ## Development 102 | 103 | This branch contains the next generation of the asciinema CLI, written in Rust 104 | ([about the 105 | rewrite](https://discourse.asciinema.org/t/rust-rewrite-of-the-asciinema-cli/777)). 106 | It is still in a work-in-progress stage, so if you wish to propose any code 107 | changes, please first reach out to the team via 108 | [forum](https://discourse.asciinema.org/), 109 | [Matrix](https://matrix.to/#/#asciinema:matrix.org) or 110 | [IRC](https://web.libera.chat/#asciinema). 111 | 112 | The previous generation of the asciinema CLI, written in Python, can be found in 113 | the `main` branch. 114 | 115 | ## Donations 116 | 117 | Sustainability of asciinema development relies on donations and sponsorships. 118 | 119 | Please help the software project you use and love. Become a 120 | [supporter](https://docs.asciinema.org/donations/#individuals) or a [corporate 121 | sponsor](https://docs.asciinema.org/donations/#corporate-sponsorship). 122 | 123 | asciinema is sponsored by: 124 | 125 | - [Brightbox](https://www.brightbox.com/) 126 | - [DataDog](https://datadoghq.com/) 127 | 128 | ## Consulting 129 | 130 | If you're interested in integration or customization of asciinema to suit your 131 | needs, check [asciinema consulting 132 | services](https://docs.asciinema.org/consulting/). 133 | 134 | ## License 135 | 136 | © 2011 Marcin Kulik. 137 | 138 | All code is licensed under the GPL, v3 or later. See [LICENSE](./LICENSE) file 139 | for details. 140 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 39 | 40 | 41 | 42 | 43 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use clap::CommandFactory; 2 | use clap::ValueEnum; 3 | use std::env; 4 | use std::fs::create_dir_all; 5 | use std::path::Path; 6 | use std::path::PathBuf; 7 | 8 | mod cli { 9 | include!("src/cli.rs"); 10 | } 11 | 12 | const ENV_KEY: &str = "ASCIINEMA_GEN_DIR"; 13 | 14 | fn main() -> std::io::Result<()> { 15 | if let Some(dir) = env::var_os(ENV_KEY).or(env::var_os("OUT_DIR")) { 16 | let mut cmd = cli::Cli::command(); 17 | let base_dir = PathBuf::from(dir); 18 | 19 | let man_dir = Path::join(&base_dir, "man"); 20 | create_dir_all(&man_dir)?; 21 | clap_mangen::generate_to(cmd.clone(), &man_dir)?; 22 | 23 | let completion_dir = Path::join(&base_dir, "completion"); 24 | create_dir_all(&completion_dir)?; 25 | 26 | for shell in clap_complete::Shell::value_variants() { 27 | clap_complete::generate_to(*shell, &mut cmd, "asciinema", &completion_dir)?; 28 | } 29 | } 30 | 31 | println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap()); 32 | println!("cargo:rerun-if-env-changed={ENV_KEY}"); 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | stdenv, 4 | rust, 5 | makeRustPlatform, 6 | packageToml, 7 | libiconv, 8 | darwin, 9 | python3, 10 | }: 11 | (makeRustPlatform { 12 | cargo = rust; 13 | rustc = rust; 14 | }).buildRustPackage 15 | { 16 | pname = packageToml.name; 17 | inherit (packageToml) version; 18 | 19 | src = builtins.path { 20 | path = ./.; 21 | inherit (packageToml) name; 22 | }; 23 | 24 | dontUseCargoParallelTests = true; 25 | 26 | cargoLock.lockFile = ./Cargo.lock; 27 | 28 | nativeBuildInputs = [ rust ]; 29 | buildInputs = lib.optional stdenv.isDarwin [ 30 | libiconv 31 | darwin.apple_sdk.frameworks.Foundation 32 | ]; 33 | 34 | nativeCheckInputs = [ python3 ]; 35 | } 36 | -------------------------------------------------------------------------------- /doc/asciicast-v1.md: -------------------------------------------------------------------------------- 1 | Moved to [https://docs.asciinema.org/manual/asciicast/v1/](https://docs.asciinema.org/manual/asciicast/v1/). 2 | -------------------------------------------------------------------------------- /doc/asciicast-v2.md: -------------------------------------------------------------------------------- 1 | Moved to [https://docs.asciinema.org/manual/asciicast/v2/](https://docs.asciinema.org/manual/asciicast/v2/). 2 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1727826117, 9 | "narHash": "sha256-K5ZLCyfO/Zj9mPFldf3iwS6oZStJcU4tSpiXTMYaaL0=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "3d04084d54bedc3d6b8b736c70ef449225c361b1", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1728018373, 24 | "narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "bc947f541ae55e999ffdb4013441347d83b00feb", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs-lib": { 38 | "locked": { 39 | "lastModified": 1727825735, 40 | "narHash": "sha256-0xHYkMkeLVQAMa7gvkddbPqpxph+hDzdu1XdGPJR+Os=", 41 | "type": "tarball", 42 | "url": "https://github.com/NixOS/nixpkgs/archive/fb192fec7cc7a4c26d51779e9bab07ce6fa5597a.tar.gz" 43 | }, 44 | "original": { 45 | "type": "tarball", 46 | "url": "https://github.com/NixOS/nixpkgs/archive/fb192fec7cc7a4c26d51779e9bab07ce6fa5597a.tar.gz" 47 | } 48 | }, 49 | "nixpkgs_2": { 50 | "locked": { 51 | "lastModified": 1718428119, 52 | "narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=", 53 | "owner": "NixOS", 54 | "repo": "nixpkgs", 55 | "rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "NixOS", 60 | "ref": "nixpkgs-unstable", 61 | "repo": "nixpkgs", 62 | "type": "github" 63 | } 64 | }, 65 | "root": { 66 | "inputs": { 67 | "flake-parts": "flake-parts", 68 | "nixpkgs": "nixpkgs", 69 | "rust-overlay": "rust-overlay" 70 | } 71 | }, 72 | "rust-overlay": { 73 | "inputs": { 74 | "nixpkgs": "nixpkgs_2" 75 | }, 76 | "locked": { 77 | "lastModified": 1728095260, 78 | "narHash": "sha256-X62hA5ivYLY5G5+mXI6l9eUDkgi6Wu/7QUrwXhJ09oo=", 79 | "owner": "oxalica", 80 | "repo": "rust-overlay", 81 | "rev": "d1d2532ab267cfe6e40dff73fbaf34436c406d26", 82 | "type": "github" 83 | }, 84 | "original": { 85 | "owner": "oxalica", 86 | "repo": "rust-overlay", 87 | "type": "github" 88 | } 89 | } 90 | }, 91 | "root": "root", 92 | "version": 7 93 | } 94 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Terminal session recorder"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | rust-overlay.url = "github:oxalica/rust-overlay"; 7 | flake-parts.url = "github:hercules-ci/flake-parts"; 8 | }; 9 | 10 | outputs = 11 | inputs@{ 12 | flake-parts, 13 | rust-overlay, 14 | ... 15 | }: 16 | flake-parts.lib.mkFlake { inherit inputs; } { 17 | systems = [ 18 | "x86_64-linux" 19 | "aarch64-linux" 20 | "aarch64-darwin" 21 | "x86_64-darwin" 22 | ]; 23 | perSystem = 24 | { 25 | self', 26 | pkgs, 27 | system, 28 | ... 29 | }: 30 | let 31 | packageToml = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package; 32 | in 33 | { 34 | formatter = pkgs.alejandra; 35 | 36 | _module.args = { 37 | pkgs = import inputs.nixpkgs { 38 | inherit system; 39 | overlays = [ (import rust-overlay) ]; 40 | }; 41 | }; 42 | 43 | devShells = pkgs.callPackages ./shell.nix { inherit pkgs packageToml self'; }; 44 | 45 | packages.default = pkgs.callPackage ./default.nix { 46 | inherit packageToml; 47 | rust = pkgs.rust-bin.stable.latest.minimal; 48 | }; 49 | }; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { 2 | self', 3 | pkgs, 4 | packageToml, 5 | rust-bin, 6 | mkShell, 7 | }: 8 | let 9 | msrv = packageToml.rust-version; 10 | 11 | mkDevShell = 12 | rust: 13 | mkShell { 14 | inputsFrom = [ 15 | (self'.packages.default.override { 16 | rust = rust.override { 17 | extensions = [ 18 | "rust-src" 19 | "rust-analyzer" 20 | ]; 21 | }; 22 | }) 23 | ]; 24 | 25 | buildInputs = [ pkgs.bashInteractive ]; 26 | 27 | env.RUST_BACKTRACE = 1; 28 | }; 29 | in 30 | { 31 | default = mkDevShell rust-bin.stable.latest.default; 32 | msrv = mkDevShell rust-bin.stable.${msrv}.default; 33 | } 34 | -------------------------------------------------------------------------------- /src/alis.rs: -------------------------------------------------------------------------------- 1 | // This module implements ALiS (asciinema live stream) protocol, 2 | // which is an application level protocol built on top of WebSocket binary messages, 3 | // used by asciinema CLI, asciinema player and asciinema server. 4 | 5 | // TODO document the protocol when it's final 6 | 7 | use std::future; 8 | 9 | use anyhow::Result; 10 | use futures_util::{stream, Stream, StreamExt}; 11 | use tokio_stream::wrappers::errors::BroadcastStreamRecvError; 12 | 13 | use crate::leb128; 14 | use crate::stream::Event; 15 | 16 | static MAGIC_STRING: &str = "ALiS\x01"; 17 | 18 | struct EventSerializer(u64); 19 | 20 | pub async fn stream>>( 21 | stream: S, 22 | ) -> Result, BroadcastStreamRecvError>>> { 23 | let header = stream::once(future::ready(Ok(MAGIC_STRING.into()))); 24 | let mut serializer = EventSerializer(0); 25 | 26 | let events = stream.map(move |event| event.map(|event| serializer.serialize_event(event))); 27 | 28 | Ok(header.chain(events)) 29 | } 30 | 31 | impl EventSerializer { 32 | fn serialize_event(&mut self, event: Event) -> Vec { 33 | use Event::*; 34 | 35 | match event { 36 | Init(last_id, time, size, theme, init) => { 37 | let last_id_bytes = leb128::encode(last_id); 38 | let time_bytes = leb128::encode(time); 39 | let cols_bytes = leb128::encode(size.0); 40 | let rows_bytes = leb128::encode(size.1); 41 | let init_len = init.len() as u32; 42 | let init_len_bytes = leb128::encode(init_len); 43 | 44 | let mut msg = vec![0x01]; 45 | msg.extend_from_slice(&last_id_bytes); 46 | msg.extend_from_slice(&time_bytes); 47 | msg.extend_from_slice(&cols_bytes); 48 | msg.extend_from_slice(&rows_bytes); 49 | 50 | match theme { 51 | Some(theme) => { 52 | msg.push(16); 53 | msg.push(theme.fg.r); 54 | msg.push(theme.fg.g); 55 | msg.push(theme.fg.b); 56 | msg.push(theme.bg.r); 57 | msg.push(theme.bg.g); 58 | msg.push(theme.bg.b); 59 | 60 | for color in &theme.palette { 61 | msg.push(color.r); 62 | msg.push(color.g); 63 | msg.push(color.b); 64 | } 65 | } 66 | 67 | None => { 68 | msg.push(0); 69 | } 70 | } 71 | 72 | msg.extend_from_slice(&init_len_bytes); 73 | msg.extend_from_slice(init.as_bytes()); 74 | 75 | self.0 = time; 76 | 77 | msg 78 | } 79 | 80 | Output(id, time, text) => { 81 | let id_bytes = leb128::encode(id); 82 | let time_bytes = leb128::encode(time - self.0); 83 | let text_len = text.len() as u32; 84 | let text_len_bytes = leb128::encode(text_len); 85 | 86 | let mut msg = vec![b'o']; 87 | msg.extend_from_slice(&id_bytes); 88 | msg.extend_from_slice(&time_bytes); 89 | msg.extend_from_slice(&text_len_bytes); 90 | msg.extend_from_slice(text.as_bytes()); 91 | 92 | self.0 = time; 93 | 94 | msg 95 | } 96 | 97 | Input(id, time, text) => { 98 | let id_bytes = leb128::encode(id); 99 | let time_bytes = leb128::encode(time - self.0); 100 | let text_len = text.len() as u32; 101 | let text_len_bytes = leb128::encode(text_len); 102 | 103 | let mut msg = vec![b'i']; 104 | msg.extend_from_slice(&id_bytes); 105 | msg.extend_from_slice(&time_bytes); 106 | msg.extend_from_slice(&text_len_bytes); 107 | msg.extend_from_slice(text.as_bytes()); 108 | 109 | self.0 = time; 110 | 111 | msg 112 | } 113 | 114 | Resize(id, time, size) => { 115 | let id_bytes = leb128::encode(id); 116 | let time_bytes = leb128::encode(time - self.0); 117 | let cols_bytes = leb128::encode(size.0); 118 | let rows_bytes = leb128::encode(size.1); 119 | 120 | let mut msg = vec![b'r']; 121 | msg.extend_from_slice(&id_bytes); 122 | msg.extend_from_slice(&time_bytes); 123 | msg.extend_from_slice(&cols_bytes); 124 | msg.extend_from_slice(&rows_bytes); 125 | 126 | self.0 = time; 127 | 128 | msg 129 | } 130 | 131 | Marker(id, time, text) => { 132 | let id_bytes = leb128::encode(id); 133 | let time_bytes = leb128::encode(time - self.0); 134 | let text_len = text.len() as u32; 135 | let text_len_bytes = leb128::encode(text_len); 136 | 137 | let mut msg = vec![b'm']; 138 | msg.extend_from_slice(&id_bytes); 139 | msg.extend_from_slice(&time_bytes); 140 | msg.extend_from_slice(&text_len_bytes); 141 | msg.extend_from_slice(text.as_bytes()); 142 | 143 | self.0 = time; 144 | 145 | msg 146 | } 147 | 148 | Exit(id, time, status) => { 149 | let id_bytes = leb128::encode(id); 150 | let time_bytes = leb128::encode(time - self.0); 151 | let status_bytes = leb128::encode(status.max(0) as u64); 152 | 153 | let mut msg = vec![b'x']; 154 | msg.extend_from_slice(&id_bytes); 155 | msg.extend_from_slice(&time_bytes); 156 | msg.extend_from_slice(&status_bytes); 157 | 158 | self.0 = time; 159 | 160 | msg 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fmt::Debug; 3 | 4 | use anyhow::{bail, Context, Result}; 5 | use reqwest::blocking::{multipart::Form, Client, RequestBuilder}; 6 | use reqwest::header; 7 | use serde::Deserialize; 8 | use url::Url; 9 | 10 | use crate::config::Config; 11 | 12 | #[derive(Debug, Deserialize)] 13 | pub struct UploadAsciicastResponse { 14 | pub url: String, 15 | pub message: Option, 16 | } 17 | 18 | #[derive(Debug, Deserialize)] 19 | pub struct GetUserStreamResponse { 20 | pub ws_producer_url: String, 21 | pub url: String, 22 | } 23 | 24 | #[derive(Debug, Deserialize)] 25 | struct ErrorResponse { 26 | reason: String, 27 | } 28 | 29 | pub fn get_auth_url(config: &Config) -> Result { 30 | let mut url = config.get_server_url()?; 31 | url.set_path(&format!("connect/{}", config.get_install_id()?)); 32 | 33 | Ok(url) 34 | } 35 | 36 | pub fn upload_asciicast(path: &str, config: &Config) -> Result { 37 | let server_url = &config.get_server_url()?; 38 | let install_id = config.get_install_id()?; 39 | let response = upload_request(server_url, path, install_id)?.send()?; 40 | 41 | if response.status().as_u16() == 413 { 42 | bail!("The size of the recording exceeds the server's configured limit"); 43 | } 44 | 45 | response.error_for_status_ref()?; 46 | 47 | Ok(response.json::()?) 48 | } 49 | 50 | fn upload_request(server_url: &Url, path: &str, install_id: String) -> Result { 51 | let client = Client::new(); 52 | let mut url = server_url.clone(); 53 | url.set_path("api/asciicasts"); 54 | let form = Form::new().file("asciicast", path)?; 55 | 56 | Ok(client 57 | .post(url) 58 | .multipart(form) 59 | .basic_auth(get_username(), Some(install_id)) 60 | .header(header::USER_AGENT, build_user_agent()) 61 | .header(header::ACCEPT, "application/json")) 62 | } 63 | 64 | pub fn create_user_stream(stream_id: String, config: &Config) -> Result { 65 | let server_url = config.get_server_url()?; 66 | let server_hostname = server_url.host().unwrap(); 67 | let install_id = config.get_install_id()?; 68 | 69 | let response = user_stream_request(&server_url, stream_id, install_id) 70 | .send() 71 | .context("cannot obtain stream producer endpoint - is the server down?")?; 72 | 73 | match response.status().as_u16() { 74 | 401 => bail!( 75 | "this CLI hasn't been authenticated with {server_hostname} - run `asciinema auth` first" 76 | ), 77 | 78 | 404 => match response.json::() { 79 | Ok(json) => bail!("{}", json.reason), 80 | Err(_) => bail!("{server_hostname} doesn't support streaming"), 81 | }, 82 | 83 | 422 => match response.json::() { 84 | Ok(json) => bail!("{}", json.reason), 85 | Err(_) => bail!("{server_hostname} doesn't support streaming"), 86 | }, 87 | 88 | _ => { 89 | response.error_for_status_ref()?; 90 | } 91 | } 92 | 93 | response 94 | .json::() 95 | .map_err(|e| e.into()) 96 | } 97 | 98 | fn user_stream_request(server_url: &Url, stream_id: String, install_id: String) -> RequestBuilder { 99 | let client = Client::new(); 100 | let mut url = server_url.clone(); 101 | 102 | let builder = if stream_id.is_empty() { 103 | url.set_path("api/streams"); 104 | client.post(url) 105 | } else { 106 | url.set_path(&format!("api/user/streams/{stream_id}")); 107 | client.get(url) 108 | }; 109 | 110 | builder 111 | .basic_auth(get_username(), Some(install_id)) 112 | .header(header::USER_AGENT, build_user_agent()) 113 | .header(header::ACCEPT, "application/json") 114 | } 115 | 116 | fn get_username() -> String { 117 | env::var("USER").unwrap_or("".to_owned()) 118 | } 119 | 120 | pub fn build_user_agent() -> String { 121 | let ua = concat!( 122 | "asciinema/", 123 | env!("CARGO_PKG_VERSION"), 124 | " target/", 125 | env!("TARGET") 126 | ); 127 | 128 | ua.to_owned() 129 | } 130 | -------------------------------------------------------------------------------- /src/asciicast/util.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use serde::{Deserialize, Deserializer}; 3 | 4 | pub fn deserialize_time<'de, D>(deserializer: D) -> Result 5 | where 6 | D: Deserializer<'de>, 7 | { 8 | use serde::de::Error; 9 | 10 | let value: serde_json::Value = Deserialize::deserialize(deserializer)?; 11 | 12 | let number = value 13 | .as_f64() 14 | .map(|v| v.to_string()) 15 | .ok_or(Error::custom("expected number"))?; 16 | 17 | let parts: Vec<&str> = number.split('.').collect(); 18 | 19 | match parts.as_slice() { 20 | [left, right] => { 21 | let secs: u64 = left.parse().map_err(Error::custom)?; 22 | let right = right.trim(); 23 | 24 | let micros: u64 = format!("{:0<6}", &right[..(6.min(right.len()))]) 25 | .parse() 26 | .map_err(Error::custom)?; 27 | 28 | Ok(secs * 1_000_000 + micros) 29 | } 30 | 31 | [number] => { 32 | let secs: u64 = number.parse().map_err(Error::custom)?; 33 | 34 | Ok(secs * 1_000_000) 35 | } 36 | 37 | _ => Err(Error::custom(format!("invalid time format: {value}"))), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/asciicast/v1.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{bail, Result}; 4 | use serde::Deserialize; 5 | 6 | use super::{Asciicast, Event, Header, Version}; 7 | use crate::asciicast::util::deserialize_time; 8 | 9 | #[derive(Debug, Deserialize)] 10 | struct V1 { 11 | version: u8, 12 | width: u16, 13 | height: u16, 14 | command: Option, 15 | title: Option, 16 | env: Option>, 17 | stdout: Vec, 18 | } 19 | 20 | #[derive(Debug, Deserialize)] 21 | struct V1OutputEvent { 22 | #[serde(deserialize_with = "deserialize_time")] 23 | time: u64, 24 | data: String, 25 | } 26 | 27 | pub fn load(json: String) -> Result> { 28 | let asciicast: V1 = serde_json::from_str(&json)?; 29 | 30 | if asciicast.version != 1 { 31 | bail!("unsupported asciicast version") 32 | } 33 | 34 | let term_type = asciicast 35 | .env 36 | .as_ref() 37 | .and_then(|env| env.get("TERM")) 38 | .cloned(); 39 | 40 | let header = Header { 41 | term_cols: asciicast.width, 42 | term_rows: asciicast.height, 43 | term_type, 44 | term_version: None, 45 | term_theme: None, 46 | timestamp: None, 47 | idle_time_limit: None, 48 | command: asciicast.command.clone(), 49 | title: asciicast.title.clone(), 50 | env: asciicast.env.clone(), 51 | }; 52 | 53 | let events = Box::new(asciicast.stdout.into_iter().scan(0, |prev_time, event| { 54 | let time = *prev_time + event.time; 55 | *prev_time = time; 56 | 57 | Some(Ok(Event::output(time, event.data))) 58 | })); 59 | 60 | Ok(Asciicast { 61 | version: Version::One, 62 | header, 63 | events, 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/asciicast/v2.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt; 3 | use std::io; 4 | 5 | use anyhow::{anyhow, bail, Context, Result}; 6 | use serde::{Deserialize, Deserializer, Serialize}; 7 | 8 | use super::{util, Asciicast, Event, EventData, Header, Version}; 9 | use crate::tty::TtyTheme; 10 | 11 | #[derive(Deserialize)] 12 | struct V2Header { 13 | version: u8, 14 | width: u16, 15 | height: u16, 16 | timestamp: Option, 17 | idle_time_limit: Option, 18 | command: Option, 19 | title: Option, 20 | env: Option>, 21 | theme: Option, 22 | } 23 | 24 | #[derive(Deserialize, Serialize, Clone)] 25 | struct V2Theme { 26 | #[serde(deserialize_with = "deserialize_color")] 27 | fg: RGB8, 28 | #[serde(deserialize_with = "deserialize_color")] 29 | bg: RGB8, 30 | #[serde(deserialize_with = "deserialize_palette")] 31 | palette: V2Palette, 32 | } 33 | 34 | #[derive(Clone)] 35 | struct RGB8(rgb::RGB8); 36 | 37 | #[derive(Clone)] 38 | struct V2Palette(Vec); 39 | 40 | #[derive(Debug, Deserialize)] 41 | struct V2Event { 42 | #[serde(deserialize_with = "util::deserialize_time")] 43 | time: u64, 44 | #[serde(deserialize_with = "deserialize_code")] 45 | code: V2EventCode, 46 | data: String, 47 | } 48 | 49 | #[derive(PartialEq, Debug)] 50 | enum V2EventCode { 51 | Output, 52 | Input, 53 | Resize, 54 | Marker, 55 | Other(char), 56 | } 57 | 58 | pub struct Parser(V2Header); 59 | 60 | pub fn open(header_line: &str) -> Result { 61 | let header = serde_json::from_str::(header_line)?; 62 | 63 | if header.version != 2 { 64 | bail!("not an asciicast v2 file") 65 | } 66 | 67 | Ok(Parser(header)) 68 | } 69 | 70 | impl Parser { 71 | pub fn parse<'a, I: Iterator> + 'a>(self, lines: I) -> Asciicast<'a> { 72 | let term_type = self.0.env.as_ref().and_then(|env| env.get("TERM").cloned()); 73 | let term_theme = self.0.theme.as_ref().map(|t| t.into()); 74 | 75 | let header = Header { 76 | term_cols: self.0.width, 77 | term_rows: self.0.height, 78 | term_type, 79 | term_version: None, 80 | term_theme, 81 | timestamp: self.0.timestamp, 82 | idle_time_limit: self.0.idle_time_limit, 83 | command: self.0.command.clone(), 84 | title: self.0.title.clone(), 85 | env: self.0.env.clone(), 86 | }; 87 | 88 | let events = Box::new(lines.filter_map(parse_line)); 89 | 90 | Asciicast { 91 | version: Version::Two, 92 | header, 93 | events, 94 | } 95 | } 96 | } 97 | 98 | fn parse_line(line: io::Result) -> Option> { 99 | match line { 100 | Ok(line) => { 101 | if line.is_empty() { 102 | None 103 | } else { 104 | Some(parse_event(line)) 105 | } 106 | } 107 | 108 | Err(e) => Some(Err(e.into())), 109 | } 110 | } 111 | 112 | fn parse_event(line: String) -> Result { 113 | let event = serde_json::from_str::(&line).context("asciicast v2 parse error")?; 114 | 115 | let data = match event.code { 116 | V2EventCode::Output => EventData::Output(event.data), 117 | V2EventCode::Input => EventData::Input(event.data), 118 | 119 | V2EventCode::Resize => match event.data.split_once('x') { 120 | Some((cols, rows)) => { 121 | let cols: u16 = cols 122 | .parse() 123 | .map_err(|e| anyhow!("invalid cols value in resize event: {e}"))?; 124 | 125 | let rows: u16 = rows 126 | .parse() 127 | .map_err(|e| anyhow!("invalid rows value in resize event: {e}"))?; 128 | 129 | EventData::Resize(cols, rows) 130 | } 131 | 132 | None => { 133 | bail!("invalid size value in resize event"); 134 | } 135 | }, 136 | 137 | V2EventCode::Marker => EventData::Marker(event.data), 138 | V2EventCode::Other(c) => EventData::Other(c, event.data), 139 | }; 140 | 141 | Ok(Event { 142 | time: event.time, 143 | data, 144 | }) 145 | } 146 | 147 | fn deserialize_code<'de, D>(deserializer: D) -> Result 148 | where 149 | D: Deserializer<'de>, 150 | { 151 | use serde::de::Error; 152 | use V2EventCode::*; 153 | 154 | let value: &str = Deserialize::deserialize(deserializer)?; 155 | 156 | match value { 157 | "o" => Ok(Output), 158 | "i" => Ok(Input), 159 | "r" => Ok(Resize), 160 | "m" => Ok(Marker), 161 | "" => Err(Error::custom("missing event code")), 162 | s => Ok(Other(s.chars().next().unwrap())), 163 | } 164 | } 165 | 166 | pub struct V2Encoder { 167 | time_offset: u64, 168 | } 169 | 170 | impl V2Encoder { 171 | pub fn new(time_offset: u64) -> Self { 172 | Self { time_offset } 173 | } 174 | 175 | pub fn header(&mut self, header: &Header) -> Vec { 176 | let header: V2Header = header.into(); 177 | let mut data = serde_json::to_string(&header).unwrap().into_bytes(); 178 | data.push(b'\n'); 179 | 180 | data 181 | } 182 | 183 | pub fn event(&mut self, event: &Event) -> Vec { 184 | let mut data = self.serialize_event(event).into_bytes(); 185 | data.push(b'\n'); 186 | 187 | data 188 | } 189 | 190 | fn serialize_event(&self, event: &Event) -> String { 191 | use EventData::*; 192 | 193 | let (code, data) = match &event.data { 194 | Output(data) => ('o', self.to_json_string(data)), 195 | Input(data) => ('i', self.to_json_string(data)), 196 | Resize(cols, rows) => ('r', self.to_json_string(&format!("{cols}x{rows}"))), 197 | Marker(data) => ('m', self.to_json_string(data)), 198 | Exit(data) => ('x', self.to_json_string(&data.to_string())), 199 | Other(code, data) => (*code, self.to_json_string(data)), 200 | }; 201 | 202 | format!( 203 | "[{}, {}, {}]", 204 | format_time(event.time + self.time_offset), 205 | self.to_json_string(&code.to_string()), 206 | data, 207 | ) 208 | } 209 | 210 | fn to_json_string(&self, s: &str) -> String { 211 | serde_json::to_string(s).unwrap() 212 | } 213 | } 214 | 215 | fn format_time(time: u64) -> String { 216 | let mut formatted_time = format!("{}.{:0>6}", time / 1_000_000, time % 1_000_000); 217 | let dot_idx = formatted_time.find('.').unwrap(); 218 | 219 | for idx in (dot_idx + 2..=formatted_time.len() - 1).rev() { 220 | if formatted_time.as_bytes()[idx] != b'0' { 221 | break; 222 | } 223 | 224 | formatted_time.truncate(idx); 225 | } 226 | 227 | formatted_time 228 | } 229 | 230 | impl serde::Serialize for V2Header { 231 | fn serialize(&self, serializer: S) -> Result 232 | where 233 | S: serde::Serializer, 234 | { 235 | use serde::ser::SerializeMap; 236 | 237 | let mut len = 4; 238 | 239 | if self.timestamp.is_some() { 240 | len += 1; 241 | } 242 | 243 | if self.idle_time_limit.is_some() { 244 | len += 1; 245 | } 246 | 247 | if self.command.is_some() { 248 | len += 1; 249 | } 250 | 251 | if self.title.is_some() { 252 | len += 1; 253 | } 254 | 255 | if self.env.as_ref().is_some_and(|env| !env.is_empty()) { 256 | len += 1; 257 | } 258 | 259 | if self.theme.is_some() { 260 | len += 1; 261 | } 262 | 263 | let mut map = serializer.serialize_map(Some(len))?; 264 | map.serialize_entry("version", &2)?; 265 | map.serialize_entry("width", &self.width)?; 266 | map.serialize_entry("height", &self.height)?; 267 | 268 | if let Some(timestamp) = self.timestamp { 269 | map.serialize_entry("timestamp", ×tamp)?; 270 | } 271 | 272 | if let Some(limit) = self.idle_time_limit { 273 | map.serialize_entry("idle_time_limit", &limit)?; 274 | } 275 | 276 | if let Some(command) = &self.command { 277 | map.serialize_entry("command", &command)?; 278 | } 279 | 280 | if let Some(title) = &self.title { 281 | map.serialize_entry("title", &title)?; 282 | } 283 | 284 | if let Some(env) = &self.env { 285 | if !env.is_empty() { 286 | map.serialize_entry("env", &env)?; 287 | } 288 | } 289 | 290 | if let Some(theme) = &self.theme { 291 | map.serialize_entry("theme", &theme)?; 292 | } 293 | 294 | map.end() 295 | } 296 | } 297 | 298 | fn deserialize_color<'de, D>(deserializer: D) -> Result 299 | where 300 | D: Deserializer<'de>, 301 | { 302 | let value: &str = Deserialize::deserialize(deserializer)?; 303 | parse_hex_color(value).ok_or(serde::de::Error::custom("invalid hex triplet")) 304 | } 305 | 306 | fn parse_hex_color(rgb: &str) -> Option { 307 | if rgb.len() != 7 { 308 | return None; 309 | } 310 | 311 | let r = u8::from_str_radix(&rgb[1..3], 16).ok()?; 312 | let g = u8::from_str_radix(&rgb[3..5], 16).ok()?; 313 | let b = u8::from_str_radix(&rgb[5..7], 16).ok()?; 314 | 315 | Some(RGB8(rgb::RGB8::new(r, g, b))) 316 | } 317 | 318 | fn deserialize_palette<'de, D>(deserializer: D) -> Result 319 | where 320 | D: Deserializer<'de>, 321 | { 322 | let value: &str = Deserialize::deserialize(deserializer)?; 323 | let mut colors: Vec = value.split(':').filter_map(parse_hex_color).collect(); 324 | let len = colors.len(); 325 | 326 | if len == 8 { 327 | colors.extend_from_within(..); 328 | } else if len != 16 { 329 | return Err(serde::de::Error::custom("expected 8 or 16 hex triplets")); 330 | } 331 | 332 | Ok(V2Palette(colors)) 333 | } 334 | 335 | impl serde::Serialize for RGB8 { 336 | fn serialize(&self, serializer: S) -> std::prelude::v1::Result 337 | where 338 | S: serde::Serializer, 339 | { 340 | serializer.serialize_str(&self.to_string()) 341 | } 342 | } 343 | 344 | impl fmt::Display for RGB8 { 345 | fn fmt(&self, f: &mut std::fmt::Formatter) -> fmt::Result { 346 | write!(f, "#{:0>2x}{:0>2x}{:0>2x}", self.0.r, self.0.g, self.0.b) 347 | } 348 | } 349 | 350 | impl serde::Serialize for V2Palette { 351 | fn serialize(&self, serializer: S) -> std::prelude::v1::Result 352 | where 353 | S: serde::Serializer, 354 | { 355 | let palette = self 356 | .0 357 | .iter() 358 | .map(|c| c.to_string()) 359 | .collect::>() 360 | .join(":"); 361 | 362 | serializer.serialize_str(&palette) 363 | } 364 | } 365 | 366 | impl From<&Header> for V2Header { 367 | fn from(header: &Header) -> Self { 368 | V2Header { 369 | version: 2, 370 | width: header.term_cols, 371 | height: header.term_rows, 372 | timestamp: header.timestamp, 373 | idle_time_limit: header.idle_time_limit, 374 | command: header.command.clone(), 375 | title: header.title.clone(), 376 | env: header.env.clone(), 377 | theme: header.term_theme.as_ref().map(|t| t.into()), 378 | } 379 | } 380 | } 381 | 382 | impl From<&TtyTheme> for V2Theme { 383 | fn from(tty_theme: &TtyTheme) -> Self { 384 | let palette = tty_theme.palette.iter().copied().map(RGB8).collect(); 385 | 386 | V2Theme { 387 | fg: RGB8(tty_theme.fg), 388 | bg: RGB8(tty_theme.bg), 389 | palette: V2Palette(palette), 390 | } 391 | } 392 | } 393 | 394 | impl From<&V2Theme> for TtyTheme { 395 | fn from(tty_theme: &V2Theme) -> Self { 396 | let palette = tty_theme.palette.0.iter().map(|c| c.0).collect(); 397 | 398 | TtyTheme { 399 | fg: tty_theme.fg.0, 400 | bg: tty_theme.bg.0, 401 | palette, 402 | } 403 | } 404 | } 405 | 406 | #[cfg(test)] 407 | mod tests { 408 | #[test] 409 | fn format_time() { 410 | assert_eq!(super::format_time(0), "0.0"); 411 | assert_eq!(super::format_time(1000001), "1.000001"); 412 | assert_eq!(super::format_time(12300000), "12.3"); 413 | assert_eq!(super::format_time(12000003), "12.000003"); 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /src/asciicast/v3.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt; 3 | use std::io; 4 | 5 | use anyhow::{anyhow, bail, Context, Result}; 6 | use serde::{Deserialize, Deserializer, Serialize}; 7 | 8 | use super::{util, Asciicast, Event, EventData, Header, Version}; 9 | use crate::tty::TtyTheme; 10 | 11 | #[derive(Deserialize)] 12 | struct V3Header { 13 | version: u8, 14 | term: V3Term, 15 | timestamp: Option, 16 | idle_time_limit: Option, 17 | command: Option, 18 | title: Option, 19 | env: Option>, 20 | } 21 | 22 | #[derive(Deserialize)] 23 | struct V3Term { 24 | cols: u16, 25 | rows: u16, 26 | #[serde(rename = "type")] 27 | type_: Option, 28 | version: Option, 29 | theme: Option, 30 | } 31 | 32 | #[derive(Deserialize, Serialize, Clone)] 33 | struct V3Theme { 34 | #[serde(deserialize_with = "deserialize_color")] 35 | fg: RGB8, 36 | #[serde(deserialize_with = "deserialize_color")] 37 | bg: RGB8, 38 | #[serde(deserialize_with = "deserialize_palette")] 39 | palette: V3Palette, 40 | } 41 | 42 | #[derive(Clone)] 43 | struct RGB8(rgb::RGB8); 44 | 45 | #[derive(Clone)] 46 | struct V3Palette(Vec); 47 | 48 | #[derive(Debug, Deserialize)] 49 | struct V3Event { 50 | #[serde(deserialize_with = "util::deserialize_time")] 51 | time: u64, 52 | #[serde(deserialize_with = "deserialize_code")] 53 | code: V3EventCode, 54 | data: String, 55 | } 56 | 57 | #[derive(PartialEq, Debug)] 58 | enum V3EventCode { 59 | Output, 60 | Input, 61 | Resize, 62 | Marker, 63 | Exit, 64 | Other(char), 65 | } 66 | 67 | pub struct Parser { 68 | header: V3Header, 69 | prev_time: u64, 70 | } 71 | 72 | pub fn open(header_line: &str) -> Result { 73 | let header = serde_json::from_str::(header_line)?; 74 | 75 | if header.version != 3 { 76 | bail!("not an asciicast v3 file") 77 | } 78 | 79 | Ok(Parser { 80 | header, 81 | prev_time: 0, 82 | }) 83 | } 84 | 85 | impl Parser { 86 | pub fn parse<'a, I: Iterator> + 'a>( 87 | mut self, 88 | lines: I, 89 | ) -> Asciicast<'a> { 90 | let term_theme = self.header.term.theme.as_ref().map(|t| t.into()); 91 | 92 | let header = Header { 93 | term_cols: self.header.term.cols, 94 | term_rows: self.header.term.rows, 95 | term_type: self.header.term.type_.clone(), 96 | term_version: self.header.term.version.clone(), 97 | term_theme, 98 | timestamp: self.header.timestamp, 99 | idle_time_limit: self.header.idle_time_limit, 100 | command: self.header.command.clone(), 101 | title: self.header.title.clone(), 102 | env: self.header.env.clone(), 103 | }; 104 | 105 | let events = Box::new(lines.filter_map(move |line| self.parse_line(line))); 106 | 107 | Asciicast { 108 | version: Version::Three, 109 | header, 110 | events, 111 | } 112 | } 113 | 114 | fn parse_line(&mut self, line: io::Result) -> Option> { 115 | match line { 116 | Ok(line) => { 117 | if line.is_empty() || line.starts_with("#") { 118 | None 119 | } else { 120 | Some(self.parse_event(line)) 121 | } 122 | } 123 | 124 | Err(e) => Some(Err(e.into())), 125 | } 126 | } 127 | 128 | fn parse_event(&mut self, line: String) -> Result { 129 | let event = serde_json::from_str::(&line).context("asciicast v3 parse error")?; 130 | 131 | let data = match event.code { 132 | V3EventCode::Output => EventData::Output(event.data), 133 | V3EventCode::Input => EventData::Input(event.data), 134 | 135 | V3EventCode::Resize => match event.data.split_once('x') { 136 | Some((cols, rows)) => { 137 | let cols: u16 = cols 138 | .parse() 139 | .map_err(|e| anyhow!("invalid cols value in resize event: {e}"))?; 140 | 141 | let rows: u16 = rows 142 | .parse() 143 | .map_err(|e| anyhow!("invalid rows value in resize event: {e}"))?; 144 | 145 | EventData::Resize(cols, rows) 146 | } 147 | 148 | None => { 149 | bail!("invalid size value in resize event"); 150 | } 151 | }, 152 | 153 | V3EventCode::Marker => EventData::Marker(event.data), 154 | V3EventCode::Exit => EventData::Exit(event.data.parse()?), 155 | V3EventCode::Other(c) => EventData::Other(c, event.data), 156 | }; 157 | 158 | let time = self.prev_time + event.time; 159 | self.prev_time = time; 160 | 161 | Ok(Event { time, data }) 162 | } 163 | } 164 | 165 | fn deserialize_code<'de, D>(deserializer: D) -> Result 166 | where 167 | D: Deserializer<'de>, 168 | { 169 | use serde::de::Error; 170 | use V3EventCode::*; 171 | 172 | let value: &str = Deserialize::deserialize(deserializer)?; 173 | 174 | match value { 175 | "o" => Ok(Output), 176 | "i" => Ok(Input), 177 | "r" => Ok(Resize), 178 | "m" => Ok(Marker), 179 | "x" => Ok(Exit), 180 | "" => Err(Error::custom("missing event code")), 181 | s => Ok(Other(s.chars().next().unwrap())), 182 | } 183 | } 184 | 185 | pub struct V3Encoder { 186 | prev_time: u64, 187 | } 188 | 189 | impl V3Encoder { 190 | pub fn new() -> Self { 191 | Self { prev_time: 0 } 192 | } 193 | 194 | pub fn header(&mut self, header: &Header) -> Vec { 195 | let header: V3Header = header.into(); 196 | let mut data = serde_json::to_string(&header).unwrap().into_bytes(); 197 | data.push(b'\n'); 198 | 199 | data 200 | } 201 | 202 | pub fn event(&mut self, event: &Event) -> Vec { 203 | let mut data = self.serialize_event(event).into_bytes(); 204 | data.push(b'\n'); 205 | 206 | data 207 | } 208 | 209 | fn serialize_event(&mut self, event: &Event) -> String { 210 | use EventData::*; 211 | 212 | let (code, data) = match &event.data { 213 | Output(data) => ('o', self.to_json_string(data)), 214 | Input(data) => ('i', self.to_json_string(data)), 215 | Resize(cols, rows) => ('r', self.to_json_string(&format!("{cols}x{rows}"))), 216 | Marker(data) => ('m', self.to_json_string(data)), 217 | Exit(data) => ('x', self.to_json_string(&data.to_string())), 218 | Other(code, data) => (*code, self.to_json_string(data)), 219 | }; 220 | 221 | let time = event.time - self.prev_time; 222 | self.prev_time = event.time; 223 | 224 | format!( 225 | "[{}, {}, {}]", 226 | format_time(time), 227 | self.to_json_string(&code.to_string()), 228 | data, 229 | ) 230 | } 231 | 232 | fn to_json_string(&self, s: &str) -> String { 233 | serde_json::to_string(s).unwrap() 234 | } 235 | } 236 | 237 | fn format_time(time: u64) -> String { 238 | let mut formatted_time = format!("{}.{:0>6}", time / 1_000_000, time % 1_000_000); 239 | let dot_idx = formatted_time.find('.').unwrap(); 240 | 241 | for idx in (dot_idx + 2..=formatted_time.len() - 1).rev() { 242 | if formatted_time.as_bytes()[idx] != b'0' { 243 | break; 244 | } 245 | 246 | formatted_time.truncate(idx); 247 | } 248 | 249 | formatted_time 250 | } 251 | 252 | impl serde::Serialize for V3Header { 253 | fn serialize(&self, serializer: S) -> Result 254 | where 255 | S: serde::Serializer, 256 | { 257 | use serde::ser::SerializeMap; 258 | 259 | let mut len = 2; 260 | 261 | if self.timestamp.is_some() { 262 | len += 1; 263 | } 264 | 265 | if self.idle_time_limit.is_some() { 266 | len += 1; 267 | } 268 | 269 | if self.command.is_some() { 270 | len += 1; 271 | } 272 | 273 | if self.title.is_some() { 274 | len += 1; 275 | } 276 | 277 | if self.env.as_ref().is_some_and(|env| !env.is_empty()) { 278 | len += 1; 279 | } 280 | 281 | let mut map = serializer.serialize_map(Some(len))?; 282 | map.serialize_entry("version", &3)?; 283 | map.serialize_entry("term", &self.term)?; 284 | 285 | if let Some(timestamp) = self.timestamp { 286 | map.serialize_entry("timestamp", ×tamp)?; 287 | } 288 | 289 | if let Some(limit) = self.idle_time_limit { 290 | map.serialize_entry("idle_time_limit", &limit)?; 291 | } 292 | 293 | if let Some(command) = &self.command { 294 | map.serialize_entry("command", &command)?; 295 | } 296 | 297 | if let Some(title) = &self.title { 298 | map.serialize_entry("title", &title)?; 299 | } 300 | 301 | if let Some(env) = &self.env { 302 | if !env.is_empty() { 303 | map.serialize_entry("env", &env)?; 304 | } 305 | } 306 | map.end() 307 | } 308 | } 309 | 310 | impl serde::Serialize for V3Term { 311 | fn serialize(&self, serializer: S) -> Result 312 | where 313 | S: serde::Serializer, 314 | { 315 | use serde::ser::SerializeMap; 316 | 317 | let mut len = 2; 318 | 319 | if self.type_.is_some() { 320 | len += 1; 321 | } 322 | 323 | if self.version.is_some() { 324 | len += 1; 325 | } 326 | 327 | if self.theme.is_some() { 328 | len += 1; 329 | } 330 | 331 | let mut map = serializer.serialize_map(Some(len))?; 332 | map.serialize_entry("cols", &self.cols)?; 333 | map.serialize_entry("rows", &self.rows)?; 334 | 335 | if let Some(type_) = &self.type_ { 336 | map.serialize_entry("type", &type_)?; 337 | } 338 | 339 | if let Some(version) = &self.version { 340 | map.serialize_entry("version", &version)?; 341 | } 342 | 343 | if let Some(theme) = &self.theme { 344 | map.serialize_entry("theme", &theme)?; 345 | } 346 | 347 | map.end() 348 | } 349 | } 350 | 351 | fn deserialize_color<'de, D>(deserializer: D) -> Result 352 | where 353 | D: Deserializer<'de>, 354 | { 355 | let value: &str = Deserialize::deserialize(deserializer)?; 356 | parse_hex_color(value).ok_or(serde::de::Error::custom("invalid hex triplet")) 357 | } 358 | 359 | fn parse_hex_color(rgb: &str) -> Option { 360 | if rgb.len() != 7 { 361 | return None; 362 | } 363 | 364 | let r = u8::from_str_radix(&rgb[1..3], 16).ok()?; 365 | let g = u8::from_str_radix(&rgb[3..5], 16).ok()?; 366 | let b = u8::from_str_radix(&rgb[5..7], 16).ok()?; 367 | 368 | Some(RGB8(rgb::RGB8::new(r, g, b))) 369 | } 370 | 371 | fn deserialize_palette<'de, D>(deserializer: D) -> Result 372 | where 373 | D: Deserializer<'de>, 374 | { 375 | let value: &str = Deserialize::deserialize(deserializer)?; 376 | let mut colors: Vec = value.split(':').filter_map(parse_hex_color).collect(); 377 | let len = colors.len(); 378 | 379 | if len == 8 { 380 | colors.extend_from_within(..); 381 | } else if len != 16 { 382 | return Err(serde::de::Error::custom("expected 8 or 16 hex triplets")); 383 | } 384 | 385 | Ok(V3Palette(colors)) 386 | } 387 | 388 | impl serde::Serialize for RGB8 { 389 | fn serialize(&self, serializer: S) -> std::prelude::v1::Result 390 | where 391 | S: serde::Serializer, 392 | { 393 | serializer.serialize_str(&self.to_string()) 394 | } 395 | } 396 | 397 | impl fmt::Display for RGB8 { 398 | fn fmt(&self, f: &mut std::fmt::Formatter) -> fmt::Result { 399 | write!(f, "#{:0>2x}{:0>2x}{:0>2x}", self.0.r, self.0.g, self.0.b) 400 | } 401 | } 402 | 403 | impl serde::Serialize for V3Palette { 404 | fn serialize(&self, serializer: S) -> std::prelude::v1::Result 405 | where 406 | S: serde::Serializer, 407 | { 408 | let palette = self 409 | .0 410 | .iter() 411 | .map(|c| c.to_string()) 412 | .collect::>() 413 | .join(":"); 414 | 415 | serializer.serialize_str(&palette) 416 | } 417 | } 418 | 419 | impl From<&Header> for V3Header { 420 | fn from(header: &Header) -> Self { 421 | V3Header { 422 | version: 3, 423 | term: V3Term { 424 | cols: header.term_cols, 425 | rows: header.term_rows, 426 | type_: header.term_type.clone(), 427 | version: header.term_version.clone(), 428 | theme: header.term_theme.as_ref().map(|t| t.into()), 429 | }, 430 | timestamp: header.timestamp, 431 | idle_time_limit: header.idle_time_limit, 432 | command: header.command.clone(), 433 | title: header.title.clone(), 434 | env: header.env.clone(), 435 | } 436 | } 437 | } 438 | 439 | impl From<&TtyTheme> for V3Theme { 440 | fn from(tty_theme: &TtyTheme) -> Self { 441 | let palette = tty_theme.palette.iter().copied().map(RGB8).collect(); 442 | 443 | V3Theme { 444 | fg: RGB8(tty_theme.fg), 445 | bg: RGB8(tty_theme.bg), 446 | palette: V3Palette(palette), 447 | } 448 | } 449 | } 450 | 451 | impl From<&V3Theme> for TtyTheme { 452 | fn from(tty_theme: &V3Theme) -> Self { 453 | let palette = tty_theme.palette.0.iter().map(|c| c.0).collect(); 454 | 455 | TtyTheme { 456 | fg: tty_theme.fg.0, 457 | bg: tty_theme.bg.0, 458 | palette, 459 | } 460 | } 461 | } 462 | 463 | #[cfg(test)] 464 | mod tests { 465 | #[test] 466 | fn format_time() { 467 | assert_eq!(super::format_time(0), "0.0"); 468 | assert_eq!(super::format_time(1000001), "1.000001"); 469 | assert_eq!(super::format_time(12300000), "12.3"); 470 | assert_eq!(super::format_time(12000003), "12.000003"); 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | use std::num::ParseIntError; 3 | use std::path::PathBuf; 4 | 5 | use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; 6 | 7 | pub const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080"; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(author, version, about)] 11 | #[command(name = "asciinema")] 12 | pub struct Cli { 13 | #[command(subcommand)] 14 | pub command: Commands, 15 | 16 | /// Quiet mode, i.e. suppress diagnostic messages 17 | #[clap(short, long, global = true, display_order = 101)] 18 | pub quiet: bool, 19 | } 20 | 21 | #[derive(Debug, Subcommand)] 22 | pub enum Commands { 23 | /// Record a terminal session 24 | Rec(Record), 25 | 26 | /// Replay a terminal session 27 | Play(Play), 28 | 29 | /// Stream a terminal session 30 | Stream(Stream), 31 | 32 | /// Record and/or stream a terminal session 33 | Session(Session), 34 | 35 | /// Concatenate multiple recordings 36 | Cat(Cat), 37 | 38 | /// Convert a recording into another format 39 | Convert(Convert), 40 | 41 | /// Upload a recording to an asciinema server 42 | Upload(Upload), 43 | 44 | /// Authenticate this CLI with an asciinema server account 45 | Auth(Auth), 46 | } 47 | 48 | #[derive(Debug, Args)] 49 | pub struct Record { 50 | /// Output file path 51 | pub output_path: String, 52 | 53 | /// Output file format [default: asciicast-v3] 54 | #[arg(short = 'f', long, value_enum, value_name = "FORMAT")] 55 | pub output_format: Option, 56 | 57 | /// Command to start in the session [default: $SHELL] 58 | #[arg(short, long)] 59 | pub command: Option, 60 | 61 | /// Enable input (keys) recording 62 | #[arg(long, short = 'I', alias = "stdin")] 63 | pub rec_input: bool, 64 | 65 | /// Comma-separated list of env vars to capture [default: SHELL] 66 | #[arg(long, value_name = "VARS")] 67 | pub rec_env: Option, 68 | 69 | /// Append to an existing recording file 70 | #[arg(short, long)] 71 | pub append: bool, 72 | 73 | /// Overwrite output file if it already exists 74 | #[arg(long, conflicts_with = "append")] 75 | pub overwrite: bool, 76 | 77 | /// Title of the recording 78 | #[arg(short, long)] 79 | pub title: Option, 80 | 81 | /// Limit idle time to a given number of seconds 82 | #[arg(short, long, value_name = "SECS")] 83 | pub idle_time_limit: Option, 84 | 85 | /// Headless mode, i.e. don't use TTY for input/output 86 | #[arg(long)] 87 | pub headless: bool, 88 | 89 | /// Override terminal size for the session 90 | #[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size)] 91 | pub window_size: Option<(Option, Option)>, 92 | 93 | /// Return session exit status 94 | #[arg(long)] 95 | pub return_: bool, 96 | 97 | #[arg(long, hide = true)] 98 | pub cols: Option, 99 | 100 | #[arg(long, hide = true)] 101 | pub rows: Option, 102 | 103 | #[arg(long, hide = true)] 104 | pub raw: bool, 105 | } 106 | 107 | #[derive(Debug, Args)] 108 | pub struct Play { 109 | #[arg(value_name = "FILENAME_OR_URL")] 110 | pub filename: String, 111 | 112 | /// Limit idle time to a given number of seconds 113 | #[arg(short, long, value_name = "SECS")] 114 | pub idle_time_limit: Option, 115 | 116 | /// Set playback speed 117 | #[arg(short, long)] 118 | pub speed: Option, 119 | 120 | /// Loop loop loop loop 121 | #[arg(short, long, name = "loop")] 122 | pub loop_: bool, 123 | 124 | /// Automatically pause on markers 125 | #[arg(short = 'm', long)] 126 | pub pause_on_markers: bool, 127 | 128 | /// Auto-resize terminal window to always match the original size (supported on some terminals) 129 | #[arg(short = 'r', long)] 130 | pub resize: bool, 131 | } 132 | 133 | #[derive(Debug, Args)] 134 | #[clap(group(ArgGroup::new("mode").args(&["local", "remote"]).multiple(true).required(true)))] 135 | pub struct Stream { 136 | /// Stream the session via a local HTTP server 137 | #[arg(short, long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1)] 138 | pub local: Option, 139 | 140 | /// Stream the session via a remote asciinema server 141 | #[arg(short, long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target)] 142 | pub remote: Option, 143 | 144 | /// Command to start in the session [default: $SHELL] 145 | #[arg(short, long)] 146 | pub command: Option, 147 | 148 | /// Enable input (keys) recording 149 | #[arg(long, short = 'I')] 150 | pub rec_input: bool, 151 | 152 | /// Comma-separated list of env vars to capture [default: SHELL] 153 | #[arg(long, value_name = "VARS")] 154 | pub rec_env: Option, 155 | 156 | /// Title of the session 157 | #[arg(short, long)] 158 | pub title: Option, 159 | 160 | /// Headless mode, i.e. don't use TTY for input/output 161 | #[arg(long)] 162 | pub headless: bool, 163 | 164 | /// Override terminal size for the session 165 | #[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size)] 166 | pub window_size: Option<(Option, Option)>, 167 | 168 | /// Return session exit status 169 | #[arg(long)] 170 | pub return_: bool, 171 | 172 | /// Log file path 173 | #[arg(long, value_name = "PATH")] 174 | pub log_file: Option, 175 | 176 | /// asciinema server URL 177 | #[arg(long, value_name = "URL")] 178 | pub server_url: Option, 179 | } 180 | 181 | #[derive(Debug, Args)] 182 | pub struct Session { 183 | /// Save the session in a file 184 | #[arg(short, long, value_name = "PATH")] 185 | pub output_file: Option, 186 | 187 | /// Output file format [default: asciicast-v3] 188 | #[arg(short = 'f', long, value_enum, value_name = "FORMAT")] 189 | pub output_format: Option, 190 | 191 | /// Stream the session via a local HTTP server 192 | #[arg(short = 'l', long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1)] 193 | pub stream_local: Option, 194 | 195 | /// Stream the session via a remote asciinema server 196 | #[arg(short = 'r', long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target)] 197 | pub stream_remote: Option, 198 | 199 | /// Command to start in the session [default: $SHELL] 200 | #[arg(short, long)] 201 | pub command: Option, 202 | 203 | /// Enable input (keys) recording 204 | #[arg(long, short = 'I')] 205 | pub rec_input: bool, 206 | 207 | /// Comma-separated list of env vars to capture [default: SHELL] 208 | #[arg(long, value_name = "VARS")] 209 | pub rec_env: Option, 210 | 211 | /// Append to an existing recording file 212 | #[arg(short, long)] 213 | pub append: bool, 214 | 215 | /// Overwrite output file if it already exists 216 | #[arg(long, conflicts_with = "append")] 217 | pub overwrite: bool, 218 | 219 | /// Title of the session 220 | #[arg(short, long)] 221 | pub title: Option, 222 | 223 | /// Limit idle time to a given number of seconds 224 | #[arg(short, long, value_name = "SECS")] 225 | pub idle_time_limit: Option, 226 | 227 | /// Headless mode, i.e. don't use TTY for input/output 228 | #[arg(long)] 229 | pub headless: bool, 230 | 231 | /// Override terminal size for the session 232 | #[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size)] 233 | pub window_size: Option<(Option, Option)>, 234 | 235 | /// Return session exit status 236 | #[arg(long)] 237 | pub return_: bool, 238 | 239 | /// Log file path 240 | #[arg(long, value_name = "PATH")] 241 | pub log_file: Option, 242 | 243 | /// asciinema server URL 244 | #[arg(long, value_name = "URL")] 245 | pub server_url: Option, 246 | } 247 | 248 | #[derive(Debug, Args)] 249 | pub struct Cat { 250 | #[arg(required = true, num_args = 2..)] 251 | pub filename: Vec, 252 | } 253 | 254 | #[derive(Debug, Args)] 255 | pub struct Convert { 256 | /// File to convert from, in asciicast format (use - for stdin) 257 | #[arg(value_name = "INPUT_FILENAME_OR_URL")] 258 | pub input_filename: String, 259 | 260 | /// File to convert to (use - for stdout) 261 | pub output_filename: String, 262 | 263 | /// Output file format [default: asciicast-v3] 264 | #[arg(short = 'f', long, value_enum, value_name = "FORMAT")] 265 | pub output_format: Option, 266 | 267 | /// Overwrite target file if it already exists 268 | #[arg(long)] 269 | pub overwrite: bool, 270 | } 271 | 272 | #[derive(Debug, Args)] 273 | pub struct Upload { 274 | /// Filename/path of asciicast to upload 275 | pub filename: String, 276 | 277 | /// asciinema server URL 278 | #[arg(long, value_name = "URL")] 279 | pub server_url: Option, 280 | } 281 | 282 | #[derive(Debug, Args)] 283 | pub struct Auth { 284 | /// asciinema server URL 285 | #[arg(long, value_name = "URL")] 286 | pub server_url: Option, 287 | } 288 | 289 | #[derive(Clone, Copy, Debug, PartialEq, ValueEnum)] 290 | pub enum Format { 291 | AsciicastV3, 292 | AsciicastV2, 293 | Raw, 294 | Txt, 295 | } 296 | 297 | #[derive(Debug, Clone)] 298 | #[allow(dead_code)] 299 | pub enum RelayTarget { 300 | StreamId(String), 301 | WsProducerUrl(url::Url), 302 | } 303 | 304 | fn parse_window_size(s: &str) -> Result<(Option, Option), String> { 305 | match s.split_once('x') { 306 | Some((cols, "")) => { 307 | let cols: u16 = cols.parse().map_err(|e: ParseIntError| e.to_string())?; 308 | 309 | Ok((Some(cols), None)) 310 | } 311 | 312 | Some(("", rows)) => { 313 | let rows: u16 = rows.parse().map_err(|e: ParseIntError| e.to_string())?; 314 | 315 | Ok((None, Some(rows))) 316 | } 317 | 318 | Some((cols, rows)) => { 319 | let cols: u16 = cols.parse().map_err(|e: ParseIntError| e.to_string())?; 320 | let rows: u16 = rows.parse().map_err(|e: ParseIntError| e.to_string())?; 321 | 322 | Ok((Some(cols), Some(rows))) 323 | } 324 | 325 | None => Err(s.to_owned()), 326 | } 327 | } 328 | 329 | fn validate_forward_target(s: &str) -> Result { 330 | let s = s.trim(); 331 | 332 | match url::Url::parse(s) { 333 | Ok(url) => { 334 | let scheme = url.scheme(); 335 | 336 | if scheme == "ws" || scheme == "wss" { 337 | Ok(RelayTarget::WsProducerUrl(url)) 338 | } else { 339 | Err("must be a WebSocket URL (ws:// or wss://)".to_owned()) 340 | } 341 | } 342 | 343 | Err(url::ParseError::RelativeUrlWithoutBase) => Ok(RelayTarget::StreamId(s.to_owned())), 344 | Err(e) => Err(e.to_string()), 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/cmd/auth.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::api; 4 | use crate::cli; 5 | use crate::config::Config; 6 | 7 | impl cli::Auth { 8 | pub fn run(self) -> Result<()> { 9 | let config = Config::new(self.server_url.clone())?; 10 | let server_url = config.get_server_url()?; 11 | let server_hostname = server_url.host().unwrap(); 12 | let auth_url = api::get_auth_url(&config)?; 13 | 14 | println!("Open the following URL in a web browser to authenticate this asciinema CLI with your {server_hostname} user account:\n"); 15 | println!("{auth_url}\n"); 16 | println!("This action will associate all recordings uploaded from this machine (past and future ones) with your account, allowing you to manage them (change the title/theme, delete) at {server_hostname}."); 17 | 18 | Ok(()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/cmd/cat.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::io::Write; 3 | 4 | use anyhow::{anyhow, Result}; 5 | 6 | use crate::asciicast::{self, Asciicast, Encoder, Event, EventData, Version}; 7 | use crate::cli; 8 | 9 | impl cli::Cat { 10 | pub fn run(self) -> Result<()> { 11 | let mut stdout = io::stdout(); 12 | let casts = self.open_input_files()?; 13 | let mut encoder = self.get_encoder(casts[0].version)?; 14 | let mut time_offset: u64 = 0; 15 | let mut first = true; 16 | let mut cols = 0; 17 | let mut rows = 0; 18 | 19 | for cast in casts.into_iter() { 20 | let mut time = time_offset; 21 | 22 | if first { 23 | first = false; 24 | stdout.write_all(&encoder.header(&cast.header))?; 25 | } else if cast.header.term_cols != cols || cast.header.term_rows != rows { 26 | let event = Event::resize(time, (cast.header.term_cols, cast.header.term_rows)); 27 | stdout.write_all(&encoder.event(&event))?; 28 | } 29 | 30 | cols = cast.header.term_cols; 31 | rows = cast.header.term_rows; 32 | 33 | for event in cast.events { 34 | let mut event = event?; 35 | time = time_offset + event.time; 36 | event.time = time; 37 | stdout.write_all(&encoder.event(&event))?; 38 | 39 | if let EventData::Resize(cols_, rows_) = event.data { 40 | cols = cols_; 41 | rows = rows_; 42 | } 43 | } 44 | 45 | time_offset = time; 46 | } 47 | 48 | Ok(()) 49 | } 50 | 51 | fn open_input_files(&self) -> Result> { 52 | self.filename 53 | .iter() 54 | .map(asciicast::open_from_path) 55 | .collect() 56 | } 57 | 58 | fn get_encoder(&self, version: Version) -> Result> { 59 | asciicast::encoder(version) 60 | .ok_or(anyhow!("asciicast v{version} files can't be concatenated")) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/cmd/convert.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::Path; 3 | 4 | use anyhow::{bail, Result}; 5 | 6 | use crate::asciicast; 7 | use crate::cli::{self, Format}; 8 | use crate::encoder::{ 9 | self, AsciicastV2Encoder, AsciicastV3Encoder, EncoderExt, RawEncoder, TextEncoder, 10 | }; 11 | use crate::util; 12 | 13 | impl cli::Convert { 14 | pub fn run(self) -> Result<()> { 15 | let input_path = self.get_input_path()?; 16 | let output_path = self.get_output_path(); 17 | let cast = asciicast::open_from_path(&*input_path)?; 18 | let mut encoder = self.get_encoder(); 19 | let mut output_file = self.open_output_file(output_path)?; 20 | 21 | encoder.encode_to_file(cast, &mut output_file) 22 | } 23 | 24 | fn get_encoder(&self) -> Box { 25 | let format = self.output_format.unwrap_or_else(|| { 26 | if self.output_filename.to_lowercase().ends_with(".txt") { 27 | Format::Txt 28 | } else { 29 | Format::AsciicastV3 30 | } 31 | }); 32 | 33 | match format { 34 | Format::AsciicastV3 => Box::new(AsciicastV3Encoder::new(false)), 35 | Format::AsciicastV2 => Box::new(AsciicastV2Encoder::new(false, 0)), 36 | Format::Raw => Box::new(RawEncoder::new()), 37 | Format::Txt => Box::new(TextEncoder::new()), 38 | } 39 | } 40 | 41 | fn get_input_path(&self) -> Result>> { 42 | if self.input_filename == "-" { 43 | Ok(Box::new(Path::new("/dev/stdin"))) 44 | } else { 45 | util::get_local_path(&self.input_filename) 46 | } 47 | } 48 | 49 | fn get_output_path(&self) -> String { 50 | if self.output_filename == "-" { 51 | "/dev/stdout".to_owned() 52 | } else { 53 | self.output_filename.clone() 54 | } 55 | } 56 | 57 | fn open_output_file(&self, path: String) -> Result { 58 | let overwrite = self.get_mode(&path)?; 59 | 60 | let file = fs::OpenOptions::new() 61 | .write(true) 62 | .create(overwrite) 63 | .create_new(!overwrite) 64 | .truncate(overwrite) 65 | .open(&path)?; 66 | 67 | Ok(file) 68 | } 69 | 70 | fn get_mode(&self, path: &str) -> Result { 71 | let mut overwrite = self.overwrite; 72 | let path = Path::new(path); 73 | 74 | if path.exists() { 75 | let metadata = fs::metadata(path)?; 76 | 77 | if metadata.len() == 0 { 78 | overwrite = true; 79 | } 80 | 81 | if !overwrite { 82 | bail!("file exists, use --overwrite option to overwrite the file"); 83 | } 84 | } 85 | 86 | Ok(overwrite) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod cat; 3 | pub mod convert; 4 | pub mod play; 5 | pub mod session; 6 | pub mod upload; 7 | -------------------------------------------------------------------------------- /src/cmd/play.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::asciicast; 4 | use crate::cli; 5 | use crate::config::{self, Config}; 6 | use crate::player::{self, KeyBindings}; 7 | use crate::status; 8 | use crate::tty; 9 | use crate::util; 10 | 11 | impl cli::Play { 12 | pub fn run(self) -> Result<()> { 13 | let config = Config::new(None)?; 14 | let speed = self.speed.or(config.playback.speed).unwrap_or(1.0); 15 | let idle_time_limit = self.idle_time_limit.or(config.playback.idle_time_limit); 16 | 17 | status::info!("Replaying session from {}", self.filename); 18 | 19 | let path = util::get_local_path(&self.filename)?; 20 | let keys = get_key_bindings(&config.playback)?; 21 | 22 | let ended = loop { 23 | let recording = asciicast::open_from_path(&*path)?; 24 | let tty = tty::DevTty::open()?; 25 | 26 | let ended = player::play( 27 | recording, 28 | tty, 29 | speed, 30 | idle_time_limit, 31 | self.pause_on_markers, 32 | &keys, 33 | self.resize, 34 | )?; 35 | 36 | if !self.loop_ || !ended { 37 | break ended; 38 | } 39 | }; 40 | 41 | if ended { 42 | status::info!("Playback ended"); 43 | } else { 44 | status::info!("Playback interrupted"); 45 | } 46 | 47 | Ok(()) 48 | } 49 | } 50 | 51 | fn get_key_bindings(config: &config::Playback) -> Result { 52 | let mut keys = KeyBindings::default(); 53 | 54 | if let Some(key) = config.pause_key()? { 55 | keys.pause = key; 56 | } 57 | 58 | if let Some(key) = config.step_key()? { 59 | keys.step = key; 60 | } 61 | 62 | if let Some(key) = config.next_marker_key()? { 63 | keys.next_marker = key; 64 | } 65 | 66 | Ok(keys) 67 | } 68 | -------------------------------------------------------------------------------- /src/cmd/upload.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::api; 4 | use crate::asciicast; 5 | use crate::cli; 6 | use crate::config::Config; 7 | 8 | impl cli::Upload { 9 | pub fn run(self) -> Result<()> { 10 | let config = Config::new(self.server_url.clone())?; 11 | let _ = asciicast::open_from_path(&self.filename)?; 12 | let response = api::upload_asciicast(&self.filename, &config)?; 13 | println!("{}", response.message.unwrap_or(response.url)); 14 | 15 | Ok(()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs; 3 | use std::io::ErrorKind; 4 | use std::path::{Path, PathBuf}; 5 | 6 | use anyhow::{anyhow, bail, Result}; 7 | use config::{self, Environment, File}; 8 | use reqwest::Url; 9 | use serde::Deserialize; 10 | use uuid::Uuid; 11 | 12 | const DEFAULT_SERVER_URL: &str = "https://asciinema.org"; 13 | const INSTALL_ID_FILENAME: &str = "install-id"; 14 | 15 | pub type Key = Option>; 16 | 17 | #[derive(Debug, Deserialize)] 18 | #[allow(unused)] 19 | pub struct Config { 20 | server: Server, 21 | pub recording: Recording, 22 | pub playback: Playback, 23 | pub notifications: Notifications, 24 | } 25 | 26 | #[derive(Debug, Deserialize)] 27 | #[allow(unused)] 28 | pub struct Server { 29 | url: Option, 30 | } 31 | 32 | #[derive(Debug, Clone, Deserialize, Default)] 33 | #[allow(unused)] 34 | pub struct Recording { 35 | pub command: Option, 36 | pub rec_input: bool, 37 | pub rec_env: Option, 38 | pub idle_time_limit: Option, 39 | pub prefix_key: Option, 40 | pub pause_key: Option, 41 | pub add_marker_key: Option, 42 | } 43 | 44 | #[derive(Debug, Clone, Deserialize)] 45 | #[allow(unused)] 46 | pub struct Playback { 47 | pub speed: Option, 48 | pub idle_time_limit: Option, 49 | pub pause_key: Option, 50 | pub step_key: Option, 51 | pub next_marker_key: Option, 52 | } 53 | 54 | #[derive(Debug, Deserialize)] 55 | #[allow(unused)] 56 | pub struct Notifications { 57 | pub enabled: bool, 58 | pub command: Option, 59 | } 60 | 61 | impl Config { 62 | pub fn new(server_url: Option) -> Result { 63 | let mut config = config::Config::builder() 64 | .set_default("server.url", None::>)? 65 | .set_default("playback.speed", None::>)? 66 | .set_default("recording.rec_input", false)? 67 | .set_default("notifications.enabled", true)? 68 | .add_source(File::with_name("/etc/asciinema/config.toml").required(false)) 69 | .add_source(File::with_name(&user_defaults_path()?.to_string_lossy()).required(false)) 70 | .add_source(File::with_name(&user_config_path()?.to_string_lossy()).required(false)) 71 | .add_source(Environment::with_prefix("asciinema").separator("_")); 72 | 73 | if let Some(url) = server_url { 74 | config = config.set_override("server.url", Some(url))?; 75 | } 76 | 77 | if let (Err(_), Ok(url)) = ( 78 | env::var("ASCIINEMA_SERVER_URL"), 79 | env::var("ASCIINEMA_API_URL"), 80 | ) { 81 | env::set_var("ASCIINEMA_SERVER_URL", url); 82 | } 83 | 84 | Ok(config.build()?.try_deserialize()?) 85 | } 86 | 87 | pub fn get_server_url(&self) -> Result { 88 | match self.server.url.as_ref() { 89 | Some(url) => Ok(parse_server_url(url)?), 90 | 91 | None => { 92 | let url = parse_server_url(&ask_for_server_url()?)?; 93 | save_default_server_url(url.as_ref())?; 94 | 95 | Ok(url) 96 | } 97 | } 98 | } 99 | 100 | pub fn get_install_id(&self) -> Result { 101 | let path = install_id_path()?; 102 | 103 | if let Some(id) = read_install_id(&path)? { 104 | Ok(id) 105 | } else { 106 | let id = create_install_id(); 107 | save_install_id(&path, &id)?; 108 | 109 | Ok(id) 110 | } 111 | } 112 | } 113 | 114 | impl Recording { 115 | pub fn prefix_key(&self) -> Result> { 116 | self.prefix_key.as_ref().map(parse_key).transpose() 117 | } 118 | 119 | pub fn pause_key(&self) -> Result> { 120 | self.pause_key.as_ref().map(parse_key).transpose() 121 | } 122 | 123 | pub fn add_marker_key(&self) -> Result> { 124 | self.add_marker_key.as_ref().map(parse_key).transpose() 125 | } 126 | } 127 | 128 | impl Playback { 129 | pub fn pause_key(&self) -> Result> { 130 | self.pause_key.as_ref().map(parse_key).transpose() 131 | } 132 | 133 | pub fn step_key(&self) -> Result> { 134 | self.step_key.as_ref().map(parse_key).transpose() 135 | } 136 | 137 | pub fn next_marker_key(&self) -> Result> { 138 | self.next_marker_key.as_ref().map(parse_key).transpose() 139 | } 140 | } 141 | 142 | fn ask_for_server_url() -> Result { 143 | println!("No asciinema server configured for this CLI."); 144 | 145 | let url = rustyline::DefaultEditor::new()?.readline_with_initial( 146 | "Enter the server URL to use by default: ", 147 | (DEFAULT_SERVER_URL, ""), 148 | )?; 149 | 150 | println!(); 151 | 152 | Ok(url) 153 | } 154 | 155 | fn save_default_server_url(url: &str) -> Result<()> { 156 | let path = user_defaults_path()?; 157 | 158 | if let Some(dir) = path.parent() { 159 | fs::create_dir_all(dir)?; 160 | } 161 | 162 | fs::write(path, format!("[server]\nurl = \"{url}\"\n"))?; 163 | 164 | Ok(()) 165 | } 166 | 167 | fn parse_server_url(s: &str) -> Result { 168 | let url = Url::parse(s)?; 169 | 170 | if url.host().is_none() { 171 | bail!("server URL is missing a host"); 172 | } 173 | 174 | Ok(url) 175 | } 176 | 177 | fn read_install_id(path: &PathBuf) -> Result> { 178 | match fs::read_to_string(path) { 179 | Ok(s) => Ok(Some(s.trim().to_string())), 180 | 181 | Err(e) => { 182 | if e.kind() == ErrorKind::NotFound { 183 | Ok(None) 184 | } else { 185 | bail!(e) 186 | } 187 | } 188 | } 189 | } 190 | 191 | fn create_install_id() -> String { 192 | Uuid::new_v4().to_string() 193 | } 194 | 195 | fn save_install_id(path: &PathBuf, id: &str) -> Result<()> { 196 | if let Some(dir) = path.parent() { 197 | fs::create_dir_all(dir)?; 198 | } 199 | 200 | fs::write(path, id)?; 201 | 202 | Ok(()) 203 | } 204 | 205 | fn user_config_path() -> Result { 206 | Ok(home()?.join("config.toml")) 207 | } 208 | 209 | fn user_defaults_path() -> Result { 210 | Ok(home()?.join("defaults.toml")) 211 | } 212 | 213 | fn install_id_path() -> Result { 214 | Ok(home()?.join(INSTALL_ID_FILENAME)) 215 | } 216 | 217 | fn home() -> Result { 218 | env::var("ASCIINEMA_CONFIG_HOME") 219 | .map(PathBuf::from) 220 | .or(env::var("XDG_CONFIG_HOME").map(|home| Path::new(&home).join("asciinema"))) 221 | .or(env::var("HOME").map(|home| Path::new(&home).join(".config").join("asciinema"))) 222 | .map_err(|_| anyhow!("need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME")) 223 | } 224 | 225 | fn parse_key>(key: S) -> Result { 226 | let key = key.as_ref(); 227 | let chars: Vec = key.chars().collect(); 228 | 229 | match chars.len() { 230 | 0 => return Ok(None), 231 | 232 | 1 => { 233 | let mut buf = [0; 4]; 234 | let str = chars[0].encode_utf8(&mut buf); 235 | 236 | return Ok(Some(str.as_bytes().into())); 237 | } 238 | 239 | 2 => { 240 | if chars[0] == '^' && chars[1].is_ascii_alphabetic() { 241 | let key = vec![chars[1].to_ascii_uppercase() as u8 - 0x40]; 242 | 243 | return Ok(Some(key)); 244 | } 245 | } 246 | 247 | 3 => { 248 | if chars[0].to_ascii_uppercase() == 'C' 249 | && ['+', '-'].contains(&chars[1]) 250 | && chars[2].is_ascii_alphabetic() 251 | { 252 | let key = vec![chars[2].to_ascii_uppercase() as u8 - 0x40]; 253 | 254 | return Ok(Some(key)); 255 | } 256 | } 257 | 258 | _ => (), 259 | } 260 | 261 | Err(anyhow!("invalid key definition '{key}'")) 262 | } 263 | -------------------------------------------------------------------------------- /src/encoder/asciicast.rs: -------------------------------------------------------------------------------- 1 | use crate::asciicast::{Event, Header, V2Encoder, V3Encoder}; 2 | 3 | pub struct AsciicastV2Encoder { 4 | inner: V2Encoder, 5 | append: bool, 6 | } 7 | 8 | impl AsciicastV2Encoder { 9 | pub fn new(append: bool, time_offset: u64) -> Self { 10 | let inner = V2Encoder::new(time_offset); 11 | 12 | Self { inner, append } 13 | } 14 | } 15 | 16 | impl super::Encoder for AsciicastV2Encoder { 17 | fn header(&mut self, header: &Header) -> Vec { 18 | if self.append { 19 | let size = (header.term_cols, header.term_rows); 20 | self.inner.event(&Event::resize(0, size)) 21 | } else { 22 | self.inner.header(header) 23 | } 24 | } 25 | 26 | fn event(&mut self, event: Event) -> Vec { 27 | self.inner.event(&event) 28 | } 29 | 30 | fn flush(&mut self) -> Vec { 31 | Vec::new() 32 | } 33 | } 34 | 35 | pub struct AsciicastV3Encoder { 36 | inner: V3Encoder, 37 | append: bool, 38 | } 39 | 40 | impl AsciicastV3Encoder { 41 | pub fn new(append: bool) -> Self { 42 | let inner = V3Encoder::new(); 43 | 44 | Self { inner, append } 45 | } 46 | } 47 | 48 | impl super::Encoder for AsciicastV3Encoder { 49 | fn header(&mut self, header: &Header) -> Vec { 50 | if self.append { 51 | let size = (header.term_cols, header.term_rows); 52 | self.inner.event(&Event::resize(0, size)) 53 | } else { 54 | self.inner.header(header) 55 | } 56 | } 57 | 58 | fn event(&mut self, event: Event) -> Vec { 59 | self.inner.event(&event) 60 | } 61 | 62 | fn flush(&mut self) -> Vec { 63 | Vec::new() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/encoder/mod.rs: -------------------------------------------------------------------------------- 1 | mod asciicast; 2 | mod raw; 3 | mod txt; 4 | 5 | use std::fs::File; 6 | use std::io::Write; 7 | 8 | use anyhow::Result; 9 | 10 | use crate::asciicast::{Event, Header}; 11 | pub use asciicast::{AsciicastV2Encoder, AsciicastV3Encoder}; 12 | pub use raw::RawEncoder; 13 | pub use txt::TextEncoder; 14 | 15 | pub trait Encoder { 16 | fn header(&mut self, header: &Header) -> Vec; 17 | fn event(&mut self, event: Event) -> Vec; 18 | fn flush(&mut self) -> Vec; 19 | } 20 | 21 | pub trait EncoderExt { 22 | fn encode_to_file(&mut self, cast: crate::asciicast::Asciicast, file: &mut File) -> Result<()>; 23 | } 24 | 25 | impl EncoderExt for E { 26 | fn encode_to_file(&mut self, cast: crate::asciicast::Asciicast, file: &mut File) -> Result<()> { 27 | file.write_all(&self.header(&cast.header))?; 28 | 29 | for event in cast.events { 30 | file.write_all(&self.event(event?))?; 31 | } 32 | 33 | file.write_all(&self.flush())?; 34 | 35 | Ok(()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/encoder/raw.rs: -------------------------------------------------------------------------------- 1 | use crate::asciicast::{Event, EventData, Header}; 2 | 3 | pub struct RawEncoder; 4 | 5 | impl RawEncoder { 6 | pub fn new() -> Self { 7 | RawEncoder 8 | } 9 | } 10 | 11 | impl super::Encoder for RawEncoder { 12 | fn header(&mut self, header: &Header) -> Vec { 13 | format!("\x1b[8;{};{}t", header.term_rows, header.term_cols).into_bytes() 14 | } 15 | 16 | fn event(&mut self, event: Event) -> Vec { 17 | if let EventData::Output(data) = event.data { 18 | data.into_bytes() 19 | } else { 20 | Vec::new() 21 | } 22 | } 23 | 24 | fn flush(&mut self) -> Vec { 25 | Vec::new() 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::RawEncoder; 32 | use crate::asciicast::{Event, Header}; 33 | use crate::encoder::Encoder; 34 | 35 | #[test] 36 | fn encoder() { 37 | let mut enc = RawEncoder::new(); 38 | 39 | let header = Header { 40 | term_cols: 100, 41 | term_rows: 50, 42 | ..Default::default() 43 | }; 44 | 45 | assert_eq!(enc.header(&header), "\x1b[8;50;100t".as_bytes()); 46 | 47 | assert_eq!( 48 | enc.event(Event::output(0, "he\x1b[1mllo\r\n".to_owned())), 49 | "he\x1b[1mllo\r\n".as_bytes() 50 | ); 51 | 52 | assert_eq!( 53 | enc.event(Event::output(1, "world\r\n".to_owned())), 54 | "world\r\n".as_bytes() 55 | ); 56 | 57 | assert!(enc.event(Event::input(2, ".".to_owned())).is_empty()); 58 | assert!(enc.event(Event::resize(3, (80, 24))).is_empty()); 59 | assert!(enc.event(Event::marker(4, ".".to_owned())).is_empty()); 60 | assert!(enc.flush().is_empty()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/encoder/txt.rs: -------------------------------------------------------------------------------- 1 | use avt::util::TextCollector; 2 | 3 | use crate::asciicast::{Event, EventData, Header}; 4 | 5 | pub struct TextEncoder { 6 | collector: Option, 7 | } 8 | 9 | impl TextEncoder { 10 | pub fn new() -> Self { 11 | TextEncoder { collector: None } 12 | } 13 | } 14 | 15 | impl super::Encoder for TextEncoder { 16 | fn header(&mut self, header: &Header) -> Vec { 17 | let vt = avt::Vt::builder() 18 | .size(header.term_cols as usize, header.term_rows as usize) 19 | .scrollback_limit(100) 20 | .build(); 21 | 22 | self.collector = Some(TextCollector::new(vt)); 23 | 24 | Vec::new() 25 | } 26 | 27 | fn event(&mut self, event: Event) -> Vec { 28 | use EventData::*; 29 | 30 | match &event.data { 31 | Output(data) => text_lines_to_bytes(self.collector.as_mut().unwrap().feed_str(data)), 32 | 33 | Resize(cols, rows) => { 34 | text_lines_to_bytes(self.collector.as_mut().unwrap().resize(*cols, *rows)) 35 | } 36 | 37 | _ => Vec::new(), 38 | } 39 | } 40 | 41 | fn flush(&mut self) -> Vec { 42 | text_lines_to_bytes(self.collector.take().unwrap().flush().iter()) 43 | } 44 | } 45 | 46 | fn text_lines_to_bytes>(lines: impl Iterator) -> Vec { 47 | lines.fold(Vec::new(), |mut bytes, line| { 48 | bytes.extend_from_slice(line.as_ref().as_bytes()); 49 | bytes.push(b'\n'); 50 | 51 | bytes 52 | }) 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::TextEncoder; 58 | use crate::asciicast::{Event, Header}; 59 | use crate::encoder::Encoder; 60 | 61 | #[test] 62 | fn encoder() { 63 | let mut enc = TextEncoder::new(); 64 | 65 | let header = Header { 66 | term_cols: 3, 67 | term_rows: 1, 68 | ..Default::default() 69 | }; 70 | 71 | assert!(enc.header(&header).is_empty()); 72 | 73 | assert!(enc 74 | .event(Event::output(0, "he\x1b[1mllo\r\n".to_owned())) 75 | .is_empty()); 76 | 77 | assert!(enc 78 | .event(Event::output(1, "world\r\n".to_owned())) 79 | .is_empty()); 80 | 81 | assert_eq!(enc.flush(), "hello\nworld\n".as_bytes()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/file_writer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::io::{self, Write}; 3 | use std::time::{SystemTime, UNIX_EPOCH}; 4 | 5 | use crate::asciicast; 6 | use crate::encoder; 7 | use crate::notifier::Notifier; 8 | use crate::session; 9 | use crate::tty::{TtySize, TtyTheme}; 10 | 11 | pub struct FileWriterStarter { 12 | pub writer: Box, 13 | pub encoder: Box, 14 | pub metadata: Metadata, 15 | pub notifier: Box, 16 | } 17 | 18 | pub struct FileWriter { 19 | pub writer: Box, 20 | pub encoder: Box, 21 | pub notifier: Box, 22 | } 23 | 24 | pub struct Metadata { 25 | pub term_type: Option, 26 | pub term_version: Option, 27 | pub idle_time_limit: Option, 28 | pub command: Option, 29 | pub title: Option, 30 | pub env: Option>, 31 | } 32 | 33 | impl session::OutputStarter for FileWriterStarter { 34 | fn start( 35 | mut self: Box, 36 | time: SystemTime, 37 | tty_size: TtySize, 38 | tty_theme: Option, 39 | ) -> io::Result> { 40 | let timestamp = time.duration_since(UNIX_EPOCH).unwrap().as_secs(); 41 | 42 | let header = asciicast::Header { 43 | term_cols: tty_size.0, 44 | term_rows: tty_size.1, 45 | term_type: self.metadata.term_type, 46 | term_version: self.metadata.term_version, 47 | term_theme: tty_theme, 48 | timestamp: Some(timestamp), 49 | idle_time_limit: self.metadata.idle_time_limit, 50 | command: self.metadata.command.as_ref().cloned(), 51 | title: self.metadata.title.as_ref().cloned(), 52 | env: self.metadata.env.as_ref().cloned(), 53 | }; 54 | 55 | if let Err(e) = self.writer.write_all(&self.encoder.header(&header)) { 56 | let _ = self 57 | .notifier 58 | .notify("Write error, session won't be recorded".to_owned()); 59 | 60 | return Err(e); 61 | } 62 | 63 | Ok(Box::new(FileWriter { 64 | writer: self.writer, 65 | encoder: self.encoder, 66 | notifier: self.notifier, 67 | })) 68 | } 69 | } 70 | 71 | impl session::Output for FileWriter { 72 | fn event(&mut self, event: session::Event) -> io::Result<()> { 73 | match self.writer.write_all(&self.encoder.event(event.into())) { 74 | Ok(_) => Ok(()), 75 | 76 | Err(e) => { 77 | let _ = self 78 | .notifier 79 | .notify("Write error, recording suspended".to_owned()); 80 | 81 | Err(e) 82 | } 83 | } 84 | } 85 | 86 | fn flush(&mut self) -> io::Result<()> { 87 | self.writer.write_all(&self.encoder.flush()) 88 | } 89 | } 90 | 91 | impl From for asciicast::Event { 92 | fn from(event: session::Event) -> Self { 93 | match event { 94 | session::Event::Output(time, text) => asciicast::Event::output(time, text), 95 | session::Event::Input(time, text) => asciicast::Event::input(time, text), 96 | session::Event::Resize(time, tty_size) => { 97 | asciicast::Event::resize(time, tty_size.into()) 98 | } 99 | session::Event::Marker(time, label) => asciicast::Event::marker(time, label), 100 | session::Event::Exit(time, status) => asciicast::Event::exit(time, status), 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/forwarder.rs: -------------------------------------------------------------------------------- 1 | use core::future::{self, Future}; 2 | use std::pin::Pin; 3 | use std::time::Duration; 4 | 5 | use anyhow::{anyhow, bail}; 6 | use axum::http::Uri; 7 | use futures_util::{SinkExt, Stream, StreamExt}; 8 | use rand::Rng; 9 | use tokio::net::TcpStream; 10 | use tokio::time::{interval, sleep, timeout}; 11 | use tokio_stream::wrappers::errors::BroadcastStreamRecvError; 12 | use tokio_stream::wrappers::IntervalStream; 13 | use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; 14 | use tokio_tungstenite::tungstenite::protocol::CloseFrame; 15 | use tokio_tungstenite::tungstenite::{self, ClientRequestBuilder, Message}; 16 | use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; 17 | use tracing::{debug, error, info}; 18 | 19 | use crate::alis; 20 | use crate::api; 21 | use crate::notifier::Notifier; 22 | use crate::stream::Subscriber; 23 | 24 | const PING_INTERVAL: u64 = 15; 25 | const PING_TIMEOUT: u64 = 10; 26 | const SEND_TIMEOUT: u64 = 10; 27 | const RECONNECT_DELAY_BASE: u64 = 500; 28 | const RECONNECT_DELAY_CAP: u64 = 10_000; 29 | 30 | pub async fn forward( 31 | url: url::Url, 32 | subscriber: Subscriber, 33 | mut notifier: N, 34 | shutdown_token: tokio_util::sync::CancellationToken, 35 | ) { 36 | info!("forwarding to {url}"); 37 | let mut reconnect_attempt = 0; 38 | let mut connection_count: u64 = 0; 39 | 40 | loop { 41 | let conn = connect_and_forward(&url, &subscriber); 42 | tokio::pin!(conn); 43 | 44 | let result = tokio::select! { 45 | result = &mut conn => result, 46 | 47 | _ = sleep(Duration::from_secs(3)) => { 48 | if reconnect_attempt > 0 { 49 | if connection_count == 0 { 50 | let _ = notifier.notify("Connected to the server".to_string()); 51 | } else { 52 | let _ = notifier.notify("Reconnected to the server".to_string()); 53 | } 54 | } 55 | 56 | connection_count += 1; 57 | reconnect_attempt = 0; 58 | 59 | conn.await 60 | } 61 | }; 62 | 63 | match result { 64 | Ok(true) => break, 65 | 66 | Ok(false) => { 67 | let _ = notifier.notify("Stream halted by the server".to_string()); 68 | break; 69 | } 70 | 71 | Err(e) => { 72 | if let Some(tungstenite::error::Error::Protocol( 73 | tungstenite::error::ProtocolError::SecWebSocketSubProtocolError(_), 74 | )) = e.downcast_ref::() 75 | { 76 | // This happens when the server accepts the websocket connection 77 | // but doesn't properly perform the protocol negotiation. 78 | // This applies to asciinema-server v20241103 and earlier. 79 | 80 | let _ = notifier 81 | .notify("The server version is too old, forwarding failed".to_string()); 82 | 83 | break; 84 | } 85 | 86 | if let Some(tungstenite::error::Error::Http(response)) = 87 | e.downcast_ref::() 88 | { 89 | if response.status().as_u16() == 400 { 90 | // This happens when the server doesn't support our protocol (version). 91 | // This applies to asciinema-server versions newer than v20241103. 92 | 93 | let _ = notifier.notify( 94 | "CLI not compatible with the server, forwarding failed".to_string(), 95 | ); 96 | 97 | break; 98 | } 99 | } 100 | 101 | error!("connection error: {e}"); 102 | 103 | if reconnect_attempt == 0 { 104 | if connection_count == 0 { 105 | let _ = notifier 106 | .notify("Cannot connect to the server, retrying...".to_string()); 107 | } else { 108 | let _ = notifier 109 | .notify("Disconnected from the server, reconnecting...".to_string()); 110 | } 111 | } 112 | } 113 | } 114 | 115 | let delay = exponential_delay(reconnect_attempt); 116 | reconnect_attempt = (reconnect_attempt + 1).min(10); 117 | info!("reconnecting in {delay}"); 118 | 119 | tokio::select! { 120 | _ = sleep(Duration::from_millis(delay)) => (), 121 | _ = shutdown_token.cancelled() => break 122 | } 123 | } 124 | } 125 | 126 | async fn connect_and_forward(url: &url::Url, subscriber: &Subscriber) -> anyhow::Result { 127 | let uri: Uri = url.to_string().parse()?; 128 | 129 | let builder = ClientRequestBuilder::new(uri) 130 | .with_sub_protocol("v1.alis") 131 | .with_header("user-agent", api::build_user_agent()); 132 | 133 | let (ws, _) = tokio_tungstenite::connect_async_with_config(builder, None, true).await?; 134 | info!("connected to the endpoint"); 135 | let events = event_stream(subscriber).await?; 136 | 137 | handle_socket(ws, events).await 138 | } 139 | 140 | async fn event_stream( 141 | subscriber: &Subscriber, 142 | ) -> anyhow::Result>> { 143 | let stream = subscriber.subscribe().await?; 144 | 145 | let stream = alis::stream(stream) 146 | .await? 147 | .map(ws_result) 148 | .chain(futures_util::stream::once(future::ready(Ok( 149 | close_message(), 150 | )))); 151 | 152 | Ok(stream) 153 | } 154 | 155 | async fn handle_socket( 156 | ws: WebSocketStream>, 157 | events: T, 158 | ) -> anyhow::Result 159 | where 160 | T: Stream> + Unpin, 161 | { 162 | let (mut sink, mut stream) = ws.split(); 163 | let mut events = events; 164 | let mut pings = ping_stream(); 165 | let mut ping_timeout: Pin + Send>> = Box::pin(future::pending()); 166 | 167 | loop { 168 | tokio::select! { 169 | event = events.next() => { 170 | match event { 171 | Some(event) => { 172 | timeout(Duration::from_secs(SEND_TIMEOUT), sink.send(event?)).await.map_err(|_| anyhow!("send timeout"))??; 173 | }, 174 | 175 | None => return Ok(true) 176 | } 177 | }, 178 | 179 | ping = pings.next() => { 180 | timeout(Duration::from_secs(SEND_TIMEOUT), sink.send(ping.unwrap())).await.map_err(|_| anyhow!("send timeout"))??; 181 | ping_timeout = Box::pin(sleep(Duration::from_secs(PING_TIMEOUT))); 182 | } 183 | 184 | _ = &mut ping_timeout => bail!("ping timeout"), 185 | 186 | message = stream.next() => { 187 | match message { 188 | Some(Ok(Message::Close(close_frame))) => { 189 | info!("server closed the connection"); 190 | handle_close_frame(close_frame)?; 191 | return Ok(false); 192 | }, 193 | 194 | Some(Ok(Message::Ping(_))) => (), 195 | 196 | Some(Ok(Message::Pong(_))) => { 197 | ping_timeout = Box::pin(future::pending()); 198 | }, 199 | 200 | Some(Ok(msg)) => debug!("unexpected message from the server: {msg:?}"), 201 | Some(Err(e)) => bail!(e), 202 | None => bail!("SplitStream closed") 203 | } 204 | } 205 | } 206 | } 207 | } 208 | 209 | fn handle_close_frame(frame: Option) -> anyhow::Result<()> { 210 | match frame { 211 | Some(CloseFrame { code, reason }) => { 212 | info!("close reason: {code} ({reason})"); 213 | 214 | match code { 215 | CloseCode::Normal => Ok(()), 216 | CloseCode::Library(code) if code < 4100 => Ok(()), 217 | c => bail!("unclean close: {c}"), 218 | } 219 | } 220 | 221 | None => { 222 | info!("close reason: none"); 223 | Ok(()) 224 | } 225 | } 226 | } 227 | 228 | fn exponential_delay(attempt: usize) -> u64 { 229 | let mut rng = rand::rng(); 230 | let base = (RECONNECT_DELAY_BASE * 2_u64.pow(attempt as u32)).min(RECONNECT_DELAY_CAP); 231 | 232 | rng.random_range(..base) 233 | } 234 | 235 | fn ws_result(m: Result, BroadcastStreamRecvError>) -> anyhow::Result { 236 | match m { 237 | Ok(bytes) => Ok(Message::binary(bytes)), 238 | Err(e) => Err(anyhow::anyhow!(e)), 239 | } 240 | } 241 | 242 | fn close_message() -> Message { 243 | Message::Close(Some(CloseFrame { 244 | code: CloseCode::Normal, 245 | reason: "ended".into(), 246 | })) 247 | } 248 | 249 | fn ping_stream() -> impl Stream { 250 | IntervalStream::new(interval(Duration::from_secs(PING_INTERVAL))) 251 | .skip(1) 252 | .map(|_| Message::Ping(vec![].into())) 253 | } 254 | -------------------------------------------------------------------------------- /src/hash.rs: -------------------------------------------------------------------------------- 1 | // This module implements FNV-1a hashing algorithm 2 | // http://www.isthe.com/chongo/tech/comp/fnv/ 3 | 4 | const FNV_128_PRIME: u128 = 309485009821345068724781371; // 2^88 + 2^8 + 0x3b 5 | const FNV_128_OFFSET_BASIS: u128 = 144066263297769815596495629667062367629; 6 | 7 | pub fn fnv1a_128>(data: D) -> u128 { 8 | let mut hash = FNV_128_OFFSET_BASIS; 9 | 10 | for byte in data.as_ref() { 11 | hash ^= *byte as u128; 12 | hash = hash.wrapping_mul(FNV_128_PRIME); 13 | } 14 | 15 | hash 16 | } 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use super::fnv1a_128; 21 | 22 | #[test] 23 | fn digest() { 24 | assert_eq!( 25 | fnv1a_128("Hello World!"), 26 | 0xd2d42892ede872031d2593366229c2d2 27 | ); 28 | 29 | assert_eq!( 30 | fnv1a_128("Hello world!"), 31 | 0x3c94fff9ede872031d95566a45770eb2 32 | ); 33 | 34 | assert_eq!(fnv1a_128("🦄🌈"), 0xa25841ae4659905b36cb0d359fad39f); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/html.rs: -------------------------------------------------------------------------------- 1 | pub fn extract_asciicast_link(html: &str) -> Option { 2 | let html_lc = html.to_ascii_lowercase(); 3 | let head_start = html_lc.find("")? + head_start; 5 | let head = &html[head_start..head_end]; 6 | let head_lc = head.to_ascii_lowercase(); 7 | let mut head_offset = 0; 8 | 9 | while let Some(link_pos) = head_lc[head_offset..].find("')? + link_start + 1; 12 | let link = &head[link_start..link_end]; 13 | head_offset = link_end; 14 | 15 | if let Some(rel) = attr(link, "rel") { 16 | if rel 17 | .split_whitespace() 18 | .any(|t| t.eq_ignore_ascii_case("alternate")) 19 | { 20 | if let Some(t) = attr(link, "type") { 21 | if t.eq_ignore_ascii_case("application/x-asciicast") 22 | || t.eq_ignore_ascii_case("application/asciicast+json") 23 | { 24 | if let Some(href) = attr(link, "href") { 25 | return Some(href.to_string()); 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | None 34 | } 35 | 36 | fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { 37 | let tag_lc = tag.to_ascii_lowercase(); 38 | let prefix = format!("{}=", name.to_ascii_lowercase()); 39 | let mut i = tag_lc.find(&prefix)? + prefix.len(); 40 | let bytes = tag.as_bytes(); 41 | 42 | while i < bytes.len() && bytes[i].is_ascii_whitespace() { 43 | i += 1; 44 | } 45 | 46 | if i >= bytes.len() { 47 | return None; 48 | } 49 | 50 | let quote = bytes[i]; 51 | 52 | if quote == b'\'' || quote == b'"' { 53 | let start = i + 1; 54 | let end = tag[start..].find(quote as char)? + start; 55 | 56 | Some(&tag[start..end]) 57 | } else { 58 | let start = i; 59 | let mut end = i; 60 | 61 | while end < bytes.len() 62 | && !bytes[end].is_ascii_whitespace() 63 | && bytes[end] != b'>' 64 | && bytes[end] != b'/' 65 | { 66 | end += 1; 67 | } 68 | 69 | Some(&tag[start..end]) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/io.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::os::fd::AsFd; 3 | 4 | use anyhow::Result; 5 | 6 | pub fn set_non_blocking(fd: &T) -> Result<(), io::Error> { 7 | use nix::fcntl::{fcntl, FcntlArg::*, OFlag}; 8 | 9 | let flags = fcntl(fd, F_GETFL)?; 10 | let mut oflags = OFlag::from_bits_truncate(flags); 11 | oflags |= OFlag::O_NONBLOCK; 12 | fcntl(fd, F_SETFL(oflags))?; 13 | 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /src/leb128.rs: -------------------------------------------------------------------------------- 1 | pub fn encode>(value: N) -> Vec { 2 | let mut value: u64 = value.into(); 3 | let mut bytes = Vec::new(); 4 | 5 | while value > 127 { 6 | let mut low = value & 127; 7 | value >>= 7; 8 | 9 | if value > 0 { 10 | low |= 128; 11 | } 12 | 13 | bytes.push(low as u8); 14 | } 15 | 16 | if value > 0 || bytes.is_empty() { 17 | bytes.push(value as u8); 18 | } 19 | 20 | bytes 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::encode; 26 | 27 | #[test] 28 | fn test_encode() { 29 | assert_eq!(encode(0u64), [0x00]); 30 | assert_eq!(encode(1u64), [0x01]); 31 | assert_eq!(encode(127u64), [0x7F]); 32 | assert_eq!(encode(128u64), [0x80, 0x01]); 33 | assert_eq!(encode(255u64), [0xFF, 0x01]); 34 | assert_eq!(encode(256u64), [0x80, 0x02]); 35 | assert_eq!(encode(16383u64), [0xFF, 0x7F]); 36 | assert_eq!(encode(16384u64), [0x80, 0x80, 0x01]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/locale.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::ffi::CStr; 3 | 4 | use nix::libc::{self, CODESET, LC_ALL}; 5 | 6 | pub fn check_utf8_locale() -> anyhow::Result<()> { 7 | initialize_from_env(); 8 | 9 | let encoding = get_encoding(); 10 | 11 | if ["US-ASCII", "UTF-8"].contains(&encoding.as_str()) { 12 | Ok(()) 13 | } else { 14 | let env = env::var("LC_ALL") 15 | .map(|v| format!("LC_ALL={v}")) 16 | .or(env::var("LC_CTYPE").map(|v| format!("LC_CTYPE={v}"))) 17 | .or(env::var("LANG").map(|v| format!("LANG={v}"))) 18 | .unwrap_or("".to_string()); 19 | 20 | Err(anyhow::anyhow!("asciinema requires ASCII or UTF-8 character encoding. The environment ({}) specifies the character set \"{}\". Check the output of `locale` command.", env, encoding)) 21 | } 22 | } 23 | 24 | pub fn initialize_from_env() { 25 | unsafe { 26 | libc::setlocale(LC_ALL, b"\0".as_ptr() as *const libc::c_char); 27 | }; 28 | } 29 | 30 | fn get_encoding() -> String { 31 | let codeset = unsafe { CStr::from_ptr(libc::nl_langinfo(CODESET)) }; 32 | 33 | let mut encoding = codeset 34 | .to_str() 35 | .expect("Locale codeset name is not a valid UTF-8 string") 36 | .to_owned(); 37 | 38 | if encoding == "ANSI_X3.4-1968" { 39 | encoding = "US-ASCII".to_owned(); 40 | } 41 | 42 | encoding 43 | } 44 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod alis; 2 | mod api; 3 | mod asciicast; 4 | mod cli; 5 | mod cmd; 6 | mod config; 7 | mod encoder; 8 | mod file_writer; 9 | mod forwarder; 10 | mod hash; 11 | mod html; 12 | mod io; 13 | mod leb128; 14 | mod locale; 15 | mod notifier; 16 | mod player; 17 | mod pty; 18 | mod server; 19 | mod session; 20 | mod status; 21 | mod stream; 22 | mod tty; 23 | mod util; 24 | 25 | use std::process::{ExitCode, Termination}; 26 | 27 | use clap::Parser; 28 | 29 | use self::cli::{Cli, Commands, Session}; 30 | 31 | fn main() -> ExitCode { 32 | let cli = Cli::parse(); 33 | 34 | if cli.quiet { 35 | status::disable(); 36 | } 37 | 38 | let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); 39 | 40 | match cli.command { 41 | Commands::Rec(cmd) => { 42 | let cmd = Session { 43 | output_file: Some(cmd.output_path), 44 | rec_input: cmd.rec_input, 45 | append: cmd.append, 46 | output_format: cmd.output_format, 47 | overwrite: cmd.overwrite, 48 | command: cmd.command, 49 | rec_env: cmd.rec_env, 50 | title: cmd.title, 51 | idle_time_limit: cmd.idle_time_limit, 52 | headless: cmd.headless, 53 | window_size: cmd.window_size, 54 | stream_local: None, 55 | stream_remote: None, 56 | return_: cmd.return_, 57 | log_file: None, 58 | server_url: None, 59 | }; 60 | 61 | cmd.run().report() 62 | } 63 | 64 | Commands::Stream(stream) => { 65 | let cmd = Session { 66 | output_file: None, 67 | rec_input: stream.rec_input, 68 | append: false, 69 | output_format: None, 70 | overwrite: false, 71 | command: stream.command, 72 | rec_env: stream.rec_env, 73 | title: stream.title, 74 | idle_time_limit: None, 75 | headless: stream.headless, 76 | window_size: stream.window_size, 77 | stream_local: stream.local, 78 | stream_remote: stream.remote, 79 | return_: stream.return_, 80 | log_file: stream.log_file, 81 | server_url: stream.server_url, 82 | }; 83 | 84 | cmd.run().report() 85 | } 86 | 87 | Commands::Session(cmd) => cmd.run().report(), 88 | Commands::Play(cmd) => cmd.run().report(), 89 | Commands::Cat(cmd) => cmd.run().report(), 90 | Commands::Convert(cmd) => cmd.run().report(), 91 | Commands::Upload(cmd) => cmd.run().report(), 92 | Commands::Auth(cmd) => cmd.run().report(), 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/notifier.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::ffi::OsStr; 3 | use std::path::PathBuf; 4 | use std::process::{Command, Stdio}; 5 | use std::sync::mpsc; 6 | use std::thread; 7 | 8 | use anyhow::Result; 9 | use which::which; 10 | 11 | pub trait Notifier: Send { 12 | fn notify(&mut self, message: String) -> Result<()>; 13 | } 14 | 15 | pub fn get_notifier(custom_command: Option) -> Box { 16 | if let Some(command) = custom_command { 17 | Box::new(CustomNotifier(command)) 18 | } else { 19 | TmuxNotifier::get() 20 | .map(|n| Box::new(n) as Box) 21 | .or_else(|| LibNotifyNotifier::get().map(|n| Box::new(n) as Box)) 22 | .or_else(|| AppleScriptNotifier::get().map(|n| Box::new(n) as Box)) 23 | .unwrap_or_else(|| Box::new(NullNotifier)) 24 | } 25 | } 26 | 27 | pub struct TmuxNotifier(PathBuf); 28 | 29 | impl TmuxNotifier { 30 | fn get() -> Option { 31 | env::var("TMUX") 32 | .ok() 33 | .and_then(|_| which("tmux").ok().map(TmuxNotifier)) 34 | } 35 | } 36 | 37 | impl Notifier for TmuxNotifier { 38 | fn notify(&mut self, message: String) -> Result<()> { 39 | let args = ["display-message", &format!("asciinema: {message}")]; 40 | 41 | exec(&mut Command::new(&self.0), &args) 42 | } 43 | } 44 | 45 | pub struct LibNotifyNotifier(PathBuf); 46 | 47 | impl LibNotifyNotifier { 48 | fn get() -> Option { 49 | which("notify-send").ok().map(LibNotifyNotifier) 50 | } 51 | } 52 | 53 | impl Notifier for LibNotifyNotifier { 54 | fn notify(&mut self, message: String) -> Result<()> { 55 | exec(&mut Command::new(&self.0), &["asciinema", &message]) 56 | } 57 | } 58 | 59 | pub struct AppleScriptNotifier(PathBuf); 60 | 61 | impl AppleScriptNotifier { 62 | fn get() -> Option { 63 | which("osascript").ok().map(AppleScriptNotifier) 64 | } 65 | } 66 | 67 | impl Notifier for AppleScriptNotifier { 68 | fn notify(&mut self, message: String) -> Result<()> { 69 | let text = message.replace('\"', "\\\""); 70 | let script = format!("display notification \"{text}\" with title \"asciinema\""); 71 | 72 | exec(&mut Command::new(&self.0), &["-e", &script]) 73 | } 74 | } 75 | 76 | pub struct CustomNotifier(String); 77 | 78 | impl Notifier for CustomNotifier { 79 | fn notify(&mut self, text: String) -> Result<()> { 80 | exec::<&str>( 81 | Command::new("/bin/sh") 82 | .args(["-c", &self.0]) 83 | .env("TEXT", text), 84 | &[], 85 | ) 86 | } 87 | } 88 | 89 | pub struct NullNotifier; 90 | 91 | impl Notifier for NullNotifier { 92 | fn notify(&mut self, _text: String) -> Result<()> { 93 | Ok(()) 94 | } 95 | } 96 | 97 | fn exec>(command: &mut Command, args: &[S]) -> Result<()> { 98 | let status = command 99 | .stdin(Stdio::null()) 100 | .stdout(Stdio::null()) 101 | .stderr(Stdio::null()) 102 | .args(args) 103 | .status()?; 104 | 105 | if status.success() { 106 | Ok(()) 107 | } else { 108 | Err(anyhow::anyhow!( 109 | "exit status: {}", 110 | status.code().unwrap_or(0) 111 | )) 112 | } 113 | } 114 | 115 | #[derive(Clone)] 116 | pub struct ThreadedNotifier(mpsc::Sender); 117 | 118 | pub fn threaded(mut notifier: Box) -> ThreadedNotifier { 119 | let (tx, rx) = mpsc::channel(); 120 | 121 | thread::spawn(move || { 122 | for message in &rx { 123 | if notifier.notify(message).is_err() { 124 | break; 125 | } 126 | } 127 | 128 | for _ in rx {} 129 | }); 130 | 131 | ThreadedNotifier(tx) 132 | } 133 | 134 | impl Notifier for ThreadedNotifier { 135 | fn notify(&mut self, message: String) -> Result<()> { 136 | self.0.send(message)?; 137 | 138 | Ok(()) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/player.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | use std::os::unix::io::AsRawFd; 3 | use std::time::{Duration, Instant}; 4 | 5 | use anyhow::Result; 6 | use nix::sys::select::{pselect, FdSet}; 7 | use nix::sys::time::{TimeSpec, TimeValLike}; 8 | 9 | use crate::asciicast::{self, Event, EventData}; 10 | use crate::config::Key; 11 | use crate::tty::Tty; 12 | 13 | pub struct KeyBindings { 14 | pub quit: Key, 15 | pub pause: Key, 16 | pub step: Key, 17 | pub next_marker: Key, 18 | } 19 | 20 | impl Default for KeyBindings { 21 | fn default() -> Self { 22 | Self { 23 | quit: Some(vec![0x03]), 24 | pause: Some(vec![b' ']), 25 | step: Some(vec![b'.']), 26 | next_marker: Some(vec![b']']), 27 | } 28 | } 29 | } 30 | 31 | pub fn play( 32 | recording: asciicast::Asciicast, 33 | mut tty: impl Tty, 34 | speed: f64, 35 | idle_time_limit: Option, 36 | pause_on_markers: bool, 37 | keys: &KeyBindings, 38 | auto_resize: bool, 39 | ) -> Result { 40 | let initial_cols = recording.header.term_cols; 41 | let initial_rows = recording.header.term_rows; 42 | let mut events = open_recording(recording, speed, idle_time_limit)?; 43 | let mut stdout = io::stdout(); 44 | let mut epoch = Instant::now(); 45 | let mut pause_elapsed_time: Option = None; 46 | let mut next_event = events.next().transpose()?; 47 | 48 | if auto_resize { 49 | resize_terminal(&mut stdout, initial_cols, initial_rows)?; 50 | } 51 | 52 | while let Some(Event { time, data }) = &next_event { 53 | if let Some(pet) = pause_elapsed_time { 54 | if let Some(input) = read_input(&mut tty, 1_000_000)? { 55 | if keys.quit.as_ref().is_some_and(|k| k == &input) { 56 | stdout.write_all("\r\n".as_bytes())?; 57 | return Ok(false); 58 | } 59 | 60 | if keys.pause.as_ref().is_some_and(|k| k == &input) { 61 | epoch = Instant::now() - Duration::from_micros(pet); 62 | pause_elapsed_time = None; 63 | } else if keys.step.as_ref().is_some_and(|k| k == &input) { 64 | pause_elapsed_time = Some(*time); 65 | 66 | match data { 67 | EventData::Output(data) => { 68 | stdout.write_all(data.as_bytes())?; 69 | stdout.flush()?; 70 | } 71 | 72 | EventData::Resize(cols, rows) if auto_resize => { 73 | resize_terminal(&mut stdout, *cols, *rows)?; 74 | } 75 | 76 | _ => {} 77 | } 78 | 79 | next_event = events.next().transpose()?; 80 | } else if keys.next_marker.as_ref().is_some_and(|k| k == &input) { 81 | while let Some(Event { time, data }) = next_event { 82 | next_event = events.next().transpose()?; 83 | 84 | match data { 85 | EventData::Output(data) => { 86 | stdout.write_all(data.as_bytes())?; 87 | } 88 | 89 | EventData::Marker(_) => { 90 | pause_elapsed_time = Some(time); 91 | break; 92 | } 93 | 94 | EventData::Resize(cols, rows) if auto_resize => { 95 | resize_terminal(&mut stdout, cols, rows)?; 96 | } 97 | 98 | _ => {} 99 | } 100 | } 101 | 102 | stdout.flush()?; 103 | } 104 | } 105 | } else { 106 | while let Some(Event { time, data }) = &next_event { 107 | let delay = *time as i64 - epoch.elapsed().as_micros() as i64; 108 | 109 | if delay > 0 { 110 | stdout.flush()?; 111 | 112 | if let Some(key) = read_input(&mut tty, delay)? { 113 | if keys.quit.as_ref().is_some_and(|k| k == &key) { 114 | stdout.write_all("\r\n".as_bytes())?; 115 | return Ok(false); 116 | } 117 | 118 | if keys.pause.as_ref().is_some_and(|k| k == &key) { 119 | pause_elapsed_time = Some(epoch.elapsed().as_micros() as u64); 120 | break; 121 | } 122 | 123 | continue; 124 | } 125 | } 126 | 127 | match data { 128 | EventData::Output(data) => { 129 | stdout.write_all(data.as_bytes())?; 130 | } 131 | 132 | EventData::Resize(cols, rows) if auto_resize => { 133 | resize_terminal(&mut stdout, *cols, *rows)?; 134 | } 135 | 136 | EventData::Marker(_) => { 137 | if pause_on_markers { 138 | pause_elapsed_time = Some(*time); 139 | next_event = events.next().transpose()?; 140 | break; 141 | } 142 | } 143 | 144 | _ => (), 145 | } 146 | 147 | next_event = events.next().transpose()?; 148 | } 149 | } 150 | } 151 | 152 | Ok(true) 153 | } 154 | 155 | fn resize_terminal(stdout: &mut impl Write, cols: u16, rows: u16) -> io::Result<()> { 156 | let resize_sequence = format!("\x1b[8;{};{}t", rows, cols); 157 | stdout.write_all(resize_sequence.as_bytes())?; 158 | stdout.flush()?; 159 | 160 | Ok(()) 161 | } 162 | 163 | fn open_recording( 164 | recording: asciicast::Asciicast<'_>, 165 | speed: f64, 166 | idle_time_limit: Option, 167 | ) -> Result> + '_> { 168 | let idle_time_limit = idle_time_limit 169 | .or(recording.header.idle_time_limit) 170 | .unwrap_or(f64::MAX); 171 | 172 | let events = asciicast::limit_idle_time(recording.events, idle_time_limit); 173 | let events = asciicast::accelerate(events, speed); 174 | 175 | Ok(events) 176 | } 177 | 178 | fn read_input(tty: &mut T, timeout: i64) -> Result>> { 179 | let tty_fd = tty.as_fd(); 180 | let nfds = Some(tty_fd.as_raw_fd() + 1); 181 | let mut rfds = FdSet::new(); 182 | rfds.insert(tty_fd); 183 | let timeout = TimeSpec::microseconds(timeout); 184 | let mut input: Vec = Vec::new(); 185 | 186 | pselect(nfds, &mut rfds, None, None, &timeout, None)?; 187 | 188 | if rfds.contains(tty_fd) { 189 | let mut buf = [0u8; 1024]; 190 | 191 | while let Ok(n) = tty.read(&mut buf) { 192 | if n == 0 { 193 | break; 194 | } 195 | 196 | input.extend_from_slice(&buf[0..n]); 197 | } 198 | 199 | if !input.is_empty() { 200 | Ok(Some(input)) 201 | } else { 202 | Ok(None) 203 | } 204 | } else { 205 | Ok(None) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/pty.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::env; 3 | use std::ffi::{CString, NulError}; 4 | use std::fs::File; 5 | use std::io::{self, ErrorKind, Read, Write}; 6 | use std::os::fd::AsFd; 7 | use std::os::fd::{BorrowedFd, OwnedFd}; 8 | use std::os::unix::io::AsRawFd; 9 | use std::time::{Duration, Instant}; 10 | 11 | use anyhow::bail; 12 | use nix::errno::Errno; 13 | use nix::libc::EIO; 14 | use nix::sys::select::{select, FdSet}; 15 | use nix::sys::signal::{self, kill, Signal}; 16 | use nix::sys::wait::{self, WaitPidFlag, WaitStatus}; 17 | use nix::unistd; 18 | use nix::{libc, pty}; 19 | use signal_hook::consts::{SIGALRM, SIGCHLD, SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGWINCH}; 20 | use signal_hook::SigId; 21 | 22 | use crate::io::set_non_blocking; 23 | use crate::tty::{Tty, TtySize, TtyTheme}; 24 | 25 | type ExtraEnv = HashMap; 26 | 27 | pub trait HandlerStarter { 28 | fn start(self, tty_size: TtySize, tty_theme: Option) -> H; 29 | } 30 | 31 | pub trait Handler { 32 | fn output(&mut self, time: Duration, data: &[u8]) -> bool; 33 | fn input(&mut self, time: Duration, data: &[u8]) -> bool; 34 | fn resize(&mut self, time: Duration, tty_size: TtySize) -> bool; 35 | fn stop(self, time: Duration, exit_status: i32) -> Self; 36 | } 37 | 38 | pub fn exec, T: Tty, H: Handler, R: HandlerStarter>( 39 | command: &[S], 40 | extra_env: &ExtraEnv, 41 | tty: &mut T, 42 | handler_starter: R, 43 | ) -> anyhow::Result<(i32, H)> { 44 | let winsize = tty.get_size(); 45 | let epoch = Instant::now(); 46 | let mut handler = handler_starter.start(winsize.into(), tty.get_theme()); 47 | let result = unsafe { pty::forkpty(Some(&winsize), None) }?; 48 | 49 | match result { 50 | pty::ForkptyResult::Parent { child, master } => { 51 | handle_parent(master, child, tty, &mut handler, epoch) 52 | .map(|code| (code, handler.stop(epoch.elapsed(), code))) 53 | } 54 | 55 | pty::ForkptyResult::Child => { 56 | handle_child(command, extra_env)?; 57 | unreachable!(); 58 | } 59 | } 60 | } 61 | 62 | fn handle_parent( 63 | master_fd: OwnedFd, 64 | child: unistd::Pid, 65 | tty: &mut T, 66 | handler: &mut H, 67 | epoch: Instant, 68 | ) -> anyhow::Result { 69 | let wait_result = match copy(master_fd, child, tty, handler, epoch) { 70 | Ok(Some(status)) => Ok(status), 71 | Ok(None) => wait::waitpid(child, None), 72 | 73 | Err(e) => { 74 | let _ = wait::waitpid(child, None); 75 | return Err(e); 76 | } 77 | }; 78 | 79 | match wait_result { 80 | Ok(WaitStatus::Exited(_pid, status)) => Ok(status), 81 | Ok(WaitStatus::Signaled(_pid, signal, ..)) => Ok(128 + signal as i32), 82 | Ok(_) => Ok(1), 83 | Err(e) => Err(anyhow::anyhow!(e)), 84 | } 85 | } 86 | 87 | const BUF_SIZE: usize = 128 * 1024; 88 | 89 | fn copy( 90 | master_fd: OwnedFd, 91 | child: unistd::Pid, 92 | tty: &mut T, 93 | handler: &mut H, 94 | epoch: Instant, 95 | ) -> anyhow::Result> { 96 | let mut master = File::from(master_fd); 97 | let master_raw_fd = master.as_raw_fd(); 98 | let mut buf = [0u8; BUF_SIZE]; 99 | let mut input: Vec = Vec::with_capacity(BUF_SIZE); 100 | let mut output: Vec = Vec::with_capacity(BUF_SIZE); 101 | let mut master_closed = false; 102 | 103 | let sigwinch_fd = SignalFd::open(SIGWINCH)?; 104 | let sigint_fd = SignalFd::open(SIGINT)?; 105 | let sigterm_fd = SignalFd::open(SIGTERM)?; 106 | let sigquit_fd = SignalFd::open(SIGQUIT)?; 107 | let sighup_fd = SignalFd::open(SIGHUP)?; 108 | let sigalrm_fd = SignalFd::open(SIGALRM)?; 109 | let sigchld_fd = SignalFd::open(SIGCHLD)?; 110 | 111 | set_non_blocking(&master)?; 112 | 113 | loop { 114 | let master_fd = master.as_fd(); 115 | let tty_fd = tty.as_fd(); 116 | let mut rfds = FdSet::new(); 117 | let mut wfds = FdSet::new(); 118 | 119 | rfds.insert(tty_fd); 120 | rfds.insert(sigwinch_fd.as_fd()); 121 | rfds.insert(sigint_fd.as_fd()); 122 | rfds.insert(sigterm_fd.as_fd()); 123 | rfds.insert(sigquit_fd.as_fd()); 124 | rfds.insert(sighup_fd.as_fd()); 125 | rfds.insert(sigalrm_fd.as_fd()); 126 | rfds.insert(sigchld_fd.as_fd()); 127 | 128 | if !master_closed { 129 | rfds.insert(master_fd); 130 | 131 | if !input.is_empty() { 132 | wfds.insert(master_fd); 133 | } 134 | } 135 | 136 | if !output.is_empty() { 137 | wfds.insert(tty_fd); 138 | } 139 | 140 | if let Err(e) = select(None, &mut rfds, &mut wfds, None, None) { 141 | if e == Errno::EINTR { 142 | continue; 143 | } 144 | 145 | bail!(e); 146 | } 147 | 148 | let master_read = rfds.contains(master_fd); 149 | let master_write = wfds.contains(master_fd); 150 | let tty_read = rfds.contains(tty_fd); 151 | let tty_write = wfds.contains(tty_fd); 152 | let sigwinch_read = rfds.contains(sigwinch_fd.as_fd()); 153 | let sigint_read = rfds.contains(sigint_fd.as_fd()); 154 | let sigterm_read = rfds.contains(sigterm_fd.as_fd()); 155 | let sigquit_read = rfds.contains(sigquit_fd.as_fd()); 156 | let sighup_read = rfds.contains(sighup_fd.as_fd()); 157 | let sigalrm_read = rfds.contains(sigalrm_fd.as_fd()); 158 | let sigchld_read = rfds.contains(sigchld_fd.as_fd()); 159 | 160 | if master_read { 161 | while let Some(n) = read_non_blocking(&mut master, &mut buf)? { 162 | if n > 0 { 163 | if handler.output(epoch.elapsed(), &buf[0..n]) { 164 | output.extend_from_slice(&buf[0..n]); 165 | } 166 | } else if output.is_empty() { 167 | return Ok(None); 168 | } else { 169 | master_closed = true; 170 | break; 171 | } 172 | } 173 | } 174 | 175 | if master_write { 176 | let mut buf: &[u8] = input.as_ref(); 177 | 178 | while let Some(n) = write_non_blocking(&mut master, buf)? { 179 | buf = &buf[n..]; 180 | 181 | if buf.is_empty() { 182 | break; 183 | } 184 | } 185 | 186 | let left = buf.len(); 187 | 188 | if left == 0 { 189 | input.clear(); 190 | } else { 191 | input.drain(..input.len() - left); 192 | } 193 | } 194 | 195 | if tty_write { 196 | let mut buf: &[u8] = output.as_ref(); 197 | 198 | while let Some(n) = write_non_blocking(tty, buf)? { 199 | buf = &buf[n..]; 200 | 201 | if buf.is_empty() { 202 | break; 203 | } 204 | } 205 | 206 | let left = buf.len(); 207 | 208 | if left == 0 { 209 | if master_closed { 210 | return Ok(None); 211 | } 212 | 213 | output.clear(); 214 | } else { 215 | output.drain(..output.len() - left); 216 | } 217 | } 218 | 219 | if tty_read { 220 | while let Some(n) = read_non_blocking(tty, &mut buf)? { 221 | if n > 0 { 222 | if handler.input(epoch.elapsed(), &buf[0..n]) { 223 | input.extend_from_slice(&buf[0..n]); 224 | } 225 | } else { 226 | return Ok(None); 227 | } 228 | } 229 | } 230 | 231 | if sigwinch_read { 232 | sigwinch_fd.flush(); 233 | let winsize = tty.get_size(); 234 | 235 | if handler.resize(epoch.elapsed(), winsize.into()) { 236 | set_pty_size(master_raw_fd, &winsize); 237 | } 238 | } 239 | 240 | let mut kill_the_child = false; 241 | 242 | if sigint_read { 243 | sigint_fd.flush(); 244 | kill_the_child = true; 245 | } 246 | 247 | if sigterm_read { 248 | sigterm_fd.flush(); 249 | kill_the_child = true; 250 | } 251 | 252 | if sigquit_read { 253 | sigquit_fd.flush(); 254 | kill_the_child = true; 255 | } 256 | 257 | if sighup_read { 258 | sighup_fd.flush(); 259 | kill_the_child = true; 260 | } 261 | 262 | if sigalrm_read { 263 | sigalrm_fd.flush(); 264 | } 265 | 266 | if sigchld_read { 267 | sigchld_fd.flush(); 268 | 269 | if let Ok(status) = wait::waitpid(child, Some(WaitPidFlag::WNOHANG)) { 270 | if status != WaitStatus::StillAlive { 271 | return Ok(Some(status)); 272 | } 273 | } 274 | } 275 | 276 | if kill_the_child { 277 | // Any errors occurred when killing the child are ignored. 278 | let _ = kill(child, Signal::SIGTERM); 279 | return Ok(None); 280 | } 281 | } 282 | } 283 | 284 | fn handle_child>(command: &[S], extra_env: &ExtraEnv) -> anyhow::Result<()> { 285 | use signal::SigHandler; 286 | 287 | let command = command 288 | .iter() 289 | .map(|s| CString::new(s.as_ref())) 290 | .collect::, NulError>>()?; 291 | 292 | for (k, v) in extra_env { 293 | env::set_var(k, v); 294 | } 295 | 296 | unsafe { signal::signal(Signal::SIGPIPE, SigHandler::SigDfl) }?; 297 | unistd::execvp(&command[0], &command)?; 298 | unsafe { libc::_exit(1) } 299 | } 300 | 301 | fn set_pty_size(pty_fd: i32, winsize: &pty::Winsize) { 302 | unsafe { libc::ioctl(pty_fd, libc::TIOCSWINSZ, winsize) }; 303 | } 304 | 305 | fn read_non_blocking( 306 | source: &mut R, 307 | buf: &mut [u8], 308 | ) -> io::Result> { 309 | match source.read(buf) { 310 | Ok(n) => Ok(Some(n)), 311 | 312 | Err(e) => { 313 | if e.kind() == ErrorKind::WouldBlock { 314 | Ok(None) 315 | } else if e.raw_os_error().is_some_and(|code| code == EIO) { 316 | Ok(Some(0)) 317 | } else { 318 | return Err(e); 319 | } 320 | } 321 | } 322 | } 323 | 324 | fn write_non_blocking(sink: &mut W, buf: &[u8]) -> io::Result> { 325 | match sink.write(buf) { 326 | Ok(n) => Ok(Some(n)), 327 | 328 | Err(e) => { 329 | if e.kind() == ErrorKind::WouldBlock { 330 | Ok(None) 331 | } else if e.raw_os_error().is_some_and(|code| code == EIO) { 332 | Ok(Some(0)) 333 | } else { 334 | return Err(e); 335 | } 336 | } 337 | } 338 | } 339 | 340 | struct SignalFd { 341 | sigid: SigId, 342 | rx: OwnedFd, 343 | } 344 | 345 | impl SignalFd { 346 | fn open(signal: libc::c_int) -> anyhow::Result { 347 | let (rx, tx) = unistd::pipe()?; 348 | set_non_blocking(&rx)?; 349 | set_non_blocking(&tx)?; 350 | 351 | let sigid = unsafe { 352 | signal_hook::low_level::register(signal, move || { 353 | let _ = unistd::write(&tx, &[0]); 354 | }) 355 | }?; 356 | 357 | Ok(Self { sigid, rx }) 358 | } 359 | 360 | fn flush(&self) { 361 | let mut buf = [0; 256]; 362 | 363 | while let Ok(n) = unistd::read(&self.rx, &mut buf) { 364 | if n == 0 { 365 | break; 366 | }; 367 | } 368 | } 369 | } 370 | 371 | impl AsFd for SignalFd { 372 | fn as_fd(&self) -> BorrowedFd<'_> { 373 | self.rx.as_fd() 374 | } 375 | } 376 | 377 | impl Drop for SignalFd { 378 | fn drop(&mut self) { 379 | signal_hook::low_level::unregister(self.sigid); 380 | } 381 | } 382 | 383 | #[cfg(test)] 384 | mod tests { 385 | use super::{Handler, HandlerStarter}; 386 | use crate::pty::ExtraEnv; 387 | use crate::tty::{FixedSizeTty, NullTty, TtySize, TtyTheme}; 388 | use std::time::Duration; 389 | 390 | struct TestHandlerStarter; 391 | 392 | #[derive(Default)] 393 | struct TestHandler { 394 | tty_size: TtySize, 395 | output: Vec>, 396 | } 397 | 398 | impl HandlerStarter for TestHandlerStarter { 399 | fn start(self, tty_size: TtySize, _tty_theme: Option) -> TestHandler { 400 | TestHandler { 401 | tty_size, 402 | output: Vec::new(), 403 | } 404 | } 405 | } 406 | 407 | impl Handler for TestHandler { 408 | fn output(&mut self, _time: Duration, data: &[u8]) -> bool { 409 | self.output.push(data.into()); 410 | 411 | true 412 | } 413 | 414 | fn input(&mut self, _time: Duration, _data: &[u8]) -> bool { 415 | true 416 | } 417 | 418 | fn resize(&mut self, _time: Duration, _size: TtySize) -> bool { 419 | true 420 | } 421 | 422 | fn stop(self, _time: Duration, _exit_status: i32) -> Self { 423 | self 424 | } 425 | } 426 | 427 | impl TestHandler { 428 | fn output(&self) -> Vec { 429 | self.output 430 | .iter() 431 | .map(|x| String::from_utf8_lossy(x).to_string()) 432 | .collect::>() 433 | } 434 | } 435 | 436 | #[test] 437 | fn exec_basic() { 438 | let starter = TestHandlerStarter; 439 | 440 | let code = r#" 441 | import sys; 442 | import time; 443 | sys.stdout.write('foo'); 444 | sys.stdout.flush(); 445 | time.sleep(0.1); 446 | sys.stdout.write('bar'); 447 | "#; 448 | 449 | let (_code, handler) = super::exec( 450 | &["python3", "-c", code], 451 | &ExtraEnv::new(), 452 | &mut NullTty::open().unwrap(), 453 | starter, 454 | ) 455 | .unwrap(); 456 | 457 | assert_eq!(handler.output(), vec!["foo", "bar"]); 458 | assert_eq!(handler.tty_size, TtySize(80, 24)); 459 | } 460 | 461 | #[test] 462 | fn exec_no_output() { 463 | let starter = TestHandlerStarter; 464 | 465 | let (_code, handler) = super::exec( 466 | &["true"], 467 | &ExtraEnv::new(), 468 | &mut NullTty::open().unwrap(), 469 | starter, 470 | ) 471 | .unwrap(); 472 | 473 | assert!(handler.output().is_empty()); 474 | } 475 | 476 | #[test] 477 | fn exec_quick() { 478 | let starter = TestHandlerStarter; 479 | 480 | let (_code, handler) = super::exec( 481 | &["printf", "hello world\n"], 482 | &ExtraEnv::new(), 483 | &mut NullTty::open().unwrap(), 484 | starter, 485 | ) 486 | .unwrap(); 487 | 488 | assert!(!handler.output().is_empty()); 489 | } 490 | 491 | #[test] 492 | fn exec_extra_env() { 493 | let starter = TestHandlerStarter; 494 | 495 | let mut env = ExtraEnv::new(); 496 | env.insert("ASCIINEMA_TEST_FOO".to_owned(), "bar".to_owned()); 497 | 498 | let (_code, handler) = super::exec( 499 | &["sh", "-c", "echo -n $ASCIINEMA_TEST_FOO"], 500 | &env, 501 | &mut NullTty::open().unwrap(), 502 | starter, 503 | ) 504 | .unwrap(); 505 | 506 | assert_eq!(handler.output(), vec!["bar"]); 507 | } 508 | 509 | #[test] 510 | fn exec_winsize_override() { 511 | let starter = TestHandlerStarter; 512 | 513 | let (_code, handler) = super::exec( 514 | &["true"], 515 | &ExtraEnv::new(), 516 | &mut FixedSizeTty::new(NullTty::open().unwrap(), Some(100), Some(50)), 517 | starter, 518 | ) 519 | .unwrap(); 520 | 521 | assert_eq!(handler.tty_size, TtySize(100, 50)); 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | use std::future; 2 | use std::io; 3 | use std::net::SocketAddr; 4 | use std::path::Path; 5 | 6 | use axum::extract::connect_info::ConnectInfo; 7 | use axum::extract::ws::{self, CloseCode, CloseFrame, Message, WebSocket, WebSocketUpgrade}; 8 | use axum::extract::State; 9 | use axum::http::{header, StatusCode, Uri}; 10 | use axum::response::IntoResponse; 11 | use axum::routing::get; 12 | use axum::serve::ListenerExt; 13 | use axum::Router; 14 | use futures_util::{sink, StreamExt}; 15 | use rust_embed::RustEmbed; 16 | use tokio_stream::wrappers::errors::BroadcastStreamRecvError; 17 | use tokio_util::sync::CancellationToken; 18 | use tower_http::trace::{DefaultMakeSpan, TraceLayer}; 19 | use tracing::info; 20 | 21 | use crate::alis; 22 | use crate::stream::Subscriber; 23 | 24 | #[derive(RustEmbed)] 25 | #[folder = "assets/"] 26 | struct Assets; 27 | 28 | pub async fn serve( 29 | listener: std::net::TcpListener, 30 | subscriber: Subscriber, 31 | shutdown_token: CancellationToken, 32 | ) -> io::Result<()> { 33 | listener.set_nonblocking(true)?; 34 | let listener = tokio::net::TcpListener::from_std(listener)?; 35 | 36 | let trace = 37 | TraceLayer::new_for_http().make_span_with(DefaultMakeSpan::default().include_headers(true)); 38 | 39 | let app = Router::new() 40 | .route("/ws", get(ws_handler)) 41 | .with_state(subscriber) 42 | .fallback(static_handler) 43 | .layer(trace); 44 | 45 | let signal = async move { 46 | let _ = shutdown_token.cancelled().await; 47 | }; 48 | 49 | info!( 50 | "HTTP server listening on {}", 51 | listener.local_addr().unwrap() 52 | ); 53 | 54 | let listener = listener.tap_io(|tcp_stream| { 55 | let _ = tcp_stream.set_nodelay(true); 56 | }); 57 | 58 | axum::serve( 59 | listener, 60 | app.into_make_service_with_connect_info::(), 61 | ) 62 | .with_graceful_shutdown(signal) 63 | .await 64 | } 65 | 66 | async fn static_handler(uri: Uri) -> impl IntoResponse { 67 | let mut path = uri.path().trim_start_matches('/'); 68 | 69 | if path.is_empty() { 70 | path = "index.html"; 71 | } 72 | 73 | match Assets::get(path) { 74 | Some(content) => { 75 | let mime = mime_from_path(path); 76 | 77 | ([(header::CONTENT_TYPE, mime)], content.data).into_response() 78 | } 79 | 80 | None => (StatusCode::NOT_FOUND, "404").into_response(), 81 | } 82 | } 83 | 84 | fn mime_from_path(path: &str) -> &str { 85 | let lowercase_path = &path.to_lowercase(); 86 | 87 | let ext = Path::new(lowercase_path) 88 | .extension() 89 | .and_then(|e| e.to_str()); 90 | 91 | match ext { 92 | Some("html") => "text/html", 93 | Some("js") => "text/javascript", 94 | Some("css") => "text/css", 95 | Some(_) | None => "application/octet-stream", 96 | } 97 | } 98 | 99 | async fn ws_handler( 100 | ws: WebSocketUpgrade, 101 | ConnectInfo(addr): ConnectInfo, 102 | State(subscriber): State, 103 | ) -> impl IntoResponse { 104 | ws.protocols(["v1.alis"]) 105 | .on_upgrade(move |socket| async move { 106 | info!("websocket client {addr} connected"); 107 | 108 | if socket.protocol().is_some() { 109 | let _ = handle_socket(socket, subscriber).await; 110 | info!("websocket client {addr} disconnected"); 111 | } else { 112 | info!("subprotocol negotiation failed, closing connection"); 113 | close_socket(socket).await; 114 | } 115 | }) 116 | } 117 | 118 | async fn handle_socket(socket: WebSocket, subscriber: Subscriber) -> anyhow::Result<()> { 119 | let (sink, stream) = socket.split(); 120 | let drainer = tokio::spawn(stream.map(Ok).forward(sink::drain())); 121 | let close_msg = close_message(ws::close_code::NORMAL, "Stream ended"); 122 | let stream = subscriber.subscribe().await?; 123 | 124 | let result = alis::stream(stream) 125 | .await? 126 | .map(ws_result) 127 | .chain(futures_util::stream::once(future::ready(Ok(close_msg)))) 128 | .forward(sink) 129 | .await; 130 | 131 | drainer.abort(); 132 | result?; 133 | 134 | Ok(()) 135 | } 136 | 137 | async fn close_socket(mut socket: WebSocket) { 138 | let msg = close_message(ws::close_code::PROTOCOL, "Subprotocol negotiation failed"); 139 | let _ = socket.send(msg).await; 140 | } 141 | 142 | fn close_message(code: CloseCode, reason: &'static str) -> Message { 143 | Message::Close(Some(CloseFrame { 144 | code, 145 | reason: reason.into(), 146 | })) 147 | } 148 | 149 | fn ws_result(m: Result, BroadcastStreamRecvError>) -> Result { 150 | match m { 151 | Ok(bytes) => Ok(Message::Binary(bytes.into())), 152 | Err(e) => Err(axum::Error::new(e)), 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/session.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::sync::mpsc; 3 | use std::thread; 4 | use std::time::{Duration, SystemTime}; 5 | 6 | use tracing::error; 7 | 8 | use crate::config::Key; 9 | use crate::notifier::Notifier; 10 | use crate::pty; 11 | use crate::tty::{TtySize, TtyTheme}; 12 | use crate::util::{JoinHandle, Utf8Decoder}; 13 | 14 | pub struct SessionStarter { 15 | starters: Vec>, 16 | record_input: bool, 17 | keys: KeyBindings, 18 | notifier: N, 19 | } 20 | 21 | pub trait OutputStarter { 22 | fn start( 23 | self: Box, 24 | time: SystemTime, 25 | tty_size: TtySize, 26 | tty_theme: Option, 27 | ) -> io::Result>; 28 | } 29 | 30 | pub trait Output: Send { 31 | fn event(&mut self, event: Event) -> io::Result<()>; 32 | fn flush(&mut self) -> io::Result<()>; 33 | } 34 | 35 | #[derive(Clone)] 36 | pub enum Event { 37 | Output(u64, String), 38 | Input(u64, String), 39 | Resize(u64, TtySize), 40 | Marker(u64, String), 41 | Exit(u64, i32), 42 | } 43 | 44 | impl SessionStarter { 45 | pub fn new( 46 | starters: Vec>, 47 | record_input: bool, 48 | keys: KeyBindings, 49 | notifier: N, 50 | ) -> Self { 51 | SessionStarter { 52 | starters, 53 | record_input, 54 | keys, 55 | notifier, 56 | } 57 | } 58 | } 59 | 60 | impl pty::HandlerStarter> for SessionStarter { 61 | fn start(self, tty_size: TtySize, tty_theme: Option) -> Session { 62 | let time = SystemTime::now(); 63 | let mut outputs = Vec::new(); 64 | 65 | for starter in self.starters { 66 | match starter.start(time, tty_size, tty_theme.clone()) { 67 | Ok(output) => { 68 | outputs.push(output); 69 | } 70 | 71 | Err(e) => { 72 | error!("output startup failed: {e:?}"); 73 | } 74 | } 75 | } 76 | 77 | let (sender, receiver) = mpsc::channel::(); 78 | 79 | let handle = thread::spawn(move || { 80 | for event in receiver { 81 | outputs.retain_mut(|output| match output.event(event.clone()) { 82 | Ok(_) => true, 83 | 84 | Err(e) => { 85 | error!("output event handler failed: {e:?}"); 86 | 87 | false 88 | } 89 | }); 90 | } 91 | 92 | for mut output in outputs { 93 | match output.flush() { 94 | Ok(_) => {} 95 | 96 | Err(e) => { 97 | error!("output flush failed: {e:?}"); 98 | } 99 | } 100 | } 101 | }); 102 | 103 | Session { 104 | notifier: self.notifier, 105 | input_decoder: Utf8Decoder::new(), 106 | output_decoder: Utf8Decoder::new(), 107 | record_input: self.record_input, 108 | keys: self.keys, 109 | tty_size, 110 | sender, 111 | time_offset: 0, 112 | pause_time: None, 113 | prefix_mode: false, 114 | _handle: JoinHandle::new(handle), 115 | } 116 | } 117 | } 118 | 119 | pub struct Session { 120 | notifier: N, 121 | input_decoder: Utf8Decoder, 122 | output_decoder: Utf8Decoder, 123 | tty_size: TtySize, 124 | record_input: bool, 125 | keys: KeyBindings, 126 | sender: mpsc::Sender, 127 | time_offset: u64, 128 | pause_time: Option, 129 | prefix_mode: bool, 130 | _handle: JoinHandle, 131 | } 132 | 133 | impl Session { 134 | fn elapsed_time(&self, time: Duration) -> u64 { 135 | if let Some(pause_time) = self.pause_time { 136 | pause_time 137 | } else { 138 | time.as_micros() as u64 - self.time_offset 139 | } 140 | } 141 | 142 | fn notify(&mut self, text: S) { 143 | self.notifier 144 | .notify(text.to_string()) 145 | .expect("notification should succeed"); 146 | } 147 | } 148 | 149 | impl pty::Handler for Session { 150 | fn output(&mut self, time: Duration, data: &[u8]) -> bool { 151 | if self.pause_time.is_none() { 152 | let text = self.output_decoder.feed(data); 153 | 154 | if !text.is_empty() { 155 | let msg = Event::Output(self.elapsed_time(time), text); 156 | self.sender.send(msg).expect("output send should succeed"); 157 | } 158 | } 159 | 160 | true 161 | } 162 | 163 | fn input(&mut self, time: Duration, data: &[u8]) -> bool { 164 | let prefix_key = self.keys.prefix.as_ref(); 165 | let pause_key = self.keys.pause.as_ref(); 166 | let add_marker_key = self.keys.add_marker.as_ref(); 167 | 168 | if !self.prefix_mode && prefix_key.is_some_and(|key| data == key) { 169 | self.prefix_mode = true; 170 | return false; 171 | } 172 | 173 | if self.prefix_mode || prefix_key.is_none() { 174 | self.prefix_mode = false; 175 | 176 | if pause_key.is_some_and(|key| data == key) { 177 | if let Some(pt) = self.pause_time { 178 | self.pause_time = None; 179 | self.time_offset += self.elapsed_time(time) - pt; 180 | self.notify("Resumed recording"); 181 | } else { 182 | self.pause_time = Some(self.elapsed_time(time)); 183 | self.notify("Paused recording"); 184 | } 185 | 186 | return false; 187 | } else if add_marker_key.is_some_and(|key| data == key) { 188 | let msg = Event::Marker(self.elapsed_time(time), "".to_owned()); 189 | self.sender.send(msg).expect("marker send should succeed"); 190 | self.notify("Marker added"); 191 | return false; 192 | } 193 | } 194 | 195 | if self.record_input && self.pause_time.is_none() { 196 | let text = self.input_decoder.feed(data); 197 | 198 | if !text.is_empty() { 199 | let msg = Event::Input(self.elapsed_time(time), text); 200 | self.sender.send(msg).expect("input send should succeed"); 201 | } 202 | } 203 | 204 | true 205 | } 206 | 207 | fn resize(&mut self, time: Duration, tty_size: TtySize) -> bool { 208 | if tty_size != self.tty_size { 209 | let msg = Event::Resize(self.elapsed_time(time), tty_size); 210 | self.sender.send(msg).expect("resize send should succeed"); 211 | 212 | self.tty_size = tty_size; 213 | } 214 | 215 | true 216 | } 217 | 218 | fn stop(self, time: Duration, exit_status: i32) -> Self { 219 | let msg = Event::Exit(self.elapsed_time(time), exit_status); 220 | self.sender.send(msg).expect("exit send should succeed"); 221 | 222 | self 223 | } 224 | } 225 | 226 | pub struct KeyBindings { 227 | pub prefix: Key, 228 | pub pause: Key, 229 | pub add_marker: Key, 230 | } 231 | 232 | impl Default for KeyBindings { 233 | fn default() -> Self { 234 | Self { 235 | prefix: None, 236 | pause: Some(vec![0x1c]), // ^\ 237 | add_marker: None, 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/status.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::{AtomicBool, Ordering::SeqCst}; 2 | static ENABLED: AtomicBool = AtomicBool::new(true); 3 | 4 | pub fn disable() { 5 | ENABLED.store(false, SeqCst); 6 | } 7 | 8 | macro_rules! info { 9 | ($fmt:expr) => (crate::status::do_info(format!($fmt))); 10 | ($fmt:expr, $($arg:tt)*) => (crate::status::do_info(format!($fmt, $($arg)*))); 11 | } 12 | 13 | macro_rules! warning { 14 | ($fmt:expr) => (crate::status::do_warn(format!($fmt))); 15 | ($fmt:expr, $($arg:tt)*) => (crate::status::do_warn(format!($fmt, $($arg)*))); 16 | } 17 | 18 | pub fn do_info(message: String) { 19 | if ENABLED.load(SeqCst) { 20 | println!("::: {message}"); 21 | } 22 | } 23 | 24 | pub fn do_warn(message: String) { 25 | if ENABLED.load(SeqCst) { 26 | println!("!!! {message}"); 27 | } 28 | } 29 | 30 | pub(crate) use info; 31 | pub(crate) use warning; 32 | -------------------------------------------------------------------------------- /src/stream.rs: -------------------------------------------------------------------------------- 1 | use std::future; 2 | use std::io; 3 | use std::time::{Duration, Instant, SystemTime}; 4 | 5 | use anyhow::Result; 6 | use avt::Vt; 7 | use futures_util::{stream, StreamExt}; 8 | use tokio::runtime::Handle; 9 | use tokio::sync::{broadcast, mpsc, oneshot}; 10 | use tokio::time; 11 | use tokio_stream::wrappers::errors::BroadcastStreamRecvError; 12 | use tokio_stream::wrappers::BroadcastStream; 13 | use tracing::info; 14 | 15 | use crate::session; 16 | use crate::tty::TtySize; 17 | use crate::tty::TtyTheme; 18 | 19 | pub struct Stream { 20 | request_tx: mpsc::Sender, 21 | request_rx: mpsc::Receiver, 22 | } 23 | 24 | type Request = oneshot::Sender; 25 | 26 | struct Subscription { 27 | init: Event, 28 | events_rx: broadcast::Receiver, 29 | } 30 | 31 | #[derive(Clone)] 32 | pub struct Subscriber(mpsc::Sender); 33 | 34 | pub struct OutputStarter { 35 | handle: Handle, 36 | request_rx: mpsc::Receiver, 37 | } 38 | 39 | struct Output(mpsc::UnboundedSender); 40 | 41 | #[derive(Clone)] 42 | pub enum Event { 43 | Init(u64, u64, TtySize, Option, String), 44 | Output(u64, u64, String), 45 | Input(u64, u64, String), 46 | Resize(u64, u64, TtySize), 47 | Marker(u64, u64, String), 48 | Exit(u64, u64, i32), 49 | } 50 | 51 | impl Stream { 52 | pub fn new() -> Self { 53 | let (request_tx, request_rx) = mpsc::channel(1); 54 | 55 | Stream { 56 | request_tx, 57 | request_rx, 58 | } 59 | } 60 | 61 | pub fn subscriber(&self) -> Subscriber { 62 | Subscriber(self.request_tx.clone()) 63 | } 64 | 65 | pub fn start(self, handle: Handle) -> OutputStarter { 66 | OutputStarter { 67 | handle, 68 | request_rx: self.request_rx, 69 | } 70 | } 71 | } 72 | 73 | async fn run( 74 | tty_size: TtySize, 75 | tty_theme: Option, 76 | mut stream_rx: mpsc::UnboundedReceiver, 77 | mut request_rx: mpsc::Receiver, 78 | ) { 79 | let (broadcast_tx, _) = broadcast::channel(1024); 80 | let mut vt = build_vt(tty_size); 81 | let mut stream_time = 0; 82 | let mut last_event_id = 0; 83 | let mut last_event_time = Instant::now(); 84 | 85 | loop { 86 | tokio::select! { 87 | event = stream_rx.recv() => { 88 | match event { 89 | Some(event) => { 90 | last_event_time = Instant::now(); 91 | last_event_id += 1; 92 | 93 | match event { 94 | session::Event::Output(time, text) => { 95 | vt.feed_str(&text); 96 | let _ = broadcast_tx.send(Event::Output(last_event_id, time, text)); 97 | stream_time = time; 98 | } 99 | 100 | session::Event::Input(time, text) => { 101 | let _ = broadcast_tx.send(Event::Input(last_event_id, time, text)); 102 | stream_time = time; 103 | } 104 | 105 | session::Event::Resize(time, tty_size) => { 106 | vt.resize(tty_size.0.into(), tty_size.1.into()); 107 | let _ = broadcast_tx.send(Event::Resize(last_event_id, time, tty_size)); 108 | stream_time = time; 109 | } 110 | 111 | session::Event::Marker(time, label) => { 112 | let _ = broadcast_tx.send(Event::Marker(last_event_id, time, label)); 113 | stream_time = time; 114 | } 115 | 116 | session::Event::Exit(time, status) => { 117 | let _ = broadcast_tx.send(Event::Exit(last_event_id, time, status)); 118 | stream_time = time; 119 | } 120 | } 121 | } 122 | 123 | None => break, 124 | } 125 | } 126 | 127 | request = request_rx.recv() => { 128 | match request { 129 | Some(request) => { 130 | let elapsed_time = stream_time + last_event_time.elapsed().as_micros() as u64; 131 | 132 | let vt_seed = if last_event_id > 0 { 133 | vt.dump() 134 | } else { 135 | "".to_owned() 136 | }; 137 | 138 | let init = Event::Init( 139 | last_event_id, 140 | elapsed_time, 141 | vt.size().into(), 142 | tty_theme.clone(), 143 | vt_seed, 144 | ); 145 | 146 | let events_rx = broadcast_tx.subscribe(); 147 | let _ = request.send(Subscription { init, events_rx }); 148 | info!("subscriber count: {}", broadcast_tx.receiver_count()); 149 | } 150 | 151 | None => break, 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | impl Subscriber { 159 | pub async fn subscribe( 160 | &self, 161 | ) -> Result>> { 162 | let (tx, rx) = oneshot::channel(); 163 | self.0.send(tx).await?; 164 | let subscription = time::timeout(Duration::from_secs(5), rx).await??; 165 | let init = stream::once(future::ready(Ok(subscription.init))); 166 | let events = BroadcastStream::new(subscription.events_rx); 167 | 168 | Ok(init.chain(events)) 169 | } 170 | } 171 | 172 | fn build_vt(tty_size: TtySize) -> Vt { 173 | Vt::builder() 174 | .size(tty_size.0 as usize, tty_size.1 as usize) 175 | .build() 176 | } 177 | 178 | impl session::OutputStarter for OutputStarter { 179 | fn start( 180 | self: Box, 181 | _time: SystemTime, 182 | tty_size: TtySize, 183 | tty_theme: Option, 184 | ) -> io::Result> { 185 | let (stream_tx, stream_rx) = mpsc::unbounded_channel(); 186 | let request_rx = self.request_rx; 187 | 188 | self.handle 189 | .spawn(async move { run(tty_size, tty_theme, stream_rx, request_rx).await }); 190 | 191 | Ok(Box::new(Output(stream_tx))) 192 | } 193 | } 194 | 195 | impl session::Output for Output { 196 | fn event(&mut self, event: session::Event) -> io::Result<()> { 197 | self.0.send(event).map_err(io::Error::other) 198 | } 199 | 200 | fn flush(&mut self) -> io::Result<()> { 201 | Ok(()) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/tty.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::io; 3 | use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; 4 | 5 | use anyhow::Result; 6 | use nix::errno::Errno; 7 | use nix::sys::select::{select, FdSet}; 8 | use nix::sys::time::TimeVal; 9 | use nix::{libc, pty, unistd}; 10 | use rgb::RGB8; 11 | use termion::raw::{IntoRawMode, RawTerminal}; 12 | 13 | #[derive(Clone, Copy, Debug, PartialEq)] 14 | pub struct TtySize(pub u16, pub u16); 15 | 16 | impl Default for TtySize { 17 | fn default() -> Self { 18 | TtySize(80, 24) 19 | } 20 | } 21 | 22 | impl From for TtySize { 23 | fn from(winsize: pty::Winsize) -> Self { 24 | TtySize(winsize.ws_col, winsize.ws_row) 25 | } 26 | } 27 | 28 | impl From<(usize, usize)> for TtySize { 29 | fn from((cols, rows): (usize, usize)) -> Self { 30 | TtySize(cols as u16, rows as u16) 31 | } 32 | } 33 | 34 | impl From for (u16, u16) { 35 | fn from(tty_size: TtySize) -> Self { 36 | (tty_size.0, tty_size.1) 37 | } 38 | } 39 | 40 | pub trait Tty: io::Write + io::Read + AsFd { 41 | fn get_size(&self) -> pty::Winsize; 42 | fn get_theme(&self) -> Option; 43 | fn get_version(&self) -> Option; 44 | } 45 | 46 | #[derive(Clone)] 47 | pub struct TtyTheme { 48 | pub fg: RGB8, 49 | pub bg: RGB8, 50 | pub palette: Vec, 51 | } 52 | 53 | pub struct DevTty { 54 | file: RawTerminal, 55 | } 56 | 57 | impl DevTty { 58 | pub fn open() -> Result { 59 | let file = fs::OpenOptions::new() 60 | .read(true) 61 | .write(true) 62 | .open("/dev/tty")? 63 | .into_raw_mode()?; 64 | 65 | crate::io::set_non_blocking(&file)?; 66 | 67 | Ok(Self { file }) 68 | } 69 | 70 | fn query(&self, query: &str) -> Result> { 71 | let mut query = query.to_string().into_bytes(); 72 | query.extend_from_slice(b"\x1b[c"); 73 | let mut query = &query[..]; 74 | let mut response = Vec::new(); 75 | let mut buf = [0u8; 1024]; 76 | let fd = self.as_fd(); 77 | 78 | loop { 79 | let mut timeout = TimeVal::new(0, 100_000); 80 | let mut rfds = FdSet::new(); 81 | let mut wfds = FdSet::new(); 82 | rfds.insert(fd); 83 | 84 | if !query.is_empty() { 85 | wfds.insert(fd); 86 | } 87 | 88 | match select(None, &mut rfds, &mut wfds, None, &mut timeout) { 89 | Ok(0) => break, 90 | 91 | Ok(_) => { 92 | if rfds.contains(fd) { 93 | let n = unistd::read(fd, &mut buf)?; 94 | response.extend_from_slice(&buf[..n]); 95 | let mut reversed = response.iter().rev(); 96 | let mut got_da_response = false; 97 | let mut da_len = 0; 98 | 99 | if let Some(b'c') = reversed.next() { 100 | da_len += 1; 101 | 102 | for b in reversed { 103 | if *b == b'[' { 104 | got_da_response = true; 105 | break; 106 | } 107 | 108 | if *b != b';' && *b != b'?' && !b.is_ascii_digit() { 109 | break; 110 | } 111 | 112 | da_len += 1; 113 | } 114 | } 115 | 116 | if got_da_response { 117 | response.truncate(response.len() - da_len - 2); 118 | break; 119 | } 120 | } 121 | 122 | if wfds.contains(fd) { 123 | let n = unistd::write(fd, query)?; 124 | query = &query[n..]; 125 | } 126 | } 127 | 128 | Err(e) => { 129 | if e == Errno::EINTR { 130 | continue; 131 | } else { 132 | return Err(e.into()); 133 | } 134 | } 135 | } 136 | } 137 | 138 | Ok(response) 139 | } 140 | } 141 | 142 | fn parse_color(rgb: &str) -> Option { 143 | let mut components = rgb.split('/'); 144 | let r_hex = components.next()?; 145 | let g_hex = components.next()?; 146 | let b_hex = components.next()?; 147 | 148 | if r_hex.len() < 2 || g_hex.len() < 2 || b_hex.len() < 2 { 149 | return None; 150 | } 151 | 152 | let r = u8::from_str_radix(&r_hex[..2], 16).ok()?; 153 | let g = u8::from_str_radix(&g_hex[..2], 16).ok()?; 154 | let b = u8::from_str_radix(&b_hex[..2], 16).ok()?; 155 | 156 | Some(RGB8::new(r, g, b)) 157 | } 158 | 159 | static COLORS_QUERY: &str = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?\x07\x1b]4;1;?\x07\x1b]4;2;?\x07\x1b]4;3;?\x07\x1b]4;4;?\x07\x1b]4;5;?\x07\x1b]4;6;?\x07\x1b]4;7;?\x07\x1b]4;8;?\x07\x1b]4;9;?\x07\x1b]4;10;?\x07\x1b]4;11;?\x07\x1b]4;12;?\x07\x1b]4;13;?\x07\x1b]4;14;?\x07\x1b]4;15;?\x07"; 160 | 161 | static XTVERSION_QUERY: &str = "\x1b[>0q"; 162 | 163 | impl Tty for DevTty { 164 | fn get_size(&self) -> pty::Winsize { 165 | let mut winsize = pty::Winsize { 166 | ws_row: 24, 167 | ws_col: 80, 168 | ws_xpixel: 0, 169 | ws_ypixel: 0, 170 | }; 171 | 172 | unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, &mut winsize) }; 173 | 174 | winsize 175 | } 176 | 177 | fn get_theme(&self) -> Option { 178 | let response = self.query(COLORS_QUERY).ok()?; 179 | let response = String::from_utf8_lossy(response.as_slice()); 180 | let mut colors = response.match_indices("rgb:"); 181 | let (idx, _) = colors.next()?; 182 | let fg = parse_color(&response[idx + 4..])?; 183 | let (idx, _) = colors.next()?; 184 | let bg = parse_color(&response[idx + 4..])?; 185 | let mut palette = Vec::new(); 186 | 187 | for _ in 0..16 { 188 | let (idx, _) = colors.next()?; 189 | let color = parse_color(&response[idx + 4..])?; 190 | palette.push(color); 191 | } 192 | 193 | Some(TtyTheme { fg, bg, palette }) 194 | } 195 | 196 | fn get_version(&self) -> Option { 197 | let response = self.query(XTVERSION_QUERY).ok()?; 198 | 199 | if let [b'\x1b', b'P', b'>', b'|', version @ .., b'\x1b', b'\\'] = &response[..] { 200 | Some(String::from_utf8_lossy(version).to_string()) 201 | } else { 202 | None 203 | } 204 | } 205 | } 206 | 207 | impl io::Read for DevTty { 208 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 209 | self.file.read(buf) 210 | } 211 | } 212 | 213 | impl io::Write for DevTty { 214 | fn write(&mut self, buf: &[u8]) -> io::Result { 215 | self.file.write(buf) 216 | } 217 | 218 | fn flush(&mut self) -> io::Result<()> { 219 | self.file.flush() 220 | } 221 | } 222 | 223 | impl AsFd for DevTty { 224 | fn as_fd(&self) -> BorrowedFd<'_> { 225 | self.file.as_fd() 226 | } 227 | } 228 | 229 | pub struct NullTty { 230 | tx: OwnedFd, 231 | _rx: OwnedFd, 232 | } 233 | 234 | impl NullTty { 235 | pub fn open() -> Result { 236 | let (rx, tx) = unistd::pipe()?; 237 | 238 | Ok(Self { tx, _rx: rx }) 239 | } 240 | } 241 | 242 | impl Tty for NullTty { 243 | fn get_size(&self) -> pty::Winsize { 244 | pty::Winsize { 245 | ws_row: 24, 246 | ws_col: 80, 247 | ws_xpixel: 0, 248 | ws_ypixel: 0, 249 | } 250 | } 251 | 252 | fn get_theme(&self) -> Option { 253 | None 254 | } 255 | 256 | fn get_version(&self) -> Option { 257 | None 258 | } 259 | } 260 | 261 | impl io::Read for NullTty { 262 | fn read(&mut self, _buf: &mut [u8]) -> io::Result { 263 | panic!("read attempt from NullTty"); 264 | } 265 | } 266 | 267 | impl io::Write for NullTty { 268 | fn write(&mut self, buf: &[u8]) -> io::Result { 269 | Ok(buf.len()) 270 | } 271 | 272 | fn flush(&mut self) -> io::Result<()> { 273 | Ok(()) 274 | } 275 | } 276 | 277 | impl AsFd for NullTty { 278 | fn as_fd(&self) -> BorrowedFd<'_> { 279 | self.tx.as_fd() 280 | } 281 | } 282 | 283 | pub struct FixedSizeTty { 284 | inner: Box, 285 | cols: Option, 286 | rows: Option, 287 | } 288 | 289 | impl FixedSizeTty { 290 | pub fn new(inner: T, cols: Option, rows: Option) -> Self { 291 | Self { 292 | inner: Box::new(inner), 293 | cols, 294 | rows, 295 | } 296 | } 297 | } 298 | 299 | impl Tty for FixedSizeTty { 300 | fn get_size(&self) -> pty::Winsize { 301 | let mut winsize = self.inner.get_size(); 302 | 303 | if let Some(cols) = self.cols { 304 | winsize.ws_col = cols; 305 | } 306 | 307 | if let Some(rows) = self.rows { 308 | winsize.ws_row = rows; 309 | } 310 | 311 | winsize 312 | } 313 | 314 | fn get_theme(&self) -> Option { 315 | self.inner.get_theme() 316 | } 317 | 318 | fn get_version(&self) -> Option { 319 | self.inner.get_version() 320 | } 321 | } 322 | 323 | impl AsFd for FixedSizeTty { 324 | fn as_fd(&self) -> BorrowedFd<'_> { 325 | return self.inner.as_fd(); 326 | } 327 | } 328 | 329 | impl io::Read for FixedSizeTty { 330 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 331 | self.inner.read(buf) 332 | } 333 | } 334 | 335 | impl io::Write for FixedSizeTty { 336 | fn write(&mut self, buf: &[u8]) -> io::Result { 337 | self.inner.write(buf) 338 | } 339 | 340 | fn flush(&mut self) -> io::Result<()> { 341 | self.inner.flush() 342 | } 343 | } 344 | 345 | #[cfg(test)] 346 | mod tests { 347 | use super::{FixedSizeTty, Tty}; 348 | use crate::tty::NullTty; 349 | use rgb::RGB8; 350 | 351 | #[test] 352 | fn parse_color() { 353 | use super::parse_color as parse; 354 | let color = Some(RGB8::new(0xaa, 0xbb, 0xcc)); 355 | 356 | assert_eq!(parse("aa11/bb22/cc33"), color); 357 | assert_eq!(parse("aa11/bb22/cc33\x07"), color); 358 | assert_eq!(parse("aa11/bb22/cc33\x1b\\"), color); 359 | assert_eq!(parse("aa11/bb22/cc33.."), color); 360 | assert_eq!(parse("aa1/bb2/cc3"), color); 361 | assert_eq!(parse("aa1/bb2/cc3\x07"), color); 362 | assert_eq!(parse("aa1/bb2/cc3\x1b\\"), color); 363 | assert_eq!(parse("aa1/bb2/cc3.."), color); 364 | assert_eq!(parse("aa/bb/cc"), color); 365 | assert_eq!(parse("aa/bb/cc\x07"), color); 366 | assert_eq!(parse("aa/bb/cc\x1b\\"), color); 367 | assert_eq!(parse("aa/bb/cc.."), color); 368 | assert_eq!(parse("aa11/bb22"), None); 369 | assert_eq!(parse("xxxx/yyyy/zzzz"), None); 370 | assert_eq!(parse("xxx/yyy/zzz"), None); 371 | assert_eq!(parse("xx/yy/zz"), None); 372 | assert_eq!(parse("foo"), None); 373 | assert_eq!(parse(""), None); 374 | } 375 | 376 | #[test] 377 | fn fixed_size_tty() { 378 | let tty = FixedSizeTty::new(NullTty::open().unwrap(), Some(100), Some(50)); 379 | 380 | let winsize = tty.get_size(); 381 | 382 | assert!(winsize.ws_col == 100); 383 | assert!(winsize.ws_row == 50); 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::{io, thread}; 3 | 4 | use anyhow::{anyhow, bail, Result}; 5 | use reqwest::Url; 6 | use tempfile::NamedTempFile; 7 | 8 | use crate::html; 9 | 10 | pub fn get_local_path(filename: &str) -> Result>> { 11 | if filename.starts_with("https://") || filename.starts_with("http://") { 12 | match download_asciicast(filename) { 13 | Ok(path) => Ok(Box::new(path)), 14 | Err(e) => bail!(anyhow!("download failed: {e}")), 15 | } 16 | } else { 17 | Ok(Box::new(PathBuf::from(filename))) 18 | } 19 | } 20 | 21 | fn download_asciicast(url: &str) -> Result { 22 | use reqwest::blocking::get; 23 | 24 | let mut response = get(Url::parse(url)?)?; 25 | response.error_for_status_ref()?; 26 | let mut file = NamedTempFile::new()?; 27 | 28 | let content_type = response 29 | .headers() 30 | .get("content-type") 31 | .ok_or(anyhow!("no content-type header in the response"))? 32 | .to_str()?; 33 | 34 | if content_type.starts_with("text/html") { 35 | if let Some(url) = html::extract_asciicast_link(&response.text()?) { 36 | let mut response = get(Url::parse(&url)?)?; 37 | response.error_for_status_ref()?; 38 | io::copy(&mut response, &mut file)?; 39 | 40 | Ok(file) 41 | } else { 42 | bail!( 43 | r#" not found in the HTML page"# 44 | ); 45 | } 46 | } else { 47 | io::copy(&mut response, &mut file)?; 48 | 49 | Ok(file) 50 | } 51 | } 52 | 53 | pub struct JoinHandle(Option>); 54 | 55 | impl JoinHandle { 56 | pub fn new(handle: thread::JoinHandle<()>) -> Self { 57 | Self(Some(handle)) 58 | } 59 | } 60 | 61 | impl Drop for JoinHandle { 62 | fn drop(&mut self) { 63 | self.0 64 | .take() 65 | .unwrap() 66 | .join() 67 | .expect("worker thread should finish cleanly"); 68 | } 69 | } 70 | 71 | pub struct Utf8Decoder(Vec); 72 | 73 | impl Utf8Decoder { 74 | pub fn new() -> Self { 75 | Self(Vec::new()) 76 | } 77 | 78 | pub fn feed(&mut self, input: &[u8]) -> String { 79 | let mut output = String::new(); 80 | self.0.extend_from_slice(input); 81 | 82 | while !self.0.is_empty() { 83 | match std::str::from_utf8(&self.0) { 84 | Ok(valid_str) => { 85 | output.push_str(valid_str); 86 | self.0.clear(); 87 | break; 88 | } 89 | 90 | Err(e) => { 91 | let n = e.valid_up_to(); 92 | let valid_bytes: Vec = self.0.drain(..n).collect(); 93 | let valid_str = unsafe { std::str::from_utf8_unchecked(&valid_bytes) }; 94 | output.push_str(valid_str); 95 | 96 | match e.error_len() { 97 | Some(len) => { 98 | self.0.drain(..len); 99 | output.push('�'); 100 | } 101 | 102 | None => { 103 | break; 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | output 111 | } 112 | } 113 | 114 | #[cfg(test)] 115 | mod tests { 116 | use super::Utf8Decoder; 117 | 118 | #[test] 119 | fn utf8_decoder() { 120 | let mut decoder = Utf8Decoder::new(); 121 | 122 | assert_eq!(decoder.feed(b"czarna "), "czarna "); 123 | assert_eq!(decoder.feed(&[0xc5, 0xbc, 0xc3]), "ż"); 124 | assert_eq!(decoder.feed(&[0xb3, 0xc5, 0x82]), "ół"); 125 | assert_eq!(decoder.feed(&[0xc4]), ""); 126 | assert_eq!(decoder.feed(&[0x87, 0x21]), "ć!"); 127 | assert_eq!(decoder.feed(&[0x80]), "�"); 128 | assert_eq!(decoder.feed(&[]), ""); 129 | assert_eq!(decoder.feed(&[0x80, 0x81]), "��"); 130 | assert_eq!(decoder.feed(&[]), ""); 131 | assert_eq!(decoder.feed(&[0x23]), "#"); 132 | assert_eq!( 133 | decoder.feed(&[0x83, 0x23, 0xf0, 0x90, 0x80, 0xc0, 0x21]), 134 | "�#��!" 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/casts/demo.cast: -------------------------------------------------------------------------------- 1 | {"env": {"TERM": "xterm-256color", "SHELL": "/usr/local/bin/fish"}, "width": 75, "height": 18, "timestamp": 1509091818, "version": 2, "idle_time_limit": 2.0} 2 | [0.089436, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"] 3 | [0.100989, "o", "\u001b[?2004h"] 4 | [0.164215, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"] 5 | [0.164513, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K"] 6 | [0.164709, "o", "\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"] 7 | [1.511526, "i", "v"] 8 | [1.511937, "o", "v"] 9 | [1.512148, "o", "\b\u001b[38;2;0;95;215mv\u001b[30m\u001b(B\u001b[m"] 10 | [1.514564, "o", "\u001b[38;2;85;85;85mim tests/vim.cast \u001b[18D\u001b[30m\u001b(B\u001b[m"] 11 | [1.615727, "i", "i"] 12 | [1.616261, "o", "\u001b[38;2;0;95;215mi\u001b[38;2;85;85;85mm tests/vim.cast \u001b[17D\u001b[30m\u001b(B\u001b[m"] 13 | [1.694908, "i", "m"] 14 | [1.695262, "o", "\u001b[38;2;0;95;215mm\u001b[38;2;85;85;85m tests/vim.cast \u001b[16D\u001b[30m\u001b(B\u001b[m"] 15 | [2.751713, "i", "\r"] 16 | [2.752186, "o", "\u001b[K\r\n\u001b[30m"] 17 | [2.752381, "o", "\u001b(B\u001b[m\u001b[?2004l"] 18 | [2.752718, "o", "\u001b]0;vim /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m\r"] 19 | [2.86619, "o", "\u001b[?1000h\u001b[?2004h\u001b[?1049h\u001b[?1h\u001b=\u001b[?2004h"] 20 | [2.867669, "o", "\u001b[1;18r\u001b[?12h\u001b[?12l\u001b[27m\u001b[29m\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[H\u001b[2J\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[1;1H\u001b[>c"] 21 | [2.868169, "i", "\u001b[2;2R\u001b[>0;95;0c"] 22 | [2.869918, "o", "\u001b[?1000l\u001b[?1002h\u001b[?12$p"] 23 | [2.870136, "o", "\u001b[?25l\u001b[1;1H\u001b[93m1 \u001b[m\u001b[38;5;231m\u001b[48;5;235m\r\n\u001b[38;5;59m\u001b[48;5;236m~ \u001b[3;1H~ \u001b[4;1H~ \u001b[5;1H~ \u001b[6;1H~ \u001b[7;1H~ \u001b[8;1H~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ "] 24 | [2.870245, "o", " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[17;1H\u001b[1m\u001b[38;5;231m\u001b[48;5;236m[No Name] (unix/utf-8/) (line 0/1, col 000)\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[3;30HVIM - Vi IMproved\u001b[5;30Hversion 8.0.1171\u001b[6;26Hby Bram Moolenaar et al.\u001b[7;17HVim is open source and freely distributable\u001b[9;24HBecome a registered Vim user!\u001b[10;15Htype :help register\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m for information \u001b[12;15Htype :q\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m to exit \u001b[13;15Htype :help\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m or \u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m for on-line help\u001b[14;15Htype :help version8\u001b[38;5;59m\u001b[48;5;236m\u001b[m\u001b[38;5;231m\u001b[48;5;235m for version"] 25 | [2.870302, "o", " info\u001b[1;5H\u001b[?25h"] 26 | [5.63147, "i", ":"] 27 | [5.631755, "o", "\u001b[?25l\u001b[18;65H:\u001b[1;5H"] 28 | [5.631934, "o", "\u001b[18;65H\u001b[K\u001b[18;1H:\u001b[?2004l\u001b[?2004h\u001b[?25h"] 29 | [6.16692, "i", "q"] 30 | [6.167137, "o", "q\u001b[?25l\u001b[?25h"] 31 | [7.463349, "i", "\r"] 32 | [7.463561, "o", "\r"] 33 | [7.498922, "o", "\u001b[?25l\u001b[?1002l\u001b[?2004l"] 34 | [7.604236, "o", "\u001b[18;1H\u001b[K\u001b[18;1H\u001b[?2004l\u001b[?1l\u001b>\u001b[?25h\u001b[?1049l"] 35 | [7.612576, "o", "\u001b[?2004h"] 36 | [7.655999, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"] 37 | [7.656239, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"] 38 | [11.891762, "i", "\u0004"] 39 | [11.893297, "o", "\r\n\u001b[30m\u001b(B\u001b[m\u001b[30m\u001b(B\u001b[m"] 40 | [11.89348, "o", "\u001b[?2004l"] 41 | -------------------------------------------------------------------------------- /tests/casts/demo.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "width": 80, 4 | "height": 40, 5 | "duration": 6.46111, 6 | "command": "/bin/bash", 7 | "title": null, 8 | "env": { 9 | "TERM": "xterm-256color", 10 | "SHELL": "/bin/bash" 11 | }, 12 | "stdout": [ 13 | [ 14 | 0.013659, 15 | "\u001b[?1034hbash-3.2$ " 16 | ], 17 | [ 18 | 1.923187, 19 | "v" 20 | ], 21 | [ 22 | 0.064049, 23 | "i" 24 | ], 25 | [ 26 | 0.032034, 27 | "m" 28 | ], 29 | [ 30 | 0.19157, 31 | "\r\n" 32 | ], 33 | [ 34 | 0.032342, 35 | "\u001b[?1049h\u001b[?1h\u001b=\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[1;1H" 36 | ], 37 | [ 38 | 0.001436, 39 | "\u001b[1;40r\u001b[?12;25h\u001b[?12l\u001b[?25h\u001b[27m\u001b[m\u001b[H\u001b[2J\u001b[>c" 40 | ], 41 | [ 42 | 0.000311, 43 | "\u001b[?25l\u001b[1;1H\u001b[33m 1 \u001b[m\r\n\u001b[1m\u001b[34m~ \u001b[3;1H~ \u001b[4;1H~ \u001b[5;1H~ \u001b[6;1H~ \u001b[7;1H~ \u001b[8;1H~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ " 44 | ], 45 | [ 46 | 3.9e-05, 47 | " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[17;1H~ \u001b[18;1H~ \u001b[19;1H~ \u001b[20;1H~ \u001b[21;1H~ \u001b[22;1H~ \u001b[23;1H~ \u001b[24;1H~ \u001b[25;1H~ " 48 | ], 49 | [ 50 | 9.2e-05, 51 | " \u001b[26;1H~ \u001b[27;1H~ \u001b[28;1H~ \u001b[29;1H~ \u001b[30;1H~ \u001b[31;1H~ \u001b[32;1H~ \u001b[33;1H~ \u001b[34;1H~ \u001b[35;1H~ \u001b[36;1H~ \u001b[37;" 52 | ], 53 | [ 54 | 2.4e-05, 55 | "1H~ \u001b[38;1H~ \u001b[m\u001b[39;1H\u001b[1m\u001b[7m[No Name] \u001b[m\u001b[14;32HVIM - Vi IMproved\u001b[16;33Hversion 7.4.8056\u001b[17;29Hby Bram Moolenaar et al.\u001b[18;19HVim is open source and freely distributable\u001b[20;26HBecome a registered Vim user!\u001b[21;18Htype :help register\u001b[32m\u001b[m for information \u001b[23;18Htype :q\u001b[32m\u001b[m to exit \u001b[24;18Htype :help\u001b[32m\u001b[m or \u001b[32m\u001b[m for on-line help\u001b[25;18Htype :help version7\u001b[32m\u001b[m for version info\u001b[1;5H\u001b[?12l\u001b[?25h" 56 | ], 57 | [ 58 | 1.070242, 59 | "\u001b[?25l\u001b[40;1H:" 60 | ], 61 | [ 62 | 2.3e-05, 63 | "\u001b[?12l\u001b[?25h" 64 | ], 65 | [ 66 | 0.503964, 67 | "q" 68 | ], 69 | [ 70 | 0.151903, 71 | "u" 72 | ], 73 | [ 74 | 0.04002, 75 | "i" 76 | ], 77 | [ 78 | 0.088084, 79 | "t" 80 | ], 81 | [ 82 | 0.287636, 83 | "\r" 84 | ], 85 | [ 86 | 0.002178, 87 | "\u001b[?25l\u001b[40;1H\u001b[K\u001b[40;1H\u001b[?1l\u001b>\u001b[?12l\u001b[?25h\u001b[?1049l" 88 | ], 89 | [ 90 | 0.000999, 91 | "bash-3.2$ " 92 | ], 93 | [ 94 | 1.58912, 95 | "e" 96 | ], 97 | [ 98 | 0.184114, 99 | "x" 100 | ], 101 | [ 102 | 0.087915, 103 | "i" 104 | ], 105 | [ 106 | 0.103987, 107 | "t" 108 | ], 109 | [ 110 | 0.087613, 111 | "\r\n" 112 | ] 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /tests/casts/full-v2.cast: -------------------------------------------------------------------------------- 1 | {"version":2,"width":100,"height":50,"timestamp": 1509091818,"command":"/bin/bash","env":{"TERM":"xterm-256color","SHELL":"/bin/bash"},"theme":{"fg":"#000000","bg":"#ffffff","palette":"#241f31:#c01c28:#2ec27e:#f5c211:#1e78e4:#9841bb:#0ab9dc:#c0bfbc:#5e5c64:#ed333b:#57e389:#f8e45c:#51a1ff:#c061cb:#4fd2fd:#f6f5f4"}} 2 | [0.000001, "o", "ż"] 3 | [1.0, "o", "ółć"] 4 | [2.3, "i", "\n"] 5 | [5.600001, "r", "80x40"] 6 | [10.5, "o", "\r\n"] 7 | -------------------------------------------------------------------------------- /tests/casts/full-v3.cast: -------------------------------------------------------------------------------- 1 | {"version":3,"term":{"cols":100,"rows":50,"theme":{"fg":"#000000","bg":"#ffffff","palette":"#241f31:#c01c28:#2ec27e:#f5c211:#1e78e4:#9841bb:#0ab9dc:#c0bfbc:#5e5c64:#ed333b:#57e389:#f8e45c:#51a1ff:#c061cb:#4fd2fd:#f6f5f4"}},"timestamp": 1509091818,"command":"/bin/bash","env":{"TERM":"xterm-256color","SHELL":"/bin/bash"}} 2 | [0.000001, "o", "ż"] 3 | [1.0, "o", "ółć"] 4 | [0.3, "i", "\n"] 5 | [1.600001, "r", "80x40"] 6 | [10.5, "o", "\r\n"] 7 | -------------------------------------------------------------------------------- /tests/casts/full.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "width": 100, 4 | "height": 50, 5 | "duration": 10.5, 6 | "command": "/bin/bash", 7 | "title": null, 8 | "env": { 9 | "TERM": "xterm-256color", 10 | "SHELL": "/bin/bash" 11 | }, 12 | "stdout": [ 13 | [ 14 | 0.000001, 15 | "ż" 16 | ], 17 | [ 18 | 10.00, 19 | "ółć" 20 | ], 21 | [ 22 | 0.5, 23 | "\r\n" 24 | ] 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tests/casts/minimal-v2.cast: -------------------------------------------------------------------------------- 1 | {"version":2,"width":100,"height":50} 2 | [1.23, "o", "hello"] 3 | -------------------------------------------------------------------------------- /tests/casts/minimal-v3.cast: -------------------------------------------------------------------------------- 1 | {"version":3,"term":{"cols":100,"rows":50}} 2 | [1.23, "o", "hello"] 3 | -------------------------------------------------------------------------------- /tests/casts/minimal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "width": 100, 4 | "height": 50, 5 | "stdout": [ 6 | [ 7 | 1.230000, 8 | "hello" 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tests/distros.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | readonly DISTROS=( 6 | 'arch' 7 | 'alpine' 8 | 'centos' 9 | 'debian' 10 | 'fedora' 11 | 'ubuntu' 12 | ) 13 | 14 | readonly DOCKER='docker' 15 | 16 | # do not redefine builtin `test` 17 | test_() { 18 | local -r tag="${1}" 19 | 20 | local -ra docker_opts=( 21 | "--tag=asciinema/asciinema:${tag}" 22 | "--file=tests/distros/Dockerfile.${tag}" 23 | ) 24 | 25 | printf "\e[1;32mTesting on %s...\e[0m\n\n" "${tag}" 26 | 27 | # shellcheck disable=SC2068 28 | "${DOCKER}" build ${docker_opts[@]} . 29 | 30 | "${DOCKER}" run --rm -it "asciinema/asciinema:${tag}" tests/integration.sh 31 | } 32 | 33 | 34 | for distro in "${DISTROS[@]}"; do 35 | test_ "${distro}" 36 | done 37 | 38 | printf "\n\e[1;32mAll tests passed.\e[0m\n" 39 | -------------------------------------------------------------------------------- /tests/distros/Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | 3 | FROM docker.io/library/alpine:3.15 4 | 5 | # https://github.com/actions/runner/issues/241 6 | RUN apk --no-cache add bash ca-certificates make python3 util-linux 7 | 8 | WORKDIR /usr/src/app 9 | 10 | COPY asciinema/ asciinema/ 11 | COPY tests/ tests/ 12 | 13 | ENV LANG="en_US.utf8" 14 | 15 | USER nobody 16 | 17 | ENTRYPOINT ["/bin/bash"] 18 | 19 | # vim:ft=dockerfile 20 | -------------------------------------------------------------------------------- /tests/distros/Dockerfile.arch: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | 3 | FROM docker.io/library/archlinux:latest 4 | 5 | RUN pacman-key --init \ 6 | && pacman --sync --refresh --sysupgrade --noconfirm make python3 \ 7 | && printf "LANG=en_US.UTF-8\n" > /etc/locale.conf \ 8 | && locale-gen \ 9 | && pacman --sync --clean --clean --noconfirm 10 | 11 | WORKDIR /usr/src/app 12 | 13 | COPY asciinema/ asciinema/ 14 | COPY tests/ tests/ 15 | 16 | ENV LANG="en_US.utf8" 17 | 18 | USER nobody 19 | 20 | ENTRYPOINT ["/bin/bash"] 21 | 22 | # vim:ft=dockerfile 23 | -------------------------------------------------------------------------------- /tests/distros/Dockerfile.centos: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | 3 | FROM docker.io/library/centos:7 4 | 5 | RUN yum install -y epel-release && yum install -y make python36 && yum clean all 6 | 7 | WORKDIR /usr/src/app 8 | 9 | COPY asciinema/ asciinema/ 10 | COPY tests/ tests/ 11 | 12 | ENV LANG="en_US.utf8" 13 | 14 | USER nobody 15 | 16 | ENTRYPOINT ["/bin/bash"] 17 | 18 | # vim:ft=dockerfile 19 | -------------------------------------------------------------------------------- /tests/distros/Dockerfile.debian: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | 3 | FROM docker.io/library/debian:bullseye 4 | 5 | ENV DEBIAN_FRONTENT="noninteractive" 6 | 7 | RUN apt-get update \ 8 | && apt-get install -y \ 9 | ca-certificates \ 10 | locales \ 11 | make \ 12 | procps \ 13 | python3 \ 14 | && localedef \ 15 | -i en_US \ 16 | -c \ 17 | -f UTF-8 \ 18 | -A /usr/share/locale/locale.alias \ 19 | en_US.UTF-8 \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | WORKDIR /usr/src/app 23 | 24 | COPY asciinema/ asciinema/ 25 | COPY tests/ tests/ 26 | 27 | ENV LANG="en_US.utf8" 28 | 29 | USER nobody 30 | 31 | ENV SHELL="/bin/bash" 32 | 33 | # vim:ft=dockerfile 34 | -------------------------------------------------------------------------------- /tests/distros/Dockerfile.fedora: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | 3 | # https://medium.com/nttlabs/ubuntu-21-10-and-fedora-35-do-not-work-on-docker-20-10-9-1cd439d9921 4 | # https://www.mail-archive.com/ubuntu-bugs@lists.ubuntu.com/msg5971024.html 5 | FROM registry.fedoraproject.org/fedora:34 6 | 7 | RUN dnf install -y make python3 procps && dnf clean all 8 | 9 | WORKDIR /usr/src/app 10 | 11 | COPY asciinema/ asciinema/ 12 | COPY tests/ tests/ 13 | 14 | ENV LANG="en_US.utf8" 15 | ENV SHELL="/bin/bash" 16 | 17 | USER nobody 18 | 19 | ENTRYPOINT ["/bin/bash"] 20 | # vim:ft=dockerfile 21 | -------------------------------------------------------------------------------- /tests/distros/Dockerfile.ubuntu: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | 3 | FROM docker.io/library/ubuntu:20.04 4 | 5 | ENV DEBIAN_FRONTENT="noninteractive" 6 | 7 | RUN apt-get update \ 8 | && apt-get install -y \ 9 | ca-certificates \ 10 | locales \ 11 | make \ 12 | python3 \ 13 | && localedef \ 14 | -i en_US \ 15 | -c \ 16 | -f UTF-8 \ 17 | -A /usr/share/locale/locale.alias \ 18 | en_US.UTF-8 \ 19 | && rm -rf /var/lib/apt/lists/* 20 | 21 | WORKDIR /usr/src/app 22 | 23 | COPY asciinema/ asciinema/ 24 | COPY tests/ tests/ 25 | 26 | ENV LANG="en_US.utf8" 27 | 28 | USER nobody 29 | 30 | ENTRYPOINT ["/bin/bash"] 31 | 32 | # vim:ft=dockerfile 33 | -------------------------------------------------------------------------------- /tests/integration.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eExuo pipefail 4 | 5 | if ! command -v "pkill" >/dev/null 2>&1; then 6 | printf "error: pkill not installed\n" 7 | exit 1 8 | fi 9 | 10 | python3 -V 11 | 12 | ASCIINEMA_CONFIG_HOME="$( 13 | mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home 14 | )" 15 | 16 | export ASCIINEMA_CONFIG_HOME 17 | 18 | TMP_DATA_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir)" 19 | 20 | trap 'rm -rf ${ASCIINEMA_CONFIG_HOME} ${TMP_DATA_DIR}' EXIT 21 | 22 | asciinema() { 23 | python3 -m asciinema "${@}" 24 | } 25 | 26 | ## disable notifications 27 | 28 | printf "[notifications]\nenabled = no\n" >> "${ASCIINEMA_CONFIG_HOME}/config" 29 | 30 | ## test help message 31 | 32 | asciinema -h 33 | 34 | ## test version command 35 | 36 | asciinema --version 37 | 38 | ## test auth command 39 | 40 | asciinema auth 41 | 42 | ## test play command 43 | 44 | # asciicast v1 45 | asciinema play -s 5 tests/demo.json 46 | asciinema play -s 5 -i 0.2 tests/demo.json 47 | # shellcheck disable=SC2002 48 | cat tests/demo.json | asciinema play -s 5 - 49 | 50 | # asciicast v2 51 | asciinema play -s 5 tests/demo.cast 52 | asciinema play -s 5 -i 0.2 tests/demo.cast 53 | # shellcheck disable=SC2002 54 | cat tests/demo.cast | asciinema play -s 5 - 55 | 56 | ## test cat command 57 | 58 | # asciicast v1 59 | asciinema cat tests/demo.json 60 | # shellcheck disable=SC2002 61 | cat tests/demo.json | asciinema cat - 62 | 63 | # asciicast v2 64 | asciinema cat tests/demo.cast 65 | # shellcheck disable=SC2002 66 | cat tests/demo.cast | asciinema cat - 67 | 68 | ## test rec command 69 | 70 | # normal program 71 | asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/1a.cast" 72 | grep '"o",' "${TMP_DATA_DIR}/1a.cast" 73 | 74 | # very quickly exiting program 75 | asciinema rec -c whoami "${TMP_DATA_DIR}/1b.cast" 76 | grep '"o",' "${TMP_DATA_DIR}/1b.cast" 77 | 78 | # signal handling 79 | bash -c "sleep 1; pkill -28 -n -f 'm asciinema'" & 80 | asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/2.cast" 81 | 82 | bash -c "sleep 1; pkill -n -f 'bash -c echo t3st'" & 83 | asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/3.cast" 84 | 85 | bash -c "sleep 1; pkill -9 -n -f 'bash -c echo t3st'" & 86 | asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/4.cast" 87 | 88 | # with stdin recording 89 | echo "ls" | asciinema rec --stdin -c 'bash -c "sleep 1"' "${TMP_DATA_DIR}/5.cast" 90 | cat "${TMP_DATA_DIR}/5.cast" 91 | grep '"i", "ls\\n"' "${TMP_DATA_DIR}/5.cast" 92 | grep '"o",' "${TMP_DATA_DIR}/5.cast" 93 | 94 | # raw output recording 95 | asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "${TMP_DATA_DIR}/6.raw" 96 | 97 | # appending to existing recording 98 | asciinema rec -c 'echo allright!; sleep 0.1' "${TMP_DATA_DIR}/7.cast" 99 | asciinema rec --append -c uptime "${TMP_DATA_DIR}/7.cast" 100 | 101 | # adding a marker 102 | printf "[record]\nadd_marker_key = C-b\n" >> "${ASCIINEMA_CONFIG_HOME}/config" 103 | (bash -c "sleep 1; printf '.'; sleep 0.5; printf '\x08'; sleep 0.5; printf '\x02'; sleep 0.5; printf '\x04'") | asciinema rec -c /bin/bash "${TMP_DATA_DIR}/8.cast" 104 | grep '"m",' "${TMP_DATA_DIR}/8.cast" 105 | --------------------------------------------------------------------------------