mirror of
https://github.com/asciinema/asciinema.git
synced 2025-12-14 18:57:59 +01:00
Merge branch 'develop' into patch-1
This commit is contained in:
40
.github/ISSUE_TEMPLATE/bug-report.md
vendored
40
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help improve asciinema CLI
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
To make life of the project maintainers easier please submit bug reports only.
|
||||
|
||||
This is a bug tracker for asciinema cli (aka recorder).
|
||||
If your issue seems to be with another component (js player, server) then open an issue in the related repository.
|
||||
If you're experiencing issue with asciinema server at asciinema.org, contact admin@asciinema.org.
|
||||
|
||||
Ideas, feature requests, help requests, questions and general discussions should be discussed on the forum: https://discourse.asciinema.org
|
||||
|
||||
If you think you've found a bug or regression, go ahead, delete this message, then fill in the details below.
|
||||
|
||||
-----
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Versions:**
|
||||
- OS: [e.g. macOS 12.6, Ubuntu 23.04]
|
||||
- asciinema cli: [e.g. 2.4.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
101
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
101
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
name: Bug Report
|
||||
description: Report a bug to help improve asciinema CLI
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**This is a bug tracker for asciinema CLI (the recorder).**
|
||||
|
||||
- If your issue is with the JavaScript player or server, please open an issue in the related repository
|
||||
- If you're experiencing issues with asciinema.org, contact admin@asciinema.org
|
||||
- For feature requests, questions, and discussions, use the [forum](https://discourse.asciinema.org) or [GitHub discussions](https://github.com/orgs/asciinema/discussions)
|
||||
|
||||
Thanks for taking the time to report a bug! Please fill out the sections below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Pre-submission checks
|
||||
description: Please confirm the following before submitting your bug report
|
||||
options:
|
||||
- label: I have searched existing issues and this bug has not been reported yet
|
||||
required: true
|
||||
- label: This is a bug report for asciinema CLI (not player or server)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Provide detailed steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Run command `asciinema ...`
|
||||
2. Do action '...'
|
||||
3. Observe error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: What should have happened instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Which OS and version are you using?
|
||||
placeholder: e.g., Ubuntu 24.04, macOS 14.0, Fedora 39
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: cli-version
|
||||
attributes:
|
||||
label: asciinema CLI Version
|
||||
description: What version of asciinema CLI are you using? Run `asciinema --version` to check.
|
||||
placeholder: e.g., 2.4.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: installation-method
|
||||
attributes:
|
||||
label: Installation Method
|
||||
description: How did you install asciinema CLI?
|
||||
options:
|
||||
- Package manager (apt, yum, brew, etc.)
|
||||
- pip/pipx
|
||||
- Built from source
|
||||
- Downloaded binary
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: terminal-info
|
||||
attributes:
|
||||
label: Terminal Information
|
||||
description: What terminal emulator and shell are you using?
|
||||
placeholder: |
|
||||
Terminal: e.g., GNOME Terminal, iTerm2, Ghostty
|
||||
Shell: e.g., bash 5.1, zsh 5.8, fish 3.6
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, or relevant information about the problem here.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Forum
|
||||
url: https://discourse.asciinema.org/
|
||||
about: Ideas, feature requests, help requests, questions and general discussions should be posted here.
|
||||
- name: GitHub discussions
|
||||
url: https://github.com/orgs/asciinema/discussions
|
||||
about: Ideas, feature requests, help requests, questions and general discussions should be posted here.
|
||||
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@@ -1,6 +1,13 @@
|
||||
---
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -11,19 +11,35 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
rust: [default, msrv]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Setup Nix cache
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ matrix.rust }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ matrix.rust }}-
|
||||
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
run: nix develop .#${{ matrix.rust }} --command cargo build --verbose
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- name: Run cargo tests
|
||||
run: nix develop .#${{ matrix.rust }} --command cargo test --verbose
|
||||
|
||||
- name: Run integration tests
|
||||
run: nix develop .#${{ matrix.rust }} --command tests/integration.sh
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
run: nix develop .#${{ matrix.rust }} --command cargo fmt --check
|
||||
|
||||
- name: Lint with clippy
|
||||
run: cargo clippy
|
||||
run: nix develop .#${{ matrix.rust }} --command cargo clippy
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Create the release
|
||||
env:
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
CARGO: cargo
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
104
CHANGELOG.md
104
CHANGELOG.md
@@ -1,12 +1,104 @@
|
||||
# asciinema changelog
|
||||
|
||||
## 3.0.0 (wip)
|
||||
## 3.0.0 (2025-09-15)
|
||||
|
||||
* Full rewrite in Rust
|
||||
* rec: `--append` can be used with `--raw` now
|
||||
* rec: use of `--append` and `--overwrite` together returns error now
|
||||
* rec: fixed saving of custom rec command in asciicast header
|
||||
* Improved error message when non-UTF-8 locale is detected
|
||||
This is a complete rewrite of asciinema in Rust, upgrading the recording file
|
||||
format, introducing terminal live streaming, and bringing numerous improvements
|
||||
across the board.
|
||||
|
||||
### New features
|
||||
|
||||
* New `stream` command for terminal live streaming, providing local mode
|
||||
(built-in HTTP server) and remote mode (relaying via asciinema server, more
|
||||
about it [here](https://docs.asciinema.org/manual/server/streaming/))
|
||||
* New `session` command for simultaneous recording and streaming
|
||||
* New `convert` command for format conversion between asciicast versions or
|
||||
exporting to plain text log / raw output
|
||||
* New [asciicast v3 file
|
||||
format](https://docs.asciinema.org/manual/asciicast/v3/) as the new default
|
||||
output format
|
||||
* Terminal theme capture - terminal colors are now automatically captured and
|
||||
saved in recordings
|
||||
* New output format - plain text log - use `.txt` extension for the output
|
||||
filename or select explicitly with `--output-format txt`
|
||||
* `rec`: New `--return` option for propagating session exit status
|
||||
* `rec`: Session exit status is now saved as "x" (exit) event in asciicast v3
|
||||
recordings
|
||||
* `rec`: Parent directories are automatically created when recording to
|
||||
non-existing paths
|
||||
* `rec`: Terminal version (XTVERSION OSC query) is now saved in asciicast
|
||||
header as `term.version`
|
||||
* `rec`: New `--headless` option for forcing headless mode
|
||||
* `rec`: New `--log-file` option for enabling logging, e.g. for troubleshooting
|
||||
I/O errors
|
||||
* `play`: New `--resize` option for enabling terminal auto-resize during playback (terminal support varies)
|
||||
* New `--server-url` option for setting custom server URL (for self-hosted
|
||||
servers), as an alternative to config file and `ASCIINEMA_SERVER_URL`
|
||||
environment variable
|
||||
* New system-wide configuration file `/etc/asciinema/config.toml` for setting
|
||||
defaults for all users
|
||||
* Command name prefix matching - you can use `asciinema r` instead of
|
||||
`asciinema rec`, `asciinema u` instead of `asciinema upload`, etc.
|
||||
* tmux status bar is now used for notifications when tmux session is detected
|
||||
and no other desktop notification mechanism is available
|
||||
|
||||
### Improvements
|
||||
|
||||
* Prompt for setting up default asciinema server URL on first use, unless one
|
||||
is configured upfront
|
||||
* Comprehensive `--help` messages (vs concise `-h`), with examples for each
|
||||
subcommand
|
||||
* Complete set of man pages and shell auto-completion files can be generated
|
||||
during build (see README.md)
|
||||
* `rec`: Fixed saving of custom record command (`--command`) value in asciicast
|
||||
header
|
||||
* `rec`: `--append` option can now be used with `--format raw`
|
||||
* `upload`: Recording file is validated for correctness/formatting before upload
|
||||
* Better error message when non-UTF-8 locale is detected
|
||||
|
||||
|
||||
### Breaking changes
|
||||
|
||||
* `rec`: Filename argument is now required, use explicit `upload` command for
|
||||
publishing a local recording
|
||||
* `rec`: Default output format is now asciicast v3 instead of v2 - asciinema
|
||||
server and player support it already, but be aware of this if you're using
|
||||
custom tooling - use `--output-format asciicast-v2` for backward compatibility
|
||||
* `rec`: `--stdin` option has been renamed to `--capture-input` / `-I` for
|
||||
clarity
|
||||
* `rec`: `--env` option has been renamed to `--capture-env`, short `-e` variant
|
||||
has been removed
|
||||
* `rec`: `--cols` and `--rows` options have been replaced with single
|
||||
`--window-size COLSxROWS` option
|
||||
* `rec`: `--raw` option has been removed, superceded by `--output-format raw`
|
||||
* `rec`: `--yes` / `-y` option has been removed since there's no upload
|
||||
confirmation anymore
|
||||
* `rec`: Using both `--append` and `--overwrite` options together now produces
|
||||
an immediate error instead of silently ignoring one option
|
||||
* `cat`: This command now concatenates multiple recordings instead of dumping
|
||||
raw output - use `convert --output-format raw` for 2.x behavior
|
||||
* `play`: `--out-fmt` and `--stream` options have been removed
|
||||
* User configuration file changed format from "ini-style" to TOML, and moved
|
||||
from `~/.config/asciinema/config` to `~/.config/asciinema/config.toml` - check
|
||||
[configuration docs](https://docs.asciinema.org/manual/cli/configuration/) for
|
||||
details
|
||||
* Removed built-in support for desktop notifications via terminal-notifier in
|
||||
favor of AppleScript on macOS
|
||||
|
||||
### Other changes
|
||||
|
||||
* Install ID location changed from `XDG_CONFIG_HOME/asciinema/install-id`
|
||||
(`$HOME/.config/asciinema`) to `XDG_STATE_HOME/asciinema/install-id`
|
||||
(`$HOME/.local/state/asciinema`) - for backward compatibility the previous
|
||||
location is still used if the file already exists there
|
||||
* `ASCIINEMA_REC` environment variable, which was set to `1` for sessions
|
||||
started with `asciinema rec`, has been superceded by `ASCIINEMA_SESSION`, which
|
||||
is set to a unique session ID by `rec`, `stream` and `session` commands - the
|
||||
original `ASCIINEMA_REC=1` is still set by `rec` command for backward
|
||||
compatibility
|
||||
* `ASCIINEMA_API_URL` environment variable has been superceded by
|
||||
`ASCIINEMA_SERVER_URL` for setting custom server URL - the original
|
||||
`ASCIINEMA_API_URL` still works but is deprecated
|
||||
|
||||
## 2.4.0 (2023-10-23)
|
||||
|
||||
|
||||
2232
Cargo.lock
generated
2232
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
84
Cargo.toml
84
Cargo.toml
@@ -1,56 +1,58 @@
|
||||
[package]
|
||||
name = "asciinema"
|
||||
version = "3.0.0-rc.3"
|
||||
version = "3.0.1"
|
||||
edition = "2021"
|
||||
authors = ["Marcin Kulik <m@ku1ik.com>"]
|
||||
homepage = "https://asciinema.org"
|
||||
repository = "https://github.com/asciinema/asciinema"
|
||||
description = "Terminal session recorder"
|
||||
license = "GPL-3.0"
|
||||
description = "Terminal session recorder, streamer, and player"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
# MSRV
|
||||
rust-version = "1.70.0"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
rust-version = "1.82.0"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.75"
|
||||
nix = { version = "0.27", features = [ "fs", "term", "process", "signal" ] }
|
||||
termion = "3.0.0"
|
||||
serde = { version = "1.0.189", features = ["derive"] }
|
||||
serde_json = "1.0.107"
|
||||
clap = { version = "4.4.7", features = ["derive"] }
|
||||
signal-hook = { version = "0.3.17", default-features = false }
|
||||
uuid = { version = "1.6.1", features = ["v4"] }
|
||||
reqwest = { version = "0.11.23", default-features = false, features = ["blocking", "rustls-tls", "multipart", "gzip", "json"] }
|
||||
rustyline = "13.0.0"
|
||||
config = { version = "0.14.0", default-features = false, features = ["toml", "ini"] }
|
||||
which = "6.0.0"
|
||||
tempfile = "3.9.0"
|
||||
scraper = { version = "0.19.0", default-features = false }
|
||||
avt = "0.11.0"
|
||||
axum = { version = "0.7.4", default-features = false, features = ["http1", "ws"] }
|
||||
tokio = { version = "1.35.1", features = ["full"] }
|
||||
futures-util = "0.3.30"
|
||||
tokio-stream = { version = "0.1.14", features = ["sync"] }
|
||||
rust-embed = "8.2.0"
|
||||
mime_guess = "2.0.4"
|
||||
tower-http = { version = "0.5.1", features = ["trace"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
rgb = "0.8.37"
|
||||
url = "2.5.0"
|
||||
tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-webpki-roots"] }
|
||||
sha2 = "0.10.8"
|
||||
tokio-util = "0.7.10"
|
||||
chrono = "0.4.38"
|
||||
hostname = "0.4.0"
|
||||
anyhow = "1.0"
|
||||
nix = { version = "0.30", features = ["fs", "term", "process", "signal", "poll"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
clap = { version = "4.0", features = ["derive", "wrap_help"] }
|
||||
signal-hook = { version = "0.3", default-features = false }
|
||||
uuid = { version = "1.6", features = ["v4"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-native-roots", "multipart", "gzip", "json", "stream"] }
|
||||
rustyline = { version = "17.0", default-features = false }
|
||||
config = { version = "0.15", default-features = false, features = ["toml"] }
|
||||
which = "8.0"
|
||||
tempfile = "3.23"
|
||||
avt = "0.16"
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "ws"] }
|
||||
tokio = { version = "1.40", features = ["rt-multi-thread", "net", "sync", "time", "fs", "process"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||
tokio-stream = { version = "0.1", default-features = false, features = ["sync", "time"] }
|
||||
rust-embed = "8.8"
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tracing = { version = "0.1", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.20", default-features = false, features = ["fmt", "env-filter"] }
|
||||
rgb = { version = "0.8", default-features = false }
|
||||
url = "2.5"
|
||||
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
rand = "0.9"
|
||||
async-trait = "0.1"
|
||||
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
|
||||
bytes = "1.0"
|
||||
|
||||
[build-dependencies]
|
||||
clap = { version = "4.4.7", features = ["derive"] }
|
||||
clap_complete = "4.4.10"
|
||||
clap_mangen = "0.2.19"
|
||||
url = "2.5.0"
|
||||
clap = { version = "4.0", features = ["derive", "wrap_help"] }
|
||||
clap_complete = "4.0"
|
||||
clap_mangen = "0.2"
|
||||
url = "2.5"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[features]
|
||||
macos-tty = []
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
ARG RUST_VERSION=1.70.0
|
||||
FROM rust:${RUST_VERSION}-bookworm as builder
|
||||
ARG RUST_VERSION=1.90.0
|
||||
FROM rust:${RUST_VERSION}-slim-trixie AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=bind,source=src,target=src \
|
||||
--mount=type=bind,source=assets,target=assets \
|
||||
--mount=type=bind,source=build.rs,target=build.rs \
|
||||
--mount=type=bind,source=Cargo.toml,target=Cargo.toml \
|
||||
--mount=type=bind,source=Cargo.lock,target=Cargo.lock \
|
||||
--mount=type=cache,target=/app/target/ \
|
||||
@@ -13,6 +15,6 @@ cargo build --locked --release
|
||||
cp ./target/release/asciinema /usr/local/bin/
|
||||
EOF
|
||||
|
||||
FROM debian:bookworm-slim as run
|
||||
FROM debian:trixie-slim AS run
|
||||
COPY --from=builder /usr/local/bin/asciinema /usr/local/bin
|
||||
ENTRYPOINT ["/usr/local/bin/asciinema"]
|
||||
|
||||
66
README.md
66
README.md
@@ -4,36 +4,39 @@
|
||||
[](https://raw.githubusercontent.com/asciinema/asciinema/master/LICENSE)
|
||||
|
||||
__asciinema__ (aka asciinema CLI or asciinema recorder) is a command-line tool
|
||||
for recording terminal sessions.
|
||||
for recording and live streaming terminal sessions.
|
||||
|
||||
Unlike typical _screen_ recording software, which records visual output of a
|
||||
screen into a heavyweight video files (`.mp4`, `.mov`), asciinema recorder runs
|
||||
screen into a heavyweight video files (`.mp4`, `.mov`), asciinema CLI runs
|
||||
_inside a terminal_, capturing terminal session output into a lightweight
|
||||
recording files in the
|
||||
[asciicast](https://docs.asciinema.org/manual/asciicast/v2/) format (`.cast`).
|
||||
[asciicast](https://docs.asciinema.org/manual/asciicast/v3/) format (`.cast`),
|
||||
or streaming it live to viewers in real-time.
|
||||
|
||||
The recordings can be replayed in a terminal, embedded on a web page with the
|
||||
[asciinema player](https://docs.asciinema.org/manual/player/), or published to
|
||||
an [asciinema server](https://docs.asciinema.org/manual/server/), such as
|
||||
[asciinema.org](https://asciinema.org), for further sharing.
|
||||
[asciinema.org](https://asciinema.org), for further sharing. Live streams allow
|
||||
viewers to watch terminal sessions as they happen.
|
||||
|
||||
asciinema runs on GNU/Linux, macOS and FreeBSD.
|
||||
|
||||
[](https://asciinema.org/a/85R4jTtjKVRIYXTcKCNq0vzYH?autoplay=1)
|
||||
|
||||
Notable features:
|
||||
|
||||
* [recording](https://docs.asciinema.org/manual/cli/usage/#asciinema-rec-filename)
|
||||
and
|
||||
[replaying](https://docs.asciinema.org/manual/cli/usage/#asciinema-play-filename)
|
||||
of sessions inside a terminal,
|
||||
* live streaming of terminal sessions, with local HTTP server mode, and a relay
|
||||
forwarding mode,
|
||||
* [light-weight recording
|
||||
format](https://docs.asciinema.org/manual/asciicast/v2/), which is highly
|
||||
- recording and replaying of sessions inside a terminal,
|
||||
- local and remote [live
|
||||
streaming](https://docs.asciinema.org/manual/cli/quick-start/#stream-a-terminal-session)
|
||||
of terminal sessions to multiple viewers in real-time,
|
||||
- [lightweight recording
|
||||
format](https://docs.asciinema.org/manual/asciicast/v3/), which is highly
|
||||
compressible (down to 15% of the original size e.g. with `zstd` or `gzip`),
|
||||
* integration with [asciinema
|
||||
- integration with [asciinema
|
||||
server](https://docs.asciinema.org/manual/server/), e.g.
|
||||
[asciinema.org](https://asciinema.org), for easy recording hosting.
|
||||
[asciinema.org](https://asciinema.org), for easy recording hosting and live
|
||||
streaming.
|
||||
|
||||
To record a session run this command in your shell:
|
||||
|
||||
@@ -44,13 +47,13 @@ asciinema rec demo.cast
|
||||
To stream a session via built-in HTTP server run:
|
||||
|
||||
```sh
|
||||
asciinema stream --serve
|
||||
asciinema stream -l
|
||||
```
|
||||
|
||||
To stream a session via a relay (asciinema server) run:
|
||||
|
||||
```sh
|
||||
asciinema stream --relay
|
||||
asciinema stream -r
|
||||
```
|
||||
|
||||
Check out the [Getting started
|
||||
@@ -60,12 +63,12 @@ overview.
|
||||
## Building
|
||||
|
||||
Building asciinema from source requires the [Rust](https://www.rust-lang.org/)
|
||||
compiler (1.70 or later), and the [Cargo package
|
||||
compiler (1.82 or later), and the [Cargo package
|
||||
manager](https://doc.rust-lang.org/cargo/). If they are not available via your
|
||||
system package manager then use [rustup](https://rustup.rs/).
|
||||
|
||||
To download the source code, build the asciinema binary, and install it in
|
||||
`$HOME/.cargo/bin` run:
|
||||
`$HOME/.cargo/bin` in one go run:
|
||||
|
||||
```sh
|
||||
cargo install --locked --git https://github.com/asciinema/asciinema
|
||||
@@ -82,9 +85,8 @@ cd asciinema
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
This produces the binary in _release mode_ (`--release`) at
|
||||
`target/release/asciinema`. You can just copy the binary to a directory in your
|
||||
`$PATH`.
|
||||
This produces the binary at `target/release/asciinema`. You can just copy the
|
||||
binary to a directory in your `$PATH`.
|
||||
|
||||
To generate man pages and shell completion files, set `ASCIINEMA_GEN_DIR` to the
|
||||
path where these artifacts should be stored. For example:
|
||||
@@ -97,27 +99,27 @@ The above command will build the binary and place the man pages in `/foo/man/`,
|
||||
and the shell completion files in the `/foo/completion/` directory.
|
||||
|
||||
> [!NOTE]
|
||||
> Windows is currently not supported. _(See [#467](https://github.com/asciinema/asciinema/issues/467))_ You can try [PowerSession](https://github.com/Watfaq/PowerSession-rs) instead.
|
||||
> Windows is currently not supported. See [#467](https://github.com/orgs/asciinema/discussions/278).
|
||||
> You can try [PowerSession](https://github.com/Watfaq/PowerSession-rs) instead.
|
||||
|
||||
## Development
|
||||
|
||||
This branch contains the next generation of the asciinema CLI, written in Rust
|
||||
([about the
|
||||
rewrite](https://discourse.asciinema.org/t/rust-rewrite-of-the-asciinema-cli/777)).
|
||||
It is still in a work-in-progress stage, so if you wish to propose any code
|
||||
changes, please first reach out to the team via
|
||||
[forum](https://discourse.asciinema.org/),
|
||||
All development happens on `develop` branch. This branch contains the current
|
||||
generation (3.x) of the asciinema CLI, written in Rust.
|
||||
|
||||
The previous generation (2.x), written in Python, can be found in the `python`
|
||||
branch.
|
||||
|
||||
If you wish to propose non-trivial code changes, please first reach out to the
|
||||
team via [forum](https://discourse.asciinema.org/),
|
||||
[Matrix](https://matrix.to/#/#asciinema:matrix.org) or
|
||||
[IRC](https://web.libera.chat/#asciinema).
|
||||
|
||||
The previous generation of the asciinema CLI, written in Python, can be found in
|
||||
the `main` branch.
|
||||
|
||||
## Donations
|
||||
|
||||
Sustainability of asciinema development relies on donations and sponsorships.
|
||||
|
||||
Please help the software project you use and love. Become a
|
||||
If you like the project then consider becoming a
|
||||
[supporter](https://docs.asciinema.org/donations/#individuals) or a [corporate
|
||||
sponsor](https://docs.asciinema.org/donations/#corporate-sponsorship).
|
||||
|
||||
|
||||
101
assets/asciinema-player.css
vendored
101
assets/asciinema-player.css
vendored
@@ -415,6 +415,7 @@ pre.ap-terminal.ap-cursor-on .ap-line .ap-cursor.ap-inverse {
|
||||
}
|
||||
pre.ap-terminal:not(.ap-blink) .ap-line .ap-blink {
|
||||
color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
pre.ap-terminal .ap-bright {
|
||||
font-weight: bold;
|
||||
@@ -450,7 +451,7 @@ div.ap-player div.ap-control-bar {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s linear;
|
||||
user-select: none;
|
||||
border-top: 2px solid color-mix(in oklab, black 33%, var(--term-color-background));
|
||||
border-top: 2px solid color-mix(in oklab, var(--term-color-background) 80%, var(--term-color-foreground));
|
||||
z-index: 30;
|
||||
}
|
||||
div.ap-player div.ap-control-bar * {
|
||||
@@ -459,13 +460,16 @@ div.ap-player div.ap-control-bar * {
|
||||
div.ap-control-bar svg.ap-icon path {
|
||||
fill: var(--term-color-foreground);
|
||||
}
|
||||
div.ap-control-bar span.ap-playback-button {
|
||||
div.ap-control-bar span.ap-button {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
height: 12px;
|
||||
}
|
||||
div.ap-control-bar span.ap-playback-button {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 10px;
|
||||
margin: 0 0 0 2px;
|
||||
}
|
||||
div.ap-control-bar span.ap-playback-button svg {
|
||||
height: 12px;
|
||||
@@ -531,13 +535,10 @@ div.ap-control-bar.ap-seekable .ap-progressbar .ap-bar {
|
||||
cursor: pointer;
|
||||
}
|
||||
div.ap-control-bar .ap-fullscreen-button {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 9px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin: 0 2px 0 4px;
|
||||
}
|
||||
div.ap-control-bar .ap-fullscreen-button svg {
|
||||
width: 14px;
|
||||
@@ -554,6 +555,33 @@ div.ap-control-bar .ap-fullscreen-button .ap-tooltip {
|
||||
left: initial;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-control-bar .ap-kbd-button {
|
||||
height: 14px;
|
||||
padding: 9px;
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
div.ap-control-bar .ap-kbd-button svg {
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
}
|
||||
div.ap-control-bar .ap-kbd-button .ap-tooltip {
|
||||
right: 5px;
|
||||
left: initial;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-control-bar .ap-speaker-button {
|
||||
width: 19px;
|
||||
padding: 6px 9px;
|
||||
margin: 0 0 0 4px;
|
||||
position: relative;
|
||||
}
|
||||
div.ap-control-bar .ap-speaker-button svg {
|
||||
width: 19px;
|
||||
}
|
||||
div.ap-control-bar .ap-speaker-button .ap-tooltip {
|
||||
left: -50%;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-wrapper.ap-hud .ap-control-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -653,6 +681,7 @@ span.ap-marker-container:hover span.ap-marker {
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button div span svg {
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button svg {
|
||||
filter: drop-shadow(0px 0px 5px rgba(0, 0, 0, 0.4));
|
||||
@@ -698,8 +727,6 @@ span.ap-marker-container:hover span.ap-marker {
|
||||
max-height: 85%;
|
||||
font-size: 18px;
|
||||
color: var(--term-color-foreground);
|
||||
background-color: var(--term-color-background);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -715,6 +742,9 @@ span.ap-marker-container:hover span.ap-marker {
|
||||
.ap-player .ap-overlay-help > div div {
|
||||
padding: calc(min(4cqw, 40px));
|
||||
font-size: calc(min(1.9cqw, 18px));
|
||||
background-color: var(--term-color-background);
|
||||
border: 1px solid color-mix(in oklab, var(--term-color-background) 90%, var(--term-color-foreground));
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div p {
|
||||
font-weight: bold;
|
||||
@@ -740,6 +770,36 @@ span.ap-marker-container:hover span.ap-marker {
|
||||
.ap-player .ap-overlay-error span {
|
||||
font-size: 8em;
|
||||
}
|
||||
.ap-player .slide-enter-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-enter-active.ap-was-playing {
|
||||
transition: top 0.2s ease-out, opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-exit-active {
|
||||
transition: top 0.2s ease-in, opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-enter {
|
||||
top: -50%;
|
||||
opacity: 0;
|
||||
}
|
||||
.ap-player .slide-enter-to {
|
||||
top: 0%;
|
||||
}
|
||||
.ap-player .slide-enter,
|
||||
.ap-player .slide-enter-to,
|
||||
.ap-player .slide-exit,
|
||||
.ap-player .slide-exit-to {
|
||||
bottom: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.ap-player .slide-exit {
|
||||
top: 0%;
|
||||
}
|
||||
.ap-player .slide-exit-to {
|
||||
top: -50%;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes ap-loader-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@@ -2350,3 +2410,26 @@ span.ap-marker-container:hover span.ap-marker {
|
||||
--term-color-14: #34e2e2;
|
||||
--term-color-15: #eeeeec;
|
||||
}
|
||||
/*
|
||||
Based on gruvbox: https://github.com/morhetz/gruvbox
|
||||
*/
|
||||
.asciinema-player-theme-gruvbox-dark {
|
||||
--term-color-foreground: #fbf1c7;
|
||||
--term-color-background: #282828;
|
||||
--term-color-0: #282828;
|
||||
--term-color-1: #cc241d;
|
||||
--term-color-2: #98971a;
|
||||
--term-color-3: #d79921;
|
||||
--term-color-4: #458588;
|
||||
--term-color-5: #b16286;
|
||||
--term-color-6: #689d6a;
|
||||
--term-color-7: #a89984;
|
||||
--term-color-8: #7c6f65;
|
||||
--term-color-9: #fb4934;
|
||||
--term-color-10: #b8bb26;
|
||||
--term-color-11: #fabd2f;
|
||||
--term-color-12: #83a598;
|
||||
--term-color-13: #d3869b;
|
||||
--term-color-14: #8ec07c;
|
||||
--term-color-15: #fbf1c7;
|
||||
}
|
||||
|
||||
2
assets/asciinema-player.min.js
vendored
2
assets/asciinema-player.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -5,6 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="asciinema-player.css">
|
||||
<style>
|
||||
:root {
|
||||
--term-color-background: black;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
@@ -17,8 +21,19 @@
|
||||
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
padding: 12pt;
|
||||
background-color: #222;
|
||||
padding: 40px;
|
||||
background-color: color-mix(in oklab, var(--term-color-background) 90%, black);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
div.ap-wrapper:fullscreen {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.ap-player {
|
||||
margin: auto 0px;
|
||||
opacity: 0;
|
||||
box-shadow: color-mix(in oklab, var(--term-color-background) 50%, black) 0px 0px 60px 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -56,6 +71,14 @@
|
||||
console.debug('initializing the player', { src, opts });
|
||||
|
||||
window.player = AsciinemaPlayer.create(src, document.body, opts);
|
||||
|
||||
window.player.addEventListener('reset', () => {
|
||||
const el = window.player.el.getElementsByClassName('ap-player')[0];
|
||||
const style = window.getComputedStyle(el);
|
||||
const color = style.getPropertyValue("--term-color-background");
|
||||
document.documentElement.style.setProperty('--term-color-background', color);
|
||||
el.style.opacity = 1;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
37
default.nix
37
default.nix
@@ -3,33 +3,30 @@
|
||||
stdenv,
|
||||
rust,
|
||||
makeRustPlatform,
|
||||
packageToml,
|
||||
version,
|
||||
libiconv,
|
||||
darwin,
|
||||
python3,
|
||||
}:
|
||||
(makeRustPlatform {
|
||||
cargo = rust;
|
||||
rustc = rust;
|
||||
})
|
||||
.buildRustPackage {
|
||||
pname = packageToml.name;
|
||||
inherit (packageToml) version;
|
||||
}).buildRustPackage
|
||||
{
|
||||
pname = "asciinema";
|
||||
inherit version;
|
||||
|
||||
src = builtins.path {
|
||||
path = ./.;
|
||||
inherit (packageToml) name;
|
||||
};
|
||||
src = builtins.path {
|
||||
path = ./.;
|
||||
name = "asciinema";
|
||||
};
|
||||
|
||||
dontUseCargoParallelTests = true;
|
||||
dontUseCargoParallelTests = true;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = [ rust ];
|
||||
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
buildInputs = lib.optional stdenv.isDarwin [
|
||||
libiconv
|
||||
];
|
||||
|
||||
nativeBuildInputs = [rust];
|
||||
buildInputs = lib.optional stdenv.isDarwin [
|
||||
libiconv
|
||||
darwin.apple_sdk.frameworks.Foundation
|
||||
];
|
||||
|
||||
nativeCheckInputs = [python3];
|
||||
}
|
||||
nativeCheckInputs = [ python3 ];
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Moved to [https://docs.asciinema.org/manual/asciicast/v1/](https://docs.asciinema.org/manual/asciicast/v1/).
|
||||
@@ -1 +0,0 @@
|
||||
Moved to [https://docs.asciinema.org/manual/asciicast/v2/](https://docs.asciinema.org/manual/asciicast/v2/).
|
||||
65
flake.lock
generated
65
flake.lock
generated
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-parts": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1727826117,
|
||||
"narHash": "sha256-K5ZLCyfO/Zj9mPFldf3iwS6oZStJcU4tSpiXTMYaaL0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "3d04084d54bedc3d6b8b736c70ef449225c361b1",
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1728018373,
|
||||
"narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=",
|
||||
"lastModified": 1750134718,
|
||||
"narHash": "sha256-v263g4GbxXv87hMXMCpjkIxd/viIF7p3JpJrwgKdNiI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "bc947f541ae55e999ffdb4013441347d83b00feb",
|
||||
"rev": "9e83b64f727c88a7711a2c463a7b16eedb69a84c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -34,25 +34,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1727825735,
|
||||
"narHash": "sha256-0xHYkMkeLVQAMa7gvkddbPqpxph+hDzdu1XdGPJR+Os=",
|
||||
"type": "tarball",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/fb192fec7cc7a4c26d51779e9bab07ce6fa5597a.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/fb192fec7cc7a4c26d51779e9bab07ce6fa5597a.tar.gz"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1718428119,
|
||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -64,7 +52,7 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
@@ -74,11 +62,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1728095260,
|
||||
"narHash": "sha256-X62hA5ivYLY5G5+mXI6l9eUDkgi6Wu/7QUrwXhJ09oo=",
|
||||
"lastModified": 1750387093,
|
||||
"narHash": "sha256-MgL1+yNVcSD6OlzSmKt5GS4RmAQnNCjckjgPC1hmMPg=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "d1d2532ab267cfe6e40dff73fbaf34436c406d26",
|
||||
"rev": "517e9871d182346b53bb7f23fed00810c14db396",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -86,6 +74,21 @@
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
66
flake.nix
66
flake.nix
@@ -2,41 +2,45 @@
|
||||
description = "Terminal session recorder";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = github:NixOS/nixpkgs/nixos-unstable;
|
||||
rust-overlay.url = github:oxalica/rust-overlay;
|
||||
flake-parts.url = github:hercules-ci/flake-parts;
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = inputs @ {
|
||||
flake-parts,
|
||||
rust-overlay,
|
||||
...
|
||||
}:
|
||||
flake-parts.lib.mkFlake {inherit inputs;} {
|
||||
systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin"];
|
||||
perSystem = {
|
||||
self',
|
||||
pkgs,
|
||||
system,
|
||||
...
|
||||
}: let
|
||||
packageToml = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package;
|
||||
in {
|
||||
formatter = pkgs.alejandra;
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
|
||||
_module.args = {
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [(import rust-overlay)];
|
||||
packageToml = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package;
|
||||
msrv = packageToml.rust-version;
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.callPackage ./default.nix {
|
||||
version = packageToml.version;
|
||||
rust = pkgs.rust-bin.stable.latest.minimal;
|
||||
};
|
||||
|
||||
devShells = pkgs.callPackages ./shell.nix {
|
||||
package = self.packages.${system}.default;
|
||||
|
||||
rust = {
|
||||
default = pkgs.rust-bin.stable.latest.minimal;
|
||||
msrv = pkgs.rust-bin.stable.${msrv}.minimal;
|
||||
};
|
||||
};
|
||||
|
||||
devShells = pkgs.callPackages ./shell.nix {inherit packageToml self';};
|
||||
|
||||
packages.default = pkgs.callPackage ./default.nix {
|
||||
inherit packageToml;
|
||||
rust = pkgs.rust-bin.stable.latest.minimal;
|
||||
};
|
||||
};
|
||||
};
|
||||
formatter = pkgs.nixfmt-tree;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
17
shell.nix
17
shell.nix
@@ -1,30 +1,31 @@
|
||||
{
|
||||
self',
|
||||
packageToml,
|
||||
rust-bin,
|
||||
package,
|
||||
shellcheck,
|
||||
mkShell,
|
||||
rust,
|
||||
}:
|
||||
let
|
||||
msrv = packageToml.rust-version;
|
||||
|
||||
mkDevShell =
|
||||
rust:
|
||||
mkShell {
|
||||
inputsFrom = [
|
||||
(self'.packages.default.override {
|
||||
(package.override {
|
||||
rust = rust.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
"clippy"
|
||||
];
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
packages = [ shellcheck ];
|
||||
|
||||
env.RUST_BACKTRACE = 1;
|
||||
};
|
||||
in
|
||||
{
|
||||
default = mkDevShell rust-bin.stable.latest.default;
|
||||
msrv = mkDevShell rust-bin.stable.${msrv}.default;
|
||||
default = mkDevShell rust.default;
|
||||
msrv = mkDevShell rust.msrv;
|
||||
}
|
||||
|
||||
421
src/alis.rs
Normal file
421
src/alis.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
// This module implements ALiS (asciinema live stream) protocol, which is an application level
|
||||
// protocol built on top of WebSocket binary messages, used by asciinema CLI, asciinema player and
|
||||
// asciinema server.
|
||||
|
||||
// See more at: https://docs.asciinema.org/manual/server/streaming/
|
||||
|
||||
use std::future;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{stream, Stream, StreamExt};
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use crate::leb128;
|
||||
use crate::stream::Event;
|
||||
|
||||
static MAGIC_STRING: &str = "ALiS\x01";
|
||||
|
||||
#[derive(Default)]
|
||||
struct EventSerializer(Duration);
|
||||
|
||||
pub fn stream<S: Stream<Item = Result<Event, BroadcastStreamRecvError>>>(
|
||||
stream: S,
|
||||
) -> impl Stream<Item = Result<Vec<u8>, BroadcastStreamRecvError>> {
|
||||
let header = stream::once(future::ready(Ok(MAGIC_STRING.into())));
|
||||
let mut serializer = EventSerializer::default();
|
||||
let events = stream.map(move |event| event.map(|event| serializer.serialize_event(event)));
|
||||
|
||||
header.chain(events)
|
||||
}
|
||||
|
||||
impl EventSerializer {
|
||||
fn serialize_event(&mut self, event: Event) -> Vec<u8> {
|
||||
use Event::*;
|
||||
|
||||
match event {
|
||||
Init(last_id, time, size, theme, init) => {
|
||||
let last_id_bytes = leb128::encode(last_id);
|
||||
let time_bytes = leb128::encode(time.as_micros() as u64);
|
||||
let cols_bytes = leb128::encode(size.0);
|
||||
let rows_bytes = leb128::encode(size.1);
|
||||
let init_len = init.len() as u32;
|
||||
let init_len_bytes = leb128::encode(init_len);
|
||||
|
||||
let mut msg = vec![0x01];
|
||||
msg.extend_from_slice(&last_id_bytes);
|
||||
msg.extend_from_slice(&time_bytes);
|
||||
msg.extend_from_slice(&cols_bytes);
|
||||
msg.extend_from_slice(&rows_bytes);
|
||||
|
||||
match theme {
|
||||
Some(theme) => {
|
||||
msg.push(16);
|
||||
msg.push(theme.fg.r);
|
||||
msg.push(theme.fg.g);
|
||||
msg.push(theme.fg.b);
|
||||
msg.push(theme.bg.r);
|
||||
msg.push(theme.bg.g);
|
||||
msg.push(theme.bg.b);
|
||||
|
||||
for color in &theme.palette {
|
||||
msg.push(color.r);
|
||||
msg.push(color.g);
|
||||
msg.push(color.b);
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
msg.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
msg.extend_from_slice(&init_len_bytes);
|
||||
msg.extend_from_slice(init.as_bytes());
|
||||
|
||||
self.0 = time;
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
Output(id, time, text) => {
|
||||
let id_bytes = leb128::encode(id);
|
||||
let time_bytes = leb128::encode(self.rel_time(time));
|
||||
let text_len = text.len() as u32;
|
||||
let text_len_bytes = leb128::encode(text_len);
|
||||
|
||||
let mut msg = vec![b'o'];
|
||||
msg.extend_from_slice(&id_bytes);
|
||||
msg.extend_from_slice(&time_bytes);
|
||||
msg.extend_from_slice(&text_len_bytes);
|
||||
msg.extend_from_slice(text.as_bytes());
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
Input(id, time, text) => {
|
||||
let id_bytes = leb128::encode(id);
|
||||
let time_bytes = leb128::encode(self.rel_time(time));
|
||||
let text_len = text.len() as u32;
|
||||
let text_len_bytes = leb128::encode(text_len);
|
||||
|
||||
let mut msg = vec![b'i'];
|
||||
msg.extend_from_slice(&id_bytes);
|
||||
msg.extend_from_slice(&time_bytes);
|
||||
msg.extend_from_slice(&text_len_bytes);
|
||||
msg.extend_from_slice(text.as_bytes());
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
Resize(id, time, size) => {
|
||||
let id_bytes = leb128::encode(id);
|
||||
let time_bytes = leb128::encode(self.rel_time(time));
|
||||
let cols_bytes = leb128::encode(size.0);
|
||||
let rows_bytes = leb128::encode(size.1);
|
||||
|
||||
let mut msg = vec![b'r'];
|
||||
msg.extend_from_slice(&id_bytes);
|
||||
msg.extend_from_slice(&time_bytes);
|
||||
msg.extend_from_slice(&cols_bytes);
|
||||
msg.extend_from_slice(&rows_bytes);
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
Marker(id, time, text) => {
|
||||
let id_bytes = leb128::encode(id);
|
||||
let time_bytes = leb128::encode(self.rel_time(time));
|
||||
let text_len = text.len() as u32;
|
||||
let text_len_bytes = leb128::encode(text_len);
|
||||
|
||||
let mut msg = vec![b'm'];
|
||||
msg.extend_from_slice(&id_bytes);
|
||||
msg.extend_from_slice(&time_bytes);
|
||||
msg.extend_from_slice(&text_len_bytes);
|
||||
msg.extend_from_slice(text.as_bytes());
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
Exit(id, time, status) => {
|
||||
let id_bytes = leb128::encode(id);
|
||||
let time_bytes = leb128::encode(self.rel_time(time));
|
||||
let status_bytes = leb128::encode(status.max(0) as u64);
|
||||
|
||||
let mut msg = vec![b'x'];
|
||||
msg.extend_from_slice(&id_bytes);
|
||||
msg.extend_from_slice(&time_bytes);
|
||||
msg.extend_from_slice(&status_bytes);
|
||||
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rel_time(&mut self, time: Duration) -> u64 {
|
||||
let time = time.max(self.0);
|
||||
let rel_time = time - self.0;
|
||||
self.0 = time;
|
||||
|
||||
rel_time.as_micros() as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rgb::RGB8;
|
||||
|
||||
use super::*;
|
||||
use crate::tty::{TtySize, TtyTheme};
|
||||
|
||||
#[test]
|
||||
fn test_serialize_init_with_theme_and_seed() {
|
||||
let mut serializer = EventSerializer(Duration::from_millis(0));
|
||||
|
||||
let theme = TtyTheme {
|
||||
fg: rgb(255, 255, 255),
|
||||
bg: rgb(0, 0, 0),
|
||||
palette: vec![
|
||||
rgb(0, 0, 0), // Black
|
||||
rgb(128, 0, 0), // Dark Red
|
||||
rgb(0, 128, 0), // Dark Green
|
||||
rgb(128, 128, 0), // Dark Yellow
|
||||
rgb(0, 0, 128), // Dark Blue
|
||||
rgb(128, 0, 128), // Dark Magenta
|
||||
rgb(0, 128, 128), // Dark Cyan
|
||||
rgb(192, 192, 192), // Light Gray
|
||||
rgb(128, 128, 128), // Dark Gray
|
||||
rgb(255, 0, 0), // Bright Red
|
||||
rgb(0, 255, 0), // Bright Green
|
||||
rgb(255, 255, 0), // Bright Yellow
|
||||
rgb(0, 0, 255), // Bright Blue
|
||||
rgb(255, 0, 255), // Bright Magenta
|
||||
rgb(0, 255, 255), // Bright Cyan
|
||||
rgb(255, 255, 255), // White
|
||||
],
|
||||
};
|
||||
|
||||
let event = Event::Init(
|
||||
42,
|
||||
Duration::from_micros(1000),
|
||||
TtySize(180, 24),
|
||||
Some(theme),
|
||||
"terminal seed".to_string(),
|
||||
);
|
||||
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let mut expected = vec![
|
||||
0x01, // Init event type
|
||||
0x2A, // id (42) in LEB128
|
||||
0xE8, 0x07, // time (1000) in LEB128
|
||||
0xB4, 0x01, // cols (180) in LEB128
|
||||
0x18, // rows (24) in LEB128
|
||||
16, // theme - 16 colors
|
||||
255, 255, 255, // foreground RGB
|
||||
0, 0, 0, // background RGB
|
||||
];
|
||||
|
||||
// Add palette colors (16 colors * 3 bytes each)
|
||||
expected.extend_from_slice(&[
|
||||
0, 0, 0, // Black
|
||||
128, 0, 0, // Dark Red
|
||||
0, 128, 0, // Dark Green
|
||||
128, 128, 0, // Dark Yellow
|
||||
0, 0, 128, // Dark Blue
|
||||
128, 0, 128, // Dark Magenta
|
||||
0, 128, 128, // Dark Cyan
|
||||
192, 192, 192, // Light Gray
|
||||
128, 128, 128, // Dark Gray
|
||||
255, 0, 0, // Bright Red
|
||||
0, 255, 0, // Bright Green
|
||||
255, 255, 0, // Bright Yellow
|
||||
0, 0, 255, // Bright Blue
|
||||
255, 0, 255, // Bright Magenta
|
||||
0, 255, 255, // Bright Cyan
|
||||
255, 255, 255, // White
|
||||
]);
|
||||
|
||||
expected.push(0x0D); // init string length (13)
|
||||
expected.extend_from_slice(b"terminal seed"); // init string
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_init_without_theme_nor_seed() {
|
||||
let mut serializer = EventSerializer::default();
|
||||
|
||||
let event = Event::Init(
|
||||
1,
|
||||
Duration::from_micros(500),
|
||||
TtySize(120, 130),
|
||||
None,
|
||||
"".to_string(),
|
||||
);
|
||||
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let expected = vec![
|
||||
0x01, // Init event type
|
||||
0x01, // id (1) in LEB128
|
||||
0xF4, 0x03, // relative time (500) in LEB128
|
||||
0x78, // cols (120) in LEB128
|
||||
0x82, 0x01, // rows (130) in LEB128
|
||||
0x00, // no theme flag
|
||||
0x00, // init string length (0) in LEB128
|
||||
];
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_output() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(1000));
|
||||
let event = Event::Output(5, Duration::from_micros(1200), "Hello 世界 🌍".to_string());
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let mut expected = vec![
|
||||
b'o', // Output event type
|
||||
0x05, // id (5) in LEB128
|
||||
0xC8, 0x01, // relative time (200) in LEB128
|
||||
0x11, // text length in bytes
|
||||
];
|
||||
|
||||
expected.extend_from_slice("Hello 世界 🌍".as_bytes()); // text bytes
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 1200); // Time updated to 1200
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_input() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(500));
|
||||
let event = Event::Input(1000000, Duration::from_micros(750), "x".to_string());
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let expected = vec![
|
||||
b'i', // Input event type
|
||||
0xC0, 0x84, 0x3D, // id (1000000) in LEB128
|
||||
0xFA, 0x01, // relative time (250) in LEB128
|
||||
0x01, // text length (1) in LEB128
|
||||
b'x', // text
|
||||
];
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_resize() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(2000));
|
||||
let event = Event::Resize(15, Duration::from_micros(2100), TtySize(180, 50));
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let expected = vec![
|
||||
b'r', // Resize event type
|
||||
0x0F, // id (15) in LEB128
|
||||
0x64, // relative time (100) in LEB128
|
||||
0xB4, 0x01, // cols (180) in LEB128
|
||||
0x32, // rows (50) in LEB128
|
||||
];
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 2100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_marker_with_label() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(3000));
|
||||
let event = Event::Marker(20, Duration::from_micros(3500), "checkpoint".to_string());
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let expected = vec![
|
||||
b'm', // Marker event type
|
||||
0x14, // id (20) in LEB128
|
||||
0xF4, 0x03, // relative time (500) in LEB128
|
||||
0x0A, // label length (10) in LEB128
|
||||
];
|
||||
let mut expected = expected;
|
||||
expected.extend_from_slice(b"checkpoint"); // label bytes
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 3500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_marker_without_label() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(3000));
|
||||
let event = Event::Marker(2, Duration::from_micros(3300), "".to_string());
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let expected = vec![
|
||||
b'm', // Marker event type
|
||||
0x02, // id (2) in LEB128
|
||||
0xAC, 0x02, // relative time (300) in LEB128
|
||||
0x00, // label length (0)
|
||||
];
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_exit_positive_status() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(4000));
|
||||
let event = Event::Exit(25, Duration::from_micros(4200), 0);
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let expected = vec![
|
||||
b'x', // Exit event type
|
||||
0x19, // id (25) in LEB128
|
||||
0xC8, 0x01, // relative time (200) in LEB128
|
||||
0x00, // status (0) in LEB128
|
||||
];
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 4200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_exit_negative_status() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(5000));
|
||||
let event = Event::Exit(30, Duration::from_micros(5300), -1);
|
||||
let bytes = serializer.serialize_event(event);
|
||||
|
||||
let expected = vec![
|
||||
b'x', // Exit event type
|
||||
0x1E, // id (30) in LEB128
|
||||
0xAC, 0x02, // relative time (300) in LEB128
|
||||
0x00, // status (clamped to 0) in LEB128
|
||||
];
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(serializer.0.as_micros(), 5300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subsequent_event_lower_time() {
|
||||
let mut serializer = EventSerializer(Duration::from_micros(1000));
|
||||
|
||||
// First event at time 1000
|
||||
let event1 = Event::Output(1, Duration::from_micros(1000), "first".to_string());
|
||||
let bytes1 = serializer.serialize_event(event1);
|
||||
|
||||
// Verify first event uses time 0 (1000 - 1000)
|
||||
assert_eq!(bytes1[2], 0x00); // relative time should be 0
|
||||
assert_eq!(serializer.0.as_micros(), 1000);
|
||||
|
||||
// Second event with lower timestamp (wraparound risk case)
|
||||
let event2 = Event::Output(2, Duration::from_micros(500), "second".to_string());
|
||||
let bytes2 = serializer.serialize_event(event2);
|
||||
|
||||
assert_eq!(bytes2[2], 0x00); // relative time should be 0
|
||||
assert_eq!(serializer.0.as_micros(), 1000); // Time should remain 1000 (not decrease)
|
||||
}
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> RGB8 {
|
||||
RGB8::new(r, g, b)
|
||||
}
|
||||
}
|
||||
201
src/api.rs
201
src/api.rs
@@ -1,80 +1,196 @@
|
||||
use crate::config::Config;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use reqwest::blocking::{multipart::Form, Client, RequestBuilder};
|
||||
use reqwest::header;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use reqwest::{header, Response};
|
||||
use reqwest::{multipart::Form, Client, RequestBuilder};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadAsciicastResponse {
|
||||
pub struct RecordingResponse {
|
||||
pub url: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetUserStreamResponse {
|
||||
pub struct StreamResponse {
|
||||
pub id: u64,
|
||||
pub ws_producer_url: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NotFoundResponse {
|
||||
reason: String,
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct StreamChangeset {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub live: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub term_type: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub term_version: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shell: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub env: Option<Option<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
pub fn get_auth_url(config: &Config) -> Result<Url> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ErrorResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub fn get_auth_url(config: &mut Config) -> Result<Url> {
|
||||
let mut url = config.get_server_url()?;
|
||||
url.set_path(&format!("connect/{}", config.get_install_id()?));
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn upload_asciicast(path: &str, config: &Config) -> Result<UploadAsciicastResponse> {
|
||||
pub async fn create_recording(path: &str, config: &mut Config) -> Result<RecordingResponse> {
|
||||
let server_url = &config.get_server_url()?;
|
||||
let install_id = config.get_install_id()?;
|
||||
let response = upload_request(server_url, path, install_id)?.send()?;
|
||||
|
||||
let response = create_recording_request(server_url, path, install_id)
|
||||
.await?
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().as_u16() == 413 {
|
||||
bail!("The size of the recording exceeds the server's configured limit");
|
||||
match response.json::<ErrorResponse>().await {
|
||||
Ok(json) => {
|
||||
bail!("{}", json.message);
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
bail!("The recording exceeds the server-configured size limit");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.error_for_status_ref()?;
|
||||
}
|
||||
|
||||
response.error_for_status_ref()?;
|
||||
|
||||
Ok(response.json::<UploadAsciicastResponse>()?)
|
||||
Ok(response.json::<RecordingResponse>().await?)
|
||||
}
|
||||
|
||||
fn upload_request(server_url: &Url, path: &str, install_id: String) -> Result<RequestBuilder> {
|
||||
async fn create_recording_request(
|
||||
server_url: &Url,
|
||||
path: &str,
|
||||
install_id: String,
|
||||
) -> Result<RequestBuilder> {
|
||||
let client = Client::new();
|
||||
let mut url = server_url.clone();
|
||||
url.set_path("api/asciicasts");
|
||||
let form = Form::new().file("asciicast", path)?;
|
||||
url.set_path("api/v1/recordings");
|
||||
let form = Form::new().file("file", path).await?;
|
||||
let builder = client.post(url).multipart(form);
|
||||
|
||||
Ok(client
|
||||
.post(url)
|
||||
.multipart(form)
|
||||
.basic_auth(get_username(), Some(install_id))
|
||||
.header(header::USER_AGENT, build_user_agent())
|
||||
.header(header::ACCEPT, "application/json"))
|
||||
Ok(add_headers(builder, &install_id))
|
||||
}
|
||||
|
||||
pub fn create_user_stream(stream_id: String, config: &Config) -> Result<GetUserStreamResponse> {
|
||||
pub async fn list_user_streams(prefix: &str, config: &mut Config) -> Result<Vec<StreamResponse>> {
|
||||
let server_url = config.get_server_url()?;
|
||||
let server_hostname = server_url.host().unwrap();
|
||||
let install_id = config.get_install_id()?;
|
||||
|
||||
let response = user_stream_request(&server_url, stream_id, install_id)
|
||||
let response = list_user_streams_request(&server_url, prefix, &install_id)
|
||||
.send()
|
||||
.context("cannot obtain stream producer endpoint")?;
|
||||
.await
|
||||
.context("cannot obtain stream producer endpoint - is the server down?")?;
|
||||
|
||||
parse_stream_response(response, &server_url).await
|
||||
}
|
||||
|
||||
fn list_user_streams_request(server_url: &Url, prefix: &str, install_id: &str) -> RequestBuilder {
|
||||
let client = Client::new();
|
||||
let mut url = server_url.clone();
|
||||
url.set_path("api/v1/user/streams");
|
||||
url.set_query(Some(&format!("prefix={prefix}&limit=10")));
|
||||
|
||||
add_headers(client.get(url), install_id)
|
||||
}
|
||||
|
||||
pub async fn create_stream(
|
||||
changeset: StreamChangeset,
|
||||
config: &mut Config,
|
||||
) -> Result<StreamResponse> {
|
||||
let server_url = config.get_server_url()?;
|
||||
let install_id = config.get_install_id()?;
|
||||
|
||||
let response = create_stream_request(&server_url, &install_id, changeset)
|
||||
.send()
|
||||
.await
|
||||
.context("cannot obtain stream producer endpoint - is the server down?")?;
|
||||
|
||||
parse_stream_response(response, &server_url).await
|
||||
}
|
||||
|
||||
fn create_stream_request(
|
||||
server_url: &Url,
|
||||
install_id: &str,
|
||||
changeset: StreamChangeset,
|
||||
) -> RequestBuilder {
|
||||
let client = Client::new();
|
||||
let mut url = server_url.clone();
|
||||
url.set_path("api/v1/streams");
|
||||
let builder = client.post(url);
|
||||
let builder = add_headers(builder, install_id);
|
||||
|
||||
builder.json(&changeset)
|
||||
}
|
||||
|
||||
pub async fn update_stream(
|
||||
stream_id: u64,
|
||||
changeset: StreamChangeset,
|
||||
config: &mut Config,
|
||||
) -> Result<StreamResponse> {
|
||||
let server_url = config.get_server_url()?;
|
||||
let install_id = config.get_install_id()?;
|
||||
|
||||
let response = update_stream_request(&server_url, &install_id, stream_id, changeset)
|
||||
.send()
|
||||
.await
|
||||
.context("cannot obtain stream producer endpoint - is the server down?")?;
|
||||
|
||||
parse_stream_response(response, &server_url).await
|
||||
}
|
||||
|
||||
fn update_stream_request(
|
||||
server_url: &Url,
|
||||
install_id: &str,
|
||||
stream_id: u64,
|
||||
changeset: StreamChangeset,
|
||||
) -> RequestBuilder {
|
||||
let client = Client::new();
|
||||
let mut url = server_url.clone();
|
||||
url.set_path(&format!("api/v1/streams/{stream_id}"));
|
||||
let builder = client.patch(url);
|
||||
let builder = add_headers(builder, install_id);
|
||||
|
||||
builder.json(&changeset)
|
||||
}
|
||||
|
||||
async fn parse_stream_response<T: DeserializeOwned>(
|
||||
response: Response,
|
||||
server_url: &Url,
|
||||
) -> Result<T> {
|
||||
let server_hostname = server_url.host().unwrap();
|
||||
|
||||
match response.status().as_u16() {
|
||||
401 => bail!(
|
||||
"this CLI hasn't been authenticated with {server_hostname} - run `ascinema auth` first"
|
||||
"this CLI hasn't been authenticated with {server_hostname} - run `asciinema auth` first"
|
||||
),
|
||||
|
||||
404 => match response.json::<NotFoundResponse>() {
|
||||
Ok(json) => bail!("{}", json.reason),
|
||||
404 => match response.json::<ErrorResponse>().await {
|
||||
Ok(json) => bail!("{}", json.message),
|
||||
Err(_) => bail!("{server_hostname} doesn't support streaming"),
|
||||
},
|
||||
|
||||
422 => match response.json::<ErrorResponse>().await {
|
||||
Ok(json) => bail!("{}", json.message),
|
||||
Err(_) => bail!("{server_hostname} doesn't support streaming"),
|
||||
},
|
||||
|
||||
@@ -83,23 +199,10 @@ pub fn create_user_stream(stream_id: String, config: &Config) -> Result<GetUserS
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GetUserStreamResponse>()
|
||||
.map_err(|e| e.into())
|
||||
response.json::<T>().await.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
fn user_stream_request(server_url: &Url, stream_id: String, install_id: String) -> RequestBuilder {
|
||||
let client = Client::new();
|
||||
let mut url = server_url.clone();
|
||||
|
||||
let builder = if stream_id.is_empty() {
|
||||
url.set_path("api/streams");
|
||||
client.post(url)
|
||||
} else {
|
||||
url.set_path(&format!("api/user/streams/{stream_id}"));
|
||||
client.get(url)
|
||||
};
|
||||
|
||||
fn add_headers(builder: RequestBuilder, install_id: &str) -> RequestBuilder {
|
||||
builder
|
||||
.basic_auth(get_username(), Some(install_id))
|
||||
.header(header::USER_AGENT, build_user_agent())
|
||||
@@ -110,7 +213,7 @@ fn get_username() -> String {
|
||||
env::var("USER").unwrap_or("".to_owned())
|
||||
}
|
||||
|
||||
fn build_user_agent() -> String {
|
||||
pub fn build_user_agent() -> String {
|
||||
let ua = concat!(
|
||||
"asciinema/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
|
||||
494
src/asciicast.rs
494
src/asciicast.rs
@@ -1,33 +1,49 @@
|
||||
mod util;
|
||||
mod v1;
|
||||
mod v2;
|
||||
use crate::tty;
|
||||
use anyhow::{anyhow, Result};
|
||||
mod v3;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead};
|
||||
use std::path::Path;
|
||||
pub use v2::Writer;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::tty::TtyTheme;
|
||||
pub use v2::V2Encoder;
|
||||
pub use v3::V3Encoder;
|
||||
|
||||
pub struct Asciicast<'a> {
|
||||
pub version: Version,
|
||||
pub header: Header,
|
||||
pub events: Box<dyn Iterator<Item = Result<Event>> + 'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum Version {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
}
|
||||
|
||||
pub struct Header {
|
||||
pub version: u8,
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
pub term_cols: u16,
|
||||
pub term_rows: u16,
|
||||
pub term_type: Option<String>,
|
||||
pub term_version: Option<String>,
|
||||
pub term_theme: Option<TtyTheme>,
|
||||
pub timestamp: Option<u64>,
|
||||
pub idle_time_limit: Option<f64>,
|
||||
pub command: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
pub theme: Option<tty::Theme>,
|
||||
}
|
||||
|
||||
pub struct Event {
|
||||
pub time: u64,
|
||||
pub time: Duration,
|
||||
pub data: EventData,
|
||||
}
|
||||
|
||||
@@ -36,76 +52,149 @@ pub enum EventData {
|
||||
Input(String),
|
||||
Resize(u16, u16),
|
||||
Marker(String),
|
||||
Exit(i32),
|
||||
Other(char, String),
|
||||
}
|
||||
|
||||
pub trait Encoder {
|
||||
fn header(&mut self, header: &Header) -> Vec<u8>;
|
||||
fn event(&mut self, event: &Event) -> Vec<u8>;
|
||||
}
|
||||
|
||||
impl PartialEq<u8> for Version {
|
||||
fn eq(&self, other: &u8) -> bool {
|
||||
matches!(
|
||||
(self, other),
|
||||
(Version::One, 1) | (Version::Two, 2) | (Version::Three, 3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Version {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Version::One => write!(f, "1"),
|
||||
Version::Two => write!(f, "2"),
|
||||
Version::Three => write!(f, "3"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Header {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
term_cols: 80,
|
||||
term_rows: 24,
|
||||
term_type: None,
|
||||
term_version: None,
|
||||
term_theme: None,
|
||||
timestamp: None,
|
||||
idle_time_limit: None,
|
||||
command: None,
|
||||
title: None,
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder for V2Encoder {
|
||||
fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
self.header(header)
|
||||
}
|
||||
|
||||
fn event(&mut self, event: &Event) -> Vec<u8> {
|
||||
self.event(event)
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder for V3Encoder {
|
||||
fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
self.header(header)
|
||||
}
|
||||
|
||||
fn event(&mut self, event: &Event) -> Vec<u8> {
|
||||
self.event(event)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_from_path<S: AsRef<Path>>(path: S) -> Result<Asciicast<'static>> {
|
||||
fs::File::open(path)
|
||||
fs::File::open(&path)
|
||||
.map(io::BufReader::new)
|
||||
.map_err(|e| anyhow!(e))
|
||||
.and_then(open)
|
||||
.map_err(|e| anyhow!("can't open asciicast file: {e}"))
|
||||
.map_err(|e| anyhow!("can't open {}: {}", path.as_ref().to_string_lossy(), e))
|
||||
}
|
||||
|
||||
pub fn open<'a, R: BufRead + 'a>(reader: R) -> Result<Asciicast<'a>> {
|
||||
let mut lines = reader.lines();
|
||||
let first_line = lines.next().ok_or(anyhow!("empty file"))??;
|
||||
|
||||
if let Ok(parser) = v2::open(&first_line) {
|
||||
if let Ok(parser) = v3::open(&first_line) {
|
||||
Ok(parser.parse(lines))
|
||||
} else if let Ok(parser) = v2::open(&first_line) {
|
||||
Ok(parser.parse(lines))
|
||||
} else {
|
||||
let json = std::iter::once(Ok(first_line))
|
||||
.chain(lines)
|
||||
.collect::<io::Result<String>>()?;
|
||||
|
||||
v1::load(json)
|
||||
v1::load(json).map_err(|_| anyhow!("not a v1, v2, v3 asciicast file"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_duration<S: AsRef<Path>>(path: S) -> Result<u64> {
|
||||
pub fn get_duration<S: AsRef<Path>>(path: S) -> Result<Duration> {
|
||||
let Asciicast { events, .. } = open_from_path(path)?;
|
||||
let time = events.last().map_or(Ok(0), |e| e.map(|e| e.time))?;
|
||||
let time = events
|
||||
.last()
|
||||
.map_or(Ok(Duration::from_micros(0)), |e| e.map(|e| e.time))?;
|
||||
|
||||
Ok(time)
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn output(time: u64, text: String) -> Self {
|
||||
pub fn output(time: Duration, text: String) -> Self {
|
||||
Event {
|
||||
time,
|
||||
data: EventData::Output(text),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input(time: u64, text: String) -> Self {
|
||||
pub fn input(time: Duration, text: String) -> Self {
|
||||
Event {
|
||||
time,
|
||||
data: EventData::Input(text),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(time: u64, size: (u16, u16)) -> Self {
|
||||
pub fn resize(time: Duration, size: (u16, u16)) -> Self {
|
||||
Event {
|
||||
time,
|
||||
data: EventData::Resize(size.0, size.1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn marker(time: u64, label: String) -> Self {
|
||||
pub fn marker(time: Duration, label: String) -> Self {
|
||||
Event {
|
||||
time,
|
||||
data: EventData::Marker(label),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit(time: Duration, status: i32) -> Self {
|
||||
Event {
|
||||
time,
|
||||
data: EventData::Exit(status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limit_idle_time(
|
||||
events: impl Iterator<Item = Result<Event>>,
|
||||
limit: f64,
|
||||
) -> impl Iterator<Item = Result<Event>> {
|
||||
let limit = (limit * 1_000_000.0) as u64;
|
||||
let mut prev_time = 0;
|
||||
let mut offset = 0;
|
||||
let limit = Duration::from_micros((limit * 1_000_000.0) as u64);
|
||||
let mut prev_time = Duration::from_micros(0);
|
||||
let mut offset = Duration::from_micros(0);
|
||||
|
||||
events.map(move |event| {
|
||||
event.map(|event| {
|
||||
@@ -129,136 +218,239 @@ pub fn accelerate(
|
||||
) -> impl Iterator<Item = Result<Event>> {
|
||||
events.map(move |event| {
|
||||
event.map(|event| {
|
||||
let time = ((event.time as f64) / speed) as u64;
|
||||
let time = event.time.div_f64(speed);
|
||||
|
||||
Event { time, ..event }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encoder(version: Version) -> Option<Box<dyn Encoder>> {
|
||||
match version {
|
||||
Version::One => None,
|
||||
Version::Two => Some(Box::new(V2Encoder::new(Duration::from_micros(0)))),
|
||||
Version::Three => Some(Box::new(V3Encoder::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Asciicast, Event, EventData, Header, Writer};
|
||||
use crate::tty;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use rgb::RGB8;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
|
||||
use super::{Asciicast, Event, EventData, Header, V2Encoder};
|
||||
use crate::tty::TtyTheme;
|
||||
|
||||
#[test]
|
||||
fn open_v1_minimal() {
|
||||
let Asciicast { header, events } =
|
||||
super::open_from_path("tests/casts/minimal.json").unwrap();
|
||||
let Asciicast {
|
||||
version,
|
||||
header,
|
||||
events,
|
||||
} = super::open_from_path("tests/casts/minimal-v1.json").unwrap();
|
||||
|
||||
let events = events.collect::<Result<Vec<Event>>>().unwrap();
|
||||
|
||||
assert_eq!(header.version, 1);
|
||||
assert_eq!((header.cols, header.rows), (100, 50));
|
||||
assert!(header.theme.is_none());
|
||||
assert_eq!(version, 1);
|
||||
assert_eq!((header.term_cols, header.term_rows), (100, 50));
|
||||
assert!(header.term_theme.is_none());
|
||||
|
||||
assert_eq!(events[0].time, 1230000);
|
||||
assert_eq!(events[0].time, Duration::from_micros(1230000));
|
||||
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_v1_full() {
|
||||
let Asciicast { header, events } = super::open_from_path("tests/casts/full.json").unwrap();
|
||||
let Asciicast {
|
||||
version,
|
||||
header,
|
||||
events,
|
||||
} = super::open_from_path("tests/casts/full-v1.json").unwrap();
|
||||
let events = events.collect::<Result<Vec<Event>>>().unwrap();
|
||||
|
||||
assert_eq!(header.version, 1);
|
||||
assert_eq!((header.cols, header.rows), (100, 50));
|
||||
assert_eq!(version, 1);
|
||||
assert_eq!((header.term_cols, header.term_rows), (100, 50));
|
||||
|
||||
assert_eq!(events[0].time, 1);
|
||||
let mut expected_env = HashMap::new();
|
||||
expected_env.insert("SHELL".to_owned(), "/bin/bash".to_owned());
|
||||
expected_env.insert("TERM".to_owned(), "xterm-256color".to_owned());
|
||||
assert_eq!(header.env.unwrap(), expected_env);
|
||||
|
||||
assert_eq!(events[0].time, Duration::from_micros(1));
|
||||
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "ż"));
|
||||
|
||||
assert_eq!(events[1].time, 1000000);
|
||||
assert_eq!(events[1].time, Duration::from_micros(10000001));
|
||||
assert!(matches!(events[1].data, EventData::Output(ref s) if s == "ółć"));
|
||||
|
||||
assert_eq!(events[2].time, 10500000);
|
||||
assert_eq!(events[2].time, Duration::from_micros(10500001));
|
||||
assert!(matches!(events[2].data, EventData::Output(ref s) if s == "\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_v1_with_nulls_in_header() {
|
||||
let Asciicast {
|
||||
version, header, ..
|
||||
} = super::open_from_path("tests/casts/nulls-v1.json").unwrap();
|
||||
assert_eq!(version, 1);
|
||||
|
||||
let mut expected_env = HashMap::new();
|
||||
expected_env.insert("SHELL".to_owned(), "/bin/bash".to_owned());
|
||||
assert_eq!(header.env.unwrap(), expected_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_v2_minimal() {
|
||||
let Asciicast { header, events } =
|
||||
super::open_from_path("tests/casts/minimal.cast").unwrap();
|
||||
let Asciicast {
|
||||
version,
|
||||
header,
|
||||
events,
|
||||
} = super::open_from_path("tests/casts/minimal-v2.cast").unwrap();
|
||||
|
||||
let events = events.collect::<Result<Vec<Event>>>().unwrap();
|
||||
|
||||
assert_eq!((header.cols, header.rows), (100, 50));
|
||||
assert!(header.theme.is_none());
|
||||
assert_eq!(version, 2);
|
||||
assert_eq!((header.term_cols, header.term_rows), (100, 50));
|
||||
assert!(header.term_theme.is_none());
|
||||
|
||||
assert_eq!(events[0].time, 1230000);
|
||||
assert_eq!(events[0].time, Duration::from_micros(1230000));
|
||||
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_v2_full() {
|
||||
let Asciicast { header, events } = super::open_from_path("tests/casts/full.cast").unwrap();
|
||||
let Asciicast {
|
||||
version,
|
||||
header,
|
||||
events,
|
||||
} = super::open_from_path("tests/casts/full-v2.cast").unwrap();
|
||||
let events = events.take(5).collect::<Result<Vec<Event>>>().unwrap();
|
||||
let theme = header.theme.unwrap();
|
||||
let theme = header.term_theme.unwrap();
|
||||
|
||||
assert_eq!((header.cols, header.rows), (100, 50));
|
||||
assert_eq!(version, 2);
|
||||
assert_eq!((header.term_cols, header.term_rows), (100, 50));
|
||||
assert_eq!(header.timestamp, Some(1509091818));
|
||||
assert_eq!(theme.fg, RGB8::new(0, 0, 0));
|
||||
assert_eq!(theme.bg, RGB8::new(0xff, 0xff, 0xff));
|
||||
assert_eq!(theme.palette[0], RGB8::new(0x24, 0x1f, 0x31));
|
||||
|
||||
assert_eq!(events[0].time, 1);
|
||||
let mut expected_env = HashMap::new();
|
||||
expected_env.insert("SHELL".to_owned(), "/bin/bash".to_owned());
|
||||
expected_env.insert("TERM".to_owned(), "xterm-256color".to_owned());
|
||||
assert_eq!(header.env.unwrap(), expected_env);
|
||||
|
||||
assert_eq!(events[0].time, Duration::from_micros(1));
|
||||
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "ż"));
|
||||
|
||||
assert_eq!(events[1].time, 1_000_000);
|
||||
assert_eq!(events[1].time, Duration::from_micros(1_000_000));
|
||||
assert!(matches!(events[1].data, EventData::Output(ref s) if s == "ółć"));
|
||||
|
||||
assert_eq!(events[2].time, 2_300_000);
|
||||
assert_eq!(events[2].time, Duration::from_micros(2_300_000));
|
||||
assert!(matches!(events[2].data, EventData::Input(ref s) if s == "\n"));
|
||||
|
||||
assert_eq!(events[3].time, 5_600_001);
|
||||
assert_eq!(events[3].time, Duration::from_micros(5_600_001));
|
||||
|
||||
assert!(
|
||||
matches!(events[3].data, EventData::Resize(ref cols, ref rows) if *cols == 80 && *rows == 40)
|
||||
);
|
||||
|
||||
assert_eq!(events[4].time, 10_500_000);
|
||||
assert_eq!(events[4].time, Duration::from_micros(10_500_000));
|
||||
assert!(matches!(events[4].data, EventData::Output(ref s) if s == "\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer() {
|
||||
fn open_v2_with_nulls_in_header() {
|
||||
let Asciicast {
|
||||
version, header, ..
|
||||
} = super::open_from_path("tests/casts/nulls-v2.cast").unwrap();
|
||||
assert_eq!(version, 2);
|
||||
|
||||
let mut expected_env = HashMap::new();
|
||||
expected_env.insert("SHELL".to_owned(), "/bin/bash".to_owned());
|
||||
assert_eq!(header.env.unwrap(), expected_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_v3_minimal() {
|
||||
let Asciicast {
|
||||
version,
|
||||
header,
|
||||
events,
|
||||
} = super::open_from_path("tests/casts/minimal-v3.cast").unwrap();
|
||||
|
||||
let events = events.collect::<Result<Vec<Event>>>().unwrap();
|
||||
|
||||
assert_eq!(version, 3);
|
||||
assert_eq!((header.term_cols, header.term_rows), (100, 50));
|
||||
assert!(header.term_theme.is_none());
|
||||
|
||||
assert_eq!(events[0].time, Duration::from_micros(1230000));
|
||||
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_v3_full() {
|
||||
let Asciicast {
|
||||
version,
|
||||
header,
|
||||
events,
|
||||
} = super::open_from_path("tests/casts/full-v3.cast").unwrap();
|
||||
let events = events.take(5).collect::<Result<Vec<Event>>>().unwrap();
|
||||
let theme = header.term_theme.unwrap();
|
||||
|
||||
assert_eq!(version, 3);
|
||||
assert_eq!((header.term_cols, header.term_rows), (100, 50));
|
||||
assert_eq!(header.timestamp, Some(1509091818));
|
||||
assert_eq!(theme.fg, RGB8::new(0, 0, 0));
|
||||
assert_eq!(theme.bg, RGB8::new(0xff, 0xff, 0xff));
|
||||
assert_eq!(theme.palette[0], RGB8::new(0x24, 0x1f, 0x31));
|
||||
|
||||
assert_eq!(events[0].time, Duration::from_micros(1));
|
||||
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "ż"));
|
||||
|
||||
assert_eq!(events[1].time, Duration::from_micros(1_000_001));
|
||||
assert!(matches!(events[1].data, EventData::Output(ref s) if s == "ółć"));
|
||||
|
||||
assert_eq!(events[2].time, Duration::from_micros(1_300_001));
|
||||
assert!(matches!(events[2].data, EventData::Input(ref s) if s == "\n"));
|
||||
|
||||
assert_eq!(events[3].time, Duration::from_micros(2_900_002));
|
||||
|
||||
assert!(
|
||||
matches!(events[3].data, EventData::Resize(ref cols, ref rows) if *cols == 80 && *rows == 40)
|
||||
);
|
||||
|
||||
assert_eq!(events[4].time, Duration::from_micros(13_400_002));
|
||||
assert!(matches!(events[4].data, EventData::Output(ref s) if s == "\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder() {
|
||||
let mut data = Vec::new();
|
||||
let header = Header::default();
|
||||
let mut enc = V2Encoder::new(Duration::from_micros(0));
|
||||
data.extend(enc.header(&header));
|
||||
data.extend(enc.event(&Event::output(
|
||||
Duration::from_micros(1000000),
|
||||
"hello\r\n".to_owned(),
|
||||
)));
|
||||
|
||||
{
|
||||
let mut fw = Writer::new(&mut data, 0);
|
||||
|
||||
let header = Header {
|
||||
version: 2,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
timestamp: None,
|
||||
idle_time_limit: None,
|
||||
command: None,
|
||||
title: None,
|
||||
env: Default::default(),
|
||||
theme: None,
|
||||
};
|
||||
|
||||
fw.write_header(&header).unwrap();
|
||||
|
||||
fw.write_event(&Event::output(1000001, "hello\r\n".to_owned()))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let mut fw = Writer::new(&mut data, 1000001);
|
||||
|
||||
fw.write_event(&Event::output(1000001, "world".to_owned()))
|
||||
.unwrap();
|
||||
|
||||
fw.write_event(&Event::input(2000002, " ".to_owned()))
|
||||
.unwrap();
|
||||
|
||||
fw.write_event(&Event::resize(3000003, (100, 40))).unwrap();
|
||||
|
||||
fw.write_event(&Event::output(4000004, "żółć".to_owned()))
|
||||
.unwrap();
|
||||
}
|
||||
let mut enc = V2Encoder::new(Duration::from_micros(1000001));
|
||||
data.extend(enc.event(&Event::output(
|
||||
Duration::from_micros(1000001),
|
||||
"world".to_owned(),
|
||||
)));
|
||||
data.extend(enc.event(&Event::input(
|
||||
Duration::from_micros(2000002),
|
||||
" ".to_owned(),
|
||||
)));
|
||||
data.extend(enc.event(&Event::resize(Duration::from_micros(3000003), (100, 40))));
|
||||
data.extend(enc.event(&Event::output(
|
||||
Duration::from_micros(4000004),
|
||||
"żółć".to_owned(),
|
||||
)));
|
||||
|
||||
let lines = parse(data);
|
||||
|
||||
@@ -266,7 +458,7 @@ mod tests {
|
||||
assert_eq!(lines[0]["width"], 80);
|
||||
assert_eq!(lines[0]["height"], 24);
|
||||
assert!(lines[0]["timestamp"].is_null());
|
||||
assert_eq!(lines[1][0], 1.000001);
|
||||
assert_eq!(lines[1][0], 1.000000);
|
||||
assert_eq!(lines[1][1], "o");
|
||||
assert_eq!(lines[1][2], "hello\r\n");
|
||||
assert_eq!(lines[2][0], 2.000002);
|
||||
@@ -284,53 +476,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_header() {
|
||||
let mut data = Vec::new();
|
||||
fn header_encoding() {
|
||||
let mut enc = V2Encoder::new(Duration::from_micros(0));
|
||||
let mut env = HashMap::new();
|
||||
env.insert("SHELL".to_owned(), "/usr/bin/fish".to_owned());
|
||||
env.insert("TERM".to_owned(), "xterm256-color".to_owned());
|
||||
|
||||
{
|
||||
let mut fw = Writer::new(io::Cursor::new(&mut data), 0);
|
||||
let mut env = HashMap::new();
|
||||
env.insert("SHELL".to_owned(), "/usr/bin/fish".to_owned());
|
||||
env.insert("TERM".to_owned(), "xterm256-color".to_owned());
|
||||
let tty_theme = TtyTheme {
|
||||
fg: RGB8::new(0, 1, 2),
|
||||
bg: RGB8::new(0, 100, 200),
|
||||
palette: vec![
|
||||
RGB8::new(0, 0, 0),
|
||||
RGB8::new(10, 11, 12),
|
||||
RGB8::new(20, 21, 22),
|
||||
RGB8::new(30, 31, 32),
|
||||
RGB8::new(40, 41, 42),
|
||||
RGB8::new(50, 51, 52),
|
||||
RGB8::new(60, 61, 62),
|
||||
RGB8::new(70, 71, 72),
|
||||
RGB8::new(80, 81, 82),
|
||||
RGB8::new(90, 91, 92),
|
||||
RGB8::new(100, 101, 102),
|
||||
RGB8::new(110, 111, 112),
|
||||
RGB8::new(120, 121, 122),
|
||||
RGB8::new(130, 131, 132),
|
||||
RGB8::new(140, 141, 142),
|
||||
RGB8::new(150, 151, 152),
|
||||
],
|
||||
};
|
||||
|
||||
let theme = tty::Theme {
|
||||
fg: RGB8::new(0, 1, 2),
|
||||
bg: RGB8::new(0, 100, 200),
|
||||
palette: vec![
|
||||
RGB8::new(0, 0, 0),
|
||||
RGB8::new(10, 11, 12),
|
||||
RGB8::new(20, 21, 22),
|
||||
RGB8::new(30, 31, 32),
|
||||
RGB8::new(40, 41, 42),
|
||||
RGB8::new(50, 51, 52),
|
||||
RGB8::new(60, 61, 62),
|
||||
RGB8::new(70, 71, 72),
|
||||
RGB8::new(80, 81, 82),
|
||||
RGB8::new(90, 91, 92),
|
||||
RGB8::new(100, 101, 102),
|
||||
RGB8::new(110, 111, 112),
|
||||
RGB8::new(120, 121, 122),
|
||||
RGB8::new(130, 131, 132),
|
||||
RGB8::new(140, 141, 142),
|
||||
RGB8::new(150, 151, 152),
|
||||
],
|
||||
};
|
||||
|
||||
let header = Header {
|
||||
version: 2,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
timestamp: Some(1704719152),
|
||||
idle_time_limit: Some(1.5),
|
||||
command: Some("/bin/bash".to_owned()),
|
||||
title: Some("Demo".to_owned()),
|
||||
env: Some(env),
|
||||
theme: Some(theme),
|
||||
};
|
||||
|
||||
fw.write_header(&header).unwrap();
|
||||
}
|
||||
let header = Header {
|
||||
timestamp: Some(1704719152),
|
||||
idle_time_limit: Some(1.5),
|
||||
command: Some("/bin/bash".to_owned()),
|
||||
title: Some("Demo".to_owned()),
|
||||
env: Some(env),
|
||||
term_theme: Some(tty_theme),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let data = enc.header(&header);
|
||||
let lines = parse(data);
|
||||
|
||||
assert_eq!(lines[0]["version"], 2);
|
||||
@@ -360,14 +545,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn accelerate() {
|
||||
let events = [(0u64, "foo"), (20, "bar"), (50, "baz")]
|
||||
.map(|(time, output)| Ok(Event::output(time, output.to_owned())));
|
||||
let events = [(0u64, "foo"), (20, "bar"), (50, "baz")].map(|(time, output)| {
|
||||
Ok(Event::output(
|
||||
Duration::from_micros(time),
|
||||
output.to_owned(),
|
||||
))
|
||||
});
|
||||
|
||||
let output = output(super::accelerate(events.into_iter(), 2.0));
|
||||
|
||||
assert_eq!(output[0], (0, "foo".to_owned()));
|
||||
assert_eq!(output[1], (10, "bar".to_owned()));
|
||||
assert_eq!(output[2], (25, "baz".to_owned()));
|
||||
assert_eq!(output[0], (Duration::from_micros(0), "foo".to_owned()));
|
||||
assert_eq!(output[1], (Duration::from_micros(10), "bar".to_owned()));
|
||||
assert_eq!(output[2], (Duration::from_micros(25), "baz".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -379,18 +568,35 @@ mod tests {
|
||||
(4_000_000, "qux"),
|
||||
(7_500_000, "quux"),
|
||||
]
|
||||
.map(|(time, output)| Ok(Event::output(time, output.to_owned())));
|
||||
.map(|(time, output)| {
|
||||
Ok(Event::output(
|
||||
Duration::from_micros(time),
|
||||
output.to_owned(),
|
||||
))
|
||||
});
|
||||
|
||||
let events = output(super::limit_idle_time(events.into_iter(), 2.0));
|
||||
|
||||
assert_eq!(events[0], (0, "foo".to_owned()));
|
||||
assert_eq!(events[1], (1_000_000, "bar".to_owned()));
|
||||
assert_eq!(events[2], (3_000_000, "baz".to_owned()));
|
||||
assert_eq!(events[3], (3_500_000, "qux".to_owned()));
|
||||
assert_eq!(events[4], (5_500_000, "quux".to_owned()));
|
||||
assert_eq!(events[0], (Duration::from_micros(0), "foo".to_owned()));
|
||||
assert_eq!(
|
||||
events[1],
|
||||
(Duration::from_micros(1_000_000), "bar".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
events[2],
|
||||
(Duration::from_micros(3_000_000), "baz".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
events[3],
|
||||
(Duration::from_micros(3_500_000), "qux".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
events[4],
|
||||
(Duration::from_micros(5_500_000), "quux".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
fn output(events: impl Iterator<Item = Result<Event>>) -> Vec<(u64, String)> {
|
||||
fn output(events: impl Iterator<Item = Result<Event>>) -> Vec<(Duration, String)> {
|
||||
events
|
||||
.filter_map(|r| {
|
||||
if let Ok(Event {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
pub fn deserialize_time<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
pub fn deserialize_time<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
@@ -25,13 +27,13 @@ where
|
||||
.parse()
|
||||
.map_err(Error::custom)?;
|
||||
|
||||
Ok(secs * 1_000_000 + micros)
|
||||
Ok(Duration::from_micros(secs * 1_000_000 + micros))
|
||||
}
|
||||
|
||||
[number] => {
|
||||
let secs: u64 = number.parse().map_err(Error::custom)?;
|
||||
|
||||
Ok(secs * 1_000_000)
|
||||
Ok(Duration::from_micros(secs * 1_000_000))
|
||||
}
|
||||
|
||||
_ => Err(Error::custom(format!("invalid time format: {value}"))),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::{Asciicast, Event, Header};
|
||||
use crate::asciicast::util::deserialize_time;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{Asciicast, Event, Header, Version};
|
||||
use crate::asciicast::util::deserialize_time;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct V1 {
|
||||
@@ -11,14 +14,14 @@ struct V1 {
|
||||
height: u16,
|
||||
command: Option<String>,
|
||||
title: Option<String>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env: Option<HashMap<String, Option<String>>>,
|
||||
stdout: Vec<V1OutputEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct V1OutputEvent {
|
||||
#[serde(deserialize_with = "deserialize_time")]
|
||||
time: u64,
|
||||
time: Duration,
|
||||
data: String,
|
||||
}
|
||||
|
||||
@@ -29,24 +32,46 @@ pub fn load(json: String) -> Result<Asciicast<'static>> {
|
||||
bail!("unsupported asciicast version")
|
||||
}
|
||||
|
||||
let term_type = asciicast
|
||||
.env
|
||||
.as_ref()
|
||||
.map(|env| env.get("TERM"))
|
||||
.unwrap_or_default()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let env = asciicast.env.map(|env| {
|
||||
env.into_iter()
|
||||
.filter_map(|(k, v)| v.map(|v| (k, v)))
|
||||
.collect()
|
||||
});
|
||||
|
||||
let header = Header {
|
||||
version: 1,
|
||||
cols: asciicast.width,
|
||||
rows: asciicast.height,
|
||||
term_cols: asciicast.width,
|
||||
term_rows: asciicast.height,
|
||||
term_type,
|
||||
term_version: None,
|
||||
term_theme: None,
|
||||
timestamp: None,
|
||||
idle_time_limit: None,
|
||||
command: asciicast.command.clone(),
|
||||
title: asciicast.title.clone(),
|
||||
env: asciicast.env.clone(),
|
||||
theme: None,
|
||||
env,
|
||||
};
|
||||
|
||||
let events = Box::new(
|
||||
asciicast
|
||||
.stdout
|
||||
.into_iter()
|
||||
.map(|e| Ok(Event::output(e.time, e.data))),
|
||||
);
|
||||
let events = Box::new(asciicast.stdout.into_iter().scan(
|
||||
Duration::from_micros(0),
|
||||
|prev_time, event| {
|
||||
let time = *prev_time + event.time;
|
||||
*prev_time = time;
|
||||
|
||||
Ok(Asciicast { header, events })
|
||||
Some(Ok(Event::output(time, event.data)))
|
||||
},
|
||||
));
|
||||
|
||||
Ok(Asciicast {
|
||||
version: Version::One,
|
||||
header,
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use super::{util, Asciicast, Event, EventData, Header};
|
||||
use crate::tty;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::io::{self, Write};
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use super::{util, Asciicast, Event, EventData, Header, Version};
|
||||
use crate::tty::TtyTheme;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct V2Header {
|
||||
@@ -15,7 +18,7 @@ struct V2Header {
|
||||
idle_time_limit: Option<f64>,
|
||||
command: Option<String>,
|
||||
title: Option<String>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
env: Option<HashMap<String, Option<String>>>,
|
||||
theme: Option<V2Theme>,
|
||||
}
|
||||
|
||||
@@ -38,7 +41,7 @@ struct V2Palette(Vec<RGB8>);
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct V2Event {
|
||||
#[serde(deserialize_with = "util::deserialize_time")]
|
||||
time: u64,
|
||||
time: Duration,
|
||||
#[serde(deserialize_with = "deserialize_code")]
|
||||
code: V2EventCode,
|
||||
data: String,
|
||||
@@ -59,32 +62,50 @@ pub fn open(header_line: &str) -> Result<Parser> {
|
||||
let header = serde_json::from_str::<V2Header>(header_line)?;
|
||||
|
||||
if header.version != 2 {
|
||||
bail!("unsupported asciicast version")
|
||||
bail!("not an asciicast v2 file")
|
||||
}
|
||||
|
||||
Ok(Parser(header))
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn parse<'a, I: Iterator<Item = io::Result<String>> + 'a>(
|
||||
&self,
|
||||
lines: I,
|
||||
) -> Asciicast<'a> {
|
||||
pub fn parse<'a, I: Iterator<Item = io::Result<String>> + 'a>(self, lines: I) -> Asciicast<'a> {
|
||||
let term_type = self
|
||||
.0
|
||||
.env
|
||||
.as_ref()
|
||||
.map(|env| env.get("TERM").cloned())
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let term_theme = self.0.theme.as_ref().map(|t| t.into());
|
||||
|
||||
let env = self.0.env.map(|env| {
|
||||
env.into_iter()
|
||||
.filter_map(|(k, v)| v.map(|v| (k, v)))
|
||||
.collect()
|
||||
});
|
||||
|
||||
let header = Header {
|
||||
version: 2,
|
||||
cols: self.0.width,
|
||||
rows: self.0.height,
|
||||
term_cols: self.0.width,
|
||||
term_rows: self.0.height,
|
||||
term_type,
|
||||
term_version: None,
|
||||
term_theme,
|
||||
timestamp: self.0.timestamp,
|
||||
idle_time_limit: self.0.idle_time_limit,
|
||||
command: self.0.command.clone(),
|
||||
title: self.0.title.clone(),
|
||||
env: self.0.env.clone(),
|
||||
theme: self.0.theme.as_ref().map(|t| t.into()),
|
||||
env,
|
||||
};
|
||||
|
||||
let events = Box::new(lines.filter_map(parse_line));
|
||||
|
||||
Asciicast { header, events }
|
||||
Asciicast {
|
||||
version: Version::Two,
|
||||
header,
|
||||
events,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +124,7 @@ fn parse_line(line: io::Result<String>) -> Option<Result<Event>> {
|
||||
}
|
||||
|
||||
fn parse_event(line: String) -> Result<Event> {
|
||||
let event = serde_json::from_str::<V2Event>(&line)?;
|
||||
let event = serde_json::from_str::<V2Event>(&line).context("asciicast v2 parse error")?;
|
||||
|
||||
let data = match event.code {
|
||||
V2EventCode::Output => EventData::Output(event.data),
|
||||
@@ -156,54 +177,69 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Writer<W: Write> {
|
||||
writer: io::LineWriter<W>,
|
||||
time_offset: u64,
|
||||
pub struct V2Encoder {
|
||||
time_offset: Duration,
|
||||
}
|
||||
|
||||
impl<W> Writer<W>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
pub fn new(writer: W, time_offset: u64) -> Self {
|
||||
Self {
|
||||
writer: io::LineWriter::new(writer),
|
||||
time_offset,
|
||||
}
|
||||
impl V2Encoder {
|
||||
pub fn new(time_offset: Duration) -> Self {
|
||||
Self { time_offset }
|
||||
}
|
||||
|
||||
pub fn write_header(&mut self, header: &Header) -> io::Result<()> {
|
||||
pub fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
let header: V2Header = header.into();
|
||||
let mut data = serde_json::to_string(&header).unwrap().into_bytes();
|
||||
data.push(b'\n');
|
||||
|
||||
writeln!(self.writer, "{}", serde_json::to_string(&header)?)
|
||||
data
|
||||
}
|
||||
|
||||
pub fn write_event(&mut self, event: &Event) -> io::Result<()> {
|
||||
writeln!(self.writer, "{}", self.serialize_event(event)?)
|
||||
pub fn event(&mut self, event: &Event) -> Vec<u8> {
|
||||
let mut data = self.serialize_event(event).into_bytes();
|
||||
data.push(b'\n');
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
fn serialize_event(&self, event: &Event) -> Result<String, serde_json::Error> {
|
||||
fn serialize_event(&self, event: &Event) -> String {
|
||||
use EventData::*;
|
||||
|
||||
let (code, data) = match &event.data {
|
||||
Output(data) => ('o', serde_json::to_string(data)?),
|
||||
Input(data) => ('i', serde_json::to_string(data)?),
|
||||
Resize(cols, rows) => ('r', serde_json::to_string(&format!("{cols}x{rows}"))?),
|
||||
Marker(data) => ('m', serde_json::to_string(data)?),
|
||||
Other(code, data) => (*code, serde_json::to_string(data)?),
|
||||
Output(data) => ('o', self.to_json_string(data)),
|
||||
Input(data) => ('i', self.to_json_string(data)),
|
||||
Resize(cols, rows) => ('r', self.to_json_string(&format!("{cols}x{rows}"))),
|
||||
Marker(data) => ('m', self.to_json_string(data)),
|
||||
Exit(data) => ('x', self.to_json_string(&data.to_string())),
|
||||
Other(code, data) => (*code, self.to_json_string(data)),
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
format!(
|
||||
"[{}, {}, {}]",
|
||||
format_time(event.time + self.time_offset).trim_end_matches('0'),
|
||||
serde_json::to_string(&code)?,
|
||||
format_time(event.time + self.time_offset),
|
||||
self.to_json_string(&code.to_string()),
|
||||
data,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
fn to_json_string(&self, s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time(time: u64) -> String {
|
||||
format!("{}.{:0>6}", time / 1_000_000, time % 1_000_000)
|
||||
fn format_time(time: Duration) -> String {
|
||||
let time = time.as_micros();
|
||||
let mut formatted_time = format!("{}.{:0>6}", time / 1_000_000, time % 1_000_000);
|
||||
let dot_idx = formatted_time.find('.').unwrap();
|
||||
|
||||
for idx in (dot_idx + 2..=formatted_time.len() - 1).rev() {
|
||||
if formatted_time.as_bytes()[idx] != b'0' {
|
||||
break;
|
||||
}
|
||||
|
||||
formatted_time.truncate(idx);
|
||||
}
|
||||
|
||||
formatted_time
|
||||
}
|
||||
|
||||
impl serde::Serialize for V2Header {
|
||||
@@ -344,40 +380,64 @@ impl serde::Serialize for V2Palette {
|
||||
|
||||
impl From<&Header> for V2Header {
|
||||
fn from(header: &Header) -> Self {
|
||||
let env = header
|
||||
.env
|
||||
.clone()
|
||||
.map(|env| env.into_iter().map(|(k, v)| (k, Some(v))).collect());
|
||||
|
||||
V2Header {
|
||||
version: 2,
|
||||
width: header.cols,
|
||||
height: header.rows,
|
||||
width: header.term_cols,
|
||||
height: header.term_rows,
|
||||
timestamp: header.timestamp,
|
||||
idle_time_limit: header.idle_time_limit,
|
||||
command: header.command.clone(),
|
||||
title: header.title.clone(),
|
||||
env: header.env.clone(),
|
||||
theme: header.theme.as_ref().map(|t| t.into()),
|
||||
env,
|
||||
theme: header.term_theme.as_ref().map(|t| t.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&tty::Theme> for V2Theme {
|
||||
fn from(theme: &tty::Theme) -> Self {
|
||||
let palette = theme.palette.iter().copied().map(RGB8).collect();
|
||||
impl From<&TtyTheme> for V2Theme {
|
||||
fn from(tty_theme: &TtyTheme) -> Self {
|
||||
let palette = tty_theme.palette.iter().copied().map(RGB8).collect();
|
||||
|
||||
V2Theme {
|
||||
fg: RGB8(theme.fg),
|
||||
bg: RGB8(theme.bg),
|
||||
fg: RGB8(tty_theme.fg),
|
||||
bg: RGB8(tty_theme.bg),
|
||||
palette: V2Palette(palette),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&V2Theme> for tty::Theme {
|
||||
fn from(theme: &V2Theme) -> Self {
|
||||
let palette = theme.palette.0.iter().map(|c| c.0).collect();
|
||||
impl From<&V2Theme> for TtyTheme {
|
||||
fn from(tty_theme: &V2Theme) -> Self {
|
||||
let palette = tty_theme.palette.0.iter().map(|c| c.0).collect();
|
||||
|
||||
tty::Theme {
|
||||
fg: theme.fg.0,
|
||||
bg: theme.bg.0,
|
||||
TtyTheme {
|
||||
fg: tty_theme.fg.0,
|
||||
bg: tty_theme.bg.0,
|
||||
palette,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn format_time() {
|
||||
assert_eq!(super::format_time(Duration::from_micros(0)), "0.0");
|
||||
assert_eq!(
|
||||
super::format_time(Duration::from_micros(1000001)),
|
||||
"1.000001"
|
||||
);
|
||||
assert_eq!(super::format_time(Duration::from_micros(12300000)), "12.3");
|
||||
assert_eq!(
|
||||
super::format_time(Duration::from_micros(12000003)),
|
||||
"12.000003"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
475
src/asciicast/v3.rs
Normal file
475
src/asciicast/v3.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use super::{util, Asciicast, Event, EventData, Header, Version};
|
||||
use crate::tty::TtyTheme;
|
||||
use crate::util::Quantizer;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct V3Header {
|
||||
version: u8,
|
||||
term: V3Term,
|
||||
timestamp: Option<u64>,
|
||||
idle_time_limit: Option<f64>,
|
||||
command: Option<String>,
|
||||
title: Option<String>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct V3Term {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
#[serde(rename = "type")]
|
||||
type_: Option<String>,
|
||||
version: Option<String>,
|
||||
theme: Option<V3Theme>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
struct V3Theme {
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
fg: RGB8,
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
bg: RGB8,
|
||||
#[serde(deserialize_with = "deserialize_palette")]
|
||||
palette: V3Palette,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RGB8(rgb::RGB8);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct V3Palette(Vec<RGB8>);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct V3Event {
|
||||
#[serde(deserialize_with = "util::deserialize_time")]
|
||||
time: Duration,
|
||||
#[serde(deserialize_with = "deserialize_code")]
|
||||
code: V3EventCode,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug)]
|
||||
enum V3EventCode {
|
||||
Output,
|
||||
Input,
|
||||
Resize,
|
||||
Marker,
|
||||
Exit,
|
||||
Other(char),
|
||||
}
|
||||
|
||||
pub struct Parser {
|
||||
header: V3Header,
|
||||
prev_time: Duration,
|
||||
}
|
||||
|
||||
pub fn open(header_line: &str) -> Result<Parser> {
|
||||
let header = serde_json::from_str::<V3Header>(header_line)?;
|
||||
|
||||
if header.version != 3 {
|
||||
bail!("not an asciicast v3 file")
|
||||
}
|
||||
|
||||
Ok(Parser {
|
||||
header,
|
||||
prev_time: Duration::from_micros(0),
|
||||
})
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn parse<'a, I: Iterator<Item = io::Result<String>> + 'a>(
|
||||
mut self,
|
||||
lines: I,
|
||||
) -> Asciicast<'a> {
|
||||
let term_theme = self.header.term.theme.as_ref().map(|t| t.into());
|
||||
|
||||
let header = Header {
|
||||
term_cols: self.header.term.cols,
|
||||
term_rows: self.header.term.rows,
|
||||
term_type: self.header.term.type_.clone(),
|
||||
term_version: self.header.term.version.clone(),
|
||||
term_theme,
|
||||
timestamp: self.header.timestamp,
|
||||
idle_time_limit: self.header.idle_time_limit,
|
||||
command: self.header.command.clone(),
|
||||
title: self.header.title.clone(),
|
||||
env: self.header.env.clone(),
|
||||
};
|
||||
|
||||
let events = Box::new(lines.filter_map(move |line| self.parse_line(line)));
|
||||
|
||||
Asciicast {
|
||||
version: Version::Three,
|
||||
header,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_line(&mut self, line: io::Result<String>) -> Option<Result<Event>> {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
None
|
||||
} else {
|
||||
Some(self.parse_event(line))
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => Some(Err(e.into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_event(&mut self, line: String) -> Result<Event> {
|
||||
let event = serde_json::from_str::<V3Event>(&line).context("asciicast v3 parse error")?;
|
||||
|
||||
let data = match event.code {
|
||||
V3EventCode::Output => EventData::Output(event.data),
|
||||
V3EventCode::Input => EventData::Input(event.data),
|
||||
|
||||
V3EventCode::Resize => match event.data.split_once('x') {
|
||||
Some((cols, rows)) => {
|
||||
let cols: u16 = cols
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("invalid cols value in resize event: {e}"))?;
|
||||
|
||||
let rows: u16 = rows
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("invalid rows value in resize event: {e}"))?;
|
||||
|
||||
EventData::Resize(cols, rows)
|
||||
}
|
||||
|
||||
None => {
|
||||
bail!("invalid size value in resize event");
|
||||
}
|
||||
},
|
||||
|
||||
V3EventCode::Marker => EventData::Marker(event.data),
|
||||
V3EventCode::Exit => EventData::Exit(event.data.parse()?),
|
||||
V3EventCode::Other(c) => EventData::Other(c, event.data),
|
||||
};
|
||||
|
||||
let time = self.prev_time + event.time;
|
||||
self.prev_time = time;
|
||||
|
||||
Ok(Event { time, data })
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_code<'de, D>(deserializer: D) -> Result<V3EventCode, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
use V3EventCode::*;
|
||||
|
||||
let value: &str = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
match value {
|
||||
"o" => Ok(Output),
|
||||
"i" => Ok(Input),
|
||||
"r" => Ok(Resize),
|
||||
"m" => Ok(Marker),
|
||||
"x" => Ok(Exit),
|
||||
"" => Err(Error::custom("missing event code")),
|
||||
s => Ok(Other(s.chars().next().unwrap())),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct V3Encoder {
|
||||
prev_time: Duration,
|
||||
time_quantizer: Quantizer,
|
||||
}
|
||||
|
||||
impl V3Encoder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
prev_time: Duration::from_micros(0),
|
||||
time_quantizer: Quantizer::new(1_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
let header: V3Header = header.into();
|
||||
let mut data = serde_json::to_string(&header).unwrap().into_bytes();
|
||||
data.push(b'\n');
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
pub fn event(&mut self, event: &Event) -> Vec<u8> {
|
||||
let mut data = self.serialize_event(event).into_bytes();
|
||||
data.push(b'\n');
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
fn serialize_event(&mut self, event: &Event) -> String {
|
||||
use EventData::*;
|
||||
|
||||
let (code, data) = match &event.data {
|
||||
Output(data) => ('o', self.to_json_string(data)),
|
||||
Input(data) => ('i', self.to_json_string(data)),
|
||||
Resize(cols, rows) => ('r', self.to_json_string(&format!("{cols}x{rows}"))),
|
||||
Marker(data) => ('m', self.to_json_string(data)),
|
||||
Exit(data) => ('x', self.to_json_string(&data.to_string())),
|
||||
Other(code, data) => (*code, self.to_json_string(data)),
|
||||
};
|
||||
|
||||
let dt = event.time - self.prev_time;
|
||||
self.prev_time = event.time;
|
||||
let dt = Duration::from_nanos(self.time_quantizer.next(dt.as_nanos()) as u64);
|
||||
|
||||
format!(
|
||||
"[{}, {}, {}]",
|
||||
format_duration(dt),
|
||||
self.to_json_string(&code.to_string()),
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
fn to_json_string(&self, s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration(duration: Duration) -> String {
|
||||
let time_ms = duration.as_millis();
|
||||
let secs = time_ms / 1_000;
|
||||
let millis = time_ms % 1_000;
|
||||
|
||||
format!("{}.{}", secs, format!("{:03}", millis))
|
||||
}
|
||||
|
||||
impl serde::Serialize for V3Header {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeMap;
|
||||
|
||||
let mut len = 2;
|
||||
|
||||
if self.timestamp.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if self.idle_time_limit.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if self.command.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if self.title.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if self.env.as_ref().is_some_and(|env| !env.is_empty()) {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
let mut map = serializer.serialize_map(Some(len))?;
|
||||
map.serialize_entry("version", &3)?;
|
||||
map.serialize_entry("term", &self.term)?;
|
||||
|
||||
if let Some(timestamp) = self.timestamp {
|
||||
map.serialize_entry("timestamp", ×tamp)?;
|
||||
}
|
||||
|
||||
if let Some(limit) = self.idle_time_limit {
|
||||
map.serialize_entry("idle_time_limit", &limit)?;
|
||||
}
|
||||
|
||||
if let Some(command) = &self.command {
|
||||
map.serialize_entry("command", &command)?;
|
||||
}
|
||||
|
||||
if let Some(title) = &self.title {
|
||||
map.serialize_entry("title", &title)?;
|
||||
}
|
||||
|
||||
if let Some(env) = &self.env {
|
||||
if !env.is_empty() {
|
||||
map.serialize_entry("env", &env)?;
|
||||
}
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for V3Term {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeMap;
|
||||
|
||||
let mut len = 2;
|
||||
|
||||
if self.type_.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if self.version.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if self.theme.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
let mut map = serializer.serialize_map(Some(len))?;
|
||||
map.serialize_entry("cols", &self.cols)?;
|
||||
map.serialize_entry("rows", &self.rows)?;
|
||||
|
||||
if let Some(type_) = &self.type_ {
|
||||
map.serialize_entry("type", &type_)?;
|
||||
}
|
||||
|
||||
if let Some(version) = &self.version {
|
||||
map.serialize_entry("version", &version)?;
|
||||
}
|
||||
|
||||
if let Some(theme) = &self.theme {
|
||||
map.serialize_entry("theme", &theme)?;
|
||||
}
|
||||
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_color<'de, D>(deserializer: D) -> Result<RGB8, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value: &str = Deserialize::deserialize(deserializer)?;
|
||||
parse_hex_color(value).ok_or(serde::de::Error::custom("invalid hex triplet"))
|
||||
}
|
||||
|
||||
fn parse_hex_color(rgb: &str) -> Option<RGB8> {
|
||||
if rgb.len() != 7 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let r = u8::from_str_radix(&rgb[1..3], 16).ok()?;
|
||||
let g = u8::from_str_radix(&rgb[3..5], 16).ok()?;
|
||||
let b = u8::from_str_radix(&rgb[5..7], 16).ok()?;
|
||||
|
||||
Some(RGB8(rgb::RGB8::new(r, g, b)))
|
||||
}
|
||||
|
||||
fn deserialize_palette<'de, D>(deserializer: D) -> Result<V3Palette, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value: &str = Deserialize::deserialize(deserializer)?;
|
||||
let mut colors: Vec<RGB8> = value.split(':').filter_map(parse_hex_color).collect();
|
||||
let len = colors.len();
|
||||
|
||||
if len == 8 {
|
||||
colors.extend_from_within(..);
|
||||
} else if len != 16 {
|
||||
return Err(serde::de::Error::custom("expected 8 or 16 hex triplets"));
|
||||
}
|
||||
|
||||
Ok(V3Palette(colors))
|
||||
}
|
||||
|
||||
impl serde::Serialize for RGB8 {
|
||||
fn serialize<S>(&self, serializer: S) -> std::prelude::v1::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RGB8 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "#{:0>2x}{:0>2x}{:0>2x}", self.0.r, self.0.g, self.0.b)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for V3Palette {
|
||||
fn serialize<S>(&self, serializer: S) -> std::prelude::v1::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let palette = self
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(":");
|
||||
|
||||
serializer.serialize_str(&palette)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Header> for V3Header {
|
||||
fn from(header: &Header) -> Self {
|
||||
V3Header {
|
||||
version: 3,
|
||||
term: V3Term {
|
||||
cols: header.term_cols,
|
||||
rows: header.term_rows,
|
||||
type_: header.term_type.clone(),
|
||||
version: header.term_version.clone(),
|
||||
theme: header.term_theme.as_ref().map(|t| t.into()),
|
||||
},
|
||||
timestamp: header.timestamp,
|
||||
idle_time_limit: header.idle_time_limit,
|
||||
command: header.command.clone(),
|
||||
title: header.title.clone(),
|
||||
env: header.env.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&TtyTheme> for V3Theme {
|
||||
fn from(tty_theme: &TtyTheme) -> Self {
|
||||
let palette = tty_theme.palette.iter().copied().map(RGB8).collect();
|
||||
|
||||
V3Theme {
|
||||
fg: RGB8(tty_theme.fg),
|
||||
bg: RGB8(tty_theme.bg),
|
||||
palette: V3Palette(palette),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&V3Theme> for TtyTheme {
|
||||
fn from(tty_theme: &V3Theme) -> Self {
|
||||
let palette = tty_theme.palette.0.iter().map(|c| c.0).collect();
|
||||
|
||||
TtyTheme {
|
||||
fg: tty_theme.fg.0,
|
||||
bg: tty_theme.bg.0,
|
||||
palette,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_duration;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn format_time() {
|
||||
assert_eq!(format_duration(Duration::from_millis(0)), "0.000");
|
||||
assert_eq!(format_duration(Duration::from_millis(666)), "0.666");
|
||||
assert_eq!(format_duration(Duration::from_millis(1000)), "1.000");
|
||||
assert_eq!(format_duration(Duration::from_millis(12345)), "12.345");
|
||||
}
|
||||
}
|
||||
607
src/cli.rs
607
src/cli.rs
@@ -1,208 +1,609 @@
|
||||
use clap::{Args, ValueEnum};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::net::SocketAddr;
|
||||
use std::num::ParseIntError;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080";
|
||||
use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
|
||||
|
||||
pub const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:0";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[clap(author, version, about)]
|
||||
#[command(name = "asciinema")]
|
||||
#[command(name = "asciinema", max_term_width = 100, infer_subcommands = true)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
|
||||
/// asciinema server URL
|
||||
#[arg(long, global = true)]
|
||||
pub server_url: Option<String>,
|
||||
|
||||
/// Quiet mode, i.e. suppress diagnostic messages
|
||||
#[clap(short, long, global = true)]
|
||||
/// Suppress diagnostic messages and progress indicators. Only error messages will be displayed.
|
||||
#[clap(
|
||||
short,
|
||||
long,
|
||||
global = true,
|
||||
display_order = 101,
|
||||
help = "Quiet mode - suppress diagnostic messages",
|
||||
long_help
|
||||
)]
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Record a terminal session
|
||||
Rec(Record),
|
||||
/// Record a terminal session to a file.
|
||||
///
|
||||
/// Captures all terminal output and optionally keyboard input, saving it for later playback. Supports various output formats, idle time limiting, and session customization options.
|
||||
///
|
||||
/// Press <ctrl+d> or type 'exit' to end the recording session.
|
||||
/// Press <ctrl+\> to pause/resume capture of the session.
|
||||
///
|
||||
/// During the session, the ASCIINEMA_SESSION environment variable is set to a unique session ID.
|
||||
#[clap(
|
||||
visible_alias = "rec",
|
||||
about = "Record a terminal session",
|
||||
long_about,
|
||||
after_help = "\x1b[1;4mExamples\x1b[0m:
|
||||
|
||||
/// Replay a terminal session
|
||||
Play(Play),
|
||||
asciinema rec demo.cast
|
||||
Records a shell session to a file
|
||||
|
||||
/// Stream a terminal session
|
||||
asciinema rec --command \"python script.py\" demo.cast
|
||||
Records execution of a Python script
|
||||
|
||||
asciinema rec --idle-time-limit 2 demo.cast
|
||||
Records with idle time capped at 2 seconds
|
||||
|
||||
asciinema rec --capture-input --title \"API Demo\" demo.cast
|
||||
Records with keyboard input and sets a title
|
||||
|
||||
asciinema rec --append demo.cast
|
||||
Continues recording to an existing file
|
||||
|
||||
asciinema rec demo.txt
|
||||
Records as a plain-text log - output format inferred from the .txt extension"
|
||||
)]
|
||||
Record(Record),
|
||||
|
||||
/// Stream a terminal session in real-time.
|
||||
///
|
||||
/// Broadcasts a terminal session live via either the local HTTP server (for local/LAN viewing) or a remote asciinema server (for public sharing). Viewers can watch the session as it happens through a web interface.
|
||||
///
|
||||
/// Press <ctrl+d> or type 'exit' to end the streaming session.
|
||||
/// Press <ctrl+\> to pause/resume capture of the session.
|
||||
///
|
||||
/// During the session, the ASCIINEMA_SESSION environment variable is set to a unique session ID.
|
||||
#[clap(
|
||||
about = "Stream a terminal session",
|
||||
long_about,
|
||||
after_help = "\x1b[1;4mExamples\x1b[0m:
|
||||
|
||||
asciinema stream --local
|
||||
Streams a shell session via the local HTTP server listening on an ephemeral port on 127.0.0.1
|
||||
|
||||
asciinema stream --local 0.0.0.0:8080
|
||||
Streams via the local HTTP server listening on port 8080 on all network interfaces
|
||||
|
||||
asciinema stream --remote
|
||||
Streams via an asciinema server for public viewing
|
||||
|
||||
asciinema stream -l -r
|
||||
Streams both locally and remotely simultaneously
|
||||
|
||||
asciinema stream -r --command \"ping asciinema.org\"
|
||||
Streams execution of the ping command
|
||||
|
||||
asciinema stream -r <ID> -t \"Live coding\"
|
||||
Streams via a remote server, reusing the existing stream ID and setting the stream title"
|
||||
)]
|
||||
Stream(Stream),
|
||||
|
||||
/// Concatenate multiple recordings
|
||||
Cat(Cat),
|
||||
/// Record and stream a terminal session simultaneously.
|
||||
///
|
||||
/// Combines the functionality of record and stream commands, allowing you to save a recording to a file while also broadcasting it live to viewers.
|
||||
///
|
||||
/// Press <ctrl+d> or type 'exit' to end the session.
|
||||
/// Press <ctrl+\> to pause/resume capture of the session.
|
||||
///
|
||||
/// During the session, the ASCIINEMA_SESSION environment variable is set to a unique session ID.
|
||||
#[clap(
|
||||
about = "Record and stream a terminal session",
|
||||
long_about,
|
||||
after_help = "\x1b[1;4mExamples\x1b[0m:
|
||||
|
||||
/// Convert a recording into another format
|
||||
Convert(Convert),
|
||||
asciinema session --output-file demo.cast --stream-local
|
||||
Records a shell session to a file and streams it via the local HTTP server listening on an ephemeral port on 127.0.0.1
|
||||
|
||||
/// Upload a recording to an asciinema server
|
||||
asciinema session -o demo.cast --stream-remote
|
||||
Records to a file and streams via an asciinema server for public viewing
|
||||
|
||||
asciinema session --stream-local --stream-remote
|
||||
Streams both locally and remotely simultaneously, without saving to a file
|
||||
|
||||
asciinema session -o demo.cast -l -r -t \"Live coding\"
|
||||
Records + streams locally + streams remotely, setting the title of the recording/stream
|
||||
|
||||
asciinema session -o demo.cast --idle-time-limit 1.5
|
||||
Records to a file with idle time capped at 1.5 seconds
|
||||
|
||||
asciinema session -o demo.cast -l 0.0.0.0:9000 -r <ID>
|
||||
Records + streams locally on port 9000 + streams remotely, reusing existing stream ID"
|
||||
)]
|
||||
Session(Session),
|
||||
|
||||
/// Play back a recorded terminal session.
|
||||
///
|
||||
/// Displays a previously recorded asciicast file in your terminal with various playback controls (see below). Supports local files and remote URLs.
|
||||
///
|
||||
/// Press <ctrl+c> to interrupt the playback.
|
||||
/// Press <space> to pause/resume.
|
||||
/// Press '.' to step forward (while paused).
|
||||
/// Press ']' to skip to the next marker (while paused).
|
||||
#[clap(
|
||||
about = "Play back a terminal session",
|
||||
long_about,
|
||||
after_help = "\x1b[1;4mExamples\x1b[0m:
|
||||
|
||||
asciinema play demo.cast
|
||||
Plays back a local recording file once
|
||||
|
||||
asciinema play --speed 2.0 --loop demo.cast
|
||||
Plays back at double speed in a loop
|
||||
|
||||
asciinema play --idle-time-limit 2 demo.cast
|
||||
Plays back with idle time capped at 2 seconds
|
||||
|
||||
asciinema play https://asciinema.org/a/569727
|
||||
Plays back directly from a URL
|
||||
|
||||
asciinema play --pause-on-markers demo.cast
|
||||
Plays back, pausing automatically at every marker"
|
||||
)]
|
||||
Play(Play),
|
||||
|
||||
/// Upload a recording to an asciinema server.
|
||||
///
|
||||
/// Takes a local asciicast file and uploads it to an asciinema server (either asciinema.org or a self-hosted server), returning a recording URL which can be shared publicly.
|
||||
#[clap(about = "Upload a recording to an asciinema server", long_about)]
|
||||
Upload(Upload),
|
||||
|
||||
/// Authenticate this CLI with an asciinema server account
|
||||
/// Authenticate with an asciinema server.
|
||||
///
|
||||
/// Creates a user account link between your local CLI and an asciinema server account. Optional for uploading with the upload command, required for remote streaming with the stream and session commands.
|
||||
#[clap(
|
||||
about = "Authenticate this CLI with an asciinema server account",
|
||||
long_about
|
||||
)]
|
||||
Auth(Auth),
|
||||
|
||||
/// Concatenate multiple recordings into one.
|
||||
///
|
||||
/// Combines two or more asciicast files in sequence, adjusting timing so each recording plays immediately after the previous one ends. Useful for creating longer recordings from multiple shorter sessions.
|
||||
///
|
||||
/// Note: in asciinema 2.x this command used to print raw terminal output for a given session
|
||||
/// file. If you're looking for this behavior then use `asciinema convert -f raw <FILE> -` instead.
|
||||
#[clap(
|
||||
about = "Concatenate multiple recordings",
|
||||
long_about,
|
||||
after_help = "\x1b[1;4mExamples\x1b[0m:
|
||||
|
||||
asciinema cat demo1.cast demo2.cast demo3.cast > combined.cast
|
||||
Combines local recordings into one file
|
||||
|
||||
asciinema cat https://asciinema.org/a/569727 part2.cast > combined.cast
|
||||
Combines a remote and a local recording into one file"
|
||||
)]
|
||||
Cat(Cat),
|
||||
|
||||
/// Convert a recording to another format.
|
||||
///
|
||||
/// Transform asciicast files between different formats (v1, v2, v3) or export to other formats like raw terminal output or plain text. Supports reading from files, URLs, or stdin and writing to files or stdout.
|
||||
#[clap(
|
||||
about = "Convert a recording to another format",
|
||||
long_about,
|
||||
after_help = "\x1b[1;4mExamples\x1b[0m:
|
||||
|
||||
asciinema convert old.cast new.cast
|
||||
Converts a recording to the latest asciicast format (v3)
|
||||
|
||||
asciinema convert demo.cast demo.txt
|
||||
Exports a recording as a plain-text log - output format inferred from the .txt extension
|
||||
|
||||
asciinema convert --output-format raw demo.cast demo.txt
|
||||
Exports as raw terminal output
|
||||
|
||||
asciinema convert -f txt demo.cast -
|
||||
Exports as plain text to stdout
|
||||
|
||||
asciinema convert https://asciinema.org/a/569727 starwars.cast
|
||||
Downloads a remote recording and converts it to the latest asciicast format (v3)"
|
||||
)]
|
||||
Convert(Convert),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct Record {
|
||||
/// Output path - either a file or a directory path
|
||||
pub path: String,
|
||||
/// Output file path
|
||||
pub file: String,
|
||||
|
||||
/// Enable input recording
|
||||
#[arg(long, short = 'I', alias = "stdin")]
|
||||
pub input: bool,
|
||||
/// Specify the format for the output file. The default is asciicast-v3. If the file path ends with .txt, the txt format will be selected automatically unless --output-format is explicitly specified.
|
||||
#[arg(
|
||||
short = 'f',
|
||||
long,
|
||||
value_enum,
|
||||
value_name = "FORMAT",
|
||||
help = "Output file format [default: asciicast-v3]",
|
||||
long_help
|
||||
)]
|
||||
pub output_format: Option<Format>,
|
||||
|
||||
/// Append to an existing recording file
|
||||
#[arg(short, long)]
|
||||
pub append: bool,
|
||||
|
||||
/// Recording file format [default: asciicast]
|
||||
#[arg(short, long, value_enum)]
|
||||
pub format: Option<Format>,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub raw: bool,
|
||||
|
||||
/// Overwrite target file if it already exists
|
||||
#[arg(long, conflicts_with = "append")]
|
||||
pub overwrite: bool,
|
||||
|
||||
/// Command to record [default: $SHELL]
|
||||
#[arg(short, long)]
|
||||
/// Specify the command to execute in the recording session. If not provided, asciinema will use your default shell from the $SHELL environment variable. This can be any command with arguments, for example: --command "python script.py" or --command "bash -l". Can also be set via the config file option session.command.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
help = "Command to start in the session [default: $SHELL]",
|
||||
long_help
|
||||
)]
|
||||
pub command: Option<String>,
|
||||
|
||||
/// Filename template, used when recording to a directory
|
||||
#[arg(long, value_name = "TEMPLATE")]
|
||||
pub filename: Option<String>,
|
||||
/// Enable recording of keyboard input in addition to terminal output. When enabled, both what you type and what appears on the screen will be captured. Note that sensitive input like passwords will also be recorded when this option is enabled. Can also be set via the config file option session.capture_input.
|
||||
#[arg(
|
||||
long,
|
||||
short = 'I',
|
||||
alias = "stdin",
|
||||
help = "Enable input (keyboard) capture",
|
||||
long_help
|
||||
)]
|
||||
pub capture_input: bool,
|
||||
|
||||
/// List of env vars to save [default: TERM,SHELL]
|
||||
#[arg(long)]
|
||||
pub env: Option<String>,
|
||||
/// Specify which environment variables to capture and include in the recording metadata. This helps ensure the recording context is preserved, e.g., for auditing. Provide a comma-separated list of variable names, for example: --rec-env "USER,SHELL,TERM". If not specified, only the SHELL variable is captured by default. Can also be set via the config file option session.capture_env.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "VARS",
|
||||
help = "Comma-separated list of environment variables to capture [default: SHELL]",
|
||||
long_help
|
||||
)]
|
||||
pub capture_env: Option<String>,
|
||||
|
||||
/// Title of the recording
|
||||
#[arg(short, long)]
|
||||
/// Append the new session to an existing recording file instead of creating a new one. This allows you to continue a previous recording session. The timing will be adjusted to maintain continuity from where the previous recording ended. Cannot be used together with --overwrite.
|
||||
#[arg(short, long, help = "Append to an existing recording file", long_help)]
|
||||
pub append: bool,
|
||||
|
||||
/// Overwrite the output file if it already exists. By default, asciinema will refuse to overwrite existing files to prevent accidental data loss. Cannot be used together with --append.
|
||||
#[arg(
|
||||
long,
|
||||
conflicts_with = "append",
|
||||
help = "Overwrite the output file if it already exists",
|
||||
long_help
|
||||
)]
|
||||
pub overwrite: bool,
|
||||
|
||||
/// Set a descriptive title that will be stored in the recording metadata. This title may be displayed by players and is useful for organizing and identifying recordings. For example: --title "Installing Podman on Ubuntu".
|
||||
#[arg(short, long, help = "Title of the recording", long_help)]
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Limit idle time to a given number of seconds
|
||||
#[arg(short, long, value_name = "SECS")]
|
||||
/// Limit the maximum idle time recorded between terminal events to the specified number of seconds. Long pauses (such as when you step away from the terminal) will be capped at this duration in the recording, making playback more watchable. For example, --idle-time-limit 2.0 will ensure no pause longer than 2 seconds appears in the recording. Note that this option doesn't alter the original (captured) timing information and instead, embeds the idle time limit value in the metadata, which is interpreted by session players at playback time. This allows tweaking of the limit after recording. Can also be set via the config file option session.idle_time_limit.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "SECS",
|
||||
help = "Limit idle time to a given number of seconds",
|
||||
long_help
|
||||
)]
|
||||
pub idle_time_limit: Option<f64>,
|
||||
|
||||
/// Use headless mode - don't use TTY for input/output
|
||||
#[arg(long)]
|
||||
/// Record in headless mode without using the terminal for input/output. This is useful for automated or scripted recordings where you don't want asciinema to interfere with the current terminal session. The recorded command will still execute normally, but asciinema won't display its output in your terminal. Headless mode is enabled automatically when running in an environment where a terminal is not available.
|
||||
#[arg(
|
||||
long,
|
||||
help = "Headless mode - don't use the terminal for I/O",
|
||||
long_help
|
||||
)]
|
||||
pub headless: bool,
|
||||
|
||||
/// Override terminal size for the recorded command
|
||||
#[arg(long, value_name = "COLSxROWS", value_parser = parse_tty_size)]
|
||||
pub tty_size: Option<(Option<u16>, Option<u16>)>,
|
||||
/// Override the terminal window size used for the recording session. Specify dimensions as COLSxROWS (e.g., 80x24 for 80 columns by 24 rows). You can specify just columns (80x) or just rows (x24) to override only one dimension. This is useful for ensuring consistent recording dimensions regardless of your current terminal size.
|
||||
#[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size, help = "Override session's terminal window size", long_help)]
|
||||
pub window_size: Option<(Option<u16>, Option<u16>)>,
|
||||
|
||||
/// Make the asciinema command exit with the same status code as the recorded session. By default, asciinema exits with status 0 regardless of what happens in the recorded session. With this option, if the recorded command exits with a non-zero status, asciinema will also exit with the same status.
|
||||
#[arg(long, help = "Return the session's exit status", long_help)]
|
||||
pub return_: bool,
|
||||
|
||||
/// Enable logging of internal events to a file at the specified path. Useful for debugging recording issues.
|
||||
#[arg(long, value_name = "PATH", help = "Log file path", long_help)]
|
||||
pub log_file: Option<PathBuf>,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub cols: Option<u16>,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub rows: Option<u16>,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub raw: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct Play {
|
||||
#[arg(value_name = "FILENAME_OR_URL")]
|
||||
pub filename: String,
|
||||
/// The path to an asciicast file or HTTP(S) URL to play back. HTTP(S) URLs allow playing recordings directly from the web without need for manual downloading. Supported formats include asciicast v1, v2, and v3.
|
||||
pub file: String,
|
||||
|
||||
/// Limit idle time to a given number of seconds
|
||||
#[arg(short, long, value_name = "SECS")]
|
||||
pub idle_time_limit: Option<f64>,
|
||||
|
||||
/// Set playback speed
|
||||
#[arg(short, long)]
|
||||
/// Control the playback speed as a multiplier of the original timing. Values greater than 1.0 make playback faster, while values less than 1.0 make it slower. For example, --speed 2.0 plays at double speed, while --speed 0.5 plays at half speed. The default is 1.0 (original speed). Can also be set via the config file option playback.speed.
|
||||
#[arg(short, long, help = "Set playback speed", long_help)]
|
||||
pub speed: Option<f64>,
|
||||
|
||||
/// Loop loop loop loop
|
||||
#[arg(short, long, name = "loop")]
|
||||
/// Enable continuous looping of the recording. When the recording reaches the end, it will automatically restart from the beginning. This continues indefinitely until you interrupt playback with <ctrl+c>.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
name = "loop",
|
||||
help = "Loop playback continuously",
|
||||
long_help
|
||||
)]
|
||||
pub loop_: bool,
|
||||
|
||||
/// Automatically pause on markers
|
||||
#[arg(short = 'm', long)]
|
||||
/// Limit the maximum idle time between events during playback to the specified number of seconds. Long pauses in the original recording (such as when the user stepped away) will be shortened to this duration, making playback more watchable. This overrides any idle time limit set in the recording itself or in your config file (playback.idle_time_limit).
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "SECS",
|
||||
help = "Limit idle time to a given number of seconds",
|
||||
long_help
|
||||
)]
|
||||
pub idle_time_limit: Option<f64>,
|
||||
|
||||
/// Automatically pause playback when encountering marker events. Markers are special events that can be added during recording to mark important points in a session. When this option is enabled, playback will pause at each marker, allowing you to control the flow of the demonstration. Use <space> to resume, '.' to step through events, or ']' to skip to the next marker.
|
||||
#[arg(short = 'm', long, help = "Automatically pause on markers", long_help)]
|
||||
pub pause_on_markers: bool,
|
||||
|
||||
/// Automatically resize the terminal window to match the original recording dimensions. This option attempts to change your terminal size to match the size used when the recording was made, ensuring the output appears exactly as it was originally recorded. Note that this feature is only supported by some terminals and may not work in all environments.
|
||||
#[arg(
|
||||
short = 'r',
|
||||
long,
|
||||
help = "Auto-resize terminal to match original size",
|
||||
long_help
|
||||
)]
|
||||
pub resize: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[clap(group(ArgGroup::new("mode").args(&["local", "remote"]).multiple(true).required(true)))]
|
||||
pub struct Stream {
|
||||
/// Enable input capture
|
||||
#[arg(long, short = 'I', alias = "stdin")]
|
||||
pub input: bool,
|
||||
/// Start the local HTTP server to stream the session in real-time. Creates a web interface accessible via browser where viewers can watch the terminal session live. Optionally specify the bind address as IP:PORT (e.g., 0.0.0.0:8080 to allow external connections). If no address is provided, it listens on an automatically assigned ephemeral port on 127.0.0.1.
|
||||
#[arg(short, long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1, help = "Stream via the local HTTP server", long_help)]
|
||||
pub local: Option<SocketAddr>,
|
||||
|
||||
/// Command to stream [default: $SHELL]
|
||||
#[arg(short, long)]
|
||||
/// Stream the session to a remote asciinema server for public viewing. This allows sharing your session on the web with anyone who has the stream URL. You can provide either a stream ID of an existing stream configuration in your asciinema server account, or a direct WebSocket URL (ws:// or wss://) for custom servers. Omitting the value for this option lets the asciinema server allocate a new stream ID automatically.
|
||||
#[arg(short, long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target, help = "Stream via remote asciinema server", long_help)]
|
||||
pub remote: Option<RelayTarget>,
|
||||
|
||||
/// Specify the command to execute in the streaming session. If not provided, asciinema will use your default shell from the $SHELL environment variable. This can be any command with arguments, for example: --command "python script.py" or --command "bash -l". Can also be set via the config file option session.command.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
help = "Command to start in the session [default: $SHELL]",
|
||||
long_help
|
||||
)]
|
||||
pub command: Option<String>,
|
||||
|
||||
/// Serve the stream with the built-in HTTP server
|
||||
#[arg(short, long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1)]
|
||||
pub serve: Option<SocketAddr>,
|
||||
/// Enable recording of keyboard input in addition to terminal output. When enabled, both what you type and what appears on the screen will be captured. Note that sensitive input like passwords will also be recorded when this option is enabled. If the server has stream recording enabled then keyboard input will be included in the recording file created on the server side. Can also be set via the config file option session.capture_input.
|
||||
#[arg(long, short = 'I', help = "Enable input (keyboard) capture", long_help)]
|
||||
pub capture_input: bool,
|
||||
|
||||
/// Relay the stream via an asciinema server
|
||||
#[arg(short, long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target)]
|
||||
pub relay: Option<RelayTarget>,
|
||||
/// Specify which environment variables to capture and include in the stream metadata. Provide a comma-separated list of variable names, for example: --rec-env "USER,SHELL,TERM". If not specified, only the SHELL variable is captured by default. If the server has stream recording enabled then these environment variables will be included in the recording file created on the server side. Can also be set via the config file option session.capture_env.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "VARS",
|
||||
help = "Comma-separated list of environment variables to capture [default: SHELL]",
|
||||
long_help
|
||||
)]
|
||||
pub capture_env: Option<String>,
|
||||
|
||||
/// Use headless mode - don't use TTY for input/output
|
||||
#[arg(long)]
|
||||
/// Set a descriptive title for the streaming session. This title is displayed to viewers (when doing remote streaming with --remote). For example: --title "Building a REST API". If the server has stream recording enabled then the title will be included in the recording file created on the server side.
|
||||
#[arg(short, long, help = "Title of the session", long_help)]
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Stream in headless mode without using the terminal for input/output. This is useful for automated or scripted streaming where you don't want asciinema to interfere with the current terminal session. The streamed command will still execute normally and be visible to viewers, but won't be displayed in your local terminal. Headless mode is enabled automatically when running in an environment where a terminal is not available.
|
||||
#[arg(
|
||||
long,
|
||||
help = "Headless mode - don't use the terminal for I/O",
|
||||
long_help
|
||||
)]
|
||||
pub headless: bool,
|
||||
|
||||
/// Override terminal size for the session
|
||||
#[arg(long, value_name = "COLSxROWS", value_parser = parse_tty_size)]
|
||||
pub tty_size: Option<(Option<u16>, Option<u16>)>,
|
||||
/// Override the terminal window size used for the streaming session. Specify dimensions as COLSxROWS (e.g., 80x24 for 80 columns by 24 rows). You can specify just columns (80x) or just rows (x24) to override only one dimension. This is useful for ensuring consistent streaming dimensions regardless of your current terminal size.
|
||||
#[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size, help = "Override session's terminal window size", long_help)]
|
||||
pub window_size: Option<(Option<u16>, Option<u16>)>,
|
||||
|
||||
/// Log file path
|
||||
#[arg(long)]
|
||||
/// Make the asciinema command exit with the same status code as the streamed session. By default, asciinema exits with status 0 regardless of what happens in the streamed session. With this option, if the streamed command exits with a non-zero status, asciinema will also exit with that same status.
|
||||
#[arg(long, help = "Return the session's exit status", long_help)]
|
||||
pub return_: bool,
|
||||
|
||||
/// Enable logging of internal events to a file at the specified path. Useful for debugging streaming issues (connection errors, disconnections, etc.).
|
||||
#[arg(long, value_name = "PATH", help = "Log file path", long_help)]
|
||||
pub log_file: Option<PathBuf>,
|
||||
|
||||
/// Specify a custom asciinema server URL for streaming to self-hosted servers. Use the base server URL (e.g., https://asciinema.example.com). Can also be set via the environment variable ASCIINEMA_SERVER_URL or the config file option server.url. If no server URL is configured via this option, environment variable, or config file, you will be prompted to choose one (defaulting to asciinema.org), which will be saved as a default.
|
||||
#[arg(long, value_name = "URL", help = "asciinema server URL", long_help)]
|
||||
pub server_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[clap(group(ArgGroup::new("mode").args(&["output_file", "stream_local", "stream_remote"]).multiple(true).required(true)))]
|
||||
pub struct Session {
|
||||
/// Save the session to a file at the specified path. Can be combined with local and remote streaming.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "PATH",
|
||||
help = "Save the session to a file",
|
||||
long_help
|
||||
)]
|
||||
pub output_file: Option<String>,
|
||||
|
||||
/// Specify the format for the output file when saving is enabled with --output-file. The default is asciicast-v3. If the output file path ends with .txt, the txt format will be selected automatically unless this option is explicitly specified.
|
||||
#[arg(
|
||||
short = 'f',
|
||||
long,
|
||||
value_enum,
|
||||
value_name = "FORMAT",
|
||||
help = "Output file format [default: asciicast-v3]",
|
||||
long_help
|
||||
)]
|
||||
pub output_format: Option<Format>,
|
||||
|
||||
/// Start the local HTTP server to stream the session in real-time. Creates a web interface accessible via browser where viewers can watch the terminal session live. Optionally specify the bind address as IP:PORT (e.g., 0.0.0.0:8080 to allow external connections). If no address is provided, it listends on an automatically assigned ephemeral port on 127.0.0.1. Can be combined with remote streaming and file output.
|
||||
#[arg(short = 'l', long, value_name = "IP:PORT", default_missing_value = DEFAULT_LISTEN_ADDR, num_args = 0..=1, help = "Stream via the local HTTP server", long_help)]
|
||||
pub stream_local: Option<SocketAddr>,
|
||||
|
||||
/// Stream the session to a remote asciinema server for public viewing. This allows sharing your session on the web with anyone who has the stream URL. You can provide either a stream ID of an existing stream configuration in your asciinema server account, or a direct WebSocket URL (ws:// or wss://) for custom servers. Omitting the value for this option lets the asciinema server allocate a new stream ID automatically. Can be combined with local streaming and file output.
|
||||
#[arg(short = 'r', long, value_name = "STREAM-ID|WS-URL", default_missing_value = "", num_args = 0..=1, value_parser = validate_forward_target, help = "Stream via remote asciinema server", long_help)]
|
||||
pub stream_remote: Option<RelayTarget>,
|
||||
|
||||
/// Specify the command to execute in the session. If not provided, asciinema will use your default shell from the $SHELL environment variable. This can be any command with arguments, for example: --command "python script.py" or --command "bash -l". Can also be set via the config file option session.command.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
help = "Command to start in the session [default: $SHELL]",
|
||||
long_help
|
||||
)]
|
||||
pub command: Option<String>,
|
||||
|
||||
/// Enable recording of keyboard input in addition to terminal output. When enabled, both what you type and what appears on the screen will be captured. Note that sensitive input like passwords will also be recorded when this option is enabled. If the server has stream recording enabled then keyboard input will be included in the recording file created on the server side. Can also be set via the config file option session.capture_input.
|
||||
#[arg(long, short = 'I', help = "Enable input (keyboard) capture", long_help)]
|
||||
pub capture_input: bool,
|
||||
|
||||
/// Specify which environment variables to capture and include in the session metadata. Provide a comma-separated list of variable names, for example: --rec-env "USER,SHELL,TERM". If not specified, only the SHELL variable is captured by default. If the server has stream recording enabled then these environment variables will be included in the recording file created on the server side. Can also be set via the config file option session.capture_env.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "VARS",
|
||||
help = "Comma-separated list of environment variables to capture [default: SHELL]",
|
||||
long_help
|
||||
)]
|
||||
pub capture_env: Option<String>,
|
||||
|
||||
/// Append the new session to an existing recording file instead of creating a new one. This allows you to continue a previous recording session. The timing will be adjusted to maintain continuity from where the previous recording ended. Cannot be used together with --overwrite. Only applies when --output-file is specified.
|
||||
#[arg(short, long, help = "Append to an existing recording file", long_help)]
|
||||
pub append: bool,
|
||||
|
||||
/// Overwrite the output file if it already exists. By default, asciinema will refuse to overwrite existing files to prevent accidental data loss. Cannot be used together with --append. Only applies when --output-file is specified.
|
||||
#[arg(
|
||||
long,
|
||||
conflicts_with = "append",
|
||||
help = "Overwrite the output file if it already exists",
|
||||
long_help
|
||||
)]
|
||||
pub overwrite: bool,
|
||||
|
||||
/// Set a descriptive title for the session that will be stored in the recording metadata and displayed to stream viewers (when doing remote streaming with --remote). For example: --title "Installing Podman on Ubuntu". If the server has stream recording enabled then the title will be included in the recording file created on the server side.
|
||||
#[arg(short, long, help = "Title of the session", long_help)]
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Limit the maximum idle time recorded between terminal events to the specified number of seconds. Long pauses (such as when you step away from the terminal) will be capped at this duration in the recording, making playback more watchable. For example, --idle-time-limit 2.0 will ensure no pause longer than 2 seconds appears in the recording. Only applies when --output-file is specified. Note that this option doesn't alter the original (captured) timing information and instead, it embeds the idle time limit value in the metadata, which is interpreted by session players at playback time. This allows tweaking of the limit after recording. Can also be set via the config file option session.idle_time_limit.
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "SECS",
|
||||
help = "Limit idle time to a given number of seconds",
|
||||
long_help
|
||||
)]
|
||||
pub idle_time_limit: Option<f64>,
|
||||
|
||||
/// Run the session in headless mode without using the terminal for input/output. This is useful for automated or scripted sessions where you don't want asciinema to interfere with the current terminal session. The session command will still execute normally and be recorded/streamed, but won't be displayed in your local terminal. Headless mode is enabled automatically when running in an environment where a terminal is not available.
|
||||
#[arg(
|
||||
long,
|
||||
help = "Headless mode - don't use the terminal for I/O",
|
||||
long_help
|
||||
)]
|
||||
pub headless: bool,
|
||||
|
||||
/// Override the terminal window size used for the session. Specify dimensions as COLSxROWS (e.g., 80x24 for 80 columns by 24 rows). You can specify just columns (80x) or just rows (x24) to override only one dimension. This is useful for ensuring consistent recording dimensions regardless of your current terminal size.
|
||||
#[arg(long, value_name = "COLSxROWS", value_parser = parse_window_size, help = "Override session's terminal window size", long_help)]
|
||||
pub window_size: Option<(Option<u16>, Option<u16>)>,
|
||||
|
||||
/// Make the asciinema command exit with the same status code as the session command. By default, asciinema exits with status 0 regardless of what happens in the session. With this option, if the session command exits with a non-zero status, asciinema will also exit with that same status.
|
||||
#[arg(long, help = "Return the session's exit status", long_help)]
|
||||
pub return_: bool,
|
||||
|
||||
/// Enable logging of internal events to a file at the specified path. Useful for debugging I/O issues (connection errors, disconnections, file write errors, etc.).
|
||||
#[arg(long, value_name = "PATH", help = "Log file path", long_help)]
|
||||
pub log_file: Option<PathBuf>,
|
||||
|
||||
/// Specify a custom asciinema server URL for streaming to self-hosted servers. Use the base server URL (e.g., https://asciinema.example.com). Can also be set via environment variable ASCIINEMA_SERVER_URL or config file option server.url. If no server URL is configured via this option, environment variable, or config file, you will be prompted to choose one (defaulting to asciinema.org), which will be saved as a default.
|
||||
#[arg(long, value_name = "URL", help = "asciinema server URL", long_help)]
|
||||
pub server_url: Option<String>,
|
||||
|
||||
#[arg(hide = true)]
|
||||
pub env: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct Cat {
|
||||
#[arg(required = true)]
|
||||
pub filename: Vec<String>,
|
||||
/// List of recording files to concatenate. Provide at least two file paths (local files or HTTP(S) URLs). The files will be combined in the order specified. All files must be in asciicast format.
|
||||
#[arg(required = true, num_args = 2.., help = "Recording files to concatenate", long_help)]
|
||||
pub file: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct Convert {
|
||||
#[arg(value_name = "INPUT_FILENAME_OR_URL")]
|
||||
pub input_filename: String,
|
||||
/// The source recording to convert. Can be a local file path, HTTP(S) URL for remote files, or '-' to read from standard input. Remote URLs allow converting recordings directly from the web without need for manual downloading. Supported input formats include asciicast v1, v2 and v3.
|
||||
pub input: String,
|
||||
|
||||
pub output_filename: String,
|
||||
/// The output path for the converted recording. Can be a file path or '-' to write to standard output.
|
||||
pub output: String,
|
||||
|
||||
/// Output file format [default: asciicast]
|
||||
#[arg(short, long, value_enum)]
|
||||
pub format: Option<Format>,
|
||||
/// Specify the format for the converted recording. The default is asciicast-v3. If the output file path ends with .txt, the txt format will be selected automatically unless this option is explicitly specified.
|
||||
#[arg(
|
||||
short = 'f',
|
||||
long,
|
||||
value_enum,
|
||||
value_name = "FORMAT",
|
||||
help = "Output file format [default: asciicast-v3]",
|
||||
long_help
|
||||
)]
|
||||
pub output_format: Option<Format>,
|
||||
|
||||
/// Overwrite target file if it already exists
|
||||
#[arg(long)]
|
||||
/// Overwrite the output file if it already exists. By default, asciinema will refuse to overwrite existing files to prevent accidental data loss. Has no effect when writing to stdout ('-').
|
||||
#[arg(
|
||||
long,
|
||||
help = "Overwrite the output file if it already exists",
|
||||
long_help
|
||||
)]
|
||||
pub overwrite: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct Upload {
|
||||
/// Filename/path of asciicast to upload
|
||||
pub filename: String,
|
||||
/// The path to the asciicast recording file to upload, in a supported asciicast format (v1, v2, or v3).
|
||||
pub file: String,
|
||||
|
||||
/// Specify a custom asciinema server URL for uploading to self-hosted servers. Use the base server URL (e.g., https://asciinema.example.com). Can also be set via environment variable ASCIINEMA_SERVER_URL or config file option server.url. If no server URL is configured via this option, environment variable, or config file, you will be prompted to choose one (defaulting to asciinema.org), which will be saved as a default.
|
||||
#[arg(long, value_name = "URL", help = "asciinema server URL", long_help)]
|
||||
pub server_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct Auth {}
|
||||
pub struct Auth {
|
||||
/// Specify a custom asciinema server URL for authenticating with self-hosted servers. Use the base server URL (e.g., https://asciinema.example.com). Can also be set via environment variable ASCIINEMA_SERVER_URL or config file option server.url. If no server URL is configured via this option, environment variable, or config file, you will be prompted to choose one (defaulting to asciinema.org), which will be saved as a default.
|
||||
#[arg(long, value_name = "URL", help = "asciinema server URL", long_help)]
|
||||
pub server_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
||||
pub enum Format {
|
||||
Asciicast,
|
||||
/// Full-featured session format, with timing and metadata (current generation) - https://docs.asciinema.org/manual/asciicast/v3/
|
||||
AsciicastV3,
|
||||
/// Full-featured session format, with timing and metadata (previous generation) - https://docs.asciinema.org/manual/asciicast/v2/
|
||||
AsciicastV2,
|
||||
/// Raw terminal output, including control sequences, without timing and metadata
|
||||
Raw,
|
||||
/// Plain text without colors or control sequences, human-readable
|
||||
Txt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum RelayTarget {
|
||||
StreamId(String),
|
||||
WsProducerUrl(url::Url),
|
||||
}
|
||||
|
||||
fn parse_tty_size(s: &str) -> Result<(Option<u16>, Option<u16>), String> {
|
||||
fn parse_window_size(s: &str) -> Result<(Option<u16>, Option<u16>), String> {
|
||||
match s.split_once('x') {
|
||||
Some((cols, "")) => {
|
||||
let cols: u16 = cols.parse().map_err(|e: ParseIntError| e.to_string())?;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
use super::Command;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api;
|
||||
use crate::cli;
|
||||
use crate::config::Config;
|
||||
use anyhow::Result;
|
||||
|
||||
impl Command for cli::Auth {
|
||||
fn run(self, config: &Config) -> Result<()> {
|
||||
impl cli::Auth {
|
||||
pub fn run(self) -> Result<()> {
|
||||
let mut config = Config::new(self.server_url.clone())?;
|
||||
let server_url = config.get_server_url()?;
|
||||
let server_hostname = server_url.host().unwrap();
|
||||
let auth_url = api::get_auth_url(config)?;
|
||||
let auth_url = api::get_auth_url(&mut config)?;
|
||||
|
||||
println!("Open the following URL in a web browser to authenticate this asciinema CLI with your {server_hostname} user account:\n");
|
||||
println!("{}\n", auth_url);
|
||||
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}.");
|
||||
println!("Open the following URL in a web browser to authenticate this CLI with your {server_hostname} user account:\n");
|
||||
println!("{auth_url}\n");
|
||||
println!("This will associate all recordings uploaded from this machine with your account (including past uploads), and enable public live streaming via {server_hostname}.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
use super::Command;
|
||||
use crate::asciicast;
|
||||
use crate::cli;
|
||||
use crate::config::Config;
|
||||
use anyhow::Result;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
impl Command for cli::Cat {
|
||||
fn run(self, _config: &Config) -> Result<()> {
|
||||
let mut writer = asciicast::Writer::new(io::stdout(), 0);
|
||||
let mut time_offset: u64 = 0;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::asciicast::{self, Asciicast, Encoder, Event, EventData, Version};
|
||||
use crate::cli;
|
||||
use crate::util;
|
||||
|
||||
impl cli::Cat {
|
||||
pub fn run(self) -> Result<()> {
|
||||
let mut stdout = io::stdout();
|
||||
let casts = self.open_input_files()?;
|
||||
let mut encoder = self.get_encoder(casts[0].version)?;
|
||||
let mut time_offset = Duration::from_micros(0);
|
||||
let mut first = true;
|
||||
let mut cols = 0;
|
||||
let mut rows = 0;
|
||||
|
||||
for path in self.filename.iter() {
|
||||
let recording = asciicast::open_from_path(path)?;
|
||||
for cast in casts.into_iter() {
|
||||
let mut time = time_offset;
|
||||
|
||||
if first {
|
||||
writer.write_header(&recording.header)?;
|
||||
first = false;
|
||||
stdout.write_all(&encoder.header(&cast.header))?;
|
||||
} else if cast.header.term_cols != cols || cast.header.term_rows != rows {
|
||||
let event = Event::resize(time, (cast.header.term_cols, cast.header.term_rows));
|
||||
stdout.write_all(&encoder.event(&event))?;
|
||||
}
|
||||
|
||||
for event in recording.events {
|
||||
cols = cast.header.term_cols;
|
||||
rows = cast.header.term_rows;
|
||||
|
||||
for event in cast.events {
|
||||
let mut event = event?;
|
||||
time = time_offset + event.time;
|
||||
event.time = time;
|
||||
writer.write_event(&event)?;
|
||||
stdout.write_all(&encoder.event(&event))?;
|
||||
|
||||
if let EventData::Resize(cols_, rows_) = event.data {
|
||||
cols = cols_;
|
||||
rows = rows_;
|
||||
}
|
||||
}
|
||||
|
||||
time_offset = time;
|
||||
@@ -32,4 +49,19 @@ impl Command for cli::Cat {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_input_files(&self) -> Result<Vec<Asciicast<'_>>> {
|
||||
self.file
|
||||
.iter()
|
||||
.map(|filename| {
|
||||
let path = util::get_local_path(filename)?;
|
||||
asciicast::open_from_path(&*path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_encoder(&self, version: Version) -> Result<Box<dyn Encoder>> {
|
||||
asciicast::encoder(version)
|
||||
.ok_or(anyhow!("asciicast v{version} files can't be concatenated"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,78 @@
|
||||
use super::Command;
|
||||
use crate::asciicast::{self, Header};
|
||||
use crate::cli::{self, Format};
|
||||
use crate::config::Config;
|
||||
use crate::encoder::{self, EncoderExt};
|
||||
use crate::util;
|
||||
use anyhow::{bail, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
impl Command for cli::Convert {
|
||||
fn run(self, _config: &Config) -> Result<()> {
|
||||
let path = util::get_local_path(&self.input_filename)?;
|
||||
let input = asciicast::open_from_path(&*path)?;
|
||||
let mut output = self.get_output(&input.header)?;
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
output.encode(input)
|
||||
}
|
||||
}
|
||||
use crate::asciicast;
|
||||
use crate::cli::{self, Format};
|
||||
use crate::encoder::{
|
||||
self, AsciicastV2Encoder, AsciicastV3Encoder, EncoderExt, RawEncoder, TextEncoder,
|
||||
};
|
||||
use crate::util;
|
||||
|
||||
impl cli::Convert {
|
||||
fn get_output(&self, header: &Header) -> Result<Box<dyn encoder::Encoder>> {
|
||||
let file = self.open_file()?;
|
||||
pub fn run(self) -> Result<()> {
|
||||
let input_path = self.get_input_path()?;
|
||||
let output_path = self.get_output_path();
|
||||
let cast = asciicast::open_from_path(&*input_path)?;
|
||||
let mut encoder = self.get_encoder();
|
||||
let mut output_file = self.open_output_file(output_path)?;
|
||||
|
||||
let format = self.format.unwrap_or_else(|| {
|
||||
if self.output_filename.to_lowercase().ends_with(".txt") {
|
||||
encoder.encode_to_file(cast, &mut output_file)
|
||||
}
|
||||
|
||||
fn get_encoder(&self) -> Box<dyn encoder::Encoder> {
|
||||
let format = self.output_format.unwrap_or_else(|| {
|
||||
if self.output.to_lowercase().ends_with(".txt") {
|
||||
Format::Txt
|
||||
} else {
|
||||
Format::Asciicast
|
||||
Format::AsciicastV3
|
||||
}
|
||||
});
|
||||
|
||||
match format {
|
||||
Format::Asciicast => Ok(Box::new(encoder::AsciicastEncoder::new(
|
||||
file,
|
||||
false,
|
||||
0,
|
||||
header.into(),
|
||||
))),
|
||||
|
||||
Format::Raw => Ok(Box::new(encoder::RawEncoder::new(file, false))),
|
||||
Format::Txt => Ok(Box::new(encoder::TextEncoder::new(file))),
|
||||
Format::AsciicastV3 => Box::new(AsciicastV3Encoder::new(false)),
|
||||
Format::AsciicastV2 => {
|
||||
Box::new(AsciicastV2Encoder::new(false, Duration::from_micros(0)))
|
||||
}
|
||||
Format::Raw => Box::new(RawEncoder::new()),
|
||||
Format::Txt => Box::new(TextEncoder::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file(&self) -> Result<fs::File> {
|
||||
let overwrite = self.get_mode()?;
|
||||
fn get_input_path(&self) -> Result<Box<dyn AsRef<Path>>> {
|
||||
if self.input == "-" {
|
||||
Ok(Box::new(Path::new("/dev/stdin")))
|
||||
} else {
|
||||
util::get_local_path(&self.input)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_output_path(&self) -> String {
|
||||
if self.output == "-" {
|
||||
"/dev/stdout".to_owned()
|
||||
} else {
|
||||
self.output.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn open_output_file(&self, path: String) -> Result<fs::File> {
|
||||
let overwrite = self.get_mode(&path)?;
|
||||
|
||||
let file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(overwrite)
|
||||
.create_new(!overwrite)
|
||||
.truncate(overwrite)
|
||||
.open(&self.output_filename)?;
|
||||
.open(&path)?;
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn get_mode(&self) -> Result<bool> {
|
||||
fn get_mode(&self, path: &str) -> Result<bool> {
|
||||
let mut overwrite = self.overwrite;
|
||||
let path = Path::new(&self.output_filename);
|
||||
let path = Path::new(path);
|
||||
|
||||
if path.exists() {
|
||||
let metadata = fs::metadata(path)?;
|
||||
|
||||
@@ -2,42 +2,5 @@ pub mod auth;
|
||||
pub mod cat;
|
||||
pub mod convert;
|
||||
pub mod play;
|
||||
pub mod rec;
|
||||
pub mod stream;
|
||||
pub mod session;
|
||||
pub mod upload;
|
||||
use crate::config::Config;
|
||||
use crate::notifier;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
|
||||
pub trait Command {
|
||||
fn run(self, config: &Config) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
fn get_notifier(config: &Config) -> Box<dyn notifier::Notifier> {
|
||||
if config.notifications.enabled {
|
||||
notifier::get_notifier(config.notifications.command.clone())
|
||||
} else {
|
||||
Box::new(notifier::NullNotifier)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exec_command(command: Option<String>) -> Vec<String> {
|
||||
let command = command
|
||||
.or(env::var("SHELL").ok())
|
||||
.unwrap_or("/bin/sh".to_owned());
|
||||
|
||||
vec!["/bin/sh".to_owned(), "-c".to_owned(), command]
|
||||
}
|
||||
|
||||
fn build_exec_extra_env(vars: &[(String, String)]) -> HashMap<String, String> {
|
||||
let mut env = HashMap::new();
|
||||
|
||||
env.insert("ASCIINEMA_REC".to_owned(), "1".to_owned());
|
||||
|
||||
for (k, v) in vars {
|
||||
env.insert(k.clone(), v.clone());
|
||||
}
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
@@ -1,63 +1,62 @@
|
||||
use super::Command;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::asciicast;
|
||||
use crate::cli;
|
||||
use crate::config::Config;
|
||||
use crate::logger;
|
||||
use crate::config::{self, Config};
|
||||
use crate::player::{self, KeyBindings};
|
||||
use crate::tty;
|
||||
use crate::status;
|
||||
use crate::util;
|
||||
use anyhow::Result;
|
||||
|
||||
impl Command for cli::Play {
|
||||
fn run(self, config: &Config) -> Result<()> {
|
||||
let speed = self.speed.or(config.cmd_play_speed()).unwrap_or(1.0);
|
||||
let idle_time_limit = self.idle_time_limit.or(config.cmd_play_idle_time_limit());
|
||||
impl cli::Play {
|
||||
pub fn run(self) -> anyhow::Result<()> {
|
||||
let config = Config::new(None)?;
|
||||
let speed = self.speed.or(config.playback.speed).unwrap_or(1.0);
|
||||
let idle_time_limit = self.idle_time_limit.or(config.playback.idle_time_limit);
|
||||
let path = util::get_local_path(&self.file)?;
|
||||
let keys = get_key_bindings(&config.playback)?;
|
||||
let runtime = Runtime::new()?;
|
||||
|
||||
logger::info!("Replaying session from {}", self.filename);
|
||||
|
||||
let path = util::get_local_path(&self.filename)?;
|
||||
status::info!("Replaying session from {}", self.file);
|
||||
|
||||
let ended = loop {
|
||||
let recording = asciicast::open_from_path(&*path)?;
|
||||
let tty = tty::DevTty::open()?;
|
||||
let keys = get_key_bindings(config)?;
|
||||
|
||||
let ended = player::play(
|
||||
let ended = runtime.block_on(player::play(
|
||||
recording,
|
||||
tty,
|
||||
speed,
|
||||
idle_time_limit,
|
||||
self.pause_on_markers,
|
||||
&keys,
|
||||
)?;
|
||||
self.resize,
|
||||
))?;
|
||||
|
||||
if !self.loop_ {
|
||||
if !self.loop_ || !ended {
|
||||
break ended;
|
||||
}
|
||||
};
|
||||
|
||||
if ended {
|
||||
logger::info!("Playback ended");
|
||||
status::info!("Playback ended");
|
||||
} else {
|
||||
logger::info!("Playback interrupted");
|
||||
status::info!("Playback interrupted");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_bindings(config: &Config) -> Result<KeyBindings> {
|
||||
fn get_key_bindings(config: &config::Playback) -> anyhow::Result<KeyBindings> {
|
||||
let mut keys = KeyBindings::default();
|
||||
|
||||
if let Some(key) = config.cmd_play_pause_key()? {
|
||||
if let Some(key) = config.pause_key()? {
|
||||
keys.pause = key;
|
||||
}
|
||||
|
||||
if let Some(key) = config.cmd_play_step_key()? {
|
||||
if let Some(key) = config.step_key()? {
|
||||
keys.step = key;
|
||||
}
|
||||
|
||||
if let Some(key) = config.cmd_play_next_marker_key()? {
|
||||
if let Some(key) = config.next_marker_key()? {
|
||||
keys.next_marker = key;
|
||||
}
|
||||
|
||||
|
||||
243
src/cmd/rec.rs
243
src/cmd/rec.rs
@@ -1,243 +0,0 @@
|
||||
use super::Command;
|
||||
use crate::asciicast;
|
||||
use crate::cli;
|
||||
use crate::config::Config;
|
||||
use crate::encoder;
|
||||
use crate::locale;
|
||||
use crate::logger;
|
||||
use crate::pty;
|
||||
use crate::recorder::{self, KeyBindings};
|
||||
use crate::tty::{self, FixedSizeTty, Tty};
|
||||
use anyhow::{bail, Result};
|
||||
use cli::Format;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process;
|
||||
|
||||
impl Command for cli::Record {
|
||||
fn run(mut self, config: &Config) -> Result<()> {
|
||||
locale::check_utf8_locale()?;
|
||||
|
||||
self.ensure_filename(config)?;
|
||||
let format = self.get_format();
|
||||
let (append, overwrite) = self.get_mode()?;
|
||||
let file = self.open_file(append, overwrite)?;
|
||||
let time_offset = self.get_time_offset(append, format)?;
|
||||
let command = self.get_command(config);
|
||||
let keys = get_key_bindings(config)?;
|
||||
let notifier = super::get_notifier(config);
|
||||
let record_input = self.input || config.cmd_rec_input();
|
||||
let exec_command = super::build_exec_command(command.as_ref().cloned());
|
||||
let exec_extra_env = super::build_exec_extra_env(&[]);
|
||||
|
||||
logger::info!("Recording session started, writing to {}", self.path);
|
||||
|
||||
if command.is_none() {
|
||||
logger::info!("Press <ctrl+d> or type 'exit' to end");
|
||||
}
|
||||
|
||||
{
|
||||
let mut tty = self.get_tty()?;
|
||||
let theme = tty.get_theme();
|
||||
let output = self.get_output(file, format, append, time_offset, theme, config);
|
||||
let mut recorder = recorder::Recorder::new(output, record_input, keys, notifier);
|
||||
pty::exec(&exec_command, &exec_extra_env, &mut tty, &mut recorder)?;
|
||||
}
|
||||
|
||||
logger::info!("Recording session ended");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl cli::Record {
|
||||
fn ensure_filename(&mut self, config: &Config) -> Result<()> {
|
||||
let mut path = PathBuf::from(&self.path);
|
||||
|
||||
if path.exists() && fs::metadata(&path)?.is_dir() {
|
||||
let mut tpl = self.filename.clone().unwrap_or(config.cmd_rec_filename());
|
||||
|
||||
if tpl.contains("{pid}") {
|
||||
let pid = process::id().to_string();
|
||||
tpl = tpl.replace("{pid}", &pid);
|
||||
}
|
||||
|
||||
if tpl.contains("{user}") {
|
||||
let user = env::var("USER").ok().unwrap_or("unknown".to_owned());
|
||||
tpl = tpl.replace("{user}", &user);
|
||||
}
|
||||
|
||||
if tpl.contains("{hostname}") {
|
||||
let hostname = hostname::get()
|
||||
.ok()
|
||||
.and_then(|h| h.into_string().ok())
|
||||
.unwrap_or("unknown".to_owned());
|
||||
|
||||
tpl = tpl.replace("{hostname}", &hostname);
|
||||
}
|
||||
|
||||
let filename = chrono::Local::now().format(&tpl).to_string();
|
||||
path.push(Path::new(&filename));
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
|
||||
self.path = path.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_mode(&self) -> Result<(bool, bool)> {
|
||||
let mut overwrite = self.overwrite;
|
||||
let mut append = self.append;
|
||||
let path = Path::new(&self.path);
|
||||
|
||||
if path.exists() {
|
||||
let metadata = fs::metadata(path)?;
|
||||
|
||||
if metadata.len() == 0 {
|
||||
overwrite = true;
|
||||
append = false;
|
||||
}
|
||||
|
||||
if !append && !overwrite {
|
||||
bail!("file exists, use --overwrite or --append");
|
||||
}
|
||||
} else {
|
||||
append = false;
|
||||
}
|
||||
|
||||
Ok((append, overwrite))
|
||||
}
|
||||
|
||||
fn open_file(&self, append: bool, overwrite: bool) -> Result<fs::File> {
|
||||
let file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.append(append)
|
||||
.create(overwrite)
|
||||
.create_new(!overwrite && !append)
|
||||
.truncate(overwrite)
|
||||
.open(&self.path)?;
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn get_format(&self) -> Format {
|
||||
self.format.unwrap_or_else(|| {
|
||||
if self.raw {
|
||||
Format::Raw
|
||||
} else if self.path.to_lowercase().ends_with(".txt") {
|
||||
Format::Txt
|
||||
} else {
|
||||
Format::Asciicast
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_time_offset(&self, append: bool, format: Format) -> Result<u64> {
|
||||
if append && format == Format::Asciicast {
|
||||
asciicast::get_duration(&self.path)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tty(&self) -> Result<FixedSizeTty> {
|
||||
let (cols, rows) = self.tty_size.unwrap_or((None, None));
|
||||
let cols = cols.or(self.cols);
|
||||
let rows = rows.or(self.rows);
|
||||
|
||||
if self.headless {
|
||||
Ok(FixedSizeTty::new(tty::NullTty::open()?, cols, rows))
|
||||
} else if let Ok(dev_tty) = tty::DevTty::open() {
|
||||
Ok(FixedSizeTty::new(dev_tty, cols, rows))
|
||||
} else {
|
||||
logger::info!("TTY not available, recording in headless mode");
|
||||
Ok(FixedSizeTty::new(tty::NullTty::open()?, cols, rows))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_output(
|
||||
&self,
|
||||
file: fs::File,
|
||||
format: Format,
|
||||
append: bool,
|
||||
time_offset: u64,
|
||||
theme: Option<tty::Theme>,
|
||||
config: &Config,
|
||||
) -> Box<dyn recorder::Output + Send> {
|
||||
match format {
|
||||
Format::Asciicast => {
|
||||
let metadata = self.build_asciicast_metadata(theme, config);
|
||||
|
||||
Box::new(encoder::AsciicastEncoder::new(
|
||||
file,
|
||||
append,
|
||||
time_offset,
|
||||
metadata,
|
||||
))
|
||||
}
|
||||
|
||||
Format::Raw => Box::new(encoder::RawEncoder::new(file, append)),
|
||||
Format::Txt => Box::new(encoder::TextEncoder::new(file)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_command(&self, config: &Config) -> Option<String> {
|
||||
self.command.as_ref().cloned().or(config.cmd_rec_command())
|
||||
}
|
||||
|
||||
fn build_asciicast_metadata(
|
||||
&self,
|
||||
theme: Option<tty::Theme>,
|
||||
config: &Config,
|
||||
) -> encoder::Metadata {
|
||||
let idle_time_limit = self.idle_time_limit.or(config.cmd_rec_idle_time_limit());
|
||||
let command = self.get_command(config);
|
||||
|
||||
let env = self
|
||||
.env
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.or(config.cmd_rec_env())
|
||||
.unwrap_or(String::from("TERM,SHELL"));
|
||||
|
||||
encoder::Metadata {
|
||||
idle_time_limit,
|
||||
command,
|
||||
title: self.title.clone(),
|
||||
env: Some(capture_env(&env)),
|
||||
theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_bindings(config: &Config) -> Result<KeyBindings> {
|
||||
let mut keys = KeyBindings::default();
|
||||
|
||||
if let Some(key) = config.cmd_rec_prefix_key()? {
|
||||
keys.prefix = key;
|
||||
}
|
||||
|
||||
if let Some(key) = config.cmd_rec_pause_key()? {
|
||||
keys.pause = key;
|
||||
}
|
||||
|
||||
if let Some(key) = config.cmd_rec_add_marker_key()? {
|
||||
keys.add_marker = key;
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn capture_env(vars: &str) -> HashMap<String, String> {
|
||||
let vars = vars.split(',').collect::<HashSet<_>>();
|
||||
|
||||
env::vars()
|
||||
.filter(|(k, _v)| vars.contains(&k.as_str()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
}
|
||||
510
src/cmd/session.rs
Normal file
510
src/cmd/session.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{self, ExitCode};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::time;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::debug;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use url::Url;
|
||||
|
||||
use crate::api::{self, StreamChangeset, StreamResponse};
|
||||
use crate::asciicast::{self, Version};
|
||||
use crate::cli::{self, Format, RelayTarget};
|
||||
use crate::config::{self, Config};
|
||||
use crate::encoder::{AsciicastV2Encoder, AsciicastV3Encoder, Encoder, RawEncoder, TextEncoder};
|
||||
use crate::file_writer::FileWriter;
|
||||
use crate::forwarder;
|
||||
use crate::hash;
|
||||
use crate::locale;
|
||||
use crate::notifier::{self, BackgroundNotifier, Notifier, NullNotifier};
|
||||
use crate::server;
|
||||
use crate::session::{self, KeyBindings, Metadata, TermInfo};
|
||||
use crate::status;
|
||||
use crate::stream::Stream;
|
||||
use crate::tty::{DevTty, FixedSizeTty, NullTty, Tty};
|
||||
|
||||
impl cli::Session {
|
||||
pub fn run(mut self) -> Result<ExitCode> {
|
||||
locale::check_utf8_locale()?;
|
||||
self.init_logging()?;
|
||||
|
||||
let exit_status = Runtime::new()?.block_on(self.do_run())?;
|
||||
|
||||
if !self.return_ || exit_status == 0 {
|
||||
Ok(ExitCode::from(0))
|
||||
} else if exit_status > 0 {
|
||||
Ok(ExitCode::from(exit_status as u8))
|
||||
} else {
|
||||
Ok(ExitCode::from(1))
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_run(&mut self) -> Result<i32> {
|
||||
let mut config = Config::new(self.server_url.clone())?;
|
||||
let command = self.get_command(&config.session);
|
||||
let keys = get_key_bindings(&config.session)?;
|
||||
let notifier = get_notifier(&config);
|
||||
let metadata = self.get_session_metadata(&config.session).await?;
|
||||
let file_writer = self.get_file_writer(&metadata, notifier.clone()).await?;
|
||||
let listener = self.get_listener().await?;
|
||||
let relay = self.get_relay(&metadata, &mut config).await?;
|
||||
let relay_id = relay.as_ref().map(|r| r.id());
|
||||
let parent_session_relay_id = get_parent_session_relay_id();
|
||||
|
||||
if relay_id.is_some()
|
||||
&& parent_session_relay_id.is_some()
|
||||
&& relay_id == parent_session_relay_id
|
||||
{
|
||||
if let Some(Relay { url: Some(url), .. }) = relay {
|
||||
bail!("This shell is already being streamed at {url}");
|
||||
} else {
|
||||
bail!("This shell is already being streamed");
|
||||
}
|
||||
}
|
||||
|
||||
status::info!("asciinema session started");
|
||||
|
||||
if let Some(path) = self.output_file.as_ref() {
|
||||
status::info!("Recording to {}", path);
|
||||
}
|
||||
|
||||
if let Some(listener) = &listener {
|
||||
status::info!(
|
||||
"Live streaming at http://{}",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(Relay { url: Some(url), .. }) = &relay {
|
||||
status::info!("Live streaming at {}", url);
|
||||
}
|
||||
|
||||
if command.is_none() {
|
||||
status::info!("Press <ctrl+d> or type 'exit' to end");
|
||||
}
|
||||
|
||||
let stream = Stream::new();
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let mut outputs: Vec<Box<dyn session::Output>> = Vec::new();
|
||||
|
||||
if let Some(writer) = file_writer {
|
||||
let output = writer.start().await?;
|
||||
outputs.push(Box::new(output));
|
||||
}
|
||||
|
||||
let server = listener.map(|listener| {
|
||||
tokio::spawn(server::serve(
|
||||
listener,
|
||||
stream.subscriber(),
|
||||
shutdown_token.clone(),
|
||||
))
|
||||
});
|
||||
|
||||
let forwarder = relay.map(|relay| {
|
||||
tokio::spawn(forwarder::forward(
|
||||
relay.ws_producer_url,
|
||||
stream.subscriber(),
|
||||
notifier.clone(),
|
||||
shutdown_token.clone(),
|
||||
))
|
||||
});
|
||||
|
||||
if server.is_some() || forwarder.is_some() {
|
||||
let output = stream.start(&metadata).await;
|
||||
outputs.push(Box::new(output));
|
||||
}
|
||||
|
||||
let command = &build_exec_command(command.as_ref().cloned());
|
||||
let extra_env = &build_exec_extra_env(&self.env, relay_id.as_ref());
|
||||
|
||||
let exit_status = {
|
||||
let mut tty = self.get_tty(true).await?;
|
||||
|
||||
session::run(
|
||||
command,
|
||||
extra_env,
|
||||
tty.as_mut(),
|
||||
self.capture_input || config.session.capture_input,
|
||||
outputs,
|
||||
keys,
|
||||
notifier,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
status::info!("asciinema session ended");
|
||||
|
||||
if let Some(path) = self.output_file.as_ref() {
|
||||
status::info!("Recorded to {}", path);
|
||||
}
|
||||
|
||||
shutdown_token.cancel();
|
||||
|
||||
if let Some(task) = server {
|
||||
debug!("waiting for server shutdown...");
|
||||
let _ = time::timeout(Duration::from_secs(5), task).await;
|
||||
}
|
||||
|
||||
if let Some(task) = forwarder {
|
||||
debug!("waiting for forwarder shutdown...");
|
||||
let _ = time::timeout(Duration::from_secs(5), task).await;
|
||||
}
|
||||
|
||||
Ok(exit_status)
|
||||
}
|
||||
|
||||
fn get_command(&self, config: &config::Session) -> Option<String> {
|
||||
self.command.as_ref().cloned().or(config.command.clone())
|
||||
}
|
||||
|
||||
async fn get_session_metadata(&self, config: &config::Session) -> Result<Metadata> {
|
||||
Ok(Metadata {
|
||||
time: SystemTime::now(),
|
||||
term: self.get_term_info().await?,
|
||||
idle_time_limit: self.idle_time_limit.or(config.idle_time_limit),
|
||||
command: self.get_command(config),
|
||||
title: self.title.clone(),
|
||||
env: capture_env(self.capture_env.clone(), config),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_term_info(&self) -> Result<TermInfo> {
|
||||
let mut tty = self.get_tty(false).await?;
|
||||
|
||||
Ok(TermInfo {
|
||||
type_: env::var("TERM").ok(),
|
||||
version: tty.get_version().await,
|
||||
size: tty.get_size().into(),
|
||||
theme: tty.get_theme().await,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_file_writer<N: Notifier + 'static>(
|
||||
&self,
|
||||
metadata: &Metadata,
|
||||
notifier: N,
|
||||
) -> Result<Option<FileWriter>> {
|
||||
let Some(path) = self.output_file.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let path = Path::new(path);
|
||||
let (overwrite, append) = self.get_file_mode(path)?;
|
||||
let file = self.open_output_file(path, overwrite, append).await?;
|
||||
let format = self.get_file_format(path, append)?;
|
||||
let writer = Box::new(file);
|
||||
let notifier = Box::new(notifier);
|
||||
let encoder = self.get_encoder(format, path, append)?;
|
||||
|
||||
Ok(Some(FileWriter::new(
|
||||
writer,
|
||||
encoder,
|
||||
notifier,
|
||||
metadata.clone(),
|
||||
)))
|
||||
}
|
||||
|
||||
fn get_file_mode(&self, path: &Path) -> Result<(bool, bool)> {
|
||||
let mut overwrite = self.overwrite;
|
||||
let mut append = self.append;
|
||||
|
||||
if path.exists() {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
|
||||
if metadata.len() == 0 {
|
||||
overwrite = true;
|
||||
append = false;
|
||||
}
|
||||
|
||||
if !append && !overwrite {
|
||||
bail!("file exists, use --overwrite or --append");
|
||||
}
|
||||
} else {
|
||||
append = false;
|
||||
}
|
||||
|
||||
Ok((overwrite, append))
|
||||
}
|
||||
|
||||
fn get_file_format(&self, path: &Path, append: bool) -> Result<Format> {
|
||||
self.output_format.map(Ok).unwrap_or_else(|| {
|
||||
if path.extension().is_some_and(|ext| ext == "txt") {
|
||||
Ok(Format::Txt)
|
||||
} else if append {
|
||||
match asciicast::open_from_path(path) {
|
||||
Ok(cast) => match cast.version {
|
||||
Version::One => bail!("appending to asciicast v1 files is not supported"),
|
||||
Version::Two => Ok(Format::AsciicastV2),
|
||||
Version::Three => Ok(Format::AsciicastV3),
|
||||
},
|
||||
|
||||
Err(e) => bail!("can't append: {e}"),
|
||||
}
|
||||
} else {
|
||||
Ok(Format::AsciicastV3)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_encoder(
|
||||
&self,
|
||||
format: Format,
|
||||
path: &Path,
|
||||
append: bool,
|
||||
) -> Result<Box<dyn Encoder + Send>> {
|
||||
match format {
|
||||
Format::AsciicastV3 => Ok(Box::new(AsciicastV3Encoder::new(append))),
|
||||
|
||||
Format::AsciicastV2 => {
|
||||
let time_offset = if append {
|
||||
asciicast::get_duration(path)?
|
||||
} else {
|
||||
Duration::from_micros(0)
|
||||
};
|
||||
|
||||
Ok(Box::new(AsciicastV2Encoder::new(append, time_offset)))
|
||||
}
|
||||
|
||||
Format::Raw => Ok(Box::new(RawEncoder::new())),
|
||||
Format::Txt => Ok(Box::new(TextEncoder::new())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_output_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
overwrite: bool,
|
||||
append: bool,
|
||||
) -> Result<tokio::fs::File> {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
|
||||
tokio::fs::File::options()
|
||||
.write(true)
|
||||
.append(append)
|
||||
.create(overwrite)
|
||||
.create_new(!overwrite && !append)
|
||||
.truncate(overwrite)
|
||||
.open(path)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
async fn get_listener(&self) -> Result<Option<TcpListener>> {
|
||||
let Some(addr) = self.stream_local else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
TcpListener::bind(addr)
|
||||
.await
|
||||
.map(Some)
|
||||
.context("cannot start listener")
|
||||
}
|
||||
|
||||
async fn get_relay(
|
||||
&mut self,
|
||||
metadata: &Metadata,
|
||||
config: &mut config::Config,
|
||||
) -> Result<Option<Relay>> {
|
||||
let Some(target) = &self.stream_remote else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let relay = match target {
|
||||
RelayTarget::StreamId(id) => {
|
||||
let stream = self.start_stream(id, metadata, config).await?;
|
||||
|
||||
Relay {
|
||||
ws_producer_url: stream.ws_producer_url.parse()?,
|
||||
url: Some(stream.url.parse()?),
|
||||
}
|
||||
}
|
||||
|
||||
RelayTarget::WsProducerUrl(url) => Relay {
|
||||
ws_producer_url: url.clone(),
|
||||
url: None,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Some(relay))
|
||||
}
|
||||
|
||||
async fn start_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
metadata: &Metadata,
|
||||
config: &mut Config,
|
||||
) -> Result<StreamResponse> {
|
||||
let env = if metadata.env.is_empty() {
|
||||
Some(None)
|
||||
} else {
|
||||
Some(Some(metadata.env.clone()))
|
||||
};
|
||||
|
||||
let changeset = StreamChangeset {
|
||||
live: Some(true),
|
||||
title: metadata.title.clone().map(Some),
|
||||
term_type: Some(metadata.term.type_.clone()),
|
||||
term_version: Some(metadata.term.version.clone()),
|
||||
shell: Some(env::var("SHELL").ok()),
|
||||
env,
|
||||
};
|
||||
|
||||
if id.is_empty() {
|
||||
api::create_stream(changeset, config).await
|
||||
} else {
|
||||
match &api::list_user_streams(id, config).await?[..] {
|
||||
[] => {
|
||||
bail!("no stream matches \"{id}\"");
|
||||
}
|
||||
|
||||
[stream] => api::update_stream(stream.id, changeset, config).await,
|
||||
|
||||
streams => {
|
||||
let urls = streams
|
||||
.iter()
|
||||
.map(|s| s.url.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
bail!("multiple streams match \"{id}\" prefix:\n\n{urls}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_tty(&self, quiet: bool) -> Result<Box<dyn Tty>> {
|
||||
let (cols, rows) = self.window_size.unwrap_or((None, None));
|
||||
|
||||
if self.headless {
|
||||
Ok(Box::new(FixedSizeTty::new(NullTty, cols, rows)))
|
||||
} else if let Ok(dev_tty) = DevTty::open().await {
|
||||
Ok(Box::new(FixedSizeTty::new(dev_tty, cols, rows)))
|
||||
} else {
|
||||
if !quiet {
|
||||
status::info!("TTY not available, recording in headless mode");
|
||||
}
|
||||
|
||||
Ok(Box::new(FixedSizeTty::new(NullTty, cols, rows)))
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging(&self) -> Result<()> {
|
||||
let Some(path) = &self.log_file else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let file = self.open_log_file(path)?;
|
||||
|
||||
let filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_env_filter(filter)
|
||||
.with_writer(file)
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_log_file(&self, path: &PathBuf) -> Result<std::fs::File> {
|
||||
std::fs::File::options()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|e| anyhow!("cannot open log file {}: {}", path.to_string_lossy(), e))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Relay {
|
||||
ws_producer_url: Url,
|
||||
url: Option<Url>,
|
||||
}
|
||||
|
||||
impl Relay {
|
||||
fn id(&self) -> String {
|
||||
format!("{:x}", hash::fnv1a_128(self.ws_producer_url.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_bindings(config: &config::Session) -> Result<KeyBindings> {
|
||||
let mut keys = KeyBindings::default();
|
||||
|
||||
if let Some(key) = config.prefix_key()? {
|
||||
keys.prefix = key;
|
||||
}
|
||||
|
||||
if let Some(key) = config.pause_key()? {
|
||||
keys.pause = key;
|
||||
}
|
||||
|
||||
if let Some(key) = config.add_marker_key()? {
|
||||
keys.add_marker = key;
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn capture_env(var_names: Option<String>, config: &config::Session) -> HashMap<String, String> {
|
||||
let var_names = var_names
|
||||
.or(config.capture_env.clone())
|
||||
.unwrap_or(String::from("SHELL"));
|
||||
|
||||
let vars = var_names.split(',').collect::<HashSet<_>>();
|
||||
|
||||
env::vars()
|
||||
.filter(|(k, _v)| vars.contains(&k.as_str()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
}
|
||||
|
||||
fn get_notifier(config: &Config) -> BackgroundNotifier {
|
||||
let inner = if config.notifications.enabled {
|
||||
notifier::get_notifier(config.notifications.command.clone())
|
||||
} else {
|
||||
Box::new(NullNotifier)
|
||||
};
|
||||
|
||||
notifier::background(inner)
|
||||
}
|
||||
|
||||
fn build_exec_command(command: Option<String>) -> Vec<String> {
|
||||
let command = command
|
||||
.or(env::var("SHELL").ok())
|
||||
.unwrap_or("/bin/sh".to_owned());
|
||||
|
||||
vec!["/bin/sh".to_owned(), "-c".to_owned(), command]
|
||||
}
|
||||
|
||||
fn build_exec_extra_env(vars: &[String], relay_id: Option<&String>) -> HashMap<String, String> {
|
||||
let mut env = HashMap::new();
|
||||
|
||||
for var in vars {
|
||||
if let Some((name, value)) = var.split_once('=') {
|
||||
env.insert(name.to_owned(), value.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
let session_id = format!("{:x}", hash::fnv1a_128(process::id().to_string()));
|
||||
env.insert("ASCIINEMA_SESSION".to_owned(), session_id);
|
||||
|
||||
if let Some(id) = relay_id {
|
||||
env.insert("ASCIINEMA_RELAY_ID".to_owned(), id.clone());
|
||||
}
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
fn get_parent_session_relay_id() -> Option<String> {
|
||||
env::var("ASCIINEMA_RELAY_ID").ok()
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
use super::Command;
|
||||
use crate::api;
|
||||
use crate::cli;
|
||||
use crate::config::Config;
|
||||
use crate::locale;
|
||||
use crate::logger;
|
||||
use crate::pty;
|
||||
use crate::streamer::{self, KeyBindings};
|
||||
use crate::tty::{self, FixedSizeTty, Tty};
|
||||
use crate::util;
|
||||
use anyhow::bail;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use cli::{RelayTarget, DEFAULT_LISTEN_ADDR};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fmt::Debug;
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Relay {
|
||||
ws_producer_url: Url,
|
||||
url: Option<Url>,
|
||||
}
|
||||
|
||||
impl Command for cli::Stream {
|
||||
fn run(mut self, config: &Config) -> Result<()> {
|
||||
locale::check_utf8_locale()?;
|
||||
|
||||
if self.serve.is_none() && self.relay.is_none() {
|
||||
self.serve = Some(DEFAULT_LISTEN_ADDR.parse().unwrap());
|
||||
}
|
||||
|
||||
let command = self.get_command(config);
|
||||
let keys = get_key_bindings(config)?;
|
||||
let notifier = super::get_notifier(config);
|
||||
let record_input = self.input || config.cmd_stream_input();
|
||||
let exec_command = super::build_exec_command(command.as_ref().cloned());
|
||||
let listener = self.get_listener()?;
|
||||
let relay = self.get_relay(config)?;
|
||||
let relay_id = relay.as_ref().map(|r| r.id());
|
||||
let exec_extra_env = build_exec_extra_env(relay_id.as_ref());
|
||||
|
||||
if let (Some(id), Some(parent_id)) = (relay_id, parent_session_relay_id()) {
|
||||
if id == parent_id {
|
||||
if let Some(Relay { url: Some(url), .. }) = relay {
|
||||
bail!("This shell is already being streamed at {url}");
|
||||
} else {
|
||||
bail!("This shell is already being streamed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger::info!("Streaming session started");
|
||||
|
||||
if let Some(listener) = &listener {
|
||||
logger::info!(
|
||||
"Live stream available at http://{}",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(Relay { url: Some(url), .. }) = &relay {
|
||||
logger::info!("Live stream available at {}", url);
|
||||
}
|
||||
|
||||
if command.is_none() {
|
||||
logger::info!("Press <ctrl+d> or type 'exit' to end");
|
||||
}
|
||||
|
||||
{
|
||||
let mut tty = self.get_tty()?;
|
||||
|
||||
let mut streamer = streamer::Streamer::new(
|
||||
listener,
|
||||
relay.map(|e| e.ws_producer_url),
|
||||
record_input,
|
||||
keys,
|
||||
notifier,
|
||||
tty.get_theme(),
|
||||
);
|
||||
|
||||
self.init_logging()?;
|
||||
pty::exec(&exec_command, &exec_extra_env, &mut tty, &mut streamer)?;
|
||||
}
|
||||
|
||||
logger::info!("Streaming session ended");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl cli::Stream {
|
||||
fn get_command(&self, config: &Config) -> Option<String> {
|
||||
self.command
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.or(config.cmd_stream_command())
|
||||
}
|
||||
|
||||
fn get_relay(&mut self, config: &Config) -> Result<Option<Relay>> {
|
||||
match self.relay.take() {
|
||||
Some(RelayTarget::StreamId(id)) => {
|
||||
let stream = api::create_user_stream(id, config)?;
|
||||
|
||||
Ok(Some(Relay {
|
||||
ws_producer_url: stream.ws_producer_url.parse()?,
|
||||
url: Some(stream.url.parse()?),
|
||||
}))
|
||||
}
|
||||
|
||||
Some(RelayTarget::WsProducerUrl(url)) => Ok(Some(Relay {
|
||||
ws_producer_url: url,
|
||||
url: None,
|
||||
})),
|
||||
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_listener(&self) -> Result<Option<TcpListener>> {
|
||||
if let Some(addr) = self.serve {
|
||||
return Ok(Some(
|
||||
TcpListener::bind(addr).context("cannot start listener")?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn get_tty(&self) -> Result<FixedSizeTty> {
|
||||
let (cols, rows) = self.tty_size.unwrap_or((None, None));
|
||||
|
||||
if self.headless {
|
||||
Ok(FixedSizeTty::new(tty::NullTty::open()?, cols, rows))
|
||||
} else if let Ok(dev_tty) = tty::DevTty::open() {
|
||||
Ok(FixedSizeTty::new(dev_tty, cols, rows))
|
||||
} else {
|
||||
logger::info!("TTY not available, streaming in headless mode");
|
||||
Ok(FixedSizeTty::new(tty::NullTty::open()?, cols, rows))
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging(&self) -> Result<()> {
|
||||
let log_file = self.log_file.as_ref().cloned();
|
||||
|
||||
if let Some(path) = &log_file {
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|e| anyhow!("cannot open log file {}: {}", path.to_string_lossy(), e))?;
|
||||
|
||||
let filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_env_filter(filter)
|
||||
.with_writer(file)
|
||||
.init();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Relay {
|
||||
fn id(&self) -> String {
|
||||
util::sha2_digest(self.ws_producer_url.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_bindings(config: &Config) -> Result<KeyBindings> {
|
||||
let mut keys = KeyBindings::default();
|
||||
|
||||
if let Some(key) = config.cmd_stream_prefix_key()? {
|
||||
keys.prefix = key;
|
||||
}
|
||||
|
||||
if let Some(key) = config.cmd_stream_pause_key()? {
|
||||
keys.pause = key;
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn build_exec_extra_env(relay_id: Option<&String>) -> HashMap<String, String> {
|
||||
match relay_id {
|
||||
Some(id) => super::build_exec_extra_env(&[("ASCIINEMA_RELAY_ID".to_string(), id.clone())]),
|
||||
None => super::build_exec_extra_env(&[]),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_session_relay_id() -> Option<String> {
|
||||
env::var("ASCIINEMA_RELAY_ID").ok()
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
use super::Command;
|
||||
use anyhow::Result;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::api;
|
||||
use crate::asciicast;
|
||||
use crate::cli;
|
||||
use crate::config::Config;
|
||||
use anyhow::Result;
|
||||
|
||||
impl Command for cli::Upload {
|
||||
fn run(self, config: &Config) -> Result<()> {
|
||||
let _ = asciicast::open_from_path(&self.filename)?;
|
||||
let response = api::upload_asciicast(&self.filename, config)?;
|
||||
impl cli::Upload {
|
||||
pub fn run(self) -> Result<()> {
|
||||
Runtime::new()?.block_on(self.do_run())
|
||||
}
|
||||
|
||||
async fn do_run(self) -> Result<()> {
|
||||
let mut config = Config::new(self.server_url.clone())?;
|
||||
let _ = asciicast::open_from_path(&self.file)?;
|
||||
let response = api::create_recording(&self.file, &mut config).await?;
|
||||
println!("{}", response.message.unwrap_or(response.url));
|
||||
|
||||
Ok(())
|
||||
|
||||
245
src/config.rs
245
src/config.rs
@@ -1,12 +1,16 @@
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use config::{self, File};
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::status;
|
||||
|
||||
const DEFAULT_SERVER_URL: &str = "https://asciinema.org";
|
||||
const INSTALL_ID_FILENAME: &str = "install-id";
|
||||
|
||||
@@ -16,7 +20,8 @@ pub type Key = Option<Vec<u8>>;
|
||||
#[allow(unused)]
|
||||
pub struct Config {
|
||||
server: Server,
|
||||
cmd: Cmd,
|
||||
pub session: Session,
|
||||
pub playback: Playback,
|
||||
pub notifications: Notifications,
|
||||
}
|
||||
|
||||
@@ -26,30 +31,21 @@ pub struct Server {
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[allow(unused)]
|
||||
pub struct Cmd {
|
||||
rec: Rec,
|
||||
play: Play,
|
||||
stream: Stream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[allow(unused)]
|
||||
pub struct Rec {
|
||||
pub struct Session {
|
||||
pub command: Option<String>,
|
||||
pub filename: String,
|
||||
pub input: bool,
|
||||
pub env: Option<String>,
|
||||
pub capture_input: bool,
|
||||
pub capture_env: Option<String>,
|
||||
pub idle_time_limit: Option<f64>,
|
||||
pub prefix_key: Option<String>,
|
||||
pub pause_key: Option<String>,
|
||||
pub add_marker_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub struct Play {
|
||||
pub struct Playback {
|
||||
pub speed: Option<f64>,
|
||||
pub idle_time_limit: Option<f64>,
|
||||
pub pause_key: Option<String>,
|
||||
@@ -57,16 +53,6 @@ pub struct Play {
|
||||
pub next_marker_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[allow(unused)]
|
||||
pub struct Stream {
|
||||
pub command: Option<String>,
|
||||
pub input: bool,
|
||||
pub env: Option<String>,
|
||||
pub prefix_key: Option<String>,
|
||||
pub pause_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub struct Notifications {
|
||||
@@ -78,41 +64,37 @@ impl Config {
|
||||
pub fn new(server_url: Option<String>) -> Result<Self> {
|
||||
let mut config = config::Config::builder()
|
||||
.set_default("server.url", None::<Option<String>>)?
|
||||
.set_default("cmd.rec.input", false)?
|
||||
.set_default("cmd.rec.filename", "%Y-%m-%d-%H-%M-%S-{pid}.cast")?
|
||||
.set_default("cmd.play.speed", None::<Option<f64>>)?
|
||||
.set_default("cmd.stream.input", false)?
|
||||
.set_default("playback.speed", None::<Option<f64>>)?
|
||||
.set_default("session.capture_input", false)?
|
||||
.set_default("notifications.enabled", true)?
|
||||
.add_source(config::File::with_name("/etc/asciinema/config.toml").required(false))
|
||||
.add_source(
|
||||
config::File::with_name(&user_defaults_path()?.to_string_lossy()).required(false),
|
||||
)
|
||||
.add_source(
|
||||
config::File::with_name(&user_config_path()?.to_string_lossy()).required(false),
|
||||
)
|
||||
.add_source(config::Environment::with_prefix("asciinema").separator("_"));
|
||||
.add_source(File::with_name("/etc/asciinema/config.toml").required(false))
|
||||
.add_source(File::with_name(&user_defaults_path()?.to_string_lossy()).required(false))
|
||||
.add_source(File::with_name(&user_config_path()?.to_string_lossy()).required(false));
|
||||
|
||||
// legacy env var
|
||||
if let Ok(url) = env::var("ASCIINEMA_API_URL") {
|
||||
config = config.set_override("server.url", Some(url))?;
|
||||
}
|
||||
|
||||
if let Ok(url) = env::var("ASCIINEMA_SERVER_URL") {
|
||||
config = config.set_override("server.url", Some(url))?;
|
||||
}
|
||||
|
||||
if let Some(url) = server_url {
|
||||
config = config.set_override("server.url", Some(url))?;
|
||||
}
|
||||
|
||||
if let (Err(_), Ok(url)) = (
|
||||
env::var("ASCIINEMA_SERVER_URL"),
|
||||
env::var("ASCIINEMA_API_URL"),
|
||||
) {
|
||||
env::set_var("ASCIINEMA_SERVER_URL", url);
|
||||
}
|
||||
|
||||
Ok(config.build()?.try_deserialize()?)
|
||||
}
|
||||
|
||||
pub fn get_server_url(&self) -> Result<Url> {
|
||||
pub fn get_server_url(&mut self) -> Result<Url> {
|
||||
match self.server.url.as_ref() {
|
||||
Some(url) => Ok(parse_server_url(url)?),
|
||||
|
||||
None => {
|
||||
let url = parse_server_url(&ask_for_server_url()?)?;
|
||||
save_default_server_url(url.as_ref())?;
|
||||
self.server.url = Some(url.to_string());
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
@@ -121,113 +103,57 @@ impl Config {
|
||||
|
||||
pub fn get_install_id(&self) -> Result<String> {
|
||||
let path = install_id_path()?;
|
||||
let legacy_path = legacy_install_id_path()?;
|
||||
|
||||
if let Some(id) = read_install_id(&path)? {
|
||||
Ok(id)
|
||||
} else if let Some(id) = read_install_id(&legacy_path)? {
|
||||
Ok(id)
|
||||
} else {
|
||||
let id = create_install_id();
|
||||
let id = generate_install_id();
|
||||
save_install_id(&path, &id)?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_rec_command(&self) -> Option<String> {
|
||||
self.cmd.rec.command.as_ref().cloned()
|
||||
impl Session {
|
||||
pub fn prefix_key(&self) -> Result<Option<Key>> {
|
||||
self.prefix_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_rec_filename(&self) -> String {
|
||||
self.cmd.rec.filename.clone()
|
||||
pub fn pause_key(&self) -> Result<Option<Key>> {
|
||||
self.pause_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_rec_input(&self) -> bool {
|
||||
self.cmd.rec.input
|
||||
pub fn add_marker_key(&self) -> Result<Option<Key>> {
|
||||
self.add_marker_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
pub fn pause_key(&self) -> Result<Option<Key>> {
|
||||
self.pause_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_rec_idle_time_limit(&self) -> Option<f64> {
|
||||
self.cmd.rec.idle_time_limit
|
||||
pub fn step_key(&self) -> Result<Option<Key>> {
|
||||
self.step_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_rec_env(&self) -> Option<String> {
|
||||
self.cmd.rec.env.as_ref().cloned()
|
||||
}
|
||||
|
||||
pub fn cmd_rec_prefix_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd.rec.prefix_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_rec_pause_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd.rec.pause_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_rec_add_marker_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd
|
||||
.rec
|
||||
.add_marker_key
|
||||
.as_ref()
|
||||
.map(parse_key)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_play_speed(&self) -> Option<f64> {
|
||||
self.cmd.play.speed
|
||||
}
|
||||
|
||||
pub fn cmd_play_idle_time_limit(&self) -> Option<f64> {
|
||||
self.cmd.play.idle_time_limit
|
||||
}
|
||||
|
||||
pub fn cmd_play_pause_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd.play.pause_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_play_step_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd.play.step_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_play_next_marker_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd
|
||||
.play
|
||||
.next_marker_key
|
||||
.as_ref()
|
||||
.map(parse_key)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_stream_command(&self) -> Option<String> {
|
||||
self.cmd.stream.command.as_ref().cloned()
|
||||
}
|
||||
|
||||
pub fn cmd_stream_input(&self) -> bool {
|
||||
self.cmd.stream.input
|
||||
}
|
||||
|
||||
pub fn cmd_stream_prefix_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd
|
||||
.stream
|
||||
.prefix_key
|
||||
.as_ref()
|
||||
.map(parse_key)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn cmd_stream_pause_key(&self) -> Result<Option<Key>> {
|
||||
self.cmd
|
||||
.stream
|
||||
.pause_key
|
||||
.as_ref()
|
||||
.map(parse_key)
|
||||
.transpose()
|
||||
pub fn next_marker_key(&self) -> Result<Option<Key>> {
|
||||
self.next_marker_key.as_ref().map(parse_key).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
fn ask_for_server_url() -> Result<String> {
|
||||
println!("No asciinema server configured for this CLI.");
|
||||
let mut rl = rustyline::DefaultEditor::new()?;
|
||||
let url = rl.readline_with_initial(
|
||||
|
||||
let url = rustyline::DefaultEditor::new()?.readline_with_initial(
|
||||
"Enter the server URL to use by default: ",
|
||||
(DEFAULT_SERVER_URL, ""),
|
||||
)?;
|
||||
|
||||
println!();
|
||||
|
||||
Ok(url)
|
||||
@@ -269,7 +195,7 @@ fn read_install_id(path: &PathBuf) -> Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_install_id() -> String {
|
||||
fn generate_install_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
@@ -283,19 +209,27 @@ fn save_install_id(path: &PathBuf, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn user_config_path() -> Result<PathBuf> {
|
||||
Ok(home()?.join("config.toml"))
|
||||
pub fn user_config_path() -> Result<PathBuf> {
|
||||
Ok(config_home()?.join("config.toml"))
|
||||
}
|
||||
|
||||
fn legacy_user_config_path() -> Result<PathBuf> {
|
||||
Ok(config_home()?.join("config"))
|
||||
}
|
||||
|
||||
fn user_defaults_path() -> Result<PathBuf> {
|
||||
Ok(home()?.join("defaults.toml"))
|
||||
Ok(config_home()?.join("defaults.toml"))
|
||||
}
|
||||
|
||||
fn install_id_path() -> Result<PathBuf> {
|
||||
Ok(home()?.join(INSTALL_ID_FILENAME))
|
||||
Ok(state_home()?.join(INSTALL_ID_FILENAME))
|
||||
}
|
||||
|
||||
fn home() -> Result<PathBuf> {
|
||||
fn legacy_install_id_path() -> Result<PathBuf> {
|
||||
Ok(config_home()?.join(INSTALL_ID_FILENAME))
|
||||
}
|
||||
|
||||
fn config_home() -> Result<PathBuf> {
|
||||
env::var("ASCIINEMA_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or(env::var("XDG_CONFIG_HOME").map(|home| Path::new(&home).join("asciinema")))
|
||||
@@ -303,6 +237,19 @@ fn home() -> Result<PathBuf> {
|
||||
.map_err(|_| anyhow!("need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME"))
|
||||
}
|
||||
|
||||
fn state_home() -> Result<PathBuf> {
|
||||
env::var("ASCIINEMA_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or(env::var("XDG_STATE_HOME").map(|home| Path::new(&home).join("asciinema")))
|
||||
.or(env::var("HOME").map(|home| {
|
||||
Path::new(&home)
|
||||
.join(".local")
|
||||
.join("state")
|
||||
.join("asciinema")
|
||||
}))
|
||||
.map_err(|_| anyhow!("need $HOME or $XDG_STATE_HOME or $ASCIINEMA_STATE_HOME"))
|
||||
}
|
||||
|
||||
fn parse_key<S: AsRef<str>>(key: S) -> Result<Key> {
|
||||
let key = key.as_ref();
|
||||
let chars: Vec<char> = key.chars().collect();
|
||||
@@ -326,7 +273,7 @@ fn parse_key<S: AsRef<str>>(key: S) -> Result<Key> {
|
||||
}
|
||||
|
||||
3 => {
|
||||
if chars[0].to_ascii_uppercase() == 'C'
|
||||
if chars[0].eq_ignore_ascii_case(&'C')
|
||||
&& ['+', '-'].contains(&chars[1])
|
||||
&& chars[2].is_ascii_alphabetic()
|
||||
{
|
||||
@@ -341,3 +288,27 @@ fn parse_key<S: AsRef<str>>(key: S) -> Result<Key> {
|
||||
|
||||
Err(anyhow!("invalid key definition '{key}'"))
|
||||
}
|
||||
|
||||
pub fn check_legacy_config_file() {
|
||||
let Ok(legacy_path) = legacy_user_config_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(new_path) = user_config_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if legacy_path.exists() && !new_path.exists() {
|
||||
status::warning!(
|
||||
"Your config file at {} uses the location and format from asciinema 2.x.",
|
||||
legacy_path.to_string_lossy()
|
||||
);
|
||||
|
||||
status::warning!(
|
||||
"For asciinema 3.x (this version) create a new config file at {}.",
|
||||
new_path.to_string_lossy()
|
||||
);
|
||||
|
||||
status::warning!("Read the documentation (CLI -> Configuration) for details.\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +1,69 @@
|
||||
use crate::asciicast::{Event, Header, Writer};
|
||||
use crate::tty;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct AsciicastEncoder<W: Write> {
|
||||
writer: Writer<W>,
|
||||
use crate::asciicast::{Event, Header, V2Encoder, V3Encoder};
|
||||
|
||||
pub struct AsciicastV2Encoder {
|
||||
inner: V2Encoder,
|
||||
append: bool,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
pub struct Metadata {
|
||||
pub idle_time_limit: Option<f64>,
|
||||
pub command: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
pub theme: Option<tty::Theme>,
|
||||
}
|
||||
impl AsciicastV2Encoder {
|
||||
pub fn new(append: bool, time_offset: Duration) -> Self {
|
||||
let inner = V2Encoder::new(time_offset);
|
||||
|
||||
impl<W> AsciicastEncoder<W>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
pub fn new(writer: W, append: bool, time_offset: u64, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
writer: Writer::new(writer, time_offset),
|
||||
append,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_header(&self, timestamp: Option<u64>, tty_size: &tty::TtySize) -> Header {
|
||||
Header {
|
||||
version: 2,
|
||||
cols: tty_size.0,
|
||||
rows: tty_size.1,
|
||||
timestamp,
|
||||
idle_time_limit: self.metadata.idle_time_limit,
|
||||
command: self.metadata.command.clone(),
|
||||
title: self.metadata.title.clone(),
|
||||
env: self.metadata.env.clone(),
|
||||
theme: self.metadata.theme.clone(),
|
||||
}
|
||||
Self { inner, append }
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> super::Encoder for AsciicastEncoder<W>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
fn start(&mut self, timestamp: Option<u64>, tty_size: &tty::TtySize) -> io::Result<()> {
|
||||
impl super::Encoder for AsciicastV2Encoder {
|
||||
fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
if self.append {
|
||||
Ok(())
|
||||
let size = (header.term_cols, header.term_rows);
|
||||
self.inner
|
||||
.event(&Event::resize(Duration::from_micros(0), size))
|
||||
} else {
|
||||
let header = self.build_header(timestamp, tty_size);
|
||||
|
||||
self.writer.write_header(&header)
|
||||
self.inner.header(header)
|
||||
}
|
||||
}
|
||||
|
||||
fn event(&mut self, event: &Event) -> io::Result<()> {
|
||||
self.writer.write_event(event)
|
||||
fn event(&mut self, event: Event) -> Vec<u8> {
|
||||
self.inner.event(&event)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Header> for Metadata {
|
||||
fn from(header: &Header) -> Self {
|
||||
Metadata {
|
||||
idle_time_limit: header.idle_time_limit.as_ref().cloned(),
|
||||
command: header.command.as_ref().cloned(),
|
||||
title: header.title.as_ref().cloned(),
|
||||
env: header.env.as_ref().cloned(),
|
||||
theme: header.theme.as_ref().cloned(),
|
||||
}
|
||||
pub struct AsciicastV3Encoder {
|
||||
inner: V3Encoder,
|
||||
append: bool,
|
||||
}
|
||||
|
||||
impl AsciicastV3Encoder {
|
||||
pub fn new(append: bool) -> Self {
|
||||
let inner = V3Encoder::new();
|
||||
|
||||
Self { inner, append }
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Encoder for AsciicastV3Encoder {
|
||||
fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
if self.append {
|
||||
let size = (header.term_cols, header.term_rows);
|
||||
self.inner
|
||||
.event(&Event::resize(Duration::from_micros(0), size))
|
||||
} else {
|
||||
self.inner.header(header)
|
||||
}
|
||||
}
|
||||
|
||||
fn event(&mut self, event: Event) -> Vec<u8> {
|
||||
self.inner.event(&event)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,73 +2,36 @@ mod asciicast;
|
||||
mod raw;
|
||||
mod txt;
|
||||
|
||||
pub use asciicast::AsciicastEncoder;
|
||||
pub use asciicast::Metadata;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::asciicast::{Event, Header};
|
||||
pub use asciicast::{AsciicastV2Encoder, AsciicastV3Encoder};
|
||||
pub use raw::RawEncoder;
|
||||
pub use txt::TextEncoder;
|
||||
|
||||
use crate::asciicast::Event;
|
||||
use crate::recorder;
|
||||
use crate::tty;
|
||||
use anyhow::Result;
|
||||
use std::io;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub trait Encoder {
|
||||
fn start(&mut self, timestamp: Option<u64>, tty_size: &tty::TtySize) -> io::Result<()>;
|
||||
fn event(&mut self, event: &Event) -> io::Result<()>;
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn header(&mut self, header: &Header) -> Vec<u8>;
|
||||
fn event(&mut self, event: Event) -> Vec<u8>;
|
||||
fn flush(&mut self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
pub trait EncoderExt {
|
||||
fn encode(&mut self, recording: crate::asciicast::Asciicast) -> Result<()>;
|
||||
fn encode_to_file(&mut self, cast: crate::asciicast::Asciicast, file: &mut File) -> Result<()>;
|
||||
}
|
||||
|
||||
impl<E: Encoder + ?Sized> EncoderExt for E {
|
||||
fn encode(&mut self, recording: crate::asciicast::Asciicast) -> Result<()> {
|
||||
let tty_size = tty::TtySize(recording.header.cols, recording.header.rows);
|
||||
self.start(recording.header.timestamp, &tty_size)?;
|
||||
fn encode_to_file(&mut self, cast: crate::asciicast::Asciicast, file: &mut File) -> Result<()> {
|
||||
file.write_all(&self.header(&cast.header))?;
|
||||
|
||||
for event in recording.events {
|
||||
self.event(&event?)?;
|
||||
for event in cast.events {
|
||||
file.write_all(&self.event(event?))?;
|
||||
}
|
||||
|
||||
self.finish()?;
|
||||
file.write_all(&self.flush())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Encoder> recorder::Output for E {
|
||||
fn start(&mut self, tty_size: &tty::TtySize) -> io::Result<()> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
self.start(Some(timestamp), tty_size)
|
||||
}
|
||||
|
||||
fn output(&mut self, time: u64, text: String) -> io::Result<()> {
|
||||
self.event(&Event::output(time, text))
|
||||
}
|
||||
|
||||
fn input(&mut self, time: u64, text: String) -> io::Result<()> {
|
||||
self.event(&Event::input(time, text))
|
||||
}
|
||||
|
||||
fn resize(&mut self, time: u64, size: (u16, u16)) -> io::Result<()> {
|
||||
self.event(&Event::resize(time, size))
|
||||
}
|
||||
|
||||
fn marker(&mut self, time: u64) -> io::Result<()> {
|
||||
self.event(&Event::marker(time, "".to_owned()))
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,76 @@
|
||||
use crate::asciicast::{Event, EventData};
|
||||
use crate::tty;
|
||||
use std::io::{self, Write};
|
||||
use crate::asciicast::{Event, EventData, Header};
|
||||
|
||||
pub struct RawEncoder<W> {
|
||||
writer: W,
|
||||
append: bool,
|
||||
}
|
||||
pub struct RawEncoder;
|
||||
|
||||
impl<W> RawEncoder<W> {
|
||||
pub fn new(writer: W, append: bool) -> Self {
|
||||
RawEncoder { writer, append }
|
||||
impl RawEncoder {
|
||||
pub fn new() -> Self {
|
||||
RawEncoder
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> super::Encoder for RawEncoder<W> {
|
||||
fn start(&mut self, _timestamp: Option<u64>, tty_size: &tty::TtySize) -> io::Result<()> {
|
||||
if self.append {
|
||||
Ok(())
|
||||
impl super::Encoder for RawEncoder {
|
||||
fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
format!("\x1b[8;{};{}t", header.term_rows, header.term_cols).into_bytes()
|
||||
}
|
||||
|
||||
fn event(&mut self, event: Event) -> Vec<u8> {
|
||||
if let EventData::Output(data) = event.data {
|
||||
data.into_bytes()
|
||||
} else {
|
||||
write!(self.writer, "\x1b[8;{};{}t", tty_size.1, tty_size.0)
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn event(&mut self, event: &Event) -> io::Result<()> {
|
||||
if let EventData::Output(data) = &event.data {
|
||||
self.writer.write_all(data.as_bytes())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
fn flush(&mut self) -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::RawEncoder;
|
||||
use crate::asciicast::Event;
|
||||
use crate::asciicast::{Event, Header};
|
||||
use crate::encoder::Encoder;
|
||||
use crate::tty::TtySize;
|
||||
|
||||
#[test]
|
||||
fn encoder_impl() -> anyhow::Result<()> {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut enc = RawEncoder::new(&mut out, false);
|
||||
fn encoder() {
|
||||
let mut enc = RawEncoder::new();
|
||||
|
||||
enc.start(None, &TtySize(100, 50))?;
|
||||
enc.event(&Event::output(0, "he\x1b[1mllo\r\n".to_owned()))?;
|
||||
enc.event(&Event::output(1, "world\r\n".to_owned()))?;
|
||||
enc.event(&Event::input(2, ".".to_owned()))?;
|
||||
enc.event(&Event::resize(3, (80, 24)))?;
|
||||
enc.event(&Event::marker(4, ".".to_owned()))?;
|
||||
enc.finish()?;
|
||||
let header = Header {
|
||||
term_cols: 100,
|
||||
term_rows: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(out, b"\x1b[8;50;100the\x1b[1mllo\r\nworld\r\n");
|
||||
assert_eq!(enc.header(&header), "\x1b[8;50;100t".as_bytes());
|
||||
|
||||
Ok(())
|
||||
assert_eq!(
|
||||
enc.event(Event::output(
|
||||
Duration::from_micros(0),
|
||||
"he\x1b[1mllo\r\n".to_owned()
|
||||
)),
|
||||
"he\x1b[1mllo\r\n".as_bytes()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
enc.event(Event::output(
|
||||
Duration::from_micros(1),
|
||||
"world\r\n".to_owned()
|
||||
)),
|
||||
"world\r\n".as_bytes()
|
||||
);
|
||||
|
||||
assert!(enc
|
||||
.event(Event::input(Duration::from_micros(2), ".".to_owned()))
|
||||
.is_empty());
|
||||
assert!(enc
|
||||
.event(Event::resize(Duration::from_micros(3), (80, 24)))
|
||||
.is_empty());
|
||||
assert!(enc
|
||||
.event(Event::marker(Duration::from_micros(4), ".".to_owned()))
|
||||
.is_empty());
|
||||
assert!(enc.flush().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,91 @@
|
||||
use crate::asciicast::{Event, EventData};
|
||||
use crate::tty;
|
||||
use avt::util::{TextCollector, TextCollectorOutput};
|
||||
use std::io::{self, Write};
|
||||
use avt::util::TextCollector;
|
||||
|
||||
pub struct TextEncoder<W: Write> {
|
||||
writer: Option<W>,
|
||||
collector: Option<TextCollector<TextWriter<W>>>,
|
||||
use crate::asciicast::{Event, EventData, Header};
|
||||
|
||||
pub struct TextEncoder {
|
||||
collector: Option<TextCollector>,
|
||||
}
|
||||
|
||||
impl<W: Write> TextEncoder<W> {
|
||||
pub fn new(writer: W) -> Self {
|
||||
TextEncoder {
|
||||
writer: Some(writer),
|
||||
collector: None,
|
||||
}
|
||||
impl TextEncoder {
|
||||
pub fn new() -> Self {
|
||||
TextEncoder { collector: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> super::Encoder for TextEncoder<W> {
|
||||
fn start(&mut self, _timestamp: Option<u64>, tty_size: &tty::TtySize) -> io::Result<()> {
|
||||
impl super::Encoder for TextEncoder {
|
||||
fn header(&mut self, header: &Header) -> Vec<u8> {
|
||||
let vt = avt::Vt::builder()
|
||||
.size(tty_size.0 as usize, tty_size.1 as usize)
|
||||
.resizable(true)
|
||||
.size(header.term_cols as usize, header.term_rows as usize)
|
||||
.scrollback_limit(100)
|
||||
.build();
|
||||
|
||||
self.collector = Some(TextCollector::new(
|
||||
vt,
|
||||
TextWriter(self.writer.take().unwrap()),
|
||||
));
|
||||
self.collector = Some(TextCollector::new(vt));
|
||||
|
||||
Ok(())
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn event(&mut self, event: &Event) -> io::Result<()> {
|
||||
fn event(&mut self, event: Event) -> Vec<u8> {
|
||||
use EventData::*;
|
||||
|
||||
match &event.data {
|
||||
Output(data) => self.collector.as_mut().unwrap().feed_str(data),
|
||||
Resize(cols, rows) => self.collector.as_mut().unwrap().resize(*cols, *rows),
|
||||
_ => Ok(()),
|
||||
Output(data) => text_lines_to_bytes(self.collector.as_mut().unwrap().feed_str(data)),
|
||||
|
||||
Resize(cols, rows) => {
|
||||
text_lines_to_bytes(self.collector.as_mut().unwrap().resize(*cols, *rows))
|
||||
}
|
||||
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.collector.as_mut().unwrap().flush()
|
||||
fn flush(&mut self) -> Vec<u8> {
|
||||
text_lines_to_bytes(self.collector.take().unwrap().flush().iter())
|
||||
}
|
||||
}
|
||||
|
||||
struct TextWriter<W: Write>(W);
|
||||
fn text_lines_to_bytes<S: AsRef<str>>(lines: impl Iterator<Item = S>) -> Vec<u8> {
|
||||
lines.fold(Vec::new(), |mut bytes, line| {
|
||||
bytes.extend_from_slice(line.as_ref().as_bytes());
|
||||
bytes.push(b'\n');
|
||||
|
||||
impl<W: Write> TextCollectorOutput for TextWriter<W> {
|
||||
type Error = io::Error;
|
||||
|
||||
fn push(&mut self, line: String) -> Result<(), Self::Error> {
|
||||
self.0.write_all(line.as_bytes())?;
|
||||
self.0.write_all(b"\n")
|
||||
}
|
||||
bytes
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::TextEncoder;
|
||||
use crate::asciicast::Event;
|
||||
use crate::asciicast::{Event, Header};
|
||||
use crate::encoder::Encoder;
|
||||
use crate::tty::TtySize;
|
||||
|
||||
#[test]
|
||||
fn encoder_impl() -> anyhow::Result<()> {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut enc = TextEncoder::new(&mut out);
|
||||
fn encoder() {
|
||||
let mut enc = TextEncoder::new();
|
||||
|
||||
enc.start(None, &TtySize(3, 1))?;
|
||||
enc.event(&Event::output(0, "he\x1b[1mllo\r\n".to_owned()))?;
|
||||
enc.event(&Event::output(1, "world\r\n".to_owned()))?;
|
||||
enc.finish()?;
|
||||
let header = Header {
|
||||
term_cols: 3,
|
||||
term_rows: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(out, b"hello\nworld\n");
|
||||
assert!(enc.header(&header).is_empty());
|
||||
|
||||
Ok(())
|
||||
assert!(enc
|
||||
.event(Event::output(
|
||||
Duration::from_micros(0),
|
||||
"he\x1b[1mllo\r\n".to_owned()
|
||||
))
|
||||
.is_empty());
|
||||
|
||||
assert!(enc
|
||||
.event(Event::output(
|
||||
Duration::from_micros(1),
|
||||
"world\r\n".to_owned()
|
||||
))
|
||||
.is_empty());
|
||||
|
||||
assert_eq!(enc.flush(), "hello\nworld\n".as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
17
src/fd.rs
Normal file
17
src/fd.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use std::io;
|
||||
use std::os::fd::AsFd;
|
||||
|
||||
use nix::fcntl::{self, FcntlArg::*, OFlag};
|
||||
|
||||
pub trait FdExt: AsFd {
|
||||
fn set_nonblocking(&self) -> io::Result<()> {
|
||||
let flags = fcntl::fcntl(self.as_fd(), F_GETFL)?;
|
||||
let mut oflags = OFlag::from_bits_truncate(flags);
|
||||
oflags |= OFlag::O_NONBLOCK;
|
||||
fcntl::fcntl(self.as_fd(), F_SETFL(oflags))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsFd> FdExt for T {}
|
||||
115
src/file_writer.rs
Normal file
115
src/file_writer.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{self, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
use crate::asciicast;
|
||||
use crate::encoder::Encoder;
|
||||
use crate::notifier::Notifier;
|
||||
use crate::session::{self, Metadata};
|
||||
|
||||
pub struct FileWriter {
|
||||
writer: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
encoder: Box<dyn Encoder + Send>,
|
||||
notifier: Box<dyn Notifier>,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
pub struct LiveFileWriter {
|
||||
writer: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
encoder: Box<dyn Encoder + Send>,
|
||||
notifier: Box<dyn Notifier>,
|
||||
}
|
||||
|
||||
impl FileWriter {
|
||||
pub fn new(
|
||||
writer: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
encoder: Box<dyn Encoder + Send>,
|
||||
notifier: Box<dyn Notifier>,
|
||||
metadata: Metadata,
|
||||
) -> Self {
|
||||
FileWriter {
|
||||
writer,
|
||||
encoder,
|
||||
notifier,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(mut self) -> io::Result<LiveFileWriter> {
|
||||
let timestamp = self
|
||||
.metadata
|
||||
.time
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let header = asciicast::Header {
|
||||
term_cols: self.metadata.term.size.0,
|
||||
term_rows: self.metadata.term.size.1,
|
||||
term_type: self.metadata.term.type_.clone(),
|
||||
term_version: self.metadata.term.version.clone(),
|
||||
term_theme: self.metadata.term.theme.clone(),
|
||||
timestamp: Some(timestamp),
|
||||
idle_time_limit: self.metadata.idle_time_limit,
|
||||
command: self.metadata.command.as_ref().cloned(),
|
||||
title: self.metadata.title.as_ref().cloned(),
|
||||
env: Some(self.metadata.env.clone()),
|
||||
};
|
||||
|
||||
if let Err(e) = self.writer.write_all(&self.encoder.header(&header)).await {
|
||||
let _ = self
|
||||
.notifier
|
||||
.notify("Write error, session won't be recorded".to_owned())
|
||||
.await;
|
||||
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(LiveFileWriter {
|
||||
writer: self.writer,
|
||||
encoder: self.encoder,
|
||||
notifier: self.notifier,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl session::Output for LiveFileWriter {
|
||||
async fn event(&mut self, event: session::Event) -> io::Result<()> {
|
||||
match self
|
||||
.writer
|
||||
.write_all(&self.encoder.event(event.into()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
|
||||
Err(e) => {
|
||||
let _ = self
|
||||
.notifier
|
||||
.notify("Write error, recording suspended".to_owned())
|
||||
.await;
|
||||
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn flush(&mut self) -> io::Result<()> {
|
||||
self.writer.write_all(&self.encoder.flush()).await
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session::Event> for asciicast::Event {
|
||||
fn from(event: session::Event) -> Self {
|
||||
match event {
|
||||
session::Event::Output(time, text) => asciicast::Event::output(time, text),
|
||||
session::Event::Input(time, text) => asciicast::Event::input(time, text),
|
||||
session::Event::Resize(time, tty_size) => {
|
||||
asciicast::Event::resize(time, tty_size.into())
|
||||
}
|
||||
session::Event::Marker(time, label) => asciicast::Event::marker(time, label),
|
||||
session::Event::Exit(time, status) => asciicast::Event::exit(time, status),
|
||||
}
|
||||
}
|
||||
}
|
||||
278
src/forwarder.rs
Normal file
278
src/forwarder.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
use core::future::{self, Future};
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail};
|
||||
use axum::http::Uri;
|
||||
use futures_util::stream::SplitSink;
|
||||
use futures_util::{SinkExt, Stream, StreamExt};
|
||||
use rand::Rng;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
use tokio_stream::wrappers::IntervalStream;
|
||||
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
||||
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
||||
use tokio_tungstenite::tungstenite::{self, ClientRequestBuilder, Message};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use crate::alis;
|
||||
use crate::api;
|
||||
use crate::notifier::Notifier;
|
||||
use crate::stream::{Event, Subscriber};
|
||||
|
||||
const PING_INTERVAL: u64 = 15;
|
||||
const PING_TIMEOUT: u64 = 10;
|
||||
const SEND_TIMEOUT: u64 = 10;
|
||||
const RECONNECT_DELAY_BASE: u64 = 500;
|
||||
const RECONNECT_DELAY_CAP: u64 = 10_000;
|
||||
|
||||
pub async fn forward<N: Notifier>(
|
||||
url: url::Url,
|
||||
subscriber: Subscriber,
|
||||
mut notifier: N,
|
||||
shutdown_token: tokio_util::sync::CancellationToken,
|
||||
) -> anyhow::Result<()> {
|
||||
info!("forwarding to {url}");
|
||||
let mut reconnect_attempt = 0;
|
||||
let mut connection_count: u64 = 0;
|
||||
|
||||
loop {
|
||||
let session_stream = subscriber.subscribe().await?;
|
||||
let conn = connect_and_forward(&url, session_stream);
|
||||
tokio::pin!(conn);
|
||||
|
||||
let result = tokio::select! {
|
||||
result = &mut conn => result,
|
||||
|
||||
_ = time::sleep(Duration::from_secs(3)) => {
|
||||
if reconnect_attempt > 0 {
|
||||
if connection_count == 0 {
|
||||
let _ = notifier.notify("Connected to the server".to_string()).await;
|
||||
} else {
|
||||
let _ = notifier.notify("Reconnected to the server".to_string()).await;
|
||||
}
|
||||
}
|
||||
|
||||
connection_count += 1;
|
||||
reconnect_attempt = 0;
|
||||
|
||||
conn.await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(true) => {
|
||||
break;
|
||||
}
|
||||
|
||||
Ok(false) => {
|
||||
let _ = notifier
|
||||
.notify("Stream halted by the server".to_string())
|
||||
.await;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
if let Some(tungstenite::error::Error::Protocol(
|
||||
tungstenite::error::ProtocolError::SecWebSocketSubProtocolError(_),
|
||||
)) = e.downcast_ref::<tungstenite::error::Error>()
|
||||
{
|
||||
// This happens when the server accepts the websocket connection
|
||||
// but doesn't properly perform the protocol negotiation.
|
||||
// This applies to asciinema-server v20241103 and earlier.
|
||||
|
||||
let _ = notifier
|
||||
.notify("The server version is too old, forwarding failed".to_string())
|
||||
.await;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(tungstenite::error::Error::Http(response)) =
|
||||
e.downcast_ref::<tungstenite::error::Error>()
|
||||
{
|
||||
if response.status().as_u16() == 400 {
|
||||
// This happens when the server doesn't support our protocol (version).
|
||||
// This applies to asciinema-server versions newer than v20241103.
|
||||
|
||||
let _ = notifier
|
||||
.notify(
|
||||
"CLI not compatible with the server, forwarding failed".to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
error!("connection error: {e}");
|
||||
|
||||
if reconnect_attempt == 0 {
|
||||
if connection_count == 0 {
|
||||
let _ = notifier
|
||||
.notify("Cannot connect to the server, retrying...".to_string())
|
||||
.await;
|
||||
} else {
|
||||
let _ = notifier
|
||||
.notify("Disconnected from the server, reconnecting...".to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let delay = exponential_delay(reconnect_attempt);
|
||||
reconnect_attempt = (reconnect_attempt + 1).min(10);
|
||||
info!("reconnecting in {delay} ms");
|
||||
|
||||
tokio::select! {
|
||||
_ = time::sleep(Duration::from_millis(delay)) => (),
|
||||
_ = shutdown_token.cancelled() => break
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_and_forward(
|
||||
url: &url::Url,
|
||||
session_stream: impl Stream<Item = Result<Event, BroadcastStreamRecvError>> + Unpin,
|
||||
) -> anyhow::Result<bool> {
|
||||
let request = build_request(url)?;
|
||||
let (ws, _) = tokio_tungstenite::connect_async_with_config(request, None, true).await?;
|
||||
info!("connected to the endpoint");
|
||||
|
||||
handle_socket(ws, get_alis_stream(session_stream)).await
|
||||
}
|
||||
|
||||
fn build_request(url: &url::Url) -> anyhow::Result<ClientRequestBuilder> {
|
||||
let uri: Uri = url.to_string().parse()?;
|
||||
|
||||
Ok(ClientRequestBuilder::new(uri)
|
||||
.with_sub_protocol("v1.alis")
|
||||
.with_header("user-agent", api::build_user_agent()))
|
||||
}
|
||||
|
||||
fn get_alis_stream(
|
||||
stream: impl Stream<Item = Result<Event, BroadcastStreamRecvError>>,
|
||||
) -> impl Stream<Item = anyhow::Result<Message>> {
|
||||
alis::stream(stream)
|
||||
.map(ws_result)
|
||||
.chain(futures_util::stream::once(future::ready(Ok(
|
||||
close_message(),
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_socket<T>(
|
||||
ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
alis_messages: T,
|
||||
) -> anyhow::Result<bool>
|
||||
where
|
||||
T: Stream<Item = anyhow::Result<Message>> + Unpin,
|
||||
{
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
let mut alis_messages = alis_messages;
|
||||
let mut pings = ping_stream();
|
||||
let mut ping_timeout: Pin<Box<dyn Future<Output = ()> + Send>> = Box::pin(future::pending());
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = alis_messages.next() => {
|
||||
match message {
|
||||
Some(message) => {
|
||||
send_with_timeout(&mut sink, message?).await??;
|
||||
},
|
||||
|
||||
None => {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
ping = pings.next() => {
|
||||
send_with_timeout(&mut sink, ping.unwrap()).await??;
|
||||
ping_timeout = Box::pin(time::sleep(Duration::from_secs(PING_TIMEOUT)));
|
||||
}
|
||||
|
||||
_ = &mut ping_timeout => bail!("ping timeout"),
|
||||
|
||||
message = stream.next() => {
|
||||
match message {
|
||||
Some(Ok(Message::Close(close_frame))) => {
|
||||
info!("server closed the connection");
|
||||
handle_close_frame(close_frame)?;
|
||||
return Ok(false);
|
||||
},
|
||||
|
||||
Some(Ok(Message::Ping(_))) => (),
|
||||
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
ping_timeout = Box::pin(future::pending());
|
||||
},
|
||||
|
||||
Some(Ok(msg)) => debug!("unexpected message from the server: {msg:?}"),
|
||||
Some(Err(e)) => bail!(e),
|
||||
None => bail!("SplitStream closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_with_timeout(
|
||||
sink: &mut SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
|
||||
message: Message,
|
||||
) -> anyhow::Result<Result<(), tungstenite::Error>> {
|
||||
time::timeout(Duration::from_secs(SEND_TIMEOUT), sink.send(message))
|
||||
.await
|
||||
.map_err(|_| anyhow!("send timeout"))
|
||||
}
|
||||
|
||||
fn handle_close_frame(frame: Option<CloseFrame>) -> anyhow::Result<()> {
|
||||
match frame {
|
||||
Some(CloseFrame { code, reason }) => {
|
||||
info!("close reason: {code} ({reason})");
|
||||
|
||||
match code {
|
||||
CloseCode::Normal => Ok(()),
|
||||
CloseCode::Library(code) if code < 4100 => Ok(()),
|
||||
c => bail!("unclean close: {c}"),
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
info!("close reason: none");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exponential_delay(attempt: usize) -> u64 {
|
||||
let mut rng = rand::rng();
|
||||
let base = (RECONNECT_DELAY_BASE * 2_u64.pow(attempt as u32)).min(RECONNECT_DELAY_CAP);
|
||||
|
||||
rng.random_range(..base)
|
||||
}
|
||||
|
||||
fn ws_result(m: Result<Vec<u8>, BroadcastStreamRecvError>) -> anyhow::Result<Message> {
|
||||
match m {
|
||||
Ok(bytes) => Ok(Message::binary(bytes)),
|
||||
Err(e) => Err(anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_message() -> Message {
|
||||
Message::Close(Some(CloseFrame {
|
||||
code: CloseCode::Normal,
|
||||
reason: "ended".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn ping_stream() -> impl Stream<Item = Message> {
|
||||
IntervalStream::new(time::interval(Duration::from_secs(PING_INTERVAL)))
|
||||
.skip(1)
|
||||
.map(|_| Message::Ping(vec![].into()))
|
||||
}
|
||||
36
src/hash.rs
Normal file
36
src/hash.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
// This module implements FNV-1a hashing algorithm
|
||||
// http://www.isthe.com/chongo/tech/comp/fnv/
|
||||
|
||||
const FNV_128_PRIME: u128 = 309485009821345068724781371; // 2^88 + 2^8 + 0x3b
|
||||
const FNV_128_OFFSET_BASIS: u128 = 144066263297769815596495629667062367629;
|
||||
|
||||
pub fn fnv1a_128<D: AsRef<[u8]>>(data: D) -> u128 {
|
||||
let mut hash = FNV_128_OFFSET_BASIS;
|
||||
|
||||
for byte in data.as_ref() {
|
||||
hash ^= *byte as u128;
|
||||
hash = hash.wrapping_mul(FNV_128_PRIME);
|
||||
}
|
||||
|
||||
hash
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fnv1a_128;
|
||||
|
||||
#[test]
|
||||
fn digest() {
|
||||
assert_eq!(
|
||||
fnv1a_128("Hello World!"),
|
||||
0xd2d42892ede872031d2593366229c2d2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fnv1a_128("Hello world!"),
|
||||
0x3c94fff9ede872031d95566a45770eb2
|
||||
);
|
||||
|
||||
assert_eq!(fnv1a_128("🦄🌈"), 0xa25841ae4659905b36cb0d359fad39f);
|
||||
}
|
||||
}
|
||||
285
src/html.rs
Normal file
285
src/html.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
pub fn extract_asciicast_link(html: &str) -> Option<String> {
|
||||
let html_lc = html.to_ascii_lowercase();
|
||||
let head_start = html_lc.find("<head")?;
|
||||
let head_end = html_lc[head_start..].find("</head>")? + head_start;
|
||||
let head = &html[head_start..head_end];
|
||||
let head_lc = head.to_ascii_lowercase();
|
||||
let mut head_offset = 0;
|
||||
|
||||
while let Some(link_pos) = head_lc[head_offset..].find("<link") {
|
||||
let link_start = head_offset + link_pos;
|
||||
let link_end = head[link_start..].find('>')? + link_start + 1;
|
||||
let link = &head[link_start..link_end];
|
||||
head_offset = link_end;
|
||||
|
||||
if let Some(rel) = attr(link, "rel") {
|
||||
if rel
|
||||
.split_whitespace()
|
||||
.any(|t| t.eq_ignore_ascii_case("alternate"))
|
||||
{
|
||||
if let Some(t) = attr(link, "type") {
|
||||
if t.eq_ignore_ascii_case("application/x-asciicast")
|
||||
|| t.eq_ignore_ascii_case("application/asciicast+json")
|
||||
{
|
||||
if let Some(href) = attr(link, "href") {
|
||||
if !href.is_empty() {
|
||||
return Some(href.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let tag_lc = tag.to_ascii_lowercase();
|
||||
let prefix = format!("{}=", name.to_ascii_lowercase());
|
||||
let mut i = tag_lc.find(&prefix)? + prefix.len();
|
||||
let bytes = tag.as_bytes();
|
||||
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let quote = bytes[i];
|
||||
|
||||
if quote == b'\'' || quote == b'"' {
|
||||
let start = i + 1;
|
||||
let end = tag[start..].find(quote as char)? + start;
|
||||
|
||||
Some(&tag[start..end])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_valid_html() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
<link rel="alternate" type="application/x-asciicast" href="https://example.com/demo.cast">
|
||||
</head>
|
||||
<body>Content</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
extract_asciicast_link(html),
|
||||
Some("https://example.com/demo.cast".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_alternate_mime_type() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="alternate" type="application/asciicast+json" href="/demo.json">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), Some("/demo.json".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_multiple_rel_values() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="foobar alternate" type="application/x-asciicast" href="demo.cast">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), Some("demo.cast".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_case_insensitive() {
|
||||
let html = r#"
|
||||
<HTML>
|
||||
<HEAD>
|
||||
<LINK REL="ALTERNATE" TYPE="APPLICATION/X-ASCIICAST" HREF="DEMO.CAST">
|
||||
</HEAD>
|
||||
</HTML>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), Some("DEMO.CAST".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_single_quotes() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel='alternate' type='application/x-asciicast' href='demo.cast'>
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), Some("demo.cast".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_mixed_quotes() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="alternate" type='application/x-asciicast' href="demo.cast">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), Some("demo.cast".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_multiple_links() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="alternate" type="application/rss+xml" href="feed.rss">
|
||||
<link rel="alternate" type="application/x-asciicast" href="first.cast">
|
||||
<link rel="alternate" type="application/x-asciicast" href="second.cast">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), Some("first.cast".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_no_head() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<body>
|
||||
<link rel="alternate" type="application/x-asciicast" href="demo.cast">
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_no_matching_link() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="alternate" type="application/rss+xml" href="feed.rss">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_wrong_rel() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" type="application/x-asciicast" href="demo.cast">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_wrong_type() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="alternate" type="text/plain" href="demo.cast">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_no_href() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="alternate" type="application/x-asciicast">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_empty_href() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="alternate" type="application/x-asciicast" href="">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_malformed_html() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="alternate" type="application/x-asciicast" href="demo.cast"
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_empty_html() {
|
||||
assert_eq!(extract_asciicast_link(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_invalid_html() {
|
||||
let html = "This is not HTML at all";
|
||||
assert_eq!(extract_asciicast_link(html), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_asciicast_link_special_characters_in_href() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<link rel="alternate" type="application/x-asciicast" href="https://example.com/cast?id=123&format=v3">
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
extract_asciicast_link(html),
|
||||
Some("https://example.com/cast?id=123&format=v3".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
14
src/io.rs
14
src/io.rs
@@ -1,14 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use std::io;
|
||||
use std::os::fd::RawFd;
|
||||
|
||||
pub fn set_non_blocking(fd: &RawFd) -> Result<(), io::Error> {
|
||||
use nix::fcntl::{fcntl, FcntlArg::*, OFlag};
|
||||
|
||||
let flags = fcntl(*fd, F_GETFL)?;
|
||||
let mut oflags = OFlag::from_bits_truncate(flags);
|
||||
oflags |= OFlag::O_NONBLOCK;
|
||||
fcntl(*fd, F_SETFL(oflags))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
38
src/leb128.rs
Normal file
38
src/leb128.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
pub fn encode<N: Into<u64>>(value: N) -> Vec<u8> {
|
||||
let mut value: u64 = value.into();
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
while value > 127 {
|
||||
let mut low = value & 127;
|
||||
value >>= 7;
|
||||
|
||||
if value > 0 {
|
||||
low |= 128;
|
||||
}
|
||||
|
||||
bytes.push(low as u8);
|
||||
}
|
||||
|
||||
if value > 0 || bytes.is_empty() {
|
||||
bytes.push(value as u8);
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::encode;
|
||||
|
||||
#[test]
|
||||
fn test_encode() {
|
||||
assert_eq!(encode(0u64), [0x00]);
|
||||
assert_eq!(encode(1u64), [0x01]);
|
||||
assert_eq!(encode(127u64), [0x7F]);
|
||||
assert_eq!(encode(128u64), [0x80, 0x01]);
|
||||
assert_eq!(encode(255u64), [0xFF, 0x01]);
|
||||
assert_eq!(encode(256u64), [0x80, 0x02]);
|
||||
assert_eq!(encode(16383u64), [0xFF, 0x7F]);
|
||||
assert_eq!(encode(16384u64), [0x80, 0x80, 0x01]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use nix::libc::{self, CODESET, LC_ALL};
|
||||
use std::env;
|
||||
use std::ffi::CStr;
|
||||
|
||||
use nix::libc::{self, CODESET, LC_ALL};
|
||||
|
||||
pub fn check_utf8_locale() -> anyhow::Result<()> {
|
||||
initialize_from_env();
|
||||
|
||||
@@ -11,9 +12,9 @@ pub fn check_utf8_locale() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
} else {
|
||||
let env = env::var("LC_ALL")
|
||||
.map(|v| format!("LC_ALL={}", v))
|
||||
.or(env::var("LC_CTYPE").map(|v| format!("LC_CTYPE={}", v)))
|
||||
.or(env::var("LANG").map(|v| format!("LANG={}", v)))
|
||||
.map(|v| format!("LC_ALL={v}"))
|
||||
.or(env::var("LC_CTYPE").map(|v| format!("LC_CTYPE={v}")))
|
||||
.or(env::var("LANG").map(|v| format!("LANG={v}")))
|
||||
.unwrap_or("".to_string());
|
||||
|
||||
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))
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
|
||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
pub fn disable() {
|
||||
ENABLED.store(false, SeqCst);
|
||||
}
|
||||
|
||||
macro_rules! info {
|
||||
($fmt:expr) => (crate::logger::println(format!($fmt)));
|
||||
($fmt:expr, $($arg:tt)*) => (crate::logger::println(format!($fmt, $($arg)*)));
|
||||
}
|
||||
|
||||
pub fn println(message: String) {
|
||||
if ENABLED.load(SeqCst) {
|
||||
println!("::: {}", message);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use info;
|
||||
95
src/main.rs
95
src/main.rs
@@ -1,39 +1,98 @@
|
||||
mod alis;
|
||||
mod api;
|
||||
mod asciicast;
|
||||
mod cli;
|
||||
mod cmd;
|
||||
mod config;
|
||||
mod encoder;
|
||||
mod io;
|
||||
mod fd;
|
||||
mod file_writer;
|
||||
mod forwarder;
|
||||
mod hash;
|
||||
mod html;
|
||||
mod leb128;
|
||||
mod locale;
|
||||
mod logger;
|
||||
mod notifier;
|
||||
mod player;
|
||||
mod pty;
|
||||
mod recorder;
|
||||
mod streamer;
|
||||
mod server;
|
||||
mod session;
|
||||
mod status;
|
||||
mod stream;
|
||||
mod tty;
|
||||
mod util;
|
||||
use crate::cli::{Cli, Commands};
|
||||
use crate::config::Config;
|
||||
use clap::Parser;
|
||||
use cmd::Command;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
use std::process::{ExitCode, Termination};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use self::cli::{Cli, Commands, Session};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
let config = Config::new(cli.server_url.clone())?;
|
||||
|
||||
if cli.quiet {
|
||||
logger::disable();
|
||||
status::disable();
|
||||
}
|
||||
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
crate::config::check_legacy_config_file();
|
||||
|
||||
match cli.command {
|
||||
Commands::Rec(record) => record.run(&config),
|
||||
Commands::Play(play) => play.run(&config),
|
||||
Commands::Stream(stream) => stream.run(&config),
|
||||
Commands::Cat(cat) => cat.run(&config),
|
||||
Commands::Convert(convert) => convert.run(&config),
|
||||
Commands::Upload(upload) => upload.run(&config),
|
||||
Commands::Auth(auth) => auth.run(&config),
|
||||
Commands::Record(cmd) => {
|
||||
let cmd = Session {
|
||||
output_file: Some(cmd.file),
|
||||
capture_input: cmd.capture_input,
|
||||
append: cmd.append,
|
||||
output_format: cmd.output_format,
|
||||
overwrite: cmd.overwrite,
|
||||
command: cmd.command,
|
||||
capture_env: cmd.capture_env,
|
||||
title: cmd.title,
|
||||
idle_time_limit: cmd.idle_time_limit,
|
||||
headless: cmd.headless,
|
||||
window_size: cmd.window_size,
|
||||
stream_local: None,
|
||||
stream_remote: None,
|
||||
return_: cmd.return_,
|
||||
log_file: cmd.log_file,
|
||||
server_url: None,
|
||||
env: vec!["ASCIINEMA_REC=1".to_owned()],
|
||||
};
|
||||
|
||||
cmd.run().report()
|
||||
}
|
||||
|
||||
Commands::Stream(cmd) => {
|
||||
let cmd = Session {
|
||||
output_file: None,
|
||||
capture_input: cmd.capture_input,
|
||||
append: false,
|
||||
output_format: None,
|
||||
overwrite: false,
|
||||
command: cmd.command,
|
||||
capture_env: cmd.capture_env,
|
||||
title: cmd.title,
|
||||
idle_time_limit: None,
|
||||
headless: cmd.headless,
|
||||
window_size: cmd.window_size,
|
||||
stream_local: cmd.local,
|
||||
stream_remote: cmd.remote,
|
||||
return_: cmd.return_,
|
||||
log_file: cmd.log_file,
|
||||
server_url: cmd.server_url,
|
||||
env: Vec::new(),
|
||||
};
|
||||
|
||||
cmd.run().report()
|
||||
}
|
||||
|
||||
Commands::Session(cmd) => cmd.run().report(),
|
||||
Commands::Play(cmd) => cmd.run().report(),
|
||||
Commands::Cat(cmd) => cmd.run().report(),
|
||||
Commands::Convert(cmd) => cmd.run().report(),
|
||||
Commands::Upload(cmd) => cmd.run().report(),
|
||||
Commands::Auth(cmd) => cmd.run().report(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use std::{
|
||||
env,
|
||||
ffi::OsStr,
|
||||
path::PathBuf,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::error;
|
||||
use which::which;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Notifier: Send {
|
||||
fn notify(&mut self, message: String) -> Result<()>;
|
||||
async fn notify(&mut self, message: String) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
pub fn get_notifier(custom_command: Option<String>) -> Box<dyn Notifier> {
|
||||
@@ -33,11 +36,12 @@ impl TmuxNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Notifier for TmuxNotifier {
|
||||
fn notify(&mut self, message: String) -> Result<()> {
|
||||
let args = ["display-message", &format!("asciinema: {}", message)];
|
||||
async fn notify(&mut self, message: String) -> anyhow::Result<()> {
|
||||
let args = ["display-message", &format!("asciinema: {message}")];
|
||||
|
||||
exec(&mut Command::new(&self.0), &args)
|
||||
exec(&mut Command::new(&self.0), &args).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +53,10 @@ impl LibNotifyNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Notifier for LibNotifyNotifier {
|
||||
fn notify(&mut self, message: String) -> Result<()> {
|
||||
exec(&mut Command::new(&self.0), &["asciinema", &message])
|
||||
async fn notify(&mut self, message: String) -> anyhow::Result<()> {
|
||||
exec(&mut Command::new(&self.0), &["asciinema", &message]).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,43 +68,84 @@ impl AppleScriptNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Notifier for AppleScriptNotifier {
|
||||
fn notify(&mut self, message: String) -> Result<()> {
|
||||
async fn notify(&mut self, message: String) -> anyhow::Result<()> {
|
||||
let text = message.replace('\"', "\\\"");
|
||||
let script = format!("display notification \"{text}\" with title \"asciinema\"");
|
||||
|
||||
exec(&mut Command::new(&self.0), &["-e", &script])
|
||||
exec(&mut Command::new(&self.0), &["-e", &script]).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CustomNotifier(String);
|
||||
|
||||
#[async_trait]
|
||||
impl Notifier for CustomNotifier {
|
||||
fn notify(&mut self, text: String) -> Result<()> {
|
||||
async fn notify(&mut self, text: String) -> anyhow::Result<()> {
|
||||
exec::<&str>(
|
||||
Command::new("/bin/sh")
|
||||
.args(["-c", &self.0])
|
||||
.env("TEXT", text),
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NullNotifier;
|
||||
|
||||
#[async_trait]
|
||||
impl Notifier for NullNotifier {
|
||||
fn notify(&mut self, _text: String) -> Result<()> {
|
||||
async fn notify(&mut self, _text: String) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn exec<S: AsRef<OsStr>>(command: &mut Command, args: &[S]) -> Result<()> {
|
||||
command
|
||||
async fn exec<S: AsRef<OsStr>>(command: &mut Command, args: &[S]) -> anyhow::Result<()> {
|
||||
let status = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.args(args)
|
||||
.status()?;
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"exit status: {}",
|
||||
status.code().unwrap_or(0)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BackgroundNotifier(mpsc::Sender<String>);
|
||||
|
||||
pub fn background(mut notifier: Box<dyn Notifier>) -> BackgroundNotifier {
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = rx.recv().await {
|
||||
if let Err(e) = notifier.notify(message).await {
|
||||
error!("notification failed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while rx.recv().await.is_some() {}
|
||||
});
|
||||
|
||||
BackgroundNotifier(tx)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Notifier for BackgroundNotifier {
|
||||
async fn notify(&mut self, message: String) -> anyhow::Result<()> {
|
||||
self.0.send(message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
181
src/player.rs
181
src/player.rs
@@ -1,12 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{self, Duration, Instant};
|
||||
|
||||
use crate::asciicast::{self, Event, EventData};
|
||||
use crate::config::Key;
|
||||
use crate::tty::Tty;
|
||||
use anyhow::Result;
|
||||
use nix::sys::select::{pselect, FdSet};
|
||||
use nix::sys::time::{TimeSpec, TimeValLike};
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::time::{Duration, Instant};
|
||||
use crate::tty::{DevTty, Tty};
|
||||
|
||||
pub struct KeyBindings {
|
||||
pub quit: Key,
|
||||
@@ -26,75 +24,97 @@ impl Default for KeyBindings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play(
|
||||
recording: asciicast::Asciicast,
|
||||
mut tty: impl Tty,
|
||||
pub async fn play(
|
||||
recording: asciicast::Asciicast<'static>,
|
||||
speed: f64,
|
||||
idle_time_limit: Option<f64>,
|
||||
idle_time_limit_override: Option<f64>,
|
||||
pause_on_markers: bool,
|
||||
keys: &KeyBindings,
|
||||
auto_resize: bool,
|
||||
) -> Result<bool> {
|
||||
let mut events = open_recording(recording, speed, idle_time_limit)?;
|
||||
let mut stdout = io::stdout();
|
||||
let initial_cols = recording.header.term_cols;
|
||||
let initial_rows = recording.header.term_rows;
|
||||
let mut events = emit_session_events(recording, speed, idle_time_limit_override)?;
|
||||
let mut epoch = Instant::now();
|
||||
let mut pause_elapsed_time: Option<u64> = None;
|
||||
let mut next_event = events.next().transpose()?;
|
||||
let mut next_event = events.recv().await.transpose()?;
|
||||
let mut input = [0u8; 1024];
|
||||
let mut tty = DevTty::open().await?;
|
||||
|
||||
if auto_resize {
|
||||
tty.resize((initial_cols as usize, initial_rows as usize).into())
|
||||
.await?;
|
||||
}
|
||||
|
||||
while let Some(Event { time, data }) = &next_event {
|
||||
if let Some(pet) = pause_elapsed_time {
|
||||
if let Some(input) = read_input(&mut tty, 1_000_000)? {
|
||||
if keys.quit.as_ref().is_some_and(|k| k == &input) {
|
||||
stdout.write_all("\r\n".as_bytes())?;
|
||||
return Ok(false);
|
||||
let n = tty.read(&mut input).await?;
|
||||
let key = &input[..n];
|
||||
|
||||
if keys.quit.as_ref().is_some_and(|k| k == key) {
|
||||
tty.write_all("\r\n".as_bytes()).await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if keys.pause.as_ref().is_some_and(|k| k == key) {
|
||||
epoch = Instant::now() - Duration::from_micros(pet);
|
||||
pause_elapsed_time = None;
|
||||
} else if keys.step.as_ref().is_some_and(|k| k == key) {
|
||||
pause_elapsed_time = Some(time.as_micros() as u64);
|
||||
|
||||
match data {
|
||||
EventData::Output(data) => {
|
||||
tty.write_all(data.as_bytes()).await?;
|
||||
}
|
||||
|
||||
EventData::Resize(cols, rows) if auto_resize => {
|
||||
tty.resize((*cols as usize, *rows as usize).into()).await?;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if keys.pause.as_ref().is_some_and(|k| k == &input) {
|
||||
epoch = Instant::now() - Duration::from_micros(pet);
|
||||
pause_elapsed_time = None;
|
||||
} else if keys.step.as_ref().is_some_and(|k| k == &input) {
|
||||
pause_elapsed_time = Some(*time);
|
||||
next_event = events.recv().await.transpose()?;
|
||||
} else if keys.next_marker.as_ref().is_some_and(|k| k == key) {
|
||||
while let Some(Event { time, data }) = next_event {
|
||||
next_event = events.recv().await.transpose()?;
|
||||
|
||||
if let EventData::Output(data) = data {
|
||||
stdout.write_all(data.as_bytes())?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
|
||||
next_event = events.next().transpose()?;
|
||||
} else if keys.next_marker.as_ref().is_some_and(|k| k == &input) {
|
||||
while let Some(Event { time, data }) = next_event {
|
||||
next_event = events.next().transpose()?;
|
||||
|
||||
match data {
|
||||
EventData::Output(data) => {
|
||||
stdout.write_all(data.as_bytes())?;
|
||||
}
|
||||
|
||||
EventData::Marker(_) => {
|
||||
pause_elapsed_time = Some(time);
|
||||
break;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
match data {
|
||||
EventData::Output(data) => {
|
||||
tty.write_all(data.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
stdout.flush()?;
|
||||
EventData::Marker(_) => {
|
||||
pause_elapsed_time = Some(time.as_micros() as u64);
|
||||
break;
|
||||
}
|
||||
|
||||
EventData::Resize(cols, rows) if auto_resize => {
|
||||
tty.resize((cols as usize, rows as usize).into()).await?;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while let Some(Event { time, data }) = &next_event {
|
||||
let delay = *time as i64 - epoch.elapsed().as_micros() as i64;
|
||||
let delay = time.as_micros() as i64 - epoch.elapsed().as_micros() as i64;
|
||||
|
||||
if delay > 0 {
|
||||
stdout.flush()?;
|
||||
if let Ok(result) =
|
||||
time::timeout(Duration::from_micros(delay as u64), tty.read(&mut input))
|
||||
.await
|
||||
{
|
||||
let n = result?;
|
||||
let key = &input[..n];
|
||||
|
||||
if let Some(key) = read_input(&mut tty, delay)? {
|
||||
if keys.quit.as_ref().is_some_and(|k| k == &key) {
|
||||
stdout.write_all("\r\n".as_bytes())?;
|
||||
if keys.quit.as_ref().is_some_and(|k| k == key) {
|
||||
tty.write_all("\r\n".as_bytes()).await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if keys.pause.as_ref().is_some_and(|k| k == &key) {
|
||||
if keys.pause.as_ref().is_some_and(|k| k == key) {
|
||||
pause_elapsed_time = Some(epoch.elapsed().as_micros() as u64);
|
||||
break;
|
||||
}
|
||||
@@ -105,13 +125,17 @@ pub fn play(
|
||||
|
||||
match data {
|
||||
EventData::Output(data) => {
|
||||
stdout.write_all(data.as_bytes())?;
|
||||
tty.write_all(data.as_bytes()).await?;
|
||||
}
|
||||
|
||||
EventData::Resize(cols, rows) if auto_resize => {
|
||||
tty.resize((*cols as usize, *rows as usize).into()).await?;
|
||||
}
|
||||
|
||||
EventData::Marker(_) => {
|
||||
if pause_on_markers {
|
||||
pause_elapsed_time = Some(*time);
|
||||
next_event = events.next().transpose()?;
|
||||
pause_elapsed_time = Some(time.as_micros() as u64);
|
||||
next_event = events.recv().await.transpose()?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -119,7 +143,7 @@ pub fn play(
|
||||
_ => (),
|
||||
}
|
||||
|
||||
next_event = events.next().transpose()?;
|
||||
next_event = events.recv().await.transpose()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,47 +151,28 @@ pub fn play(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn open_recording(
|
||||
recording: asciicast::Asciicast<'_>,
|
||||
fn emit_session_events(
|
||||
recording: asciicast::Asciicast<'static>,
|
||||
speed: f64,
|
||||
idle_time_limit: Option<f64>,
|
||||
) -> Result<impl Iterator<Item = Result<Event>> + '_> {
|
||||
let idle_time_limit = idle_time_limit
|
||||
idle_time_limit_override: Option<f64>,
|
||||
) -> Result<mpsc::Receiver<Result<Event>>> {
|
||||
let idle_time_limit = idle_time_limit_override
|
||||
.or(recording.header.idle_time_limit)
|
||||
.unwrap_or(f64::MAX);
|
||||
|
||||
let events = asciicast::limit_idle_time(recording.events, idle_time_limit);
|
||||
let events = asciicast::accelerate(events, speed);
|
||||
// TODO avoid collect, support playback from stdin
|
||||
let events: Vec<_> = events.collect();
|
||||
let (tx, rx) = mpsc::channel::<Result<Event>>(1024);
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn read_input<T: Tty>(tty: &mut T, timeout: i64) -> Result<Option<Vec<u8>>> {
|
||||
let nfds = Some(tty.as_fd().as_raw_fd() + 1);
|
||||
let mut rfds = FdSet::new();
|
||||
rfds.insert(tty);
|
||||
let timeout = TimeSpec::microseconds(timeout);
|
||||
let mut input: Vec<u8> = Vec::new();
|
||||
|
||||
pselect(nfds, &mut rfds, None, None, &timeout, None)?;
|
||||
|
||||
if rfds.contains(tty) {
|
||||
let mut buf = [0u8; 1024];
|
||||
|
||||
while let Ok(n) = tty.read(&mut buf) {
|
||||
if n == 0 {
|
||||
tokio::spawn(async move {
|
||||
for event in events {
|
||||
if tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
input.extend_from_slice(&buf[0..n]);
|
||||
}
|
||||
});
|
||||
|
||||
if !input.is_empty() {
|
||||
Ok(Some(input))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
560
src/pty.rs
560
src/pty.rs
@@ -1,278 +1,95 @@
|
||||
use crate::io::set_non_blocking;
|
||||
use crate::tty::{Tty, TtySize};
|
||||
use anyhow::{bail, Result};
|
||||
use nix::errno::Errno;
|
||||
use nix::sys::select::{select, FdSet};
|
||||
use nix::sys::signal;
|
||||
use nix::sys::wait::{self, WaitPidFlag, WaitStatus};
|
||||
use nix::unistd::{self, ForkResult};
|
||||
use nix::{libc, pty};
|
||||
use signal_hook::consts::{SIGALRM, SIGCHLD, SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGWINCH};
|
||||
use signal_hook::SigId;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::ffi::{CString, NulError};
|
||||
use std::fs::File;
|
||||
use std::io::{self, ErrorKind, Read, Write};
|
||||
use std::os::fd::AsFd;
|
||||
use std::os::fd::{BorrowedFd, OwnedFd};
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
type ExtraEnv = HashMap<String, String>;
|
||||
use nix::errno::Errno;
|
||||
use nix::pty::{ForkptyResult, Winsize};
|
||||
use nix::sys::signal::{self, SigHandler, Signal};
|
||||
use nix::sys::wait::{self, WaitPidFlag, WaitStatus};
|
||||
use nix::unistd::{self, Pid};
|
||||
use nix::{libc, pty};
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::io::{self, Interest};
|
||||
use tokio::task;
|
||||
|
||||
pub trait Handler {
|
||||
fn start(&mut self, epoch: Instant, tty_size: TtySize);
|
||||
fn output(&mut self, time: Duration, data: &[u8]) -> bool;
|
||||
fn input(&mut self, time: Duration, data: &[u8]) -> bool;
|
||||
fn resize(&mut self, time: Duration, tty_size: TtySize) -> bool;
|
||||
use crate::fd::FdExt;
|
||||
|
||||
pub struct Pty {
|
||||
child: Pid,
|
||||
master: AsyncFd<OwnedFd>,
|
||||
}
|
||||
|
||||
pub fn exec<S: AsRef<str>, T: Tty + ?Sized, H: Handler>(
|
||||
impl Pty {
|
||||
pub async fn read(&self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
self.master
|
||||
.async_io(Interest::READABLE, |fd| match unistd::read(fd, buffer) {
|
||||
Ok(n) => Ok(n),
|
||||
Err(Errno::EIO) => Ok(0),
|
||||
Err(e) => Err(e.into()),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn write(&self, buffer: &[u8]) -> io::Result<usize> {
|
||||
self.master
|
||||
.async_io(Interest::WRITABLE, |fd| match unistd::write(fd, buffer) {
|
||||
Ok(n) => Ok(n),
|
||||
Err(Errno::EIO) => Ok(0),
|
||||
Err(e) => Err(e.into()),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn resize(&self, winsize: Winsize) {
|
||||
unsafe { libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &winsize) };
|
||||
}
|
||||
|
||||
pub fn kill(&self) {
|
||||
// Any errors occurred when killing the child are ignored.
|
||||
let _ = signal::kill(self.child, Signal::SIGTERM);
|
||||
}
|
||||
|
||||
pub async fn wait(&self, options: Option<WaitPidFlag>) -> io::Result<WaitStatus> {
|
||||
let pid = self.child;
|
||||
task::spawn_blocking(move || Ok(wait::waitpid(pid, options)?)).await?
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pty {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
let _ = wait::waitpid(self.child, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn<S: AsRef<str>>(
|
||||
command: &[S],
|
||||
extra_env: &ExtraEnv,
|
||||
tty: &mut T,
|
||||
handler: &mut H,
|
||||
) -> Result<i32> {
|
||||
let winsize = tty.get_size();
|
||||
let epoch = Instant::now();
|
||||
handler.start(epoch, winsize.into());
|
||||
winsize: Winsize,
|
||||
extra_env: &HashMap<String, String>,
|
||||
) -> anyhow::Result<Pty> {
|
||||
let result = unsafe { pty::forkpty(Some(&winsize), None) }?;
|
||||
|
||||
match result.fork_result {
|
||||
ForkResult::Parent { child } => handle_parent(result.master, child, tty, handler, epoch),
|
||||
match result {
|
||||
ForkptyResult::Parent { child, master } => {
|
||||
master.set_nonblocking()?;
|
||||
let master = AsyncFd::new(master)?;
|
||||
|
||||
ForkResult::Child => {
|
||||
Ok(Pty { child, master })
|
||||
}
|
||||
|
||||
ForkptyResult::Child => {
|
||||
handle_child(command, extra_env)?;
|
||||
unreachable!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_parent<T: Tty + ?Sized, H: Handler>(
|
||||
master_fd: OwnedFd,
|
||||
child: unistd::Pid,
|
||||
tty: &mut T,
|
||||
handler: &mut H,
|
||||
epoch: Instant,
|
||||
) -> Result<i32> {
|
||||
let wait_result = match copy(master_fd, child, tty, handler, epoch) {
|
||||
Ok(Some(status)) => Ok(status),
|
||||
Ok(None) => wait::waitpid(child, None),
|
||||
|
||||
Err(e) => {
|
||||
let _ = wait::waitpid(child, None);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
match wait_result {
|
||||
Ok(WaitStatus::Exited(_pid, status)) => Ok(status),
|
||||
Ok(WaitStatus::Signaled(_pid, signal, ..)) => Ok(128 + signal as i32),
|
||||
Ok(_) => Ok(1),
|
||||
Err(e) => Err(anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
|
||||
const BUF_SIZE: usize = 128 * 1024;
|
||||
|
||||
fn copy<T: Tty + ?Sized, H: Handler>(
|
||||
master_fd: OwnedFd,
|
||||
child: unistd::Pid,
|
||||
tty: &mut T,
|
||||
handler: &mut H,
|
||||
epoch: Instant,
|
||||
) -> Result<Option<WaitStatus>> {
|
||||
let mut master = File::from(master_fd);
|
||||
let master_raw_fd = master.as_raw_fd();
|
||||
let mut buf = [0u8; BUF_SIZE];
|
||||
let mut input: Vec<u8> = Vec::with_capacity(BUF_SIZE);
|
||||
let mut output: Vec<u8> = Vec::with_capacity(BUF_SIZE);
|
||||
let mut master_closed = false;
|
||||
|
||||
let sigwinch_fd = SignalFd::open(SIGWINCH)?;
|
||||
let sigint_fd = SignalFd::open(SIGINT)?;
|
||||
let sigterm_fd = SignalFd::open(SIGTERM)?;
|
||||
let sigquit_fd = SignalFd::open(SIGQUIT)?;
|
||||
let sighup_fd = SignalFd::open(SIGHUP)?;
|
||||
let sigalrm_fd = SignalFd::open(SIGALRM)?;
|
||||
let sigchld_fd = SignalFd::open(SIGCHLD)?;
|
||||
|
||||
set_non_blocking(&master_raw_fd)?;
|
||||
|
||||
loop {
|
||||
let master_fd = master.as_fd();
|
||||
let tty_fd = tty.as_fd();
|
||||
let mut rfds = FdSet::new();
|
||||
let mut wfds = FdSet::new();
|
||||
|
||||
rfds.insert(&tty_fd);
|
||||
rfds.insert(&sigwinch_fd);
|
||||
rfds.insert(&sigint_fd);
|
||||
rfds.insert(&sigterm_fd);
|
||||
rfds.insert(&sigquit_fd);
|
||||
rfds.insert(&sighup_fd);
|
||||
rfds.insert(&sigalrm_fd);
|
||||
rfds.insert(&sigchld_fd);
|
||||
|
||||
if !master_closed {
|
||||
rfds.insert(&master_fd);
|
||||
|
||||
if !input.is_empty() {
|
||||
wfds.insert(&master_fd);
|
||||
}
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
wfds.insert(&tty_fd);
|
||||
}
|
||||
|
||||
if let Err(e) = select(None, &mut rfds, &mut wfds, None, None) {
|
||||
if e == Errno::EINTR {
|
||||
continue;
|
||||
}
|
||||
|
||||
bail!(e);
|
||||
}
|
||||
|
||||
let master_read = rfds.contains(&master_fd);
|
||||
let master_write = wfds.contains(&master_fd);
|
||||
let tty_read = rfds.contains(&tty_fd);
|
||||
let tty_write = wfds.contains(&tty_fd);
|
||||
let sigwinch_read = rfds.contains(&sigwinch_fd);
|
||||
let sigint_read = rfds.contains(&sigint_fd);
|
||||
let sigterm_read = rfds.contains(&sigterm_fd);
|
||||
let sigquit_read = rfds.contains(&sigquit_fd);
|
||||
let sighup_read = rfds.contains(&sighup_fd);
|
||||
let sigalrm_read = rfds.contains(&sigalrm_fd);
|
||||
let sigchld_read = rfds.contains(&sigchld_fd);
|
||||
|
||||
if master_read {
|
||||
while let Some(n) = read_non_blocking(&mut master, &mut buf)? {
|
||||
if n > 0 {
|
||||
if handler.output(epoch.elapsed(), &buf[0..n]) {
|
||||
output.extend_from_slice(&buf[0..n]);
|
||||
}
|
||||
} else if output.is_empty() {
|
||||
return Ok(None);
|
||||
} else {
|
||||
master_closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if master_write {
|
||||
let mut buf: &[u8] = input.as_ref();
|
||||
|
||||
while let Some(n) = write_non_blocking(&mut master, buf)? {
|
||||
buf = &buf[n..];
|
||||
|
||||
if buf.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let left = buf.len();
|
||||
|
||||
if left == 0 {
|
||||
input.clear();
|
||||
} else {
|
||||
input.drain(..input.len() - left);
|
||||
}
|
||||
}
|
||||
|
||||
if tty_write {
|
||||
let mut buf: &[u8] = output.as_ref();
|
||||
|
||||
while let Some(n) = write_non_blocking(tty, buf)? {
|
||||
buf = &buf[n..];
|
||||
|
||||
if buf.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let left = buf.len();
|
||||
|
||||
if left == 0 {
|
||||
if master_closed {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
output.clear();
|
||||
} else {
|
||||
output.drain(..output.len() - left);
|
||||
}
|
||||
}
|
||||
|
||||
if tty_read {
|
||||
while let Some(n) = read_non_blocking(tty, &mut buf)? {
|
||||
if n > 0 {
|
||||
if handler.input(epoch.elapsed(), &buf[0..n]) {
|
||||
input.extend_from_slice(&buf[0..n]);
|
||||
}
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sigwinch_read {
|
||||
sigwinch_fd.flush();
|
||||
let winsize = tty.get_size();
|
||||
|
||||
if handler.resize(epoch.elapsed(), winsize.into()) {
|
||||
set_pty_size(master_raw_fd, &winsize);
|
||||
}
|
||||
}
|
||||
|
||||
let mut kill_the_child = false;
|
||||
|
||||
if sigint_read {
|
||||
sigint_fd.flush();
|
||||
kill_the_child = true;
|
||||
}
|
||||
|
||||
if sigterm_read {
|
||||
sigterm_fd.flush();
|
||||
kill_the_child = true;
|
||||
}
|
||||
|
||||
if sigquit_read {
|
||||
sigquit_fd.flush();
|
||||
kill_the_child = true;
|
||||
}
|
||||
|
||||
if sighup_read {
|
||||
sighup_fd.flush();
|
||||
kill_the_child = true;
|
||||
}
|
||||
|
||||
if sigalrm_read {
|
||||
sigalrm_fd.flush();
|
||||
}
|
||||
|
||||
if sigchld_read {
|
||||
sigchld_fd.flush();
|
||||
|
||||
if let Ok(status) = wait::waitpid(child, Some(WaitPidFlag::WNOHANG)) {
|
||||
if status != WaitStatus::StillAlive {
|
||||
return Ok(Some(status));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if kill_the_child {
|
||||
unsafe { libc::kill(child.as_raw(), SIGTERM) };
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_child<S: AsRef<str>>(command: &[S], extra_env: &ExtraEnv) -> Result<()> {
|
||||
use signal::{SigHandler, Signal};
|
||||
|
||||
fn handle_child<S: AsRef<str>>(
|
||||
command: &[S],
|
||||
extra_env: &HashMap<String, String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let command = command
|
||||
.iter()
|
||||
.map(|s| CString::new(s.as_ref()))
|
||||
@@ -287,136 +104,34 @@ fn handle_child<S: AsRef<str>>(command: &[S], extra_env: &ExtraEnv) -> Result<()
|
||||
unsafe { libc::_exit(1) }
|
||||
}
|
||||
|
||||
fn set_pty_size(pty_fd: i32, winsize: &pty::Winsize) {
|
||||
unsafe { libc::ioctl(pty_fd, libc::TIOCSWINSZ, winsize) };
|
||||
}
|
||||
|
||||
fn read_non_blocking<R: Read + ?Sized>(
|
||||
source: &mut R,
|
||||
buf: &mut [u8],
|
||||
) -> io::Result<Option<usize>> {
|
||||
match source.read(buf) {
|
||||
Ok(n) => Ok(Some(n)),
|
||||
|
||||
Err(e) => {
|
||||
if e.kind() == ErrorKind::WouldBlock {
|
||||
Ok(None)
|
||||
} else if e.raw_os_error().is_some_and(|code| code == 5) {
|
||||
Ok(Some(0))
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_non_blocking<W: Write + ?Sized>(sink: &mut W, buf: &[u8]) -> io::Result<Option<usize>> {
|
||||
match sink.write(buf) {
|
||||
Ok(n) => Ok(Some(n)),
|
||||
|
||||
Err(e) => {
|
||||
if e.kind() == ErrorKind::WouldBlock {
|
||||
Ok(None)
|
||||
} else if e.raw_os_error().is_some_and(|code| code == 5) {
|
||||
Ok(Some(0))
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SignalFd {
|
||||
sigid: SigId,
|
||||
rx: OwnedFd,
|
||||
}
|
||||
|
||||
impl SignalFd {
|
||||
fn open(signal: libc::c_int) -> Result<Self> {
|
||||
let (rx, tx) = unistd::pipe()?;
|
||||
set_non_blocking(&rx)?;
|
||||
set_non_blocking(&tx)?;
|
||||
let rx = unsafe { OwnedFd::from_raw_fd(rx) };
|
||||
let tx = unsafe { OwnedFd::from_raw_fd(tx) };
|
||||
|
||||
let sigid = unsafe {
|
||||
signal_hook::low_level::register(signal, move || {
|
||||
let _ = unistd::write(tx.as_raw_fd(), &[0]);
|
||||
})
|
||||
}?;
|
||||
|
||||
Ok(Self { sigid, rx })
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
let mut buf = [0; 256];
|
||||
|
||||
while let Ok(n) = unistd::read(self.rx.as_raw_fd(), &mut buf) {
|
||||
if n == 0 {
|
||||
break;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsFd for SignalFd {
|
||||
fn as_fd(&self) -> BorrowedFd<'_> {
|
||||
self.rx.as_fd()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SignalFd {
|
||||
fn drop(&mut self) {
|
||||
signal_hook::low_level::unregister(self.sigid);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Handler;
|
||||
use crate::pty::ExtraEnv;
|
||||
use crate::tty::{FixedSizeTty, NullTty, TtySize};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestHandler {
|
||||
tty_size: Option<TtySize>,
|
||||
output: Vec<Vec<u8>>,
|
||||
use super::Pty;
|
||||
use crate::tty::TtySize;
|
||||
|
||||
async fn spawn<S: AsRef<str>>(command: &[S], extra_env: &HashMap<String, String>) -> Pty {
|
||||
super::spawn(command, TtySize::default().into(), extra_env).unwrap()
|
||||
}
|
||||
|
||||
impl Handler for TestHandler {
|
||||
fn start(&mut self, _epoch: Instant, tty_size: TtySize) {
|
||||
self.tty_size = Some(tty_size);
|
||||
async fn read_output(pty: Pty) -> Vec<String> {
|
||||
let mut buf = [0u8; 1024];
|
||||
let mut output = Vec::new();
|
||||
|
||||
while let Ok(n) = pty.read(&mut buf).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
output.push(String::from_utf8_lossy(&buf[..n]).to_string());
|
||||
}
|
||||
|
||||
fn output(&mut self, _time: Duration, data: &[u8]) -> bool {
|
||||
self.output.push(data.into());
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn input(&mut self, _time: Duration, _data: &[u8]) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn resize(&mut self, _time: Duration, _size: TtySize) -> bool {
|
||||
true
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
impl TestHandler {
|
||||
fn output(&self) -> Vec<String> {
|
||||
self.output
|
||||
.iter()
|
||||
.map(|x| String::from_utf8_lossy(x).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_basic() {
|
||||
let mut handler = TestHandler::default();
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_basic() {
|
||||
let code = r#"
|
||||
import sys;
|
||||
import time;
|
||||
@@ -426,78 +141,47 @@ time.sleep(0.1);
|
||||
sys.stdout.write('bar');
|
||||
"#;
|
||||
|
||||
super::exec(
|
||||
&["python3", "-c", code],
|
||||
&ExtraEnv::new(),
|
||||
&mut NullTty::open().unwrap(),
|
||||
&mut handler,
|
||||
)
|
||||
.unwrap();
|
||||
let pty = spawn(&["python3", "-c", code], &HashMap::new()).await;
|
||||
let output = read_output(pty).await;
|
||||
|
||||
assert_eq!(handler.output(), vec!["foo", "bar"]);
|
||||
assert_eq!(handler.tty_size, Some(TtySize(80, 24)));
|
||||
assert_eq!(output, vec!["foo", "bar"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_no_output() {
|
||||
let mut handler = TestHandler::default();
|
||||
#[tokio::test]
|
||||
async fn spawn_no_output() {
|
||||
let pty = spawn(&["true"], &HashMap::new()).await;
|
||||
let output = read_output(pty).await;
|
||||
|
||||
super::exec(
|
||||
&["true"],
|
||||
&ExtraEnv::new(),
|
||||
&mut NullTty::open().unwrap(),
|
||||
&mut handler,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(handler.output().is_empty());
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_quick() {
|
||||
let mut handler = TestHandler::default();
|
||||
#[tokio::test]
|
||||
async fn spawn_quick() {
|
||||
let pty = spawn(&["printf", "hello world\n"], &HashMap::new()).await;
|
||||
let output = read_output(pty).await.join("");
|
||||
|
||||
super::exec(
|
||||
&["printf", "hello world\n"],
|
||||
&ExtraEnv::new(),
|
||||
&mut NullTty::open().unwrap(),
|
||||
&mut handler,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!handler.output().is_empty());
|
||||
assert_eq!(output, "hello world\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_extra_env() {
|
||||
let mut handler = TestHandler::default();
|
||||
#[tokio::test]
|
||||
async fn spawn_extra_env() {
|
||||
let mut extra_env = HashMap::new();
|
||||
extra_env.insert("ASCIINEMA_TEST_FOO".to_owned(), "bar".to_owned());
|
||||
|
||||
let mut env = ExtraEnv::new();
|
||||
env.insert("ASCIINEMA_TEST_FOO".to_owned(), "bar".to_owned());
|
||||
let pty = spawn(&["sh", "-c", "echo -n $ASCIINEMA_TEST_FOO"], &extra_env).await;
|
||||
let output = read_output(pty).await;
|
||||
|
||||
super::exec(
|
||||
&["sh", "-c", "echo -n $ASCIINEMA_TEST_FOO"],
|
||||
&env,
|
||||
&mut NullTty::open().unwrap(),
|
||||
&mut handler,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(handler.output(), vec!["bar"]);
|
||||
assert_eq!(output, vec!["bar"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_winsize_override() {
|
||||
let mut handler = TestHandler::default();
|
||||
#[tokio::test]
|
||||
async fn spawn_echo_input() {
|
||||
let pty = spawn(&["cat"], &HashMap::new()).await;
|
||||
pty.write(b"foo").await.unwrap();
|
||||
pty.write(b"bar").await.unwrap();
|
||||
pty.kill();
|
||||
let output = read_output(pty).await.join("");
|
||||
|
||||
super::exec(
|
||||
&["true"],
|
||||
&ExtraEnv::new(),
|
||||
&mut FixedSizeTty::new(NullTty::open().unwrap(), Some(100), Some(50)),
|
||||
&mut handler,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(handler.tty_size, Some(TtySize(100, 50)));
|
||||
assert_eq!(output, "foobar");
|
||||
}
|
||||
}
|
||||
|
||||
209
src/recorder.rs
209
src/recorder.rs
@@ -1,209 +0,0 @@
|
||||
use crate::config::Key;
|
||||
use crate::notifier::Notifier;
|
||||
use crate::pty;
|
||||
use crate::tty;
|
||||
use crate::util;
|
||||
use std::io;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct Recorder {
|
||||
output: Option<Box<dyn Output + Send>>,
|
||||
record_input: bool,
|
||||
keys: KeyBindings,
|
||||
notifier: Option<Box<dyn Notifier>>,
|
||||
sender: mpsc::Sender<Message>,
|
||||
receiver: Option<mpsc::Receiver<Message>>,
|
||||
handle: Option<util::JoinHandle>,
|
||||
time_offset: u64,
|
||||
pause_time: Option<u64>,
|
||||
prefix_mode: bool,
|
||||
}
|
||||
|
||||
pub trait Output {
|
||||
fn start(&mut self, tty_size: &tty::TtySize) -> io::Result<()>;
|
||||
fn output(&mut self, time: u64, text: String) -> io::Result<()>;
|
||||
fn input(&mut self, time: u64, text: String) -> io::Result<()>;
|
||||
fn resize(&mut self, time: u64, size: (u16, u16)) -> io::Result<()>;
|
||||
fn marker(&mut self, time: u64) -> io::Result<()>;
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
enum Message {
|
||||
Output(u64, Vec<u8>),
|
||||
Input(u64, Vec<u8>),
|
||||
Resize(u64, tty::TtySize),
|
||||
Marker(u64),
|
||||
Notification(String),
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
pub fn new(
|
||||
output: Box<dyn Output + Send>,
|
||||
record_input: bool,
|
||||
keys: KeyBindings,
|
||||
notifier: Box<dyn Notifier>,
|
||||
) -> Self {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
|
||||
Recorder {
|
||||
output: Some(output),
|
||||
record_input,
|
||||
keys,
|
||||
notifier: Some(notifier),
|
||||
sender,
|
||||
receiver: Some(receiver),
|
||||
handle: None,
|
||||
time_offset: 0,
|
||||
pause_time: None,
|
||||
prefix_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_time(&self, time: Duration) -> u64 {
|
||||
if let Some(pause_time) = self.pause_time {
|
||||
pause_time
|
||||
} else {
|
||||
time.as_micros() as u64 - self.time_offset
|
||||
}
|
||||
}
|
||||
|
||||
fn notify<S: ToString>(&self, text: S) {
|
||||
let msg = Message::Notification(text.to_string());
|
||||
|
||||
self.sender
|
||||
.send(msg)
|
||||
.expect("notification send should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
impl pty::Handler for Recorder {
|
||||
fn start(&mut self, _epoch: Instant, tty_size: tty::TtySize) {
|
||||
let mut output = self.output.take().unwrap();
|
||||
let _ = output.start(&tty_size);
|
||||
let receiver = self.receiver.take().unwrap();
|
||||
let mut notifier = self.notifier.take().unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
use Message::*;
|
||||
let mut last_tty_size = tty_size;
|
||||
let mut input_decoder = util::Utf8Decoder::new();
|
||||
let mut output_decoder = util::Utf8Decoder::new();
|
||||
|
||||
for msg in receiver {
|
||||
match msg {
|
||||
Output(time, data) => {
|
||||
let text = output_decoder.feed(&data);
|
||||
|
||||
if !text.is_empty() {
|
||||
let _ = output.output(time, text);
|
||||
}
|
||||
}
|
||||
|
||||
Input(time, data) => {
|
||||
let text = input_decoder.feed(&data);
|
||||
|
||||
if !text.is_empty() {
|
||||
let _ = output.input(time, text);
|
||||
}
|
||||
}
|
||||
|
||||
Resize(time, new_tty_size) => {
|
||||
if new_tty_size != last_tty_size {
|
||||
let _ = output.resize(time, new_tty_size.into());
|
||||
last_tty_size = new_tty_size;
|
||||
}
|
||||
}
|
||||
|
||||
Marker(time) => {
|
||||
let _ = output.marker(time);
|
||||
}
|
||||
|
||||
Notification(text) => {
|
||||
let _ = notifier.notify(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = output.finish();
|
||||
});
|
||||
|
||||
self.handle = Some(util::JoinHandle::new(handle));
|
||||
}
|
||||
|
||||
fn output(&mut self, time: Duration, data: &[u8]) -> bool {
|
||||
if self.pause_time.is_none() {
|
||||
let msg = Message::Output(self.elapsed_time(time), data.into());
|
||||
self.sender.send(msg).expect("output send should succeed");
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn input(&mut self, time: Duration, data: &[u8]) -> bool {
|
||||
let prefix_key = self.keys.prefix.as_ref();
|
||||
let pause_key = self.keys.pause.as_ref();
|
||||
let add_marker_key = self.keys.add_marker.as_ref();
|
||||
|
||||
if !self.prefix_mode && prefix_key.is_some_and(|key| data == key) {
|
||||
self.prefix_mode = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.prefix_mode || prefix_key.is_none() {
|
||||
self.prefix_mode = false;
|
||||
|
||||
if pause_key.is_some_and(|key| data == key) {
|
||||
if let Some(pt) = self.pause_time {
|
||||
self.pause_time = None;
|
||||
self.time_offset += self.elapsed_time(time) - pt;
|
||||
self.notify("Resumed recording");
|
||||
} else {
|
||||
self.pause_time = Some(self.elapsed_time(time));
|
||||
self.notify("Paused recording");
|
||||
}
|
||||
|
||||
return false;
|
||||
} else if add_marker_key.is_some_and(|key| data == key) {
|
||||
let msg = Message::Marker(self.elapsed_time(time));
|
||||
self.sender.send(msg).expect("marker send should succeed");
|
||||
self.notify("Marker added");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if self.record_input && self.pause_time.is_none() {
|
||||
let msg = Message::Input(self.elapsed_time(time), data.into());
|
||||
self.sender.send(msg).expect("input send should succeed");
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn resize(&mut self, time: Duration, tty_size: tty::TtySize) -> bool {
|
||||
let msg = Message::Resize(self.elapsed_time(time), tty_size);
|
||||
self.sender.send(msg).expect("resize send should succeed");
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeyBindings {
|
||||
pub prefix: Key,
|
||||
pub pause: Key,
|
||||
pub add_marker: Key,
|
||||
}
|
||||
|
||||
impl Default for KeyBindings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prefix: None,
|
||||
pause: Some(vec![0x1c]), // ^\
|
||||
add_marker: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
174
src/server.rs
Normal file
174
src/server.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use std::future;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
|
||||
use axum::extract::connect_info::ConnectInfo;
|
||||
use axum::extract::ws::{self, CloseCode, CloseFrame, Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, StatusCode, Uri};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::serve::ListenerExt;
|
||||
use axum::Router;
|
||||
use futures_util::{sink, StreamExt};
|
||||
use rust_embed::RustEmbed;
|
||||
use tokio::time::{self, Duration};
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use tower_http::trace::{DefaultMakeSpan, TraceLayer};
|
||||
use tracing::info;
|
||||
|
||||
use crate::alis;
|
||||
use crate::stream::Subscriber;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "assets/"]
|
||||
struct Assets;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
subscriber: Subscriber,
|
||||
tracker: TaskTracker,
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
listener: tokio::net::TcpListener,
|
||||
subscriber: Subscriber,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> io::Result<()> {
|
||||
let trace =
|
||||
TraceLayer::new_for_http().make_span_with(DefaultMakeSpan::default().include_headers(true));
|
||||
|
||||
let tracker = TaskTracker::new();
|
||||
|
||||
let state = AppState {
|
||||
subscriber,
|
||||
tracker: tracker.clone(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.with_state(state)
|
||||
.fallback(static_handler)
|
||||
.layer(trace);
|
||||
|
||||
let signal = async move {
|
||||
let _ = shutdown_token.cancelled().await;
|
||||
};
|
||||
|
||||
info!(
|
||||
"HTTP server listening on {}",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
|
||||
let listener = listener.tap_io(|tcp_stream| {
|
||||
let _ = tcp_stream.set_nodelay(true);
|
||||
});
|
||||
|
||||
let result = axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(signal)
|
||||
.await;
|
||||
|
||||
tracker.close();
|
||||
let _ = time::timeout(Duration::from_secs(3), tracker.wait()).await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
let mut path = uri.path().trim_start_matches('/');
|
||||
|
||||
if path.is_empty() {
|
||||
path = "index.html";
|
||||
}
|
||||
|
||||
match Assets::get(path) {
|
||||
Some(content) => {
|
||||
let mime = mime_from_path(path);
|
||||
|
||||
([(header::CONTENT_TYPE, mime)], content.data).into_response()
|
||||
}
|
||||
|
||||
None => (StatusCode::NOT_FOUND, "404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mime_from_path(path: &str) -> &str {
|
||||
let lowercase_path = &path.to_lowercase();
|
||||
|
||||
let ext = Path::new(lowercase_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str());
|
||||
|
||||
match ext {
|
||||
Some("html") => "text/html",
|
||||
Some("js") => "text/javascript",
|
||||
Some("css") => "text/css",
|
||||
Some(_) | None => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.protocols(["v1.alis"])
|
||||
.on_upgrade(move |socket| async move {
|
||||
info!("websocket client {addr} connected");
|
||||
|
||||
if socket.protocol().is_some() {
|
||||
let _ = state
|
||||
.tracker
|
||||
.track_future(handle_socket(socket, state.subscriber))
|
||||
.await;
|
||||
|
||||
info!("websocket client {addr} disconnected");
|
||||
} else {
|
||||
info!("subprotocol negotiation failed, closing connection");
|
||||
close_socket(socket).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, subscriber: Subscriber) -> anyhow::Result<()> {
|
||||
let (sink, stream) = socket.split();
|
||||
let drainer = tokio::spawn(stream.map(Ok).forward(sink::drain()));
|
||||
let close_msg = close_message(ws::close_code::NORMAL, "Stream ended");
|
||||
let stream = subscriber.subscribe().await?;
|
||||
|
||||
let result = alis::stream(stream)
|
||||
.map(ws_result)
|
||||
.chain(futures_util::stream::once(future::ready(Ok(close_msg))))
|
||||
.forward(sink)
|
||||
.await;
|
||||
|
||||
drainer.abort();
|
||||
result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close_socket(mut socket: WebSocket) {
|
||||
let msg = close_message(ws::close_code::PROTOCOL, "Subprotocol negotiation failed");
|
||||
let _ = socket.send(msg).await;
|
||||
}
|
||||
|
||||
fn close_message(code: CloseCode, reason: &'static str) -> Message {
|
||||
Message::Close(Some(CloseFrame {
|
||||
code,
|
||||
reason: reason.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn ws_result(m: Result<Vec<u8>, BroadcastStreamRecvError>) -> Result<Message, axum::Error> {
|
||||
match m {
|
||||
Ok(bytes) => Ok(Message::Binary(bytes.into())),
|
||||
Err(e) => Err(axum::Error::new(e)),
|
||||
}
|
||||
}
|
||||
342
src/session.rs
Normal file
342
src/session.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::{Buf, BytesMut};
|
||||
use futures_util::future;
|
||||
use futures_util::stream::StreamExt;
|
||||
use nix::sys::wait::{WaitPidFlag, WaitStatus};
|
||||
use signal_hook::consts::signal::*;
|
||||
use signal_hook_tokio::Signals;
|
||||
use tokio::io;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
use tracing::error;
|
||||
|
||||
use crate::config::Key;
|
||||
use crate::notifier::Notifier;
|
||||
use crate::pty::{self, Pty};
|
||||
use crate::tty::{Tty, TtySize, TtyTheme};
|
||||
use crate::util::Utf8Decoder;
|
||||
|
||||
const BUF_SIZE: usize = 128 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Event {
|
||||
Output(Duration, String),
|
||||
Input(Duration, String),
|
||||
Resize(Duration, TtySize),
|
||||
Marker(Duration, String),
|
||||
Exit(Duration, i32),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Metadata {
|
||||
pub time: SystemTime,
|
||||
pub term: TermInfo,
|
||||
pub idle_time_limit: Option<f64>,
|
||||
pub command: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TermInfo {
|
||||
pub type_: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub size: TtySize,
|
||||
pub theme: Option<TtyTheme>,
|
||||
}
|
||||
|
||||
struct Session<N: Notifier> {
|
||||
epoch: Instant,
|
||||
events_tx: mpsc::Sender<Event>,
|
||||
input_decoder: Utf8Decoder,
|
||||
keys: KeyBindings,
|
||||
notifier: N,
|
||||
output_decoder: Utf8Decoder,
|
||||
pause_time: Option<Duration>,
|
||||
prefix_mode: bool,
|
||||
record_input: bool,
|
||||
time_offset: Duration,
|
||||
tty_size: TtySize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Output: Send {
|
||||
async fn event(&mut self, event: Event) -> io::Result<()>;
|
||||
async fn flush(&mut self) -> io::Result<()>;
|
||||
}
|
||||
|
||||
pub async fn run<S: AsRef<str>, T: Tty + ?Sized, N: Notifier>(
|
||||
command: &[S],
|
||||
extra_env: &HashMap<String, String>,
|
||||
tty: &mut T,
|
||||
record_input: bool,
|
||||
outputs: Vec<Box<dyn Output>>,
|
||||
keys: KeyBindings,
|
||||
notifier: N,
|
||||
) -> anyhow::Result<i32> {
|
||||
let epoch = Instant::now();
|
||||
let (events_tx, events_rx) = mpsc::channel::<Event>(1024);
|
||||
let winsize = tty.get_size();
|
||||
let pty = pty::spawn(command, winsize, extra_env)?;
|
||||
let forwarder = tokio::spawn(forward_events(events_rx, outputs));
|
||||
|
||||
let session = Session {
|
||||
epoch,
|
||||
events_tx,
|
||||
input_decoder: Utf8Decoder::new(),
|
||||
keys,
|
||||
notifier,
|
||||
output_decoder: Utf8Decoder::new(),
|
||||
pause_time: None,
|
||||
prefix_mode: false,
|
||||
record_input,
|
||||
time_offset: Duration::from_micros(0),
|
||||
tty_size: winsize.into(),
|
||||
};
|
||||
|
||||
let result = session.run(pty, tty).await;
|
||||
let _ = forwarder.await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn forward_events(mut events_rx: mpsc::Receiver<Event>, outputs: Vec<Box<dyn Output>>) {
|
||||
let mut outputs = outputs;
|
||||
|
||||
while let Some(event) = events_rx.recv().await {
|
||||
let futs: Vec<_> = outputs
|
||||
.into_iter()
|
||||
.map(|output| forward_event(output, event.clone()))
|
||||
.collect();
|
||||
|
||||
outputs = future::join_all(futs).await.into_iter().flatten().collect();
|
||||
}
|
||||
|
||||
for mut output in outputs {
|
||||
if let Err(e) = output.flush().await {
|
||||
error!("output flush failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_event(mut output: Box<dyn Output>, event: Event) -> Option<Box<dyn Output>> {
|
||||
match output.event(event).await {
|
||||
Ok(()) => Some(output),
|
||||
|
||||
Err(e) => {
|
||||
error!("output event handler failed: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<N: Notifier> Session<N> {
|
||||
async fn run<T: Tty + ?Sized>(mut self, pty: Pty, tty: &mut T) -> anyhow::Result<i32> {
|
||||
let mut signals =
|
||||
Signals::new([SIGWINCH, SIGINT, SIGTERM, SIGQUIT, SIGHUP, SIGALRM, SIGCHLD])?;
|
||||
let mut output_buf = [0u8; BUF_SIZE];
|
||||
let mut input_buf = [0u8; BUF_SIZE];
|
||||
let mut input = BytesMut::with_capacity(BUF_SIZE);
|
||||
let mut output = BytesMut::with_capacity(BUF_SIZE);
|
||||
let mut wait_status = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = pty.read(&mut output_buf) => {
|
||||
let n = result?;
|
||||
|
||||
if n > 0 {
|
||||
self.handle_output(&output_buf[..n]).await;
|
||||
output.extend_from_slice(&output_buf[0..n]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result = pty.write(&input), if !input.is_empty() => {
|
||||
let n = result?;
|
||||
input.advance(n);
|
||||
}
|
||||
|
||||
result = tty.read(&mut input_buf) => {
|
||||
let n = result?;
|
||||
|
||||
if n > 0 {
|
||||
if self.handle_input(&input_buf[..n]).await {
|
||||
input.extend_from_slice(&input_buf[..n]);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result = tty.write(&output), if !output.is_empty() => {
|
||||
let n = result?;
|
||||
output.advance(n);
|
||||
}
|
||||
|
||||
Some(signal) = signals.next() => {
|
||||
match signal {
|
||||
SIGWINCH => {
|
||||
let winsize = tty.get_size();
|
||||
pty.resize(winsize);
|
||||
self.handle_resize(winsize.into()).await;
|
||||
}
|
||||
|
||||
SIGINT | SIGTERM | SIGQUIT | SIGHUP => {
|
||||
pty.kill();
|
||||
}
|
||||
|
||||
SIGCHLD => {
|
||||
if let Ok(status) = pty.wait(Some(WaitPidFlag::WNOHANG)).await {
|
||||
if status != WaitStatus::StillAlive {
|
||||
wait_status = Some(status);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(n) = pty.read(&mut output_buf).await {
|
||||
if n > 0 {
|
||||
self.handle_output(&output_buf[..n]).await;
|
||||
output.extend_from_slice(&output_buf[0..n]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
let _ = tty.write_all(&output).await;
|
||||
}
|
||||
|
||||
let wait_status = match wait_status {
|
||||
Some(ws) => ws,
|
||||
None => pty.wait(None).await?,
|
||||
};
|
||||
|
||||
let status = match wait_status {
|
||||
WaitStatus::Exited(_pid, status) => status,
|
||||
WaitStatus::Signaled(_pid, signal, ..) => 128 + signal as i32,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
self.handle_exit(status).await;
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
async fn handle_output(&mut self, data: &[u8]) {
|
||||
if self.pause_time.is_none() {
|
||||
let text = self.output_decoder.feed(data);
|
||||
|
||||
if !text.is_empty() {
|
||||
let event = Event::Output(self.elapsed_time(), text);
|
||||
self.send_session_event(event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_input(&mut self, data: &[u8]) -> bool {
|
||||
let prefix_key = self.keys.prefix.as_ref();
|
||||
let pause_key = self.keys.pause.as_ref();
|
||||
let add_marker_key = self.keys.add_marker.as_ref();
|
||||
|
||||
if !self.prefix_mode && prefix_key.is_some_and(|key| data == key) {
|
||||
self.prefix_mode = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.prefix_mode || prefix_key.is_none() {
|
||||
self.prefix_mode = false;
|
||||
|
||||
if pause_key.is_some_and(|key| data == key) {
|
||||
if let Some(pt) = self.pause_time {
|
||||
self.pause_time = None;
|
||||
self.time_offset += self.elapsed_time() - pt;
|
||||
self.notify("Resumed recording").await;
|
||||
} else {
|
||||
self.pause_time = Some(self.elapsed_time());
|
||||
self.notify("Paused recording").await;
|
||||
}
|
||||
|
||||
return false;
|
||||
} else if add_marker_key.is_some_and(|key| data == key) {
|
||||
let event = Event::Marker(self.elapsed_time(), "".to_owned());
|
||||
self.send_session_event(event).await;
|
||||
self.notify("Marker added").await;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if self.record_input && self.pause_time.is_none() {
|
||||
let text = self.input_decoder.feed(data);
|
||||
|
||||
if !text.is_empty() {
|
||||
let event = Event::Input(self.elapsed_time(), text);
|
||||
self.send_session_event(event).await;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle_resize(&mut self, tty_size: TtySize) {
|
||||
if tty_size != self.tty_size {
|
||||
let event = Event::Resize(self.elapsed_time(), tty_size);
|
||||
self.send_session_event(event).await;
|
||||
self.tty_size = tty_size;
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_exit(&mut self, status: i32) {
|
||||
let event = Event::Exit(self.elapsed_time(), status);
|
||||
self.send_session_event(event).await;
|
||||
}
|
||||
|
||||
fn elapsed_time(&self) -> Duration {
|
||||
if let Some(pause_time) = self.pause_time {
|
||||
pause_time
|
||||
} else {
|
||||
self.epoch.elapsed() - self.time_offset
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_session_event(&mut self, event: Event) {
|
||||
self.events_tx
|
||||
.send(event)
|
||||
.await
|
||||
.expect("session event send should succeed");
|
||||
}
|
||||
|
||||
async fn notify<S: ToString>(&mut self, text: S) {
|
||||
self.notifier
|
||||
.notify(text.to_string())
|
||||
.await
|
||||
.expect("notification should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeyBindings {
|
||||
pub prefix: Key,
|
||||
pub pause: Key,
|
||||
pub add_marker: Key,
|
||||
}
|
||||
|
||||
impl Default for KeyBindings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prefix: None,
|
||||
pause: Some(vec![0x1c]), // ^\
|
||||
add_marker: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/status.rs
Normal file
31
src/status.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
|
||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
pub fn disable() {
|
||||
ENABLED.store(false, SeqCst);
|
||||
}
|
||||
|
||||
macro_rules! info {
|
||||
($fmt:expr) => (crate::status::do_info(format!($fmt)));
|
||||
($fmt:expr, $($arg:tt)*) => (crate::status::do_info(format!($fmt, $($arg)*)));
|
||||
}
|
||||
|
||||
macro_rules! warning {
|
||||
($fmt:expr) => (crate::status::do_warn(format!($fmt)));
|
||||
($fmt:expr, $($arg:tt)*) => (crate::status::do_warn(format!($fmt, $($arg)*)));
|
||||
}
|
||||
|
||||
pub fn do_info(message: String) {
|
||||
if ENABLED.load(SeqCst) {
|
||||
println!("::: {message}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn do_warn(message: String) {
|
||||
if ENABLED.load(SeqCst) {
|
||||
eprintln!("!!! {message}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use info;
|
||||
pub(crate) use warning;
|
||||
192
src/stream.rs
Normal file
192
src/stream.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use std::future;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use avt::Vt;
|
||||
use futures_util::{stream, StreamExt};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tokio::{io, time};
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tracing::info;
|
||||
|
||||
use crate::session::{self, Metadata};
|
||||
use crate::tty::{TtySize, TtyTheme};
|
||||
|
||||
pub struct Stream {
|
||||
request_tx: mpsc::Sender<Request>,
|
||||
request_rx: mpsc::Receiver<Request>,
|
||||
}
|
||||
|
||||
type Request = oneshot::Sender<Subscription>;
|
||||
|
||||
struct Subscription {
|
||||
init: Event,
|
||||
events_rx: broadcast::Receiver<Event>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Event {
|
||||
Init(u64, Duration, TtySize, Option<TtyTheme>, String),
|
||||
Output(u64, Duration, String),
|
||||
Input(u64, Duration, String),
|
||||
Resize(u64, Duration, TtySize),
|
||||
Marker(u64, Duration, String),
|
||||
Exit(u64, Duration, i32),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Subscriber(mpsc::Sender<Request>);
|
||||
|
||||
pub struct LiveStream(mpsc::Sender<session::Event>);
|
||||
|
||||
impl Stream {
|
||||
pub fn new() -> Self {
|
||||
let (request_tx, request_rx) = mpsc::channel(16);
|
||||
|
||||
Stream {
|
||||
request_tx,
|
||||
request_rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscriber(&self) -> Subscriber {
|
||||
Subscriber(self.request_tx.clone())
|
||||
}
|
||||
|
||||
pub async fn start(self, metadata: &Metadata) -> LiveStream {
|
||||
let (stream_tx, stream_rx) = mpsc::channel(1024);
|
||||
let request_rx = self.request_rx;
|
||||
|
||||
tokio::spawn(run(
|
||||
metadata.term.size,
|
||||
metadata.term.theme.clone(),
|
||||
stream_rx,
|
||||
request_rx,
|
||||
));
|
||||
|
||||
LiveStream(stream_tx)
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
tty_size: TtySize,
|
||||
tty_theme: Option<TtyTheme>,
|
||||
mut stream_rx: mpsc::Receiver<session::Event>,
|
||||
mut request_rx: mpsc::Receiver<Request>,
|
||||
) {
|
||||
let (broadcast_tx, _) = broadcast::channel(1024);
|
||||
let mut vt = build_vt(tty_size);
|
||||
let mut stream_time = Duration::from_micros(0);
|
||||
let mut last_event_id = 0;
|
||||
let mut last_event_time = Instant::now();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = stream_rx.recv() => {
|
||||
match event {
|
||||
Some(event) => {
|
||||
last_event_time = Instant::now();
|
||||
last_event_id += 1;
|
||||
|
||||
match event {
|
||||
session::Event::Output(time, text) => {
|
||||
vt.feed_str(&text);
|
||||
let _ = broadcast_tx.send(Event::Output(last_event_id, time, text));
|
||||
stream_time = time;
|
||||
}
|
||||
|
||||
session::Event::Input(time, text) => {
|
||||
let _ = broadcast_tx.send(Event::Input(last_event_id, time, text));
|
||||
stream_time = time;
|
||||
}
|
||||
|
||||
session::Event::Resize(time, tty_size) => {
|
||||
vt.resize(tty_size.0.into(), tty_size.1.into());
|
||||
let _ = broadcast_tx.send(Event::Resize(last_event_id, time, tty_size));
|
||||
stream_time = time;
|
||||
}
|
||||
|
||||
session::Event::Marker(time, label) => {
|
||||
let _ = broadcast_tx.send(Event::Marker(last_event_id, time, label));
|
||||
stream_time = time;
|
||||
}
|
||||
|
||||
session::Event::Exit(time, status) => {
|
||||
let _ = broadcast_tx.send(Event::Exit(last_event_id, time, status));
|
||||
stream_time = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
request = request_rx.recv() => {
|
||||
match request {
|
||||
Some(request) => {
|
||||
let init = if last_event_id > 0 {
|
||||
let elapsed_time = stream_time + last_event_time.elapsed();
|
||||
|
||||
Event::Init(
|
||||
last_event_id,
|
||||
elapsed_time,
|
||||
vt.size().into(),
|
||||
tty_theme.clone(),
|
||||
vt.dump(),
|
||||
)
|
||||
} else {
|
||||
Event::Init(
|
||||
last_event_id,
|
||||
stream_time,
|
||||
vt.size().into(),
|
||||
tty_theme.clone(),
|
||||
"".to_owned(),
|
||||
)
|
||||
};
|
||||
|
||||
let events_rx = broadcast_tx.subscribe();
|
||||
let _ = request.send(Subscription { init, events_rx });
|
||||
info!("subscriber count: {}", broadcast_tx.receiver_count());
|
||||
}
|
||||
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Subscriber {
|
||||
pub async fn subscribe(
|
||||
&self,
|
||||
) -> anyhow::Result<impl futures_util::Stream<Item = Result<Event, BroadcastStreamRecvError>>>
|
||||
{
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.0.send(tx).await?;
|
||||
let subscription = time::timeout(Duration::from_secs(5), rx).await??;
|
||||
let init = stream::once(future::ready(Ok(subscription.init)));
|
||||
let events = BroadcastStream::new(subscription.events_rx);
|
||||
|
||||
Ok(init.chain(events))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_vt(tty_size: TtySize) -> Vt {
|
||||
Vt::builder()
|
||||
.size(tty_size.0 as usize, tty_size.1 as usize)
|
||||
.scrollback_limit(1000)
|
||||
.build()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl session::Output for LiveStream {
|
||||
async fn event(&mut self, event: session::Event) -> io::Result<()> {
|
||||
self.0.send(event).await.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
async fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// This module implements ALiS (asciinema live stream) protocol,
|
||||
// which is an application level protocol built on top of WebSocket binary messages,
|
||||
// used by asciinema CLI, asciinema player and asciinema server.
|
||||
|
||||
// TODO document the protocol
|
||||
|
||||
use super::session;
|
||||
use anyhow::Result;
|
||||
use futures_util::{stream, Stream, StreamExt, TryStreamExt};
|
||||
use std::future;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
static HEADER: &str = "ALiS\x01";
|
||||
static SECOND: f64 = 1_000_000.0;
|
||||
|
||||
pub async fn stream(
|
||||
clients_tx: &mpsc::Sender<session::Client>,
|
||||
) -> Result<impl Stream<Item = Result<Vec<u8>, BroadcastStreamRecvError>>> {
|
||||
let header = stream::once(future::ready(Ok(HEADER.into())));
|
||||
let events = session::stream(clients_tx).await?.map_ok(encode_event);
|
||||
|
||||
Ok(header.chain(events))
|
||||
}
|
||||
|
||||
fn encode_event(event: session::Event) -> Vec<u8> {
|
||||
use session::Event::*;
|
||||
|
||||
match event {
|
||||
Init(time, size, theme, init) => {
|
||||
let (cols, rows): (u16, u16) = (size.0, size.1);
|
||||
let cols_bytes = cols.to_le_bytes();
|
||||
let rows_bytes = rows.to_le_bytes();
|
||||
let time_bytes = ((time as f64 / SECOND) as f32).to_le_bytes();
|
||||
let init_len = init.len() as u32;
|
||||
let init_len_bytes = init_len.to_le_bytes();
|
||||
|
||||
let mut msg = vec![0x01]; // 1 byte
|
||||
msg.extend_from_slice(&cols_bytes); // 2 bytes
|
||||
msg.extend_from_slice(&rows_bytes); // 2 bytes
|
||||
msg.extend_from_slice(&time_bytes); // 4 bytes
|
||||
|
||||
match theme {
|
||||
Some(theme) => {
|
||||
msg.push(1);
|
||||
msg.push(theme.fg.r);
|
||||
msg.push(theme.fg.g);
|
||||
msg.push(theme.fg.b);
|
||||
msg.push(theme.bg.r);
|
||||
msg.push(theme.bg.g);
|
||||
msg.push(theme.bg.b);
|
||||
|
||||
for color in &theme.palette {
|
||||
msg.push(color.r);
|
||||
msg.push(color.g);
|
||||
msg.push(color.b);
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
msg.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
msg.extend_from_slice(&init_len_bytes); // 4 bytes
|
||||
msg.extend_from_slice(init.as_bytes()); // init_len bytes
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
Stdout(time, text) => {
|
||||
let time_bytes = ((time as f64 / SECOND) as f32).to_le_bytes();
|
||||
let text_len = text.len() as u32;
|
||||
let text_len_bytes = text_len.to_le_bytes();
|
||||
|
||||
let mut msg = vec![b'o']; // 1 byte
|
||||
msg.extend_from_slice(&time_bytes); // 4 bytes
|
||||
msg.extend_from_slice(&text_len_bytes); // 4 bytes
|
||||
msg.extend_from_slice(text.as_bytes()); // text_len bytes
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
Resize(time, size) => {
|
||||
let (cols, rows): (u16, u16) = (size.0, size.1);
|
||||
let time_bytes = ((time as f64 / SECOND) as f32).to_le_bytes();
|
||||
let cols_bytes = cols.to_le_bytes();
|
||||
let rows_bytes = rows.to_le_bytes();
|
||||
|
||||
let mut msg = vec![b'r']; // 1 byte
|
||||
msg.extend_from_slice(&time_bytes); // 4 bytes
|
||||
msg.extend_from_slice(&cols_bytes); // 2 bytes
|
||||
msg.extend_from_slice(&rows_bytes); // 2 bytes
|
||||
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
use super::alis;
|
||||
use super::session;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use core::future::{self, Future};
|
||||
use futures_util::{stream, SinkExt, Stream, StreamExt};
|
||||
use std::borrow::Cow;
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{interval, sleep, timeout};
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
use tokio_stream::wrappers::IntervalStream;
|
||||
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
||||
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
const PING_INTERVAL: u64 = 15;
|
||||
const PING_TIMEOUT: u64 = 10;
|
||||
const SEND_TIMEOUT: u64 = 10;
|
||||
const MAX_RECONNECT_DELAY: u64 = 5000;
|
||||
|
||||
pub async fn forward(
|
||||
url: url::Url,
|
||||
clients_tx: mpsc::Sender<session::Client>,
|
||||
notifier_tx: std::sync::mpsc::Sender<String>,
|
||||
shutdown_token: tokio_util::sync::CancellationToken,
|
||||
) {
|
||||
info!("forwarding to {url}");
|
||||
let mut reconnect_attempt = 0;
|
||||
let mut connection_count: u64 = 0;
|
||||
|
||||
loop {
|
||||
let conn = connect_and_forward(&url, &clients_tx);
|
||||
tokio::pin!(conn);
|
||||
|
||||
let result = tokio::select! {
|
||||
result = &mut conn => result,
|
||||
|
||||
_ = sleep(Duration::from_secs(3)) => {
|
||||
if reconnect_attempt > 0 {
|
||||
if connection_count == 0 {
|
||||
let _ = notifier_tx.send("Connected to the server".to_string());
|
||||
} else {
|
||||
let _ = notifier_tx.send("Reconnected to the server".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
connection_count += 1;
|
||||
reconnect_attempt = 0;
|
||||
|
||||
conn.await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(true) => break,
|
||||
|
||||
Ok(false) => {
|
||||
let _ = notifier_tx.send("Stream halted by the server".to_string());
|
||||
break;
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
error!("connection error: {e}");
|
||||
|
||||
if reconnect_attempt == 0 {
|
||||
if connection_count == 0 {
|
||||
let _ = notifier_tx
|
||||
.send("Cannot connect to the server, retrying...".to_string());
|
||||
} else {
|
||||
let _ = notifier_tx
|
||||
.send("Disconnected from the server, reconnecting...".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let delay = exponential_delay(reconnect_attempt);
|
||||
reconnect_attempt = (reconnect_attempt + 1).min(10);
|
||||
info!("reconnecting in {delay}");
|
||||
|
||||
tokio::select! {
|
||||
_ = sleep(Duration::from_millis(delay)) => (),
|
||||
_ = shutdown_token.cancelled() => break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_forward(
|
||||
url: &url::Url,
|
||||
clients_tx: &mpsc::Sender<session::Client>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let (ws, _) = tokio_tungstenite::connect_async_with_config(url, None, true).await?;
|
||||
info!("connected to the endpoint");
|
||||
let events = event_stream(clients_tx).await?;
|
||||
|
||||
handle_socket(ws, events).await
|
||||
}
|
||||
|
||||
async fn event_stream(
|
||||
clients_tx: &mpsc::Sender<session::Client>,
|
||||
) -> anyhow::Result<impl Stream<Item = anyhow::Result<Message>>> {
|
||||
let stream = alis::stream(clients_tx)
|
||||
.await?
|
||||
.map(ws_result)
|
||||
.chain(stream::once(future::ready(Ok(close_message()))));
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
async fn handle_socket<T>(
|
||||
ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
events: T,
|
||||
) -> anyhow::Result<bool>
|
||||
where
|
||||
T: Stream<Item = anyhow::Result<Message>> + Unpin,
|
||||
{
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
let mut events = events;
|
||||
let mut pings = ping_stream();
|
||||
let mut ping_timeout: Pin<Box<dyn Future<Output = ()> + Send>> = Box::pin(future::pending());
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = events.next() => {
|
||||
match event {
|
||||
Some(event) => {
|
||||
timeout(Duration::from_secs(SEND_TIMEOUT), sink.send(event?)).await.map_err(|_| anyhow!("send timeout"))??;
|
||||
},
|
||||
|
||||
None => return Ok(true)
|
||||
}
|
||||
},
|
||||
|
||||
ping = pings.next() => {
|
||||
timeout(Duration::from_secs(SEND_TIMEOUT), sink.send(ping.unwrap())).await.map_err(|_| anyhow!("send timeout"))??;
|
||||
ping_timeout = Box::pin(sleep(Duration::from_secs(PING_TIMEOUT)));
|
||||
}
|
||||
|
||||
_ = &mut ping_timeout => bail!("ping timeout"),
|
||||
|
||||
message = stream.next() => {
|
||||
match message {
|
||||
Some(Ok(Message::Close(close_frame))) => {
|
||||
info!("server closed the connection");
|
||||
handle_close_frame(close_frame)?;
|
||||
return Ok(false);
|
||||
},
|
||||
|
||||
Some(Ok(Message::Ping(_))) => (),
|
||||
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
ping_timeout = Box::pin(future::pending());
|
||||
},
|
||||
|
||||
Some(Ok(msg)) => debug!("unexpected message from the server: {msg:?}"),
|
||||
Some(Err(e)) => bail!(e),
|
||||
None => bail!("SplitStream closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_close_frame(frame: Option<CloseFrame>) -> anyhow::Result<()> {
|
||||
match frame {
|
||||
Some(CloseFrame { code, reason }) => {
|
||||
info!("close reason: {code} ({reason})");
|
||||
|
||||
match code {
|
||||
CloseCode::Normal => Ok(()),
|
||||
CloseCode::Library(code) if code < 4100 => Ok(()),
|
||||
c => bail!("unclean close: {c}"),
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
info!("close reason: none");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exponential_delay(attempt: usize) -> u64 {
|
||||
(2_u64.pow(attempt as u32) * 500).min(MAX_RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
fn ws_result(m: Result<Vec<u8>, BroadcastStreamRecvError>) -> anyhow::Result<Message> {
|
||||
match m {
|
||||
Ok(bytes) => Ok(Message::binary(bytes)),
|
||||
Err(e) => Err(anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_message() -> Message {
|
||||
Message::Close(Some(CloseFrame {
|
||||
code: CloseCode::Normal,
|
||||
reason: Cow::from("ended"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn ping_stream() -> impl Stream<Item = Message> {
|
||||
IntervalStream::new(interval(Duration::from_secs(PING_INTERVAL)))
|
||||
.skip(1)
|
||||
.map(|_| Message::Ping(vec![]))
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
mod alis;
|
||||
mod forwarder;
|
||||
mod server;
|
||||
mod session;
|
||||
use crate::config::Key;
|
||||
use crate::notifier::Notifier;
|
||||
use crate::pty;
|
||||
use crate::tty;
|
||||
use crate::util;
|
||||
use std::net;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
pub struct Streamer {
|
||||
record_input: bool,
|
||||
keys: KeyBindings,
|
||||
notifier: Option<Box<dyn Notifier>>,
|
||||
notifier_rx: Option<std::sync::mpsc::Receiver<String>>,
|
||||
pty_rx: Option<mpsc::UnboundedReceiver<Event>>,
|
||||
paused: bool,
|
||||
prefix_mode: bool,
|
||||
listener: Option<net::TcpListener>,
|
||||
forward_url: Option<url::Url>,
|
||||
theme: Option<tty::Theme>,
|
||||
// XXX: field (drop) order below is crucial for correct shutdown
|
||||
pty_tx: mpsc::UnboundedSender<Event>,
|
||||
notifier_tx: std::sync::mpsc::Sender<String>,
|
||||
event_loop_handle: Option<util::JoinHandle>,
|
||||
notifier_handle: Option<util::JoinHandle>,
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Output(u64, Vec<u8>),
|
||||
Input(u64, Vec<u8>),
|
||||
Resize(u64, tty::TtySize),
|
||||
}
|
||||
|
||||
impl Streamer {
|
||||
pub fn new(
|
||||
listener: Option<net::TcpListener>,
|
||||
forward_url: Option<url::Url>,
|
||||
record_input: bool,
|
||||
keys: KeyBindings,
|
||||
notifier: Box<dyn Notifier>,
|
||||
theme: Option<tty::Theme>,
|
||||
) -> Self {
|
||||
let (notifier_tx, notifier_rx) = std::sync::mpsc::channel();
|
||||
let (pty_tx, pty_rx) = mpsc::unbounded_channel();
|
||||
|
||||
Self {
|
||||
record_input,
|
||||
keys,
|
||||
notifier: Some(notifier),
|
||||
notifier_tx,
|
||||
notifier_rx: Some(notifier_rx),
|
||||
notifier_handle: None,
|
||||
pty_tx,
|
||||
pty_rx: Some(pty_rx),
|
||||
event_loop_handle: None,
|
||||
paused: false,
|
||||
prefix_mode: false,
|
||||
listener,
|
||||
forward_url,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_time(&self, time: Duration) -> u64 {
|
||||
time.as_micros() as u64
|
||||
}
|
||||
|
||||
fn notify<S: ToString>(&self, message: S) {
|
||||
let message = message.to_string();
|
||||
info!(message);
|
||||
|
||||
self.notifier_tx
|
||||
.send(message)
|
||||
.expect("notification send should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
impl pty::Handler for Streamer {
|
||||
fn start(&mut self, _epoch: Instant, tty_size: tty::TtySize) {
|
||||
let pty_rx = self.pty_rx.take().unwrap();
|
||||
let (clients_tx, mut clients_rx) = mpsc::channel(1);
|
||||
let shutdown_token = tokio_util::sync::CancellationToken::new();
|
||||
let runtime = build_tokio_runtime();
|
||||
|
||||
let server = self.listener.take().map(|listener| {
|
||||
runtime.spawn(server::serve(
|
||||
listener,
|
||||
clients_tx.clone(),
|
||||
shutdown_token.clone(),
|
||||
))
|
||||
});
|
||||
|
||||
let forwarder = self.forward_url.take().map(|url| {
|
||||
runtime.spawn(forwarder::forward(
|
||||
url,
|
||||
clients_tx,
|
||||
self.notifier_tx.clone(),
|
||||
shutdown_token.clone(),
|
||||
))
|
||||
});
|
||||
|
||||
let theme = self.theme.take();
|
||||
|
||||
self.event_loop_handle = wrap_thread_handle(thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
event_loop(pty_rx, &mut clients_rx, tty_size, theme).await;
|
||||
info!("shutting down");
|
||||
shutdown_token.cancel();
|
||||
|
||||
if let Some(task) = server {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
|
||||
}
|
||||
|
||||
if let Some(task) = forwarder {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
|
||||
}
|
||||
|
||||
let _ = clients_rx.recv().await;
|
||||
});
|
||||
}));
|
||||
|
||||
let mut notifier = self.notifier.take().unwrap();
|
||||
let notifier_rx = self.notifier_rx.take().unwrap();
|
||||
|
||||
self.notifier_handle = wrap_thread_handle(thread::spawn(move || {
|
||||
for message in notifier_rx {
|
||||
let _ = notifier.notify(message);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn output(&mut self, time: Duration, raw: &[u8]) -> bool {
|
||||
if !self.paused {
|
||||
let event = Event::Output(self.elapsed_time(time), raw.into());
|
||||
let _ = self.pty_tx.send(event);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn input(&mut self, time: Duration, raw: &[u8]) -> bool {
|
||||
let prefix_key = self.keys.prefix.as_ref();
|
||||
let pause_key = self.keys.pause.as_ref();
|
||||
|
||||
if !self.prefix_mode && prefix_key.is_some_and(|key| raw == key) {
|
||||
self.prefix_mode = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.prefix_mode || prefix_key.is_none() {
|
||||
self.prefix_mode = false;
|
||||
|
||||
if pause_key.is_some_and(|key| raw == key) {
|
||||
if self.paused {
|
||||
self.paused = false;
|
||||
self.notify("Resumed streaming");
|
||||
} else {
|
||||
self.paused = true;
|
||||
self.notify("Paused streaming");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if self.record_input && !self.paused {
|
||||
let event = Event::Input(self.elapsed_time(time), raw.into());
|
||||
let _ = self.pty_tx.send(event);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn resize(&mut self, time: Duration, tty_size: tty::TtySize) -> bool {
|
||||
let event = Event::Resize(self.elapsed_time(time), tty_size);
|
||||
let _ = self.pty_tx.send(event);
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
async fn event_loop(
|
||||
mut events: mpsc::UnboundedReceiver<Event>,
|
||||
clients: &mut mpsc::Receiver<session::Client>,
|
||||
tty_size: tty::TtySize,
|
||||
theme: Option<tty::Theme>,
|
||||
) {
|
||||
let mut session = session::Session::new(tty_size, theme);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = events.recv() => {
|
||||
match event {
|
||||
Some(Event::Output(time, data)) => {
|
||||
session.output(time, &data);
|
||||
}
|
||||
|
||||
Some(Event::Input(time, data)) => {
|
||||
session.input(time, &data);
|
||||
}
|
||||
|
||||
Some(Event::Resize(time, new_tty_size)) => {
|
||||
session.resize(time, new_tty_size);
|
||||
}
|
||||
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
client = clients.recv() => {
|
||||
match client {
|
||||
Some(client) => {
|
||||
client.accept(session.subscribe());
|
||||
info!("client count: {}", session.subscriber_count());
|
||||
}
|
||||
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tokio_runtime() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn wrap_thread_handle(handle: thread::JoinHandle<()>) -> Option<util::JoinHandle> {
|
||||
Some(util::JoinHandle::new(handle))
|
||||
}
|
||||
|
||||
pub struct KeyBindings {
|
||||
pub prefix: Key,
|
||||
pub pause: Key,
|
||||
}
|
||||
|
||||
impl Default for KeyBindings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prefix: None,
|
||||
pause: Some(vec![0x1c]), // ^\
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
use super::alis;
|
||||
use super::session;
|
||||
use axum::{
|
||||
extract::connect_info::ConnectInfo,
|
||||
extract::ws,
|
||||
extract::State,
|
||||
http::{header, StatusCode, Uri},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use futures_util::sink;
|
||||
use futures_util::{stream, StreamExt};
|
||||
use rust_embed::RustEmbed;
|
||||
use std::borrow::Cow;
|
||||
use std::future;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
use tower_http::trace;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "assets/"]
|
||||
struct Assets;
|
||||
|
||||
pub async fn serve(
|
||||
listener: std::net::TcpListener,
|
||||
clients_tx: mpsc::Sender<session::Client>,
|
||||
shutdown_token: tokio_util::sync::CancellationToken,
|
||||
) -> io::Result<()> {
|
||||
listener.set_nonblocking(true)?;
|
||||
let listener = tokio::net::TcpListener::from_std(listener)?;
|
||||
|
||||
let trace = trace::TraceLayer::new_for_http()
|
||||
.make_span_with(trace::DefaultMakeSpan::default().include_headers(true));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.with_state(clients_tx)
|
||||
.fallback(static_handler)
|
||||
.layer(trace);
|
||||
|
||||
let signal = async move {
|
||||
let _ = shutdown_token.cancelled().await;
|
||||
};
|
||||
|
||||
info!(
|
||||
"HTTP server listening on {}",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(signal)
|
||||
// TODO .tcp_nodelay(true) - requires axum 0.8
|
||||
.await
|
||||
}
|
||||
|
||||
async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
let mut path = uri.path().trim_start_matches('/');
|
||||
|
||||
if path.is_empty() {
|
||||
path = "index.html";
|
||||
}
|
||||
|
||||
match Assets::get(path) {
|
||||
Some(content) => {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
|
||||
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
|
||||
}
|
||||
|
||||
None => (StatusCode::NOT_FOUND, "404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ws_handler(
|
||||
ws: ws::WebSocketUpgrade,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
State(clients_tx): State<mpsc::Sender<session::Client>>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
info!("websocket client {addr} connected");
|
||||
let _ = handle_socket(socket, clients_tx).await;
|
||||
info!("websocket client {addr} disconnected");
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_socket(
|
||||
socket: ws::WebSocket,
|
||||
clients_tx: mpsc::Sender<session::Client>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (sink, stream) = socket.split();
|
||||
let drainer = tokio::spawn(stream.map(Ok).forward(sink::drain()));
|
||||
|
||||
let result = alis::stream(&clients_tx)
|
||||
.await?
|
||||
.map(ws_result)
|
||||
.chain(stream::once(future::ready(Ok(close_message()))))
|
||||
.forward(sink)
|
||||
.await;
|
||||
|
||||
drainer.abort();
|
||||
result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_message() -> ws::Message {
|
||||
ws::Message::Close(Some(ws::CloseFrame {
|
||||
code: ws::close_code::NORMAL,
|
||||
reason: Cow::from("ended"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn ws_result(m: Result<Vec<u8>, BroadcastStreamRecvError>) -> Result<ws::Message, axum::Error> {
|
||||
match m {
|
||||
Ok(bytes) => Ok(ws::Message::Binary(bytes)),
|
||||
Err(e) => Err(axum::Error::new(e)),
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
use crate::tty;
|
||||
use crate::util;
|
||||
use anyhow::Result;
|
||||
use futures_util::{stream, Stream, StreamExt};
|
||||
use std::{
|
||||
future,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream};
|
||||
|
||||
pub struct Session {
|
||||
vt: avt::Vt,
|
||||
broadcast_tx: broadcast::Sender<Event>,
|
||||
stream_time: u64,
|
||||
last_event_time: Instant,
|
||||
theme: Option<tty::Theme>,
|
||||
output_decoder: util::Utf8Decoder,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Event {
|
||||
Init(u64, tty::TtySize, Option<tty::Theme>, String),
|
||||
Stdout(u64, String),
|
||||
Resize(u64, tty::TtySize),
|
||||
}
|
||||
|
||||
pub struct Client(oneshot::Sender<Subscription>);
|
||||
|
||||
pub struct Subscription {
|
||||
init: Event,
|
||||
broadcast_rx: broadcast::Receiver<Event>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(tty_size: tty::TtySize, theme: Option<tty::Theme>) -> Self {
|
||||
let (broadcast_tx, _) = broadcast::channel(1024);
|
||||
|
||||
Self {
|
||||
vt: build_vt(tty_size),
|
||||
broadcast_tx,
|
||||
stream_time: 0,
|
||||
last_event_time: Instant::now(),
|
||||
theme,
|
||||
output_decoder: util::Utf8Decoder::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn output(&mut self, time: u64, data: &[u8]) {
|
||||
let text = self.output_decoder.feed(data);
|
||||
|
||||
if !text.is_empty() {
|
||||
self.vt.feed_str(&text);
|
||||
let _ = self.broadcast_tx.send(Event::Stdout(time, text));
|
||||
}
|
||||
|
||||
self.stream_time = time;
|
||||
self.last_event_time = Instant::now();
|
||||
}
|
||||
|
||||
pub fn input(&mut self, time: u64, _data: &[u8]) {
|
||||
self.stream_time = time;
|
||||
self.last_event_time = Instant::now();
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, time: u64, tty_size: tty::TtySize) {
|
||||
if tty_size != self.vt.size().into() {
|
||||
resize_vt(&mut self.vt, &tty_size);
|
||||
let _ = self.broadcast_tx.send(Event::Resize(time, tty_size));
|
||||
self.stream_time = time;
|
||||
self.last_event_time = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> Subscription {
|
||||
let init = Event::Init(
|
||||
self.elapsed_time(),
|
||||
self.vt.size().into(),
|
||||
self.theme.clone(),
|
||||
self.vt.dump(),
|
||||
);
|
||||
|
||||
let broadcast_rx = self.broadcast_tx.subscribe();
|
||||
|
||||
Subscription { init, broadcast_rx }
|
||||
}
|
||||
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.broadcast_tx.receiver_count()
|
||||
}
|
||||
|
||||
fn elapsed_time(&self) -> u64 {
|
||||
self.stream_time + self.last_event_time.elapsed().as_micros() as u64
|
||||
}
|
||||
}
|
||||
|
||||
fn build_vt(tty_size: tty::TtySize) -> avt::Vt {
|
||||
avt::Vt::builder()
|
||||
.size(tty_size.0 as usize, tty_size.1 as usize)
|
||||
.resizable(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn resize_vt(vt: &mut avt::Vt, tty_size: &tty::TtySize) {
|
||||
vt.feed_str(&format!("\x1b[8;{};{}t", tty_size.1, tty_size.0));
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn accept(self, subscription: Subscription) {
|
||||
let _ = self.0.send(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stream(
|
||||
clients_tx: &mpsc::Sender<Client>,
|
||||
) -> Result<impl Stream<Item = Result<Event, BroadcastStreamRecvError>>> {
|
||||
let (sub_tx, sub_rx) = oneshot::channel();
|
||||
clients_tx.send(Client(sub_tx)).await?;
|
||||
let sub = tokio::time::timeout(Duration::from_secs(5), sub_rx).await??;
|
||||
let init = stream::once(future::ready(Ok(sub.init)));
|
||||
let events = BroadcastStream::new(sub.broadcast_rx);
|
||||
|
||||
Ok(init.chain(events))
|
||||
}
|
||||
517
src/tty.rs
517
src/tty.rs
@@ -1,28 +1,88 @@
|
||||
use anyhow::Result;
|
||||
use nix::{
|
||||
errno::Errno,
|
||||
libc, pty,
|
||||
sys::{
|
||||
select::{select, FdSet},
|
||||
time::TimeVal,
|
||||
},
|
||||
unistd,
|
||||
};
|
||||
use std::os::fd::AsFd;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nix::libc;
|
||||
use nix::pty::Winsize;
|
||||
use nix::sys::termios::{self, SetArg};
|
||||
use rgb::RGB8;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
|
||||
use termion::raw::{IntoRawMode, RawTerminal};
|
||||
use tokio::io;
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
const QUERY_READ_TIMEOUT: u64 = 1000;
|
||||
const THEME_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";
|
||||
const XTVERSION_QUERY: &str = "\x1b[>0q";
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(feature = "macos-tty")))]
|
||||
mod default;
|
||||
|
||||
#[cfg(any(target_os = "macos", feature = "macos-tty"))]
|
||||
mod macos;
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(feature = "macos-tty")))]
|
||||
pub use default::DevTty;
|
||||
|
||||
#[cfg(any(target_os = "macos", feature = "macos-tty"))]
|
||||
pub use macos::DevTty;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TtySize(pub u16, pub u16);
|
||||
|
||||
impl From<pty::Winsize> for TtySize {
|
||||
fn from(winsize: pty::Winsize) -> Self {
|
||||
#[derive(Clone)]
|
||||
pub struct TtyTheme {
|
||||
pub fg: RGB8,
|
||||
pub bg: RGB8,
|
||||
pub palette: Vec<RGB8>,
|
||||
}
|
||||
|
||||
pub struct NullTty;
|
||||
|
||||
pub struct FixedSizeTty<T> {
|
||||
inner: T,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
pub trait Tty {
|
||||
fn get_size(&self) -> Winsize;
|
||||
async fn get_theme(&mut self) -> Option<TtyTheme>;
|
||||
async fn get_version(&mut self) -> Option<String>;
|
||||
async fn read(&self, buf: &mut [u8]) -> io::Result<usize>;
|
||||
async fn write(&self, buf: &[u8]) -> io::Result<usize>;
|
||||
|
||||
async fn write_all(&self, mut buf: &[u8]) -> io::Result<()> {
|
||||
while !buf.is_empty() {
|
||||
let n = self.write(buf).await?;
|
||||
buf = &buf[n..];
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TtySize {
|
||||
fn default() -> Self {
|
||||
TtySize(80, 24)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Winsize> for TtySize {
|
||||
fn from(winsize: Winsize) -> Self {
|
||||
TtySize(winsize.ws_col, winsize.ws_row)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TtySize> for Winsize {
|
||||
fn from(tty_size: TtySize) -> Self {
|
||||
Winsize {
|
||||
ws_col: tty_size.0,
|
||||
ws_row: tty_size.1,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(usize, usize)> for TtySize {
|
||||
fn from((cols, rows): (usize, usize)) -> Self {
|
||||
TtySize(cols as u16, rows as u16)
|
||||
@@ -35,36 +95,170 @@ impl From<TtySize> for (u16, u16) {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Tty: io::Write + io::Read + AsFd {
|
||||
fn get_size(&self) -> pty::Winsize;
|
||||
fn get_theme(&self) -> Option<Theme>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Theme {
|
||||
pub fg: RGB8,
|
||||
pub bg: RGB8,
|
||||
pub palette: Vec<RGB8>,
|
||||
}
|
||||
|
||||
pub struct DevTty {
|
||||
file: RawTerminal<fs::File>,
|
||||
}
|
||||
|
||||
impl DevTty {
|
||||
pub fn open() -> Result<Self> {
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/tty")?
|
||||
.into_raw_mode()?;
|
||||
|
||||
crate::io::set_non_blocking(&file.as_raw_fd())?;
|
||||
|
||||
Ok(Self { file })
|
||||
impl<T: Tty> FixedSizeTty<T> {
|
||||
pub fn new(inner: T, cols: Option<u16>, rows: Option<u16>) -> Self {
|
||||
Self { inner, cols, rows }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl Tty for NullTty {
|
||||
fn get_size(&self) -> Winsize {
|
||||
Winsize {
|
||||
ws_row: 24,
|
||||
ws_col: 80,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_theme(&mut self) -> Option<TtyTheme> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_version(&mut self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn read(&self, _buf: &mut [u8]) -> io::Result<usize> {
|
||||
std::future::pending().await
|
||||
}
|
||||
|
||||
async fn write(&self, buf: &[u8]) -> io::Result<usize> {
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl<T: Tty> Tty for FixedSizeTty<T> {
|
||||
fn get_size(&self) -> Winsize {
|
||||
let mut winsize = self.inner.get_size();
|
||||
|
||||
if let Some(cols) = self.cols {
|
||||
winsize.ws_col = cols;
|
||||
}
|
||||
|
||||
if let Some(rows) = self.rows {
|
||||
winsize.ws_row = rows;
|
||||
}
|
||||
|
||||
winsize
|
||||
}
|
||||
|
||||
async fn get_theme(&mut self) -> Option<TtyTheme> {
|
||||
self.inner.get_theme().await
|
||||
}
|
||||
|
||||
async fn get_version(&mut self) -> Option<String> {
|
||||
self.inner.get_version().await
|
||||
}
|
||||
|
||||
async fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.inner.read(buf).await
|
||||
}
|
||||
|
||||
async fn write(&self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.inner.write(buf).await
|
||||
}
|
||||
}
|
||||
|
||||
fn make_raw<F: AsFd>(fd: F) -> anyhow::Result<libc::termios> {
|
||||
let termios = termios::tcgetattr(fd.as_fd())?;
|
||||
let mut raw_termios = termios.clone();
|
||||
termios::cfmakeraw(&mut raw_termios);
|
||||
termios::tcsetattr(fd.as_fd(), SetArg::TCSANOW, &raw_termios)?;
|
||||
|
||||
Ok(termios.into())
|
||||
}
|
||||
|
||||
async fn get_theme<T: Tty>(tty: &T) -> Option<TtyTheme> {
|
||||
parse_theme_response(&query(tty, THEME_QUERY).await.ok()?)
|
||||
}
|
||||
|
||||
async fn get_version<T: Tty>(tty: &T) -> Option<String> {
|
||||
parse_version_response(&query(tty, XTVERSION_QUERY).await.ok()?)
|
||||
}
|
||||
|
||||
async fn query<T: Tty>(tty: &T, query: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let mut query = query.to_string().into_bytes();
|
||||
query.extend_from_slice(b"\x1b[c");
|
||||
let mut query = &query[..];
|
||||
let mut response = Vec::new();
|
||||
let mut buf = [0u8; 1024];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = tty.read(&mut buf) => {
|
||||
let n = result?;
|
||||
response.extend_from_slice(&buf[..n]);
|
||||
|
||||
if let Some(len) = complete_da_response_len(&response) {
|
||||
response.truncate(len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result = tty.write(query), if !query.is_empty() => {
|
||||
let n = result?;
|
||||
query = &query[n..];
|
||||
}
|
||||
|
||||
_ = time::sleep(Duration::from_millis(QUERY_READ_TIMEOUT)) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn complete_da_response_len(response: &[u8]) -> Option<usize> {
|
||||
let mut reversed = response.iter().rev();
|
||||
let mut includes_da_response = false;
|
||||
let mut da_response_len = 0;
|
||||
|
||||
if let Some(b'c') = reversed.next() {
|
||||
da_response_len += 1;
|
||||
|
||||
for b in reversed {
|
||||
if *b == b'[' {
|
||||
includes_da_response = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if *b != b';' && *b != b'?' && !b.is_ascii_digit() {
|
||||
break;
|
||||
}
|
||||
|
||||
da_response_len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if includes_da_response {
|
||||
Some(response.len() - da_response_len - 2)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_theme_response(response: &[u8]) -> Option<TtyTheme> {
|
||||
let response = String::from_utf8_lossy(response);
|
||||
let mut colors = response.match_indices("rgb:");
|
||||
let (idx, _) = colors.next()?;
|
||||
let fg = parse_color(&response[idx + 4..])?;
|
||||
let (idx, _) = colors.next()?;
|
||||
let bg = parse_color(&response[idx + 4..])?;
|
||||
let mut palette = Vec::new();
|
||||
|
||||
for _ in 0..16 {
|
||||
let (idx, _) = colors.next()?;
|
||||
let color = parse_color(&response[idx + 4..])?;
|
||||
palette.push(color);
|
||||
}
|
||||
|
||||
Some(TtyTheme { fg, bg, palette })
|
||||
}
|
||||
|
||||
fn parse_color(rgb: &str) -> Option<RGB8> {
|
||||
let mut components = rgb.split('/');
|
||||
let r_hex = components.next()?;
|
||||
@@ -82,227 +276,18 @@ fn parse_color(rgb: &str) -> Option<RGB8> {
|
||||
Some(RGB8::new(r, g, b))
|
||||
}
|
||||
|
||||
static COLORS_QUERY: &[u8; 148] = b"\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";
|
||||
|
||||
impl Tty for DevTty {
|
||||
fn get_size(&self) -> pty::Winsize {
|
||||
let mut winsize = pty::Winsize {
|
||||
ws_row: 24,
|
||||
ws_col: 80,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
|
||||
unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, &mut winsize) };
|
||||
|
||||
winsize
|
||||
}
|
||||
|
||||
fn get_theme(&self) -> Option<Theme> {
|
||||
let mut query = &COLORS_QUERY[..];
|
||||
let mut response = Vec::new();
|
||||
let mut buf = [0u8; 1024];
|
||||
let mut color_count = 0;
|
||||
let fd = self.as_fd().as_raw_fd();
|
||||
|
||||
loop {
|
||||
let mut timeout = TimeVal::new(0, 100_000);
|
||||
let mut rfds = FdSet::new();
|
||||
let mut wfds = FdSet::new();
|
||||
rfds.insert(self);
|
||||
|
||||
if !query.is_empty() {
|
||||
wfds.insert(self);
|
||||
}
|
||||
|
||||
match select(None, &mut rfds, &mut wfds, None, &mut timeout) {
|
||||
Ok(0) => return None,
|
||||
|
||||
Ok(_) => {
|
||||
if rfds.contains(self) {
|
||||
let n = unistd::read(fd, &mut buf).ok()?;
|
||||
response.extend_from_slice(&buf[..n]);
|
||||
|
||||
color_count += &buf[..n]
|
||||
.iter()
|
||||
.filter(|b| *b == &0x07 || *b == &b'\\')
|
||||
.count();
|
||||
|
||||
if color_count == 18 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if wfds.contains(self) {
|
||||
let n = unistd::write(fd, query).ok()?;
|
||||
query = &query[n..];
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
if e == Errno::EINTR {
|
||||
continue;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = String::from_utf8_lossy(response.as_slice());
|
||||
let mut colors = response.match_indices("rgb:");
|
||||
let (idx, _) = colors.next()?;
|
||||
let fg = parse_color(&response[idx + 4..])?;
|
||||
let (idx, _) = colors.next()?;
|
||||
let bg = parse_color(&response[idx + 4..])?;
|
||||
let mut palette = Vec::new();
|
||||
|
||||
for _ in 0..16 {
|
||||
let (idx, _) = colors.next()?;
|
||||
let color = parse_color(&response[idx + 4..])?;
|
||||
palette.push(color);
|
||||
}
|
||||
|
||||
Some(Theme { fg, bg, palette })
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Read for DevTty {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.file.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for DevTty {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.file.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsFd for DevTty {
|
||||
fn as_fd(&self) -> BorrowedFd<'_> {
|
||||
self.file.as_fd()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NullTty {
|
||||
tx: OwnedFd,
|
||||
_rx: OwnedFd,
|
||||
}
|
||||
|
||||
impl NullTty {
|
||||
pub fn open() -> Result<Self> {
|
||||
let (rx, tx) = unistd::pipe()?;
|
||||
let rx = unsafe { OwnedFd::from_raw_fd(rx) };
|
||||
let tx = unsafe { OwnedFd::from_raw_fd(tx) };
|
||||
|
||||
Ok(Self { tx, _rx: rx })
|
||||
}
|
||||
}
|
||||
|
||||
impl Tty for NullTty {
|
||||
fn get_size(&self) -> pty::Winsize {
|
||||
pty::Winsize {
|
||||
ws_row: 24,
|
||||
ws_col: 80,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_theme(&self) -> Option<Theme> {
|
||||
fn parse_version_response(response: &[u8]) -> Option<String> {
|
||||
if let [b'\x1b', b'P', b'>', b'|', version @ .., b'\x1b', b'\\'] = response {
|
||||
Some(String::from_utf8_lossy(version).to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Read for NullTty {
|
||||
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
|
||||
panic!("read attempt from NullTty");
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for NullTty {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsFd for NullTty {
|
||||
fn as_fd(&self) -> BorrowedFd<'_> {
|
||||
self.tx.as_fd()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FixedSizeTty {
|
||||
inner: Box<dyn Tty>,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
impl FixedSizeTty {
|
||||
pub fn new<T: Tty + 'static>(inner: T, cols: Option<u16>, rows: Option<u16>) -> Self {
|
||||
Self {
|
||||
inner: Box::new(inner),
|
||||
cols,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tty for FixedSizeTty {
|
||||
fn get_size(&self) -> pty::Winsize {
|
||||
let mut winsize = self.inner.get_size();
|
||||
|
||||
if let Some(cols) = self.cols {
|
||||
winsize.ws_col = cols;
|
||||
}
|
||||
|
||||
if let Some(rows) = self.rows {
|
||||
winsize.ws_row = rows;
|
||||
}
|
||||
|
||||
winsize
|
||||
}
|
||||
|
||||
fn get_theme(&self) -> Option<Theme> {
|
||||
self.inner.get_theme()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsFd for FixedSizeTty {
|
||||
fn as_fd(&self) -> BorrowedFd<'_> {
|
||||
return self.inner.as_fd();
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Read for FixedSizeTty {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.inner.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for FixedSizeTty {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.inner.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FixedSizeTty, Tty};
|
||||
use crate::tty::NullTty;
|
||||
use super::{FixedSizeTty, NullTty, Tty};
|
||||
|
||||
use rgb::RGB8;
|
||||
|
||||
#[test]
|
||||
@@ -331,12 +316,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_size_tty() {
|
||||
let tty = FixedSizeTty::new(NullTty::open().unwrap(), Some(100), Some(50));
|
||||
|
||||
fn fixed_size_tty_get_size() {
|
||||
let tty = FixedSizeTty::new(NullTty, Some(100), Some(50));
|
||||
let winsize = tty.get_size();
|
||||
|
||||
assert!(winsize.ws_col == 100);
|
||||
assert!(winsize.ws_row == 50);
|
||||
|
||||
let tty = FixedSizeTty::new(NullTty, Some(100), None);
|
||||
let winsize = tty.get_size();
|
||||
assert!(winsize.ws_col == 100);
|
||||
assert!(winsize.ws_row == 24);
|
||||
|
||||
let tty = FixedSizeTty::new(NullTty, None, None);
|
||||
let winsize = tty.get_size();
|
||||
assert!(winsize.ws_col == 80);
|
||||
assert!(winsize.ws_row == 24);
|
||||
}
|
||||
}
|
||||
|
||||
83
src/tty/default.rs
Normal file
83
src/tty/default.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nix::libc;
|
||||
use nix::pty::Winsize;
|
||||
use nix::sys::termios::{self, SetArg, Termios};
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::io::{self, Interest};
|
||||
|
||||
use super::{Tty, TtySize, TtyTheme};
|
||||
|
||||
pub struct DevTty {
|
||||
file: AsyncFd<File>,
|
||||
settings: libc::termios,
|
||||
}
|
||||
|
||||
impl DevTty {
|
||||
pub async fn open() -> anyhow::Result<Self> {
|
||||
let file = File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open("/dev/tty")?;
|
||||
|
||||
let file = AsyncFd::new(file)?;
|
||||
let settings = super::make_raw(&file)?;
|
||||
|
||||
Ok(Self { file, settings })
|
||||
}
|
||||
|
||||
pub async fn resize(&mut self, size: TtySize) -> io::Result<()> {
|
||||
let xtwinops_seq = format!("\x1b[8;{};{}t", size.1, size.0);
|
||||
self.write_all(xtwinops_seq.as_bytes()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DevTty {
|
||||
fn drop(&mut self) {
|
||||
let termios = Termios::from(self.settings);
|
||||
let _ = termios::tcsetattr(self.file.as_fd(), SetArg::TCSANOW, &termios);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl Tty for DevTty {
|
||||
fn get_size(&self) -> Winsize {
|
||||
let mut winsize = Winsize {
|
||||
ws_row: 24,
|
||||
ws_col: 80,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
|
||||
unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, &mut winsize) };
|
||||
|
||||
winsize
|
||||
}
|
||||
|
||||
async fn get_theme(&mut self) -> Option<TtyTheme> {
|
||||
super::get_theme(self).await
|
||||
}
|
||||
|
||||
async fn get_version(&mut self) -> Option<String> {
|
||||
super::get_version(self).await
|
||||
}
|
||||
|
||||
async fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.file
|
||||
.async_io(Interest::READABLE, |mut file| file.read(buf))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write(&self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.file
|
||||
.async_io(Interest::WRITABLE, |mut file| file.write(buf))
|
||||
.await
|
||||
}
|
||||
}
|
||||
226
src/tty/macos.rs
Normal file
226
src/tty/macos.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
/// This is an alternative implementation of DevTty that we use on macOS due to a bug in macOS's
|
||||
/// kqueue implementation when polling /dev/tty.
|
||||
///
|
||||
/// See below links for more about the problem:
|
||||
///
|
||||
/// https://code.saghul.net/2016/05/libuv-internals-the-osx-select2-trick/
|
||||
/// https://nathancraddock.com/blog/macos-dev-tty-polling/
|
||||
///
|
||||
use std::fs::File;
|
||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::thread;
|
||||
|
||||
use bytes::{Buf, BytesMut};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nix::errno::Errno;
|
||||
use nix::pty::Winsize;
|
||||
use nix::sys::select;
|
||||
use nix::sys::termios::{self, SetArg, Termios};
|
||||
use nix::{libc, unistd};
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::io::{self, Interest};
|
||||
|
||||
use super::{Tty, TtySize, TtyTheme};
|
||||
use crate::fd::FdExt;
|
||||
|
||||
const BUF_SIZE: usize = 128 * 1024;
|
||||
|
||||
pub struct DevTty {
|
||||
file: File,
|
||||
read_r_fd: AsyncFd<OwnedFd>,
|
||||
write_w_fd: AsyncFd<OwnedFd>,
|
||||
settings: libc::termios,
|
||||
}
|
||||
|
||||
impl DevTty {
|
||||
pub async fn open() -> anyhow::Result<Self> {
|
||||
let file = File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open("/dev/tty")?;
|
||||
|
||||
let settings = super::make_raw(&file)?;
|
||||
|
||||
let (read_r_fd, read_w_fd) = unistd::pipe()?;
|
||||
read_r_fd.set_nonblocking()?;
|
||||
let read_r_fd = AsyncFd::new(read_r_fd)?;
|
||||
|
||||
let (write_r_fd, write_w_fd) = unistd::pipe()?;
|
||||
write_w_fd.set_nonblocking()?;
|
||||
let write_w_fd = AsyncFd::new(write_w_fd)?;
|
||||
|
||||
// Note about unsafe borrow below: This is on purpose. We can't move proper BorrowedFd to a
|
||||
// thread (does not live long enough), and we also don't want to use Arc because the
|
||||
// threads would prevent closing of the file when DevTty is dropped. Use of borrow_raw here
|
||||
// lets us rely on the fact that dropping of DevTty will close the file and cause EOF or
|
||||
// I/O error in the background threads, which is what lets us shut down those threads.
|
||||
|
||||
let tty_fd = unsafe { BorrowedFd::borrow_raw(file.as_raw_fd()) };
|
||||
|
||||
thread::spawn(move || {
|
||||
copy(tty_fd, read_w_fd);
|
||||
});
|
||||
|
||||
let tty_fd = unsafe { BorrowedFd::borrow_raw(file.as_raw_fd()) };
|
||||
|
||||
thread::spawn(move || {
|
||||
copy(write_r_fd, tty_fd);
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
read_r_fd,
|
||||
write_w_fd,
|
||||
settings,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn resize(&mut self, size: TtySize) -> io::Result<()> {
|
||||
let xtwinops_seq = format!("\x1b[8;{};{}t", size.1, size.0);
|
||||
self.write_all(xtwinops_seq.as_bytes()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DevTty {
|
||||
fn drop(&mut self) {
|
||||
let termios = Termios::from(self.settings);
|
||||
let _ = termios::tcsetattr(self.file.as_fd(), SetArg::TCSANOW, &termios);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl Tty for DevTty {
|
||||
fn get_size(&self) -> Winsize {
|
||||
let mut winsize = Winsize {
|
||||
ws_row: 24,
|
||||
ws_col: 80,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
|
||||
unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, &mut winsize) };
|
||||
|
||||
winsize
|
||||
}
|
||||
|
||||
async fn get_theme(&mut self) -> Option<TtyTheme> {
|
||||
super::get_theme(self).await
|
||||
}
|
||||
|
||||
async fn get_version(&mut self) -> Option<String> {
|
||||
super::get_version(self).await
|
||||
}
|
||||
|
||||
async fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.read_r_fd
|
||||
.async_io(Interest::READABLE, |fd| {
|
||||
unistd::read(fd, buf).map_err(|e| e.into())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write(&self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.write_w_fd
|
||||
.async_io(Interest::WRITABLE, |fd| {
|
||||
unistd::write(fd, buf).map_err(|e| e.into())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn copy<F: AsFd, G: AsFd>(src_fd: F, dst_fd: G) {
|
||||
let src_fd = src_fd.as_fd();
|
||||
let dst_fd = dst_fd.as_fd();
|
||||
let mut buf = [0u8; BUF_SIZE];
|
||||
let mut data = BytesMut::with_capacity(BUF_SIZE);
|
||||
|
||||
loop {
|
||||
let mut read_fds = select::FdSet::new();
|
||||
let mut write_fds = select::FdSet::new();
|
||||
read_fds.insert(src_fd);
|
||||
|
||||
if !data.is_empty() {
|
||||
write_fds.insert(dst_fd);
|
||||
}
|
||||
|
||||
match select::select(None, Some(&mut read_fds), Some(&mut write_fds), None, None) {
|
||||
Ok(0) | Err(Errno::EINTR) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
Ok(_) => {}
|
||||
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if read_fds.contains(src_fd) {
|
||||
match unistd::read(src_fd, &mut buf) {
|
||||
Ok(0) => break,
|
||||
|
||||
Ok(n) => {
|
||||
data.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
|
||||
Err(Errno::EWOULDBLOCK) => {}
|
||||
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if write_fds.contains(dst_fd) {
|
||||
match unistd::write(dst_fd, &data) {
|
||||
Ok(n) => {
|
||||
data.advance(n);
|
||||
}
|
||||
|
||||
Err(Errno::EWOULDBLOCK) => {}
|
||||
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while !data.is_empty() {
|
||||
let mut write_fds = select::FdSet::new();
|
||||
write_fds.insert(dst_fd);
|
||||
|
||||
match select::select(None, None, Some(&mut write_fds), None, None) {
|
||||
Ok(1) => {}
|
||||
|
||||
Ok(0) | Err(Errno::EINTR) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
Ok(_) => {
|
||||
unreachable!();
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match unistd::write(dst_fd, &data) {
|
||||
Ok(n) => {
|
||||
data.advance(n);
|
||||
}
|
||||
|
||||
Err(Errno::EWOULDBLOCK) => {}
|
||||
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/util.rs
114
src/util.rs
@@ -1,12 +1,13 @@
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use reqwest::Url;
|
||||
use sha2::Digest;
|
||||
use std::fmt::Write;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{io, thread};
|
||||
|
||||
use anyhow::{anyhow, bail};
|
||||
use reqwest::Url;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
pub fn get_local_path(filename: &str) -> Result<Box<dyn AsRef<Path>>> {
|
||||
use crate::html;
|
||||
|
||||
pub fn get_local_path(filename: &str) -> anyhow::Result<Box<dyn AsRef<Path>>> {
|
||||
if filename.starts_with("https://") || filename.starts_with("http://") {
|
||||
match download_asciicast(filename) {
|
||||
Ok(path) => Ok(Box::new(path)),
|
||||
@@ -17,11 +18,8 @@ pub fn get_local_path(filename: &str) -> Result<Box<dyn AsRef<Path>>> {
|
||||
}
|
||||
}
|
||||
|
||||
const LINK_REL_SELECTOR: &str = r#"link[rel="alternate"][type="application/x-asciicast"], link[rel="alternate"][type="application/asciicast+json"]"#;
|
||||
|
||||
fn download_asciicast(url: &str) -> Result<NamedTempFile> {
|
||||
fn download_asciicast(url: &str) -> anyhow::Result<NamedTempFile> {
|
||||
use reqwest::blocking::get;
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
let mut response = get(Url::parse(url)?)?;
|
||||
response.error_for_status_ref()?;
|
||||
@@ -34,12 +32,8 @@ fn download_asciicast(url: &str) -> Result<NamedTempFile> {
|
||||
.to_str()?;
|
||||
|
||||
if content_type.starts_with("text/html") {
|
||||
let document = Html::parse_document(&response.text()?);
|
||||
let selector = Selector::parse(LINK_REL_SELECTOR).unwrap();
|
||||
let mut elements = document.select(&selector);
|
||||
|
||||
if let Some(url) = elements.find_map(|e| e.value().attr("href")) {
|
||||
let mut response = get(Url::parse(url)?)?;
|
||||
if let Some(url) = html::extract_asciicast_link(&response.text()?) {
|
||||
let mut response = get(Url::parse(&url)?)?;
|
||||
response.error_for_status_ref()?;
|
||||
io::copy(&mut response, &mut file)?;
|
||||
|
||||
@@ -56,24 +50,6 @@ fn download_asciicast(url: &str) -> Result<NamedTempFile> {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JoinHandle(Option<thread::JoinHandle<()>>);
|
||||
|
||||
impl JoinHandle {
|
||||
pub fn new(handle: thread::JoinHandle<()>) -> Self {
|
||||
Self(Some(handle))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for JoinHandle {
|
||||
fn drop(&mut self) {
|
||||
self.0
|
||||
.take()
|
||||
.unwrap()
|
||||
.join()
|
||||
.expect("worker thread should finish cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Utf8Decoder(Vec<u8>);
|
||||
|
||||
impl Utf8Decoder {
|
||||
@@ -117,23 +93,36 @@ impl Utf8Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sha2_digest(s: &str) -> String {
|
||||
let mut hasher = sha2::Sha224::new();
|
||||
hasher.update(s.as_bytes());
|
||||
/// Quantizer using error diffusion based on Bresenham algorithm.
|
||||
/// It ensures the accumulated error at any point is less than Q/2.
|
||||
pub struct Quantizer {
|
||||
q: i128,
|
||||
error: i128,
|
||||
}
|
||||
|
||||
hasher
|
||||
.finalize()
|
||||
.as_slice()
|
||||
.iter()
|
||||
.fold(String::new(), |mut out, byte| {
|
||||
let _ = write!(out, "{byte:02X}");
|
||||
out
|
||||
})
|
||||
impl Quantizer {
|
||||
pub fn new(q: u128) -> Self {
|
||||
Quantizer {
|
||||
q: q as i128,
|
||||
error: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self, value: u128) -> u128 {
|
||||
let error_corrected_value = value as i128 + self.error;
|
||||
let steps = (error_corrected_value + self.q / 2) / self.q;
|
||||
let quantized_value = steps * self.q;
|
||||
|
||||
self.error = error_corrected_value - quantized_value;
|
||||
debug_assert!((self.error).abs() <= self.q / 2);
|
||||
|
||||
quantized_value as u128
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Utf8Decoder;
|
||||
use super::{Quantizer, Utf8Decoder};
|
||||
|
||||
#[test]
|
||||
fn utf8_decoder() {
|
||||
@@ -154,4 +143,37 @@ mod tests {
|
||||
"<EFBFBD>#<23><>!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantizer() {
|
||||
let mut quantizer = Quantizer::new(1_000);
|
||||
|
||||
let input = [
|
||||
026692, 540290, 064736, 105951, 171006, 191943, 107942, 128108, 148904, 108973, 211002,
|
||||
044701, 489307, 405987, 105028, 194590, 061043, 532296, 319015, 152786, 032578, 005445,
|
||||
040542, 000756,
|
||||
];
|
||||
|
||||
let expected = [
|
||||
27000, 540000, 65000, 106000, 171000, 192000, 108000, 128000, 149000, 109000, 211000,
|
||||
44000, 490000, 406000, 105000, 194000, 61000, 532000, 320000, 152000, 33000, 5000,
|
||||
41000, 1000,
|
||||
];
|
||||
|
||||
let mut quantized = Vec::new();
|
||||
let mut input_sum = 0;
|
||||
let mut quantized_sum = 0;
|
||||
|
||||
for input_value in input {
|
||||
let quantized_value = quantizer.next(input_value);
|
||||
quantized.push(quantized_value);
|
||||
input_sum += input_value;
|
||||
quantized_sum += quantized_value;
|
||||
let error = (input_sum as i128 - quantized_sum as i128).abs();
|
||||
|
||||
assert!(error <= 500, "error: {error}");
|
||||
}
|
||||
|
||||
assert_eq!(quantized, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
"ż"
|
||||
],
|
||||
[
|
||||
1.000000,
|
||||
10.00,
|
||||
"ółć"
|
||||
],
|
||||
[
|
||||
10.500000,
|
||||
0.5,
|
||||
"\r\n"
|
||||
]
|
||||
]
|
||||
6
tests/casts/full-v2.cast
Normal file
6
tests/casts/full-v2.cast
Normal file
@@ -0,0 +1,6 @@
|
||||
{"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"}}
|
||||
[0.000001, "o", "ż"]
|
||||
[1.0, "o", "ółć"]
|
||||
[2.3, "i", "\n"]
|
||||
[5.600001, "r", "80x40"]
|
||||
[10.5, "o", "\r\n"]
|
||||
6
tests/casts/full-v3.cast
Normal file
6
tests/casts/full-v3.cast
Normal file
@@ -0,0 +1,6 @@
|
||||
{"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"}}
|
||||
[0.000001, "o", "ż"]
|
||||
[1.0, "o", "ółć"]
|
||||
[0.3, "i", "\n"]
|
||||
[1.600001, "r", "80x40"]
|
||||
[10.5, "o", "\r\n"]
|
||||
@@ -1,6 +0,0 @@
|
||||
{"version":2,"width":100,"height":50,"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"}}
|
||||
[0.000001, "o", "ż"]
|
||||
[1.0, "o", "ółć"]
|
||||
[2.3, "i", "\n"]
|
||||
[5.600001, "r", "80x40"]
|
||||
[10.5, "o", "\r\n"]
|
||||
2
tests/casts/minimal-v3.cast
Normal file
2
tests/casts/minimal-v3.cast
Normal file
@@ -0,0 +1,2 @@
|
||||
{"version":3,"term":{"cols":100,"rows":50}}
|
||||
[1.23, "o", "hello"]
|
||||
15
tests/casts/nulls-v1.json
Normal file
15
tests/casts/nulls-v1.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"width": 100,
|
||||
"height": 50,
|
||||
"env": {
|
||||
"SHELL": "/bin/bash",
|
||||
"TERM": null
|
||||
},
|
||||
"stdout": [
|
||||
[
|
||||
1.230000,
|
||||
"hello"
|
||||
]
|
||||
]
|
||||
}
|
||||
2
tests/casts/nulls-v2.cast
Normal file
2
tests/casts/nulls-v2.cast
Normal file
@@ -0,0 +1,2 @@
|
||||
{"version":2,"width":100,"height":50,"env":{"TERM":null,"SHELL":"/bin/bash"}}
|
||||
[1.23, "o", "hello"]
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly DISTROS=(
|
||||
'arch'
|
||||
'alpine'
|
||||
'centos'
|
||||
'debian'
|
||||
'fedora'
|
||||
'ubuntu'
|
||||
)
|
||||
|
||||
readonly DOCKER='docker'
|
||||
|
||||
# do not redefine builtin `test`
|
||||
test_() {
|
||||
local -r tag="${1}"
|
||||
|
||||
local -ra docker_opts=(
|
||||
"--tag=asciinema/asciinema:${tag}"
|
||||
"--file=tests/distros/Dockerfile.${tag}"
|
||||
)
|
||||
|
||||
printf "\e[1;32mTesting on %s...\e[0m\n\n" "${tag}"
|
||||
|
||||
# shellcheck disable=SC2068
|
||||
"${DOCKER}" build ${docker_opts[@]} .
|
||||
|
||||
"${DOCKER}" run --rm -it "asciinema/asciinema:${tag}" tests/integration.sh
|
||||
}
|
||||
|
||||
|
||||
for distro in "${DISTROS[@]}"; do
|
||||
test_ "${distro}"
|
||||
done
|
||||
|
||||
printf "\n\e[1;32mAll tests passed.\e[0m\n"
|
||||
@@ -1,19 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/alpine:3.15
|
||||
|
||||
# https://github.com/actions/runner/issues/241
|
||||
RUN apk --no-cache add bash ca-certificates make python3 util-linux
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
# vim:ft=dockerfile
|
||||
@@ -1,22 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/archlinux:latest
|
||||
|
||||
RUN pacman-key --init \
|
||||
&& pacman --sync --refresh --sysupgrade --noconfirm make python3 \
|
||||
&& printf "LANG=en_US.UTF-8\n" > /etc/locale.conf \
|
||||
&& locale-gen \
|
||||
&& pacman --sync --clean --clean --noconfirm
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
# vim:ft=dockerfile
|
||||
@@ -1,18 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/centos:7
|
||||
|
||||
RUN yum install -y epel-release && yum install -y make python36 && yum clean all
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
# vim:ft=dockerfile
|
||||
@@ -1,33 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/debian:bullseye
|
||||
|
||||
ENV DEBIAN_FRONTENT="noninteractive"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
make \
|
||||
procps \
|
||||
python3 \
|
||||
&& localedef \
|
||||
-i en_US \
|
||||
-c \
|
||||
-f UTF-8 \
|
||||
-A /usr/share/locale/locale.alias \
|
||||
en_US.UTF-8 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENV SHELL="/bin/bash"
|
||||
|
||||
# vim:ft=dockerfile
|
||||
@@ -1,20 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
# https://medium.com/nttlabs/ubuntu-21-10-and-fedora-35-do-not-work-on-docker-20-10-9-1cd439d9921
|
||||
# https://www.mail-archive.com/ubuntu-bugs@lists.ubuntu.com/msg5971024.html
|
||||
FROM registry.fedoraproject.org/fedora:34
|
||||
|
||||
RUN dnf install -y make python3 procps && dnf clean all
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
ENV SHELL="/bin/bash"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
# vim:ft=dockerfile
|
||||
@@ -1,32 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
|
||||
FROM docker.io/library/ubuntu:20.04
|
||||
|
||||
ENV DEBIAN_FRONTENT="noninteractive"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
ca-certificates \
|
||||
locales \
|
||||
make \
|
||||
python3 \
|
||||
&& localedef \
|
||||
-i en_US \
|
||||
-c \
|
||||
-f UTF-8 \
|
||||
-A /usr/share/locale/locale.alias \
|
||||
en_US.UTF-8 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY asciinema/ asciinema/
|
||||
COPY tests/ tests/
|
||||
|
||||
ENV LANG="en_US.utf8"
|
||||
|
||||
USER nobody
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
# vim:ft=dockerfile
|
||||
@@ -1,104 +1,451 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eExuo pipefail
|
||||
set -eEuo pipefail -o errtrace
|
||||
|
||||
if ! command -v "pkill" >/dev/null 2>&1; then
|
||||
printf "error: pkill not installed\n"
|
||||
exit 1
|
||||
# Colors for output (disabled if no TTY or NO_COLOR set)
|
||||
if [[ ! -t 1 ]] || [[ -n "${NO_COLOR:-}" ]]; then
|
||||
RED=""
|
||||
GREEN=""
|
||||
YELLOW=""
|
||||
BLUE=""
|
||||
NC=""
|
||||
else
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
fi
|
||||
|
||||
python3 -V
|
||||
# Test tracking
|
||||
TESTS_RUN=0
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
|
||||
ASCIINEMA_CONFIG_HOME="$(
|
||||
mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home
|
||||
)"
|
||||
|
||||
export ASCIINEMA_CONFIG_HOME
|
||||
|
||||
TMP_DATA_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir)"
|
||||
|
||||
trap 'rm -rf ${ASCIINEMA_CONFIG_HOME} ${TMP_DATA_DIR}' EXIT
|
||||
|
||||
asciinema() {
|
||||
python3 -m asciinema "${@}"
|
||||
# Helper functions
|
||||
log_info() {
|
||||
printf "%b\n" "${BLUE}INFO:${NC} $*"
|
||||
}
|
||||
|
||||
## disable notifications
|
||||
log_success() {
|
||||
printf "%b\n" "${GREEN}PASS:${NC} $*"
|
||||
((TESTS_PASSED++))
|
||||
}
|
||||
|
||||
printf "[notifications]\nenabled = no\n" >> "${ASCIINEMA_CONFIG_HOME}/config"
|
||||
log_error() {
|
||||
printf "%b\n" "${RED}FAIL:${NC} $*"
|
||||
((TESTS_FAILED++))
|
||||
}
|
||||
|
||||
## test help message
|
||||
log_warning() {
|
||||
printf "%b\n" "${YELLOW}WARN:${NC} $*"
|
||||
}
|
||||
|
||||
asciinema -h
|
||||
assert_exit_code() {
|
||||
local expected=$1
|
||||
local actual=$2
|
||||
local test_name=$3
|
||||
|
||||
((TESTS_RUN++))
|
||||
if [[ $actual -eq $expected ]]; then
|
||||
log_success "$test_name - exit code $actual"
|
||||
else
|
||||
log_error "$test_name - expected exit code $expected, got $actual"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
## test version command
|
||||
assert_file_exists() {
|
||||
local file=$1
|
||||
local test_name=$2
|
||||
|
||||
((TESTS_RUN++))
|
||||
if [[ -f "$file" ]]; then
|
||||
log_success "$test_name - file exists: $file"
|
||||
else
|
||||
log_error "$test_name - file does not exist: $file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
asciinema --version
|
||||
assert_file_not_empty() {
|
||||
local file=$1
|
||||
local test_name=$2
|
||||
|
||||
((TESTS_RUN++))
|
||||
if [[ -s "$file" ]]; then
|
||||
log_success "$test_name - file not empty: $file"
|
||||
else
|
||||
log_error "$test_name - file is empty: $file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
## test auth command
|
||||
assert_output_contains() {
|
||||
local expected=$1
|
||||
local output=$2
|
||||
local test_name=$3
|
||||
|
||||
((TESTS_RUN++))
|
||||
if echo "$output" | grep -q "$expected"; then
|
||||
log_success "$test_name - output contains: $expected"
|
||||
else
|
||||
log_error "$test_name - output missing: $expected"
|
||||
log_error "Actual output: $output"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
asciinema auth
|
||||
assert_file_contains() {
|
||||
local expected=$1
|
||||
local file=$2
|
||||
local test_name=$3
|
||||
|
||||
((TESTS_RUN++))
|
||||
if grep -q "$expected" "$file"; then
|
||||
log_success "$test_name - file contains: $expected"
|
||||
else
|
||||
log_error "$test_name - file missing: $expected"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
## test play command
|
||||
# SETUP
|
||||
setup() {
|
||||
log_info "Setting up test environment..."
|
||||
|
||||
ASCIINEMA_CONFIG_HOME="$(
|
||||
mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home
|
||||
)"
|
||||
|
||||
# asciicast v1
|
||||
asciinema play -s 5 tests/demo.json
|
||||
asciinema play -s 5 -i 0.2 tests/demo.json
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.json | asciinema play -s 5 -
|
||||
ASCIINEMA_STATE_HOME="$(
|
||||
mktemp -d 2>/dev/null || mktemp -d -t asciinema-state-home
|
||||
)"
|
||||
|
||||
# asciicast v2
|
||||
asciinema play -s 5 tests/demo.cast
|
||||
asciinema play -s 5 -i 0.2 tests/demo.cast
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.cast | asciinema play -s 5 -
|
||||
ASCIINEMA_GEN_DIR="$(
|
||||
mktemp -d 2>/dev/null || mktemp -d -t asciinema-gen-dir
|
||||
)"
|
||||
|
||||
## test cat command
|
||||
export ASCIINEMA_CONFIG_HOME ASCIINEMA_STATE_HOME ASCIINEMA_GEN_DIR
|
||||
export ASCIINEMA_SERVER_URL=https://asciinema.example.com
|
||||
|
||||
# asciicast v1
|
||||
asciinema cat tests/demo.json
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.json | asciinema cat -
|
||||
TMP_DATA_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FIXTURES="$SCRIPT_DIR/casts"
|
||||
ASCIINEMA_BIN="$SCRIPT_DIR/../target/release/asciinema"
|
||||
|
||||
# asciicast v2
|
||||
asciinema cat tests/demo.cast
|
||||
# shellcheck disable=SC2002
|
||||
cat tests/demo.cast | asciinema cat -
|
||||
trap 'cleanup' EXIT
|
||||
|
||||
## test rec command
|
||||
log_info "Building release binary..."
|
||||
cargo build --release --locked
|
||||
|
||||
# normal program
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/1a.cast"
|
||||
grep '"o",' "${TMP_DATA_DIR}/1a.cast"
|
||||
# disable notifications
|
||||
printf "[notifications]\nenabled = false\n" >> "${ASCIINEMA_CONFIG_HOME}/config.toml"
|
||||
|
||||
log_info "Setup complete"
|
||||
}
|
||||
|
||||
# very quickly exiting program
|
||||
asciinema rec -c whoami "${TMP_DATA_DIR}/1b.cast"
|
||||
grep '"o",' "${TMP_DATA_DIR}/1b.cast"
|
||||
cleanup() {
|
||||
log_info "Cleaning up..."
|
||||
rm -rf "${ASCIINEMA_CONFIG_HOME:-}" "${ASCIINEMA_STATE_HOME:-}" "${ASCIINEMA_GEN_DIR:-}" "${TMP_DATA_DIR:-}"
|
||||
}
|
||||
|
||||
# signal handling
|
||||
bash -c "sleep 1; pkill -28 -n -f 'm asciinema'" &
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/2.cast"
|
||||
# Test runner function
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
shift
|
||||
|
||||
if [[ -z "${TEST:-}" || "${TEST:-}" == "$test_name" ]]; then
|
||||
echo
|
||||
echo "#################### TEST $test_name ####################"
|
||||
"$@" || true # Don't exit on test failure
|
||||
fi
|
||||
}
|
||||
|
||||
bash -c "sleep 1; pkill -n -f 'bash -c echo t3st'" &
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/3.cast"
|
||||
# TEST FUNCTIONS
|
||||
|
||||
bash -c "sleep 1; pkill -9 -n -f 'bash -c echo t3st'" &
|
||||
asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/4.cast"
|
||||
test_help() {
|
||||
log_info "Testing help command..."
|
||||
|
||||
# Test short help
|
||||
local output rc
|
||||
if output=$("$ASCIINEMA_BIN" -h 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "help short flag"
|
||||
assert_output_contains "Terminal session recorder" "$output" "help content"
|
||||
assert_output_contains "Commands:" "$output" "help shows commands"
|
||||
|
||||
# Test long help
|
||||
if output=$("$ASCIINEMA_BIN" --help 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "help long flag"
|
||||
assert_output_contains "Terminal session recorder" "$output" "help content"
|
||||
|
||||
# Test help subcommand
|
||||
if output=$("$ASCIINEMA_BIN" help 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "help subcommand"
|
||||
assert_output_contains "Terminal session recorder" "$output" "help subcommand content"
|
||||
}
|
||||
|
||||
# with stdin recording
|
||||
echo "ls" | asciinema rec --stdin -c 'bash -c "sleep 1"' "${TMP_DATA_DIR}/5.cast"
|
||||
cat "${TMP_DATA_DIR}/5.cast"
|
||||
grep '"i", "ls\\n"' "${TMP_DATA_DIR}/5.cast"
|
||||
grep '"o",' "${TMP_DATA_DIR}/5.cast"
|
||||
test_version() {
|
||||
log_info "Testing version command..."
|
||||
|
||||
# Test short version
|
||||
local output rc
|
||||
if output=$("$ASCIINEMA_BIN" -V 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "version short flag"
|
||||
assert_output_contains "asciinema" "$output" "version output format"
|
||||
|
||||
# Test long version
|
||||
if output=$("$ASCIINEMA_BIN" --version 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "version long flag"
|
||||
assert_output_contains "asciinema" "$output" "version output format"
|
||||
}
|
||||
|
||||
# raw output recording
|
||||
asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "${TMP_DATA_DIR}/6.raw"
|
||||
test_auth() {
|
||||
log_info "Testing auth command..."
|
||||
|
||||
# Test auth command (should handle offline gracefully)
|
||||
local output rc
|
||||
if output=$("$ASCIINEMA_BIN" auth 2>&1); then rc=0; else rc=$?; fi
|
||||
|
||||
# Auth should complete without hanging and show expected message
|
||||
assert_exit_code 0 "$rc" "auth"
|
||||
assert_output_contains "Open the following URL in a web browser" "$output" "auth command output"
|
||||
}
|
||||
|
||||
# appending to existing recording
|
||||
asciinema rec -c 'echo allright!; sleep 0.1' "${TMP_DATA_DIR}/7.cast"
|
||||
asciinema rec --append -c uptime "${TMP_DATA_DIR}/7.cast"
|
||||
test_record() {
|
||||
log_info "Testing record command..."
|
||||
|
||||
# Test basic recording
|
||||
local file1="$TMP_DATA_DIR/record_basic.cast"
|
||||
local rc
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "hello world"' --return "$file1"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record basic"
|
||||
assert_file_contains '"o",' "$file1" "record output event"
|
||||
assert_file_contains 'hello world' "$file1" "record output content"
|
||||
|
||||
# Test different formats
|
||||
local file2="$TMP_DATA_DIR/record_v2.cast"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "test v2"' --output-format asciicast-v2 --return "$file2"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record v2 format"
|
||||
assert_file_not_empty "$file2" "record v2 format"
|
||||
|
||||
local file3="$TMP_DATA_DIR/record_v3.cast"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "test v3"' --output-format asciicast-v3 --return "$file3"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record v3 format"
|
||||
assert_file_not_empty "$file3" "record v3 format"
|
||||
|
||||
# Test raw format
|
||||
local file4="$TMP_DATA_DIR/record_raw.raw"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "test raw"' --output-format raw --return "$file4"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record raw format"
|
||||
assert_file_not_empty "$file4" "record raw format"
|
||||
|
||||
# Test txt format
|
||||
local file5="$TMP_DATA_DIR/record_txt.txt"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "test txt"' --output-format txt --return "$file5"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record txt format"
|
||||
assert_file_not_empty "$file5" "record txt format"
|
||||
|
||||
# Test return flag with failure
|
||||
local file6="$TMP_DATA_DIR/record_fail.cast"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'exit 42' --return "$file6"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 42 "$rc" "record return flag with failure"
|
||||
assert_file_not_empty "$file6" "record failure"
|
||||
|
||||
# Test append mode
|
||||
local file7="$TMP_DATA_DIR/record_append.cast"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "first"' --return "$file7"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record append setup"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "second"' --append --return "$file7"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record append"
|
||||
assert_file_contains 'first' "$file7" "record append first content"
|
||||
assert_file_contains 'second' "$file7" "record append second content"
|
||||
|
||||
# Test idle time limits
|
||||
local file8="$TMP_DATA_DIR/record_idle.cast"
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'bash -c "echo start; sleep 2; echo end"' --idle-time-limit 1 --return "$file8"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "record idle time limit"
|
||||
assert_file_not_empty "$file8" "record idle time limit"
|
||||
}
|
||||
|
||||
# adding a marker
|
||||
printf "[record]\nadd_marker_key = C-b\n" >> "${ASCIINEMA_CONFIG_HOME}/config"
|
||||
(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"
|
||||
grep '"m",' "${TMP_DATA_DIR}/8.cast"
|
||||
test_stream() {
|
||||
log_info "Testing stream command..."
|
||||
|
||||
# Test local streaming
|
||||
timeout 10s "$ASCIINEMA_BIN" stream --headless --local 127.0.0.1:8081 --command 'bash -c "echo streaming test; sleep 3; echo done"' --return &
|
||||
local stream_pid=$!
|
||||
|
||||
# Wait a moment for server to start
|
||||
sleep 1
|
||||
|
||||
# Test if HTTP server is responding and serving the player
|
||||
local curl_output rc
|
||||
if curl_output=$(curl -fsS "http://127.0.0.1:8081" 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "stream server responding"
|
||||
assert_output_contains "AsciinemaPlayer" "$curl_output" "stream server AsciinemaPlayer content"
|
||||
|
||||
# Clean up
|
||||
kill $stream_pid 2>/dev/null || true
|
||||
wait $stream_pid 2>/dev/null || true
|
||||
}
|
||||
|
||||
test_session() {
|
||||
log_info "Testing session command..."
|
||||
|
||||
# Test session with file output
|
||||
local file1="$TMP_DATA_DIR/session_basic.cast"
|
||||
local rc
|
||||
if "$ASCIINEMA_BIN" session --headless --output-file "$file1" --command 'echo "session test"' --return; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "session basic"
|
||||
assert_file_contains 'session test' "$file1" "session output content"
|
||||
|
||||
# Test session with return flag failure
|
||||
local file2="$TMP_DATA_DIR/session_fail.cast"
|
||||
if "$ASCIINEMA_BIN" session --headless --output-file "$file2" --command 'exit 13' --return; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 13 "$rc" "session return flag with failure"
|
||||
assert_file_contains '"x", "13"' "$file2" "session exit event"
|
||||
|
||||
# Test session with local streaming + file output
|
||||
local file3="$TMP_DATA_DIR/session_stream.cast"
|
||||
timeout 8s "$ASCIINEMA_BIN" session --headless --output-file "$file3" --stream-local 127.0.0.1:8081 --command 'bash -c "echo stream session; sleep 3; echo done"' --return &
|
||||
local session_pid=$!
|
||||
|
||||
# Wait a moment for server to start
|
||||
sleep 1
|
||||
|
||||
# Test if both file and HTTP server work
|
||||
local curl_output
|
||||
if curl_output=$(curl -fsS "http://127.0.0.1:8081" 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "stream server responding"
|
||||
assert_output_contains "AsciinemaPlayer" "$curl_output" "session streaming server AsciinemaPlayer content"
|
||||
|
||||
# Clean up and check file
|
||||
kill $session_pid 2>/dev/null || true
|
||||
wait $session_pid 2>/dev/null || true
|
||||
|
||||
if [[ -f "$file3" ]]; then
|
||||
assert_file_contains 'stream session' "$file3" "session output content"
|
||||
fi
|
||||
|
||||
# Test different output formats
|
||||
local file4="$TMP_DATA_DIR/session_v2.cast"
|
||||
if "$ASCIINEMA_BIN" session --headless --output-file "$file4" --output-format asciicast-v2 --command 'echo "session v2"' --return; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "session v2 format"
|
||||
assert_file_contains 'session v2' "$file4" "session output content"
|
||||
|
||||
# Test append mode
|
||||
local file5="$TMP_DATA_DIR/session_append.cast"
|
||||
if "$ASCIINEMA_BIN" session --headless --output-file "$file5" --command 'echo "first session"' --return; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "session append setup"
|
||||
if "$ASCIINEMA_BIN" session --headless --output-file "$file5" --append --command 'echo "second session"' --return; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "session append"
|
||||
assert_file_contains 'first session' "$file5" "session append first content"
|
||||
assert_file_contains 'second session' "$file5" "session append second content"
|
||||
}
|
||||
|
||||
test_cat() {
|
||||
log_info "Testing cat command..."
|
||||
|
||||
# Create test recordings first
|
||||
local file1="$TMP_DATA_DIR/cat_input1.cast"
|
||||
local file2="$TMP_DATA_DIR/cat_input2.cast"
|
||||
local rc
|
||||
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "first recording"' --return "$file1"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "cat setup first recording"
|
||||
|
||||
if "$ASCIINEMA_BIN" record --headless --command 'echo "second recording"' --return "$file2"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "cat setup second recording"
|
||||
|
||||
# Test concatenation to stdout
|
||||
local output
|
||||
if output=$("$ASCIINEMA_BIN" cat "$file1" "$file2" 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "cat concatenate"
|
||||
assert_output_contains 'first recording' "$output" "cat first content"
|
||||
assert_output_contains 'second recording' "$output" "cat second content"
|
||||
|
||||
# Test with different format inputs (using fixtures, v2+v3 only since v1 can't be concatenated)
|
||||
if output=$("$ASCIINEMA_BIN" cat "$FIXTURES/minimal-v2.cast" "$FIXTURES/minimal-v3.cast" 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "cat mixed formats"
|
||||
assert_output_contains '"version":' "$output" "cat mixed formats output"
|
||||
}
|
||||
|
||||
test_convert() {
|
||||
log_info "Testing convert command..."
|
||||
|
||||
# Test v1 to v3 conversion
|
||||
local file1="$TMP_DATA_DIR/convert_v1_to_v3.cast"
|
||||
local rc
|
||||
if "$ASCIINEMA_BIN" convert "$FIXTURES/minimal-v1.json" "$file1"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "convert v1 to v3"
|
||||
assert_file_contains '"version":3' "$file1" "convert v1 to v3 version"
|
||||
|
||||
# Test v2 to v3 conversion
|
||||
local file2="$TMP_DATA_DIR/convert_v2_to_v3.cast"
|
||||
if "$ASCIINEMA_BIN" convert "$FIXTURES/minimal-v2.cast" "$file2"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "convert v2 to v3"
|
||||
assert_file_contains '"version":3' "$file2" "convert v2 to v3 version"
|
||||
|
||||
# Test to raw format
|
||||
local file3="$TMP_DATA_DIR/convert_to_raw.raw"
|
||||
if "$ASCIINEMA_BIN" convert --output-format raw "$FIXTURES/minimal-v2.cast" "$file3"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "convert to raw"
|
||||
assert_file_exists "$file3" "convert to raw output"
|
||||
|
||||
# Test to txt format
|
||||
local file4="$TMP_DATA_DIR/convert_to_txt.txt"
|
||||
if "$ASCIINEMA_BIN" convert --output-format txt "$FIXTURES/minimal-v2.cast" "$file4"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "convert to txt"
|
||||
assert_file_exists "$file4" "convert to txt output"
|
||||
|
||||
# Test output to stdout
|
||||
local output
|
||||
if output=$("$ASCIINEMA_BIN" convert "$FIXTURES/minimal-v2.cast" - 2>&1); then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "convert to stdout"
|
||||
assert_output_contains '"version":3' "$output" "convert stdout version"
|
||||
|
||||
# Test overwrite behavior
|
||||
local file5="$TMP_DATA_DIR/convert_overwrite.cast"
|
||||
echo "existing content" > "$file5"
|
||||
if "$ASCIINEMA_BIN" convert --overwrite "$FIXTURES/minimal-v2.cast" "$file5"; then rc=0; else rc=$?; fi
|
||||
assert_exit_code 0 "$rc" "convert overwrite"
|
||||
assert_file_contains '"version":3' "$file5" "convert overwrite content"
|
||||
}
|
||||
|
||||
# MAIN EXECUTION
|
||||
|
||||
# Setup always runs
|
||||
setup
|
||||
|
||||
echo
|
||||
echo "######################################################"
|
||||
echo "# ASCIINEMA CLI INTEGRATION TESTS"
|
||||
echo "######################################################"
|
||||
echo "# Test filter: ${TEST:-ALL}"
|
||||
echo "######################################################"
|
||||
|
||||
# Individual test blocks
|
||||
run_test "help" test_help
|
||||
run_test "version" test_version
|
||||
run_test "auth" test_auth
|
||||
run_test "record" test_record
|
||||
run_test "stream" test_stream
|
||||
run_test "session" test_session
|
||||
run_test "cat" test_cat
|
||||
run_test "convert" test_convert
|
||||
|
||||
# Final summary
|
||||
echo
|
||||
echo "######################################################"
|
||||
echo "# TEST SUMMARY"
|
||||
echo "######################################################"
|
||||
echo "Tests run: $TESTS_RUN"
|
||||
printf "%bTests passed: %b%s%b\n" "" "${GREEN}" "$TESTS_PASSED" "${NC}"
|
||||
if [[ $TESTS_FAILED -gt 0 ]]; then
|
||||
printf "%bTests failed: %b%s%b\n" "" "${RED}" "$TESTS_FAILED" "${NC}"
|
||||
echo "OVERALL RESULT: FAILED"
|
||||
exit 1
|
||||
else
|
||||
printf "%bTests failed: %b%s%b\n" "" "${GREEN}" "0" "${NC}"
|
||||
echo "OVERALL RESULT: SUCCESS"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user