├── .gitignore ├── LICENSE ├── README.md ├── bin └── buildlibs.sh ├── cap10.nimble ├── config.nims └── src ├── cap10.nim ├── common.nim ├── convert.nim ├── demo.nim ├── expect.nim ├── play.nim └── record.nim /.gitignore: -------------------------------------------------------------------------------- 1 | *.cap10 2 | cap10 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cap10 2 | ## Script, capture and replay terminal sessions 3 | 4 | ![Cap10 Demo](https://github-production-user-asset-6210df.s3.amazonaws.com/4764481/280741508-3a3d90cd-b4d2-44e0-9b1a-c38543cbd903.gif) 5 | 6 | You record and play back terminal sessions or other terminal programs 7 | with the `cap10` command, which will record a process until it exits. 8 | 9 | Cap10 will automatically remove long pauses in your input stream. So 10 | type as slow as you want... walk away and come back an hour 11 | later. You're good. 12 | 13 | - `cap10 record [optional command]` starts recording. 14 | - `cap10 play file.cap10` 15 | - `cap10 convert [filename]` will convert a cap10 file to the _asciicast 2.0_ format. 16 | 17 | The `cap10` capture format is binary, and there's currently not a web 18 | player for it. But you can convert it to asciicast format. 19 | 20 | On Linux, cap10 should build as a static ELF binary, so can run in 21 | minimal environments like Alpine, without the Python dependencies of 22 | asciinema. 23 | 24 | Also, we produce a separate input log to make it easier to automate 25 | scripting for demos (in conjunction w/ our expect capabilities 26 | below). It captures keypresses only, so if you don't type out a full 27 | command, it won't capture the exact commands (in the future we may do 28 | some shell integration to get the shell view). 29 | 30 | Essentially, cap10 should replace the following tools: 31 | 32 | - script 33 | - asciinema 34 | - expect 35 | - autoexpect (which never worked well anyway) 36 | 37 | ## GIF conversion 38 | 39 | If you'd like to convert the output to a video for a web page, instead 40 | of an Asciicast, we recommend producing an GIF, which you can do 41 | do this by exporting to asciicast and then running the 42 | [agg tool](https://github.com/asciinema/agg) from asciinema. 43 | 44 | ## Expect More 45 | 46 | You can also use `cap10` as an expect-like library, with the added 47 | benefits: 48 | 49 | 1. You can fully capture your `expect` sessions if you want. 50 | 2. You can interact with your scripts if you want. 51 | 3. You can capture the input from your interactions. 52 | 53 | The second makes expect-like automation easier to write. I've had many 54 | cases where the regexp didn't fire, and the result was a hanging 55 | process, with a long iteration cycle to test. Here, you can just 56 | manually enter input, which will generate output that runs through the 57 | pattern matcher. 58 | 59 | For example, the below code waits for a prompt, runs a command, then 60 | waits for 'hiho' followed by the enter key (The enter key generates 61 | '\r' NOT '\n'; the log file does translate them back to '\n' to be more 62 | human readable). 63 | 64 | But the code never does anything to send that string to the 65 | terminal. Instead, you're left to manually interact with it, and when 66 | the pattern is matched, the 'expect' call returns. 67 | 68 | The `passthrough` flag specifies that the user should be able to 69 | interact. At some point, we'll expose the ability to turn this on and 70 | off at will, and to provide more control over tty settings. 71 | 72 | ``` 73 | import cap10 74 | var s: ExpectObject 75 | 76 | s.spawnSession(captureFile = "expect.cap10", passthrough = true) 77 | s.expect(".*\\$ ") 78 | s.send("~/dev/chalk/chalk") 79 | s.expect("hiho\r") 80 | s.send("exit") 81 | s.expect("eof") 82 | echo "Capture saved to: ", s.capturePath 83 | ``` 84 | 85 | ## The switchboard 86 | 87 | At some point, we'll package this up as a library with bindings to 88 | other languages. 89 | 90 | Underlying everything is an IO multiplexing system that allows us to 91 | abitrarily (and dynamically) route output from file descriptors to 92 | other file descriptors, callbacks, etc, all without the need for 93 | threads. We've got a bunch of little utilities we'd like to see on top 94 | of this, that we may eventually add. 95 | 96 | ## Status 97 | 98 | It's early, and there are some rough edges (for instance, if there are 99 | file perms issues, we're currently not handling gracefully). This was 100 | done for our internal use, but eventually we will polish this 101 | up. Until then, use at your own risk :) 102 | 103 | In the not too distant future, we'll bundle the API for direct calling 104 | from other languages, with wrappers for (at least) Go and Python. 105 | 106 | Eventually, we'd like to have a native player and more, and be able to 107 | natively produce GIFs. But no hurry; the asciinema tools there are 108 | good. 109 | 110 | ## Building 111 | 112 | You need to have Nim 2.0 installed, and be on a 64-bit posix 113 | system. We really develop only on Linux and Mac, so there's more than 114 | a non-zero chance that *BSD or WSL won't work at this point... 115 | 116 | But all you should need to do to build the `cap10` binary is run from 117 | the root of the repo: 118 | 119 | ``` 120 | nimble build 121 | ``` 122 | -------------------------------------------------------------------------------- /bin/buildlibs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function color { 4 | case $1 in 5 | black) CODE=0 ;; 6 | red) CODE=1 ;; RED) CODE=9 ;; 7 | green) CODE=2 ;; GREEN) CODE=10 ;; 8 | yellow) CODE=3 ;; YELLOW) CODE=11 ;; 9 | blue) CODE=4 ;; BLUE) CODE=12 ;; 10 | magenta) CODE=5 ;; MAGENTA) CODE=13 ;; 11 | cyan) CODE=6 ;; CYAN) CODE=14 ;; 12 | white) CODE=7 ;; WHITE) CODE=15 ;; 13 | grey) CODE=8 ;; *) CODE=$1 ;; 14 | esac 15 | shift 16 | 17 | export TERM=${TERM:-vt100} 18 | echo -n $(tput -T ${TERM} setaf ${CODE})$@$(tput -T ${TERM} op) 19 | } 20 | 21 | function colorln { 22 | echo $(color $@) 23 | } 24 | 25 | if [[ ${#} -eq 0 ]] ; then 26 | colorln RED Script requires an argument pointing to the deps directory 27 | exit 1 28 | fi 29 | 30 | ARCH=$(uname -m) 31 | OS=$(uname -o 2>/dev/null) 32 | if [[ $? -ne 0 ]] ; then 33 | # Older macOS/OSX versions of uname don't support -o 34 | OS=$(uname -s) 35 | fi 36 | 37 | if [[ ${OS} = "Darwin" ]] ; then 38 | # Not awesome, but this is what nim calls it. 39 | OS=macosx 40 | 41 | # We might be running virtualized, so do some more definitive 42 | # tesitng. Note that there's no cross-compiling flag; if you 43 | # want to cross compile, you currently need to manually build 44 | # these libs. 45 | SYSCTL=$(sysctl -n sysctl.proc_translated 2>/dev/null) 46 | if [[ ${SYSCTL} = '0' ]] || [[ ${SYSCTL} == '1' ]] ; then 47 | NIMARCH=arm64 48 | else 49 | NIMARCH=amd64 50 | fi 51 | else 52 | # We don't support anything else at the moment. 53 | OS=linux 54 | if [[ ${ARCH} = "x86_64" ]] ; then 55 | NIMARCH=amd64 56 | else 57 | NIMARCH=arm64 58 | fi 59 | fi 60 | 61 | DEPS_DIR=${DEPS_DIR:-${HOME}/.local/c0} 62 | 63 | PKG_LIBS=${1}/lib/${OS}-${NIMARCH} 64 | MY_LIBS=${DEPS_DIR}/libs 65 | SRC_DIR=${DEPS_DIR}/src 66 | MUSL_DIR=${DEPS_DIR}/musl 67 | MUSL_GCC=${MUSL_DIR}/bin/musl-gcc 68 | 69 | mkdir -p ${MY_LIBS} 70 | 71 | # The paste doesn't work from stdin on MacOS, so leave this as is, please. 72 | export OPENSSL_CONFIG_OPTS=$(echo " 73 | enable-ec_nistp_64_gcc_128 74 | no-afalgeng 75 | no-apps 76 | no-bf 77 | no-camellia 78 | no-cast 79 | no-comp 80 | no-deprecated 81 | no-des 82 | no-docs 83 | no-dtls 84 | no-dtls1 85 | no-egd 86 | no-engine 87 | no-err 88 | no-idea 89 | no-md2 90 | no-md4 91 | no-mdc2 92 | no-psk 93 | no-quic 94 | no-rc2 95 | no-rc4 96 | no-rc5 97 | no-seed 98 | no-shared 99 | no-srp 100 | no-ssl 101 | no-tests 102 | no-tls1 103 | no-tls1_1 104 | no-uplink 105 | no-weak-ssl-ciphers 106 | no-zlib 107 | " | tr '\n' ' ') 108 | 109 | function copy_from_package { 110 | for item in ${@} 111 | do 112 | if [[ ! -f ${MY_LIBS}/${item} ]] ; then 113 | if [[ ! -f ${PKG_LIBS}/${item} ]] ; then 114 | return 1 115 | else 116 | cp ${PKG_LIBS}/${item} ${MY_LIBS} 117 | fi 118 | fi 119 | done 120 | return 0 121 | } 122 | 123 | function get_src { 124 | mkdir -p ${SRC_DIR} 125 | cd ${SRC_DIR} 126 | 127 | if [[ ! -d ${SRC_DIR}/${1} ]] ; then 128 | echo $(color CYAN Downloading ${1} from:) ${2} 129 | git clone ${2} 130 | fi 131 | if [[ ! -d ${1} ]] ; then 132 | echo $(color RED Could not create directory: ) ${SRC_DIR}/${1} 133 | exit 1 134 | fi 135 | cd ${1} 136 | } 137 | 138 | function ensure_musl { 139 | if [[ ${OS} = "macosx" ]] ; then 140 | return 141 | fi 142 | if [[ ! -f ${MUSL_GCC} ]] ; then 143 | # if musl-gcc is already installed, use it 144 | existing_musl=$(which musl-gcc 2> /dev/null) 145 | if [[ -n "${existing_musl}" ]]; then 146 | mkdir -p $(dirname ${MUSL_GCC}) 147 | ln -s ${existing_musl} ${MUSL_GCC} 148 | echo $(color GREEN Linking existing musl-gcc: ) ${existing_musl} $(color GREEN "->" ) ${MUSL_GCC} 149 | fi 150 | fi 151 | if [[ ! -f ${MUSL_GCC} ]] ; then 152 | get_src musl git://git.musl-libc.org/musl 153 | colorln CYAN Building musl 154 | unset CC 155 | ./configure --disable-shared --prefix=${MUSL_DIR} 156 | make clean 157 | make 158 | make install 159 | mv lib/*.a ${MY_LIBS} 160 | 161 | if [[ -f ${MUSL_GCC} ]] ; then 162 | echo $(color GREEN Installed musl wrapper to:) ${MUSL_GCC} 163 | else 164 | colorln RED Installation of musl failed! 165 | exit 1 166 | fi 167 | fi 168 | export CC=${MUSL_GCC} 169 | export CXX=${MUSL_GCC} 170 | } 171 | 172 | function install_kernel_headers { 173 | if [[ ${OS} = "macosx" ]] ; then 174 | return 175 | fi 176 | colorln CYAN Installing kernel headers needed for musl install 177 | get_src kernel-headers https://github.com/sabotage-linux/kernel-headers.git 178 | make ARCH=${ARCH} prefix= DESTDIR=${MUSL_DIR} install 179 | } 180 | 181 | function ensure_openssl { 182 | 183 | if ! copy_from_package libssl.a libcrypto.a ; then 184 | ensure_musl 185 | install_kernel_headers 186 | 187 | get_src openssl https://github.com/openssl/openssl.git 188 | colorln CYAN Building openssl 189 | if [[ ${OS} == "macosx" ]]; then 190 | ./config ${OPENSSL_CONFIG_OPTS} 191 | else 192 | ./config ${OPENSSL_CONFIG_OPTS} -static 193 | fi 194 | make clean 195 | make build_libs 196 | mv *.a ${MY_LIBS} 197 | if [[ -f ${MY_LIBS}/libssl.a ]] && [[ -f ${MY_LIBS}/libcrypto.a ]] ; then 198 | echo $(color GREEN Installed openssl libs to:) ${MY_LIBS} 199 | else 200 | colorln RED Installation of openssl failed! 201 | exit 1 202 | fi 203 | fi 204 | } 205 | 206 | function ensure_pcre { 207 | if ! copy_from_package libpcre.a ; then 208 | 209 | get_src pcre https://github.com/luvit/pcre.git 210 | colorln CYAN "Building libpcre" 211 | # For some reason, build fails on arm if we try to compile w/ musl? 212 | unset CC 213 | ./configure --disable-cpp --disable-shared 214 | make clean 215 | make 216 | 217 | mv .libs/libpcre.a ${MY_LIBS} 218 | if [[ -f ${MY_LIBS}/libpcre.a ]] ; then 219 | echo $(color GREEN Installed libpcre to:) ${MY_LIBS}/libpcre.a 220 | else 221 | colorln RED "Installation of libprce failed. This may be due to missing build dependencies. Please make sure autoconf, m4 and perl are installed." 222 | exit 1 223 | fi 224 | fi 225 | } 226 | 227 | function ensure_gumbo { 228 | if ! copy_from_package libgumbo.a ; then 229 | ensure_musl 230 | get_src sigil-gumbo https://github.com/Sigil-Ebook/sigil-gumbo/ 231 | colorln CYAN "Watching our waistline, selecting only required gumbo ingredients..." 232 | cat > CMakeLists.txt <= 2.0.0" 9 | requires "https://github.com/crashappsec/nimutils#de08f11339ccd5d06079747271329b29ca9e27a9" 10 | -------------------------------------------------------------------------------- /config.nims: -------------------------------------------------------------------------------- 1 | import nimutils/nimscript, os 2 | 3 | switch("debugger", "native") 4 | 5 | when not defined(debug): 6 | switch("d", "release") 7 | switch("opt", "speed") 8 | 9 | applyCommonLinkOptions() 10 | 11 | var 12 | default = getEnv("HOME").joinPath(".local/c0") 13 | localDir = getEnv("LOCAL_INSTALL_DIR", default) 14 | libDir = localdir.joinPath("libs") 15 | libs = ["pcre", "ssl", "crypto", "gumbo", "hatrack"] 16 | 17 | applyCommonLinkOptions() 18 | staticLinkLibraries(libs, libDir, muslBase = localDir) 19 | -------------------------------------------------------------------------------- /src/cap10.nim: -------------------------------------------------------------------------------- 1 | ## :Author: John Viega (john@crashoverride.com) 2 | ## :Copyright: 2023, Crash Override, Inc. 3 | ## 4 | ## I'm going to hook up con4m for command line arguments 5 | ## after my next batch of con4m work. Until then, a lot 6 | ## of the options are going to stay hardcoded. 7 | 8 | import record, play, nimutils, os, expect, convert, common, std/terminal 9 | export record, play, expect, common 10 | 11 | proc usage() {. noreturn .} = 12 | var 13 | rec = text("Record terminal output, running the named command if provided; spawning a shell if not. Saves to output.cap10 in the current working directory.") 14 | play = text("Plays named capture files, in order. If no file names are provided, assumes 'output.cap10'. If the ") + em("-i") + text(" flag is passed, then you can use space to pause/play and q to quit. This is off by default at the moment.") 15 | convert = text("Convert a cap10 file to an asciicast v2 file.") 16 | cap = text("Record, producing full capture (cap10, input log, asciicast and gif, if agg is installed).") 17 | cmds = @[@[em("record [command]"), rec], 18 | @[em("play [-i] [capfile]*"), play], 19 | @[em("convert [capfile]"), convert], 20 | @[em("cap [command]"), cap]] 21 | 22 | print(quickTable(cmds, noheaders = true, title = "cap10: Capture, replay, and convert terminal recordings")) 23 | quit(1) 24 | 25 | when isMainModule: 26 | cap10ThemeSetup() 27 | useNativeLocale() 28 | useCurrentTermStateOnSignal() 29 | 30 | var params = commandLineParams() 31 | if len(params) >= 1: 32 | if params[0] == "cap": 33 | if len(params) == 1: 34 | params.add(getLoginShell()) 35 | params.add("-i") 36 | let 37 | c10File = cmdCaptureProcess(params[1], params[2 .. ^1], verbose = false) 38 | acFile = toAsciiCast2(c10File) 39 | parts = acFile.splitFile() 40 | gifFile = joinPath(parts.dir, parts.name & ".gif") 41 | 42 | let aggOpts = findAllExePaths("agg") 43 | if len(aggOpts) == 0: 44 | print(h2("Skipping gif conversion; agg not found. Install " & 45 | "https://github.com/asciinema/agg for next time.")) 46 | 47 | else: 48 | print(h2("Creating gif by calling agg:")) 49 | let 50 | res = runCommand(aggOpts[0], @[acFile, gifFile], 51 | passthrough = SpIoStdout, capture = SpIoNone) 52 | removeFile(c10File) 53 | removeFile(c10File & ".log") 54 | echo("") 55 | if res.getExit() == 0: 56 | removeFile(acFile) 57 | print(h2(text("Output gif to: ") + em(gifFile))) 58 | else: 59 | print(h2(text("Asciicast file: ") + em(acFile))) 60 | 61 | elif params[0] in ["record", "rec"]: 62 | if len(params) == 1: 63 | params.add(getLoginShell()) 64 | params.add("-i") 65 | cmdCaptureProcess(params[1], params[2 .. ^1]) 66 | 67 | elif params[0] == "play": 68 | if len(params) == 1 or (len(params) == 2 and params[1] == "-n"): 69 | params.add("output.cap10") 70 | 71 | var allowInput = if "-i" in params: true else: false 72 | 73 | cmdPlaybackProcess(params[1 .. 1], allowInput) 74 | 75 | elif params[0] in ["convert", "export"]: 76 | if len(params) == 1: 77 | params.add("output.cap10") 78 | 79 | toAsciiCast2(params[1]) 80 | 81 | else: 82 | usage() 83 | else: 84 | usage() 85 | 86 | restoreTermState() 87 | showCursor() 88 | quit() 89 | -------------------------------------------------------------------------------- /src/common.nim: -------------------------------------------------------------------------------- 1 | ## :Author: John Viega (john@crashoverride.com) 2 | ## :Copyright: 2023, Crash Override, Inc. 3 | 4 | import nimutils, posix, tables, std/terminal, std/termios 5 | 6 | proc cap10ThemeSetup*() = 7 | useCrashTheme() 8 | 9 | var 10 | TIOCSWINSZ*{.importc, header: "".}: culong 11 | SIGWINCH* {.importc, header: "".}: cint 12 | LC_ALL* {.importc, header: "".}: cint 13 | 14 | proc setlocale*(category: cint, locale: cstring): cstring {. importc, cdecl, 15 | nodecl, header: "", discardable .} 16 | 17 | proc useNativeLocale*() = 18 | setlocale(LC_ALL, cstring("")) 19 | 20 | type 21 | CaptureContentType = enum CctInput, CctOutput 22 | CaptureState* = object 23 | includeInput*: bool 24 | fd*: cint 25 | inputLog*: File 26 | 27 | WriteHeader* = object 28 | timeStamp*: uint64 29 | contentLen*: int 30 | 31 | var 32 | gotResize* = false 33 | winchProxy = true 34 | winchProxyFd: cint = -1 35 | savedTermState: Termcap 36 | 37 | proc setWinchProxy*(proxy: bool) = 38 | winchProxy = proxy 39 | 40 | proc registerPtyFd*(ctx: var SubProcess) {.cdecl, gcsafe.} = 41 | ## Called once the forkpty() call succeeds to stash the FD 42 | ## for the sigwinch signal handler. 43 | winchProxyFd = ctx.getPtyFd() 44 | 45 | template restoreTermState*(how = TcsaConst.TCSAFLUSH) = 46 | tcSetAttr(cint(1), how, savedTermState) 47 | 48 | proc restoreOnQuit() {.noconv.} = 49 | restoreTermState() 50 | showCursor() 51 | stdout.close() 52 | 53 | let sigNameMap = { 1: "SIGHUP", 2: "SIGINT", 3: "SIGQUIT", 4: "SIGILL", 54 | 6: "SIGABRT",7: "SIGBUS", 9: "SIGKILL", 11: "SIGSEGV", 55 | 15: "SIGTERM", 28: "SIGWINCH" }.toTable() 56 | 57 | proc onParentResize(signal: cint) {.noconv.} = 58 | gotResize = true 59 | 60 | var 61 | newWinsz: IoCtlWinSize 62 | childWinSz: IoCtlWinSize 63 | 64 | if winchProxyFd == -1: 65 | return 66 | 67 | discard ioctl(1, TIOCGWINSZ, addr newWinsz) 68 | discard ioctl(winchProxyFd, TIOCSWINSZ, addr newWinsz) 69 | 70 | # If we don't do a little bit of busy-waiting, it's definitely 71 | # possible that the signal will not get delivered. Specifically, on 72 | # my mac, I've got a little program that does nothing but spin until 73 | # it gets SIGWINCH, at which point it prints out its new dimensions. 74 | # 75 | # Without this loop, or something else that takes up time, (like an 76 | # IO call), the parent process never sees the parent fd as ready fro 77 | # read, which means the signal did not get delivered. 78 | # 79 | # I've seen reports of this on Linux too; this seems to be a 80 | # somewhat unavoidable race condition in the OS, and waiting like 81 | # this before returning seems to be the only fix? 82 | # 83 | # If we just wait until the ioctl condition is true, we can also get hit 84 | # by the race condition and hang the parent. 85 | # 86 | # Should probably use a call to check the clock and limit this to a 87 | # wall-clock time, otherwise this # might eventually end up too low 88 | # on some machines (and might be too high on some machines now). 89 | 90 | for i in 0 ..< 1000000: 91 | discard ioctl(winchProxyFd, TIOCGWINSZ, addr childWinsz) 92 | if childWinSz != newWinSz: 93 | break 94 | 95 | proc regularTerminationSignal(signal: cint) {.noconv.} = 96 | showCursor() 97 | stdout.close() 98 | 99 | echo "Aborting due to signal: " & sigNameMap[signal] & "(" & $(signal) & ")" 100 | 101 | var sigset: SigSet 102 | 103 | discard sigemptyset(sigset) 104 | 105 | for signal in [SIGHUP, SIGINT, SIGQUIT, SIGILL, SIGABRT, SIGBUS, SIGKILL, 106 | SIGSEGV, SIGTERM]: 107 | discard sigaddset(sigset, signal) 108 | discard sigprocmask(SIG_SETMASK, sigset, sigset) 109 | 110 | exitnow(signal + 128) 111 | 112 | proc setupParentSignalHandlers*() = 113 | var handler: SigAction 114 | 115 | handler.sa_handler = regularTerminationSignal 116 | handler.sa_flags = 0 117 | 118 | for signal in [SIGHUP, SIGINT, SIGQUIT, SIGILL, SIGABRT, SIGBUS, SIGKILL, 119 | SIGSEGV, SIGTERM]: 120 | discard sigaction(signal, handler, nil) 121 | 122 | if winchProxy: 123 | signal(SIGWINCH, onParentResize) 124 | 125 | proc useCurrentTermStateOnSignal*(installHandlers = true) = 126 | tcGetAttr(cint(1), savedTermState) 127 | if installHandlers: 128 | setupParentSignalHandlers() 129 | -------------------------------------------------------------------------------- /src/convert.nim: -------------------------------------------------------------------------------- 1 | import os, nimutils, common, json, strutils, unicode 2 | 3 | proc escapeAllJson*(s: string, result: var string) = 4 | # If we use the built-in Nim version of this, we end up generating 5 | # output that Asciinema can't handle, because it goes one byte at 6 | # a time, when it really needs to go one CODEPOINT at a time. 7 | # Silly Nim. 8 | result.add("\"") 9 | 10 | for c in s.toRunes(): 11 | case c 12 | of Rune('\n'): 13 | result.add("\\n") 14 | of Rune('\b'): 15 | result.add("\\b") 16 | of Rune('\f'): 17 | result.add("\\f") 18 | of Rune('\t'): 19 | result.add("\\t") 20 | of Rune('\v'): 21 | result.add("\\u000b") 22 | of Rune('\r'): 23 | result.add("\\r") 24 | of Rune('"'): 25 | result.add("\\\"") 26 | of Rune('\\'): 27 | result.add("\\\\") 28 | of Rune(0x00) .. Rune(0x07), 29 | Rune(0x0e) .. Rune(0x1f), 30 | Rune(0x7f): 31 | result.add("\\u" & toHex(ord(c), 4)) 32 | else: 33 | result.add($(c)) 34 | 35 | result.add("\"") 36 | 37 | proc escapeAllJson*(s: string): string = 38 | result = newStringOfCap(s.len + s.len shr 3) 39 | s.escapeAllJson(result) 40 | 41 | template makeUtf8CutoffAdjustments() = 42 | var 43 | s: string 44 | i: int 45 | expectedNum: int 46 | ## For our binary file format, we capture and replay 512-byte chunks 47 | ## directly from the file descriptor. There, if UTF-8 characters get 48 | ## split between chunks, it's no big deal, because they will be 49 | ## played back in a consecutive stream. 50 | ## 51 | ## However, when converting to JSON, we need to make sure NOT to 52 | ## split bits up. So when we're about to chop off a character, we'll 53 | ## save it in buffer, and append it to the next packet, which we 54 | ## can be sure is coming. 55 | 56 | if waiting != 0: 57 | for n in 0 ..< waiting: 58 | s.add(utf8Buffer[n]) 59 | payload = s & payload 60 | waiting = 0 61 | 62 | 63 | i = payload.validateUtf8() 64 | while true: 65 | if i != -1: 66 | waiting = payload.len() - i 67 | if waiting >= 4: 68 | let n = payload[i + 1 .. ^1].validateUtf8() 69 | if n == -1: 70 | waiting = 0 71 | break 72 | else: 73 | i += n 74 | continue 75 | for n in 0 ..< waiting: 76 | utf8Buffer[n] = payload[i + n] 77 | payload = payload[0 ..< i] 78 | break 79 | 80 | proc toAsciiCast2*(fname: string, outfname = ""): string {.cdecl, 81 | discardable.} = 82 | var 83 | buf: array[1024, uint8] 84 | hdr: WriteHeader 85 | hdrptr = addr hdr 86 | inf = open(fname, fmRead) 87 | maxLen = inf.getFileSize() 88 | parts = fname.splitFile() 89 | startTime: uint64 = 0 90 | outf: File 91 | utf8Buffer: array[4, char] 92 | waiting: int 93 | hdrLen: int 94 | payload: string 95 | b: ptr char 96 | hdrStr: string 97 | progress: ProgressBar 98 | processed: int 99 | 100 | 101 | if outfname != "": 102 | result = outfname 103 | else: 104 | result = joinPath(parts.dir, parts.name & ".cast") 105 | 106 | outf = open(result, fmWrite) 107 | 108 | inf.setFilePos(0) 109 | try: 110 | discard inf.readBuffer(addr hdrLen, sizeof(int)) 111 | b = cast[ptr char](alloc(hdrLen + 1)) 112 | discard inf.readBuffer(b, hdrLen) 113 | 114 | hdrStr = binaryCstringToString(cast[cstring](b), hdrLen) 115 | dealloc(b) 116 | discard parseJson(hdrStr) 117 | except: 118 | print(h2("Invalid cap10 file.")) 119 | quit(1) 120 | 121 | print(h2("Converting...")) 122 | outf.write(hdrStr) 123 | outf.write("\n") 124 | 125 | 126 | progress.initProgress(maxLen - inf.getFilePos()) 127 | 128 | while inf.getFilePos() < maxLen: 129 | discard inf.readBuffer(hdrptr, sizeof(WriteHeader)) 130 | 131 | if hdr.contentLen == -1: 132 | var w, h: int 133 | discard inf.readBuffer(addr w, sizeof(w)) 134 | discard inf.readBuffer(addr h, sizeof(h)) 135 | var t = int(hdr.timeStamp - startTime) / 1000 136 | outf.write("[" & $(t) & ",\"r\", \"" & $(w) & "x" & $(h) & "\"]") 137 | continue 138 | 139 | discard inf.readBytes(buf, 0, hdr.contentLen) 140 | 141 | if startTime == 0: 142 | startTime = hdr.timeStamp 143 | 144 | payload = binaryCStringToString(cast[cstring](addr buf), hdr.contentLen) 145 | makeUtf8CutoffAdjustments() 146 | 147 | var t = int(hdr.timeStamp - startTime) / 1000 148 | 149 | outf.write("[" & $(t) & ",\"o\", " & payLoad.escapeAllJson() & "]\n") 150 | processed += hdr.contentLen + sizeof(WriteHeader) 151 | 152 | progress.update(processed) 153 | 154 | inf.close() 155 | outf.close() 156 | restoreTermState() 157 | print(h2("Conversion complete.")) 158 | -------------------------------------------------------------------------------- /src/demo.nim: -------------------------------------------------------------------------------- 1 | import expect, common, nimutils, strutils 2 | 3 | proc showTitle(ctx: var ExpectObject, x: string, before = 1000, after = 2000) = 4 | once: 5 | cap10ThemeSetup() 6 | if before > 0: 7 | ctx.pollFor(before) 8 | print(callout(x)) 9 | ctx.send("") 10 | if after > 0: 11 | ctx.pollFor(after) 12 | 13 | var s: ExpectObject 14 | 15 | var keptStuff: string 16 | 17 | proc customMatch(s: string): (int, string) = 18 | if "exit" in s: 19 | return (-1, "abort") 20 | let 21 | match1 = "Conversion complete" 22 | n = s.find(match1) 23 | match2 = "$ " 24 | 25 | if n == -1: 26 | return (-1, "") 27 | 28 | let 29 | pos = n + len(match1) 30 | m = s[n + len(match1) .. ^1].find(match2) 31 | 32 | if m == -1: 33 | return (-1, "") 34 | 35 | keptStuff = s 36 | return (m + pos, "match") 37 | 38 | proc basicDemo() = 39 | s.showTitle("Let's start by playing a small cap10 recording of me manually running our command 'chalk'.") 40 | s.send("./cap10 play basic.cap10") 41 | s.expect("Playback complete") 42 | s.expect(".*\\$ ") 43 | s.showTitle("Great. Now, let's go ahead and convert that to asciicast format, so we can see how well it works there.") 44 | s.send("./cap10 convert basic.cap10") 45 | if s.expect(customMatch) == "abort": 46 | s.showTitle("Ending early.\n") 47 | return 48 | s.showTitle("Now, let's go ahead and play it in asciinema.") 49 | s.send("asciinema play basic.cast && echo Done") 50 | s.expect("Done") 51 | s.expect(".*\\$ ") 52 | s.showTitle("We can convert the asciicast to a gif for our web site, using the asciicast gif generator (https://github.com/asciinema/agg)") 53 | s.send("agg basic.cast basic.gif") 54 | s.expect(".*\\$ ") 55 | s.showTitle("""Now, you can load 'basic.gif' in your browser!

56 | 57 | Note that, when recording a session, by running 'cap10 cap' instead 58 | of 'cap10 record' you can automatically generate cap10, asciicast and 59 | gif output in one command. 60 |

""") 61 | s.send("exit") 62 | s.expect("eof") 63 | 64 | when isMainModule: 65 | # While the SpawnSession call can do capture of the sub-process, if 66 | # you want to capture the banners too, you don't need to capture here; 67 | # wrap it by running this demo in cap10 68 | 69 | useNativeLocale() 70 | useCurrentTermStateOnSignal() 71 | s.spawnSession(passthrough = true) 72 | basicDemo() 73 | 74 | 75 | #proc chalkDemo() = 76 | # showTitle("""

In this demo, we're going to use Chalk to automatically 77 | #build and sign a container, and then look at the signature.

We'll 78 | # start by """) 79 | 80 | #s.send("alias docker=chalk") 81 | #docker login 82 | #git clone https://github.com/dockersamples/wordsmith 83 | #docker build -t ghcr.io/viega/wordsmith:latest . --push 84 | #chalk extract ghcr.io/viega/wordsmith:latest 85 | -------------------------------------------------------------------------------- /src/expect.nim: -------------------------------------------------------------------------------- 1 | ## :Author: John Viega (john@crashoverride.com) 2 | ## :Copyright: 2023, Crash Override, Inc. 3 | 4 | import common, record, tables, nimutils, re, posix, os, sugar 5 | 6 | type ExpectObject* = object 7 | captureFile*: File 8 | capturePath*: string 9 | captureState*: CaptureState 10 | patterns*: OrderedTable[string, Regex] 11 | subproc*: SubProcess 12 | exited*: bool 13 | matchable*: string 14 | pty_fd*: cint 15 | 16 | proc expectInput(ctx: var ExpectObject, 17 | unused: pointer, 18 | capture: cstring, 19 | l: int) {.cdecl.} = 20 | ctx.captureState.handleInput(unused, capture, l) 21 | 22 | proc expectOutput(ctx: var ExpectObject, 23 | unused: pointer, 24 | capture: cstring, 25 | l: int) {.cdecl.} = 26 | if ctx.captureFile != nil: 27 | ctx.captureState.handleCapture(unused, capture, l) 28 | 29 | ctx.matchable &= binaryCStringToString(capture, l) 30 | 31 | proc close*(ctx: var ExpectObject) {.cdecl.} = 32 | if not ctx.exited: 33 | discard ctx.pty_fd.close() 34 | 35 | if ctx.captureFile != nil: 36 | ctx.captureFile.close() 37 | ctx.captureFile = File(nil) 38 | 39 | proc c10_close*(ctx: var ExpectObject) {.exportc, cdecl.} = 40 | ctx.close() 41 | 42 | proc pollFor*(ctx: var ExpectObject, ms: int) = 43 | let endTime = unixTimeInMs() + uint64(ms) 44 | 45 | while true: 46 | discard ctx.subproc.poll() 47 | if unixTimeInMs() > endTime: 48 | break 49 | 50 | proc expect*(ctx: var ExpectObject, pattern = ""): string 51 | {.cdecl, discardable.} = 52 | if pattern == "" and ctx.patterns.len() == 0: 53 | ctx.patterns["default"] = re(".") 54 | elif pattern != "": 55 | ctx.patterns["default"] = re(pattern) 56 | 57 | while ctx.exited == false: 58 | if ctx.subproc.poll(): 59 | ctx.exited = true 60 | discard ctx.pty_fd.close() 61 | break 62 | 63 | var 64 | toMatch = cstring(ctx.matchable) 65 | l = toMatch.len() 66 | 67 | for k, v in ctx.patterns: 68 | let (f, l) = toMatch.findBounds(v, 0, l) 69 | if f == -1 or (f == 0 and l == 0): 70 | continue 71 | ctx.matchable = ctx.matchable[l+1 .. ^1] 72 | ctx.patterns.clear() 73 | ctx.pollFor(100) 74 | return k 75 | 76 | ctx.close() 77 | return "eof" 78 | 79 | proc expect*(ctx: var ExpectObject, f: (string) -> (int, string)): string = 80 | while ctx.exited == false: 81 | if ctx.subproc.poll(): 82 | ctx.exited = true 83 | discard ctx.pty_fd.close() 84 | break 85 | 86 | let (ix, tag) = f(ctx.matchable) 87 | 88 | if ix != -1: 89 | ctx.matchable = ctx.matchable[ix .. ^1] 90 | 91 | if tag != "": 92 | ctx.pollFor(100) 93 | return tag 94 | 95 | 96 | proc expect*(ctx: var ExpectObject, pats: OrderedTable[string, string]): 97 | string {.cdecl.} = 98 | for k, v in pats: 99 | ctx.patterns[k] = re(".") 100 | return ctx.expect() 101 | 102 | proc cap10_expect*(ctx: var ExpectObject, pattern: cstring): cstring {. 103 | exportc, cdecl .} = 104 | return cstring(ctx.expect($(pattern))) 105 | 106 | proc addPattern*(ctx: var ExpectObject, text: string, tag: string) {.cdecl.} = 107 | ## Patterns get cleared every time a match occurs. 108 | ctx.patterns[tag] = re(text) 109 | 110 | proc cap10_add_pattern*(ctx: var ExpectObject, text: cstring, tag: cstring) 111 | {.exportc, cdecl.} = 112 | ctx.addPattern($(text), $(tag)) 113 | 114 | proc send*(ctx: var ExpectObject, text: string, pause = 0, addCr = true, 115 | keyWait = 100) 116 | {.cdecl.} = 117 | if pause != 0: 118 | sleep(pause) 119 | discard ctx.subproc.poll() 120 | 121 | var toWrite = if addCr: text & "\r" else: text 122 | for i in 0 ..< toWrite.len(): 123 | ctx.pty_fd.rawFdWrite(addr toWrite[i], csize_t(1)) 124 | if keyWait != 0: 125 | sleep(keyWait) 126 | discard ctx.subproc.poll() 127 | 128 | if pause != 0: 129 | sleep(pause div 2) 130 | discard ctx.subproc.poll() 131 | 132 | 133 | proc cap10_send*(ctx: var ExpectObject, text: cstring, pause = 0, 134 | addCr = true, keywait = 100) 135 | {.exportc, cdecl.} = 136 | ctx.send($text, pause, addCr, keywait) 137 | 138 | proc spawnSession*(ctx: var ExpectObject, cmd = "/bin/bash", 139 | args = @["-i"], captureFile = "", passthrough = false, 140 | inputLogFile = "") {.cdecl.} = 141 | 142 | var timeout: Timeval 143 | 144 | timeout.tv_sec = Time(0) 145 | timeout.tv_usec = Suseconds(100) 146 | 147 | 148 | ctx = ExpectObject() 149 | 150 | if inputLogFile != "": 151 | ctx.captureState.inputLog = open(inputLogFile.resolvePath(), fmWrite) 152 | 153 | if captureFile != "": 154 | (ctx.captureFile, ctx.capturePath) = captureFile.openWithoutClobber() 155 | 156 | if ctx.captureFile == nil: 157 | raise newException(IoError, "Cannot open capture file") 158 | 159 | ctx.captureState.captureSetup(ctx.captureFile.getFileHandle()) 160 | 161 | 162 | ctx.subproc.initSubprocess(cmd, @[cmd] & args) 163 | ctx.subproc.setTimeout(timeout) 164 | ctx.subproc.usePty() 165 | ctx.subproc.setExtra(addr ctx) 166 | ctx.subproc.setPassthrough(SpIoAll, passthrough) 167 | 168 | if passthrough: 169 | ctx.subproc.setIoCallback(SpIoStdin, cast[SubProcCallback](expectInput)) 170 | 171 | ctx.subproc.setIoCallback(SpIoStdout, cast[SubprocCallback](expectOutput)) 172 | 173 | ctx.subproc.start() 174 | ctx.pty_fd = ctx.subproc.getPtyFd() 175 | 176 | proc spawnSession*(cmd = "/bin/bash", args = @["-i"], captureFile = "", 177 | passthrough = false, inputLogFile = ""): 178 | ExpectObject {.cdecl.} = 179 | result = ExpectObject() 180 | result.spawn_session(cmd, args, captureFile, passthrough, inputLogFile) 181 | 182 | proc cap10_spawn*(cmd: cstring, args: cStringArray, captureFile: cstring, 183 | passthrough: bool, inputLogFile: cstring): 184 | ExpectObject {.exportc, cdecl.} = 185 | var 186 | strargs: seq[string] 187 | i = 0 188 | while true: 189 | if args[i] == nil: 190 | break 191 | strargs.add($(args[i])) 192 | i += 1 193 | 194 | return spawnSession($(cmd), strargs, $(captureFile), passthrough, 195 | $(inputLogFile)) 196 | 197 | when isMainModule: 198 | var 199 | s: ExpectObject 200 | 201 | s.spawnSession(captureFile = "expect.cap10", passthrough = true) 202 | s.expect(".*\\$ ") 203 | s.send("~/dev/chalk/chalk") 204 | s.expect("hiho\r") 205 | s.send("exit") 206 | s.expect("eof") 207 | echo "Capture saved to: ", s.capturePath 208 | -------------------------------------------------------------------------------- /src/play.nim: -------------------------------------------------------------------------------- 1 | ## :Author: John Viega (john@crashoverride.com) 2 | ## :Copyright: 2023, Crash Override, Inc. 3 | 4 | import os, nimutils, posix, common, json 5 | 6 | var 7 | paused = false 8 | exit = false 9 | 10 | proc handlePlayerInput(ignore0: var RootRef, 11 | ignore1: var RootRef, 12 | capture: cstring, 13 | caplen: int) {.cdecl.} = 14 | var incap = bytesToString(cast[ptr UncheckedArray[char]](capture), caplen) 15 | 16 | for ch in incap: 17 | if ch == ' ': 18 | paused = not paused 19 | elif ch == 'q': 20 | exit = true 21 | 22 | proc applyHeader(hdr: string): int {.cdecl.} = 23 | try: 24 | var 25 | jObj = parseJson(hdr) 26 | w = jObj["width"].getInt() 27 | h = jObj["height"].getInt() 28 | t = int(jObj["idle_time_limit"].getFloat() * 1000) 29 | 30 | if w <= 0 or h <= 0: 31 | return 32 | 33 | stdout.write("\e[8;" & $(h) & ";" & $(w) & "t") 34 | return t 35 | except: 36 | discard 37 | 38 | proc replayProcess*(fname: string, 39 | allowInput = true, 40 | maxTimeBetweenEvents = 1500) {.cdecl.} = 41 | var 42 | buf: array[1024, uint8] 43 | hdr: WriteHeader 44 | lastStamp: uint64 = 0 45 | hdrptr = addr hdr 46 | f = open(fname, fmRead) 47 | maxLen = f.getFileSize() 48 | tty = open("/dev/tty", fmWrite) 49 | sepTime: int 50 | sleepTime: int 51 | hdrLen: int 52 | b: ptr char 53 | hdrStr: string 54 | termSave: Termcap 55 | newTerm: Termcap 56 | switchboard: Switchboard 57 | stdinFd: Party 58 | cb: Party 59 | tv: Timeval 60 | 61 | discard dup2(tty.getFileHandle(), 1) 62 | # tcGetAttr(cint(1), newterm) 63 | # newTerm.rawMode() 64 | 65 | f.setFilePos(0) 66 | 67 | try: 68 | discard f.readBuffer(addr hdrLen, sizeof(int)) 69 | b = cast[ptr char](alloc(hdrLen + 1)) 70 | discard f.readBuffer(b, hdrLen) 71 | 72 | hdrStr = binaryCstringToString(cast[cstring](b), hdrLen) 73 | dealloc(b) 74 | sepTime = applyHeader(hdrStr) 75 | if sepTime > maxTimeBetweenEvents or sepTime == 0: 76 | sepTime = maxTimeBetweenEvents 77 | 78 | except: 79 | print(h2("Invalid cap10 file.")) 80 | quit(1) 81 | 82 | if allowInput: 83 | tv.tv_sec = Time(0) 84 | tv.tv_usec = Suseconds(0) 85 | 86 | switchboard.initSwitchboard() 87 | switchboard.initPartyFd(stdinFd, 0, sbRead) 88 | switchboard.initPartyCallback(cb, handlePlayerInput) 89 | switchboard.route(stdinFd, cb) 90 | switchboard.setTimeout(tv) 91 | 92 | while f.getFilePos() < maxLen: 93 | if allowInput: 94 | switchboard.run() 95 | 96 | if exit: 97 | print(h2("Quitting early.")) 98 | quit(0) 99 | if paused: 100 | sleep(100) 101 | continue 102 | 103 | discard f.readBuffer(hdrptr, sizeof(WriteHeader)) 104 | 105 | if hdr.contentLen == -1: 106 | var w, h: int 107 | discard f.readBuffer(addr w, sizeof(w)) 108 | discard f.readBuffer(addr h, sizeof(h)) 109 | # Not sure we need to process the resize at all, but if we do, 110 | # all the sudden we will need to keep track of UTF-8 code point 111 | # state *and* ansi term state, so let's avoid this for now. 112 | # 113 | # Really, probably what we'd do to be safe is look for a newline or 114 | # \e and inject before those. 115 | # 116 | # But really, we're only capturing resize info rn because 117 | # asciicast wants the info. 118 | continue 119 | 120 | discard f.readBytes(buf, 0, hdr.contentLen) 121 | 122 | sleepTime = int(hdr.timeStamp - lastStamp) 123 | lastStamp = hdr.timeStamp 124 | 125 | if sleepTime > sepTime: 126 | sleepTime = sepTime 127 | 128 | if sleepTime > 0: 129 | sleep(sleepTime) 130 | 131 | rawFdWrite(cint(1), addr buf, csize_t(hdr.contentLen)) 132 | sleep(400) 133 | tty.close() 134 | 135 | proc cmdPlaybackProcess*(params: seq[string], allowInput: bool) = 136 | print(h2("Beginning playback.")) 137 | for item in params: 138 | if item == "-i": 139 | continue 140 | replayProcess(item, allowInput) 141 | 142 | restoreTermState() 143 | print(h2("Playback complete.")) 144 | -------------------------------------------------------------------------------- /src/record.nim: -------------------------------------------------------------------------------- 1 | ## :Author: John Viega (john@crashoverride.com) 2 | ## :Copyright: 2023, Crash Override, Inc. 3 | 4 | import nimutils, terminal, common, std/tempfiles, os, json, posix, strutils 5 | 6 | {.emit: """ 7 | extern ssize_t read_one(int, char *, size_t); 8 | extern bool write_data(int, char *, size_t); 9 | """.} 10 | 11 | proc getLoginShell*(): string = 12 | result = $(getpwuid(geteuid())[].pw_shell) 13 | 14 | # Changing the raw file format to go ahead and use the ASCIIcast JSON 15 | # header. We first save a length as an int, then the JSON. 16 | 17 | proc createASciicastHeader*(title = "Terminal Capture", idle_time_limit = 3, 18 | command = "", fg_theme = "", bg_theme = "", palette_theme = ""): 19 | string {.cdecl.} = 20 | var 21 | shell = getenv("SHELL") 22 | term = getenv("TERM") 23 | (w, h) = terminalSize() 24 | start = int(int(unixTimeInMs()) / 1000) 25 | 26 | if shell == "": 27 | shell = getLoginShell() 28 | if term == "": 29 | term = "xterm" 30 | 31 | var jobj = %* {"version": 2, "width" : w, "height" : h, 32 | "timestamp" : start, "title" : title} 33 | 34 | jobj["env"] = %* { "TERM" : term, "SHELL" : shell } 35 | 36 | if command != "": 37 | jobj["command"] = %* command 38 | 39 | if fg_theme != "" or bg_theme != "" or palette_theme != "": 40 | jobj["theme"] = %* { "fg" : fg_theme, "bg" : bg_theme, 41 | "palette" : palette_theme } 42 | return $(jobj) 43 | 44 | proc handleCapture*(state: var CaptureState, 45 | unused: pointer, 46 | capture: cstring, 47 | caplen: int) {.cdecl.} = 48 | var hdr: WriteHeader 49 | hdr.timeStamp = unixTimeInMs() 50 | 51 | if gotResize: 52 | hdr.contentLen = -1 53 | var (w, h) = terminalSize() 54 | 55 | rawFdWrite(state.fd, addr hdr, csize_t(sizeof(hdr))) 56 | rawFdWrite(state.fd, addr w, csize_t(sizeof(w))) 57 | rawFdWrite(state.fd, addr h, csize_t(sizeof(h))) 58 | 59 | hdr.contentLen = caplen 60 | 61 | rawFdWrite(state.fd, addr hdr, csize_t(sizeof(hdr))) 62 | rawFdWrite(state.fd, capture, csize_t(caplen)) 63 | 64 | proc handleInput*(state: var CaptureState, 65 | unused: pointer, 66 | capture: cstring, 67 | caplen: int) {.cdecl.} = 68 | 69 | if state.includeInput: 70 | state.handleCapture(unused, capture, caplen) 71 | 72 | if state.inputLog != nil: 73 | state.inputLog.write(`$`(capture).replace("\r", "\n")) 74 | 75 | proc captureSetup*(state: var CaptureState, fd: cint, 76 | exe = "", args: seq[string] = @[]) {.cdecl.} = 77 | var 78 | cmd = exe & args.join(" ") 79 | header = createAsciiCastHeader(command = cmd) 80 | l = header.len() 81 | 82 | state.fd = fd 83 | 84 | fd.rawFdWrite(addr l, csize_t(sizeof(int))) 85 | fd.rawFdWrite(addr header[0], csize_t(l)) 86 | 87 | proc captureProcess*(exe: string, args: seq[string], fd: cint, 88 | inputlog = "", includeInput = false): int 89 | {.cdecl, discardable.} = 90 | ## includeInput should be true when echo is off remotely. 91 | var 92 | subproc: SubProcess 93 | state: CaptureState 94 | 95 | state.captureSetup(fd, exe, args) 96 | state.includeInput = includeInput 97 | 98 | if inputLog != "": 99 | state.inputLog = open(resolvePath(inputLog), fmWrite) 100 | 101 | subproc.initSubprocess(exe, @[exe] & args) 102 | subproc.setStartupCallback(SpStartupCallback(registerPtyFd)) 103 | subproc.usePty() 104 | subproc.setExtra(addr state) 105 | subproc.setPassthrough(SpIoAll, false) 106 | subproc.setIoCallback(SpIoStdin, cast[SubProcCallback](handleInput)) 107 | subproc.setIoCallback(SpIoStdout, cast[SubprocCallback](handleCapture)) 108 | subproc.run() 109 | 110 | result = subproc.getExitCode() 111 | 112 | if inputLog != "": 113 | state.inputLog.close() 114 | 115 | proc openWithoutClobber*(filename: string): (File, string) {.cdecl.} = 116 | var 117 | path = filename.resolvePath() 118 | f = open(path, fmAppend) 119 | 120 | if f.getFilePos() != 0: 121 | f.close() 122 | let (dir, name, ext) = path.splitFile() 123 | return createTempFile(name & "-", ext, dir) 124 | else: 125 | return (f, path) 126 | 127 | proc captureProcess*(exe: string, args: seq[string], inputLogExt = "", 128 | includeInput = false): string {.cdecl.} = 129 | # Throws exception if the open fails. 130 | var 131 | f: File 132 | desiredPath: string = resolvePath("output.cap10") 133 | path: string 134 | 135 | (f, path) = openWithoutClobber(desiredPath) 136 | 137 | captureProcess(exe, args, f.getFileHandle(), 138 | path & inputLogExt, includeInput) 139 | f.close() 140 | 141 | return path 142 | 143 | const logext = ".log" 144 | 145 | proc cmdCaptureProcess*(exe: string, args: seq[string], verbose=true): string 146 | {.discardable.} = 147 | print(h2("Recording.")) 148 | result = captureProcess(exe, args, logExt) 149 | restoreTermState() 150 | if verbose: 151 | print(h2(text("Output saved to: ") + em(result))) 152 | print(h2(text("Input log in: ") + em(logExt))) 153 | --------------------------------------------------------------------------------