shatty 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +5 -0
- data/Gemfile.lock +27 -0
- data/Procfile +1 -0
- data/README.md +59 -0
- data/examples/blinkenlights.shatty +0 -0
- data/examples/output.shatty +0 -0
- data/misc/shatty.go +173 -0
- data/shatty.rb +115 -0
- data/web.rb +84 -0
- metadata +72 -0
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
addressable (2.2.6)
|
5
|
+
backports (2.3.0)
|
6
|
+
cabin (0.4.4)
|
7
|
+
json
|
8
|
+
clamp (0.3.1)
|
9
|
+
ftw (0.0.14)
|
10
|
+
addressable (= 2.2.6)
|
11
|
+
backports (= 2.3.0)
|
12
|
+
cabin (> 0)
|
13
|
+
http_parser.rb (= 0.5.3)
|
14
|
+
json (= 1.6.5)
|
15
|
+
minitest (> 0)
|
16
|
+
http_parser.rb (0.5.3)
|
17
|
+
json (1.6.5)
|
18
|
+
minitest (2.11.0)
|
19
|
+
uuidtools (2.1.3)
|
20
|
+
|
21
|
+
PLATFORMS
|
22
|
+
ruby
|
23
|
+
|
24
|
+
DEPENDENCIES
|
25
|
+
clamp
|
26
|
+
ftw
|
27
|
+
uuidtools
|
data/Procfile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
web: ruby web.rb
|
data/README.md
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# shatty
|
2
|
+
|
3
|
+
share tty.
|
4
|
+
|
5
|
+
## Play a demo recording
|
6
|
+
|
7
|
+
```
|
8
|
+
% ruby shatty.rb play examples/output.shatty
|
9
|
+
```
|
10
|
+
|
11
|
+
## Recording
|
12
|
+
|
13
|
+
```
|
14
|
+
% shatty.rb record <command>
|
15
|
+
```
|
16
|
+
|
17
|
+
By default will record to 'output.shatty'
|
18
|
+
|
19
|
+
## Playback
|
20
|
+
|
21
|
+
```
|
22
|
+
% shatty.rb play output.shatty
|
23
|
+
```
|
24
|
+
|
25
|
+
## Sharing
|
26
|
+
|
27
|
+
TBD.
|
28
|
+
|
29
|
+
* read-only
|
30
|
+
* read/write
|
31
|
+
|
32
|
+
## Tricks
|
33
|
+
|
34
|
+
### Record an active tmux session
|
35
|
+
|
36
|
+
```bash
|
37
|
+
# From any shell in your tmux session:
|
38
|
+
% TMUX= ruby shatty.rb record --headless tmux -2 attach
|
39
|
+
```
|
40
|
+
|
41
|
+
The '--headless' is required otherwise you end up tmux printing to tmux and you get a loop.
|
42
|
+
|
43
|
+
|
44
|
+
## TODO
|
45
|
+
|
46
|
+
* Improved player
|
47
|
+
* Skip forward/back
|
48
|
+
* Tunable playing speed (1x, 2x, etc)
|
49
|
+
* Search.
|
50
|
+
* Pause/rewind/etc live while viewing or recording.
|
51
|
+
* Online sharing
|
52
|
+
* Live sharing
|
53
|
+
* Multiuser
|
54
|
+
* Sharing recorded sessions
|
55
|
+
* Terminal size options
|
56
|
+
* Currently stuck at default 80x24, fix that.
|
57
|
+
* Improve & document recording format
|
58
|
+
* Currently a sequence of [play_time, length, data].pack("GNA*")
|
59
|
+
* Implement a terminal emulator so we can calculate key frames to better support playback/rewind
|
Binary file
|
Binary file
|
data/misc/shatty.go
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
package main
|
2
|
+
|
3
|
+
import (
|
4
|
+
"os"
|
5
|
+
"time"
|
6
|
+
"io"
|
7
|
+
"os/exec"
|
8
|
+
"syscall"
|
9
|
+
"unsafe"
|
10
|
+
"fmt"
|
11
|
+
)
|
12
|
+
|
13
|
+
/*
|
14
|
+
forkpty == openpty + fork
|
15
|
+
parent: close slave
|
16
|
+
child: close master
|
17
|
+
|
18
|
+
openpty ==
|
19
|
+
master = getpt
|
20
|
+
granpt(master)
|
21
|
+
unlockpt(master)
|
22
|
+
open as file ptsname(master)
|
23
|
+
tcsetattr (slave, TCSAFLUSH, termp);
|
24
|
+
ioctl (slave, TIOCSWINSZ, winp);
|
25
|
+
|
26
|
+
getpt
|
27
|
+
open /dev/ptmx, return fd (linux specific)
|
28
|
+
grantpt
|
29
|
+
call ptsname (get the filename of the slave)
|
30
|
+
chown/chgrp/chmod the filename to us
|
31
|
+
unlockpt
|
32
|
+
ioctl(master, TIOCSPTLCK, 0)
|
33
|
+
open slave
|
34
|
+
if 'login terminal'
|
35
|
+
ioctl(slave, TIOCSCTTY, NULL) // set this procses as controlling terminal
|
36
|
+
dup stdin/stdout/stderr to slave
|
37
|
+
*/
|
38
|
+
|
39
|
+
func getpt() (file *os.File, err error) {
|
40
|
+
file, err = os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
41
|
+
if err != nil {
|
42
|
+
return nil, err
|
43
|
+
}
|
44
|
+
return file, nil
|
45
|
+
} /* getpt */
|
46
|
+
|
47
|
+
func ptsname(file *os.File) (name string, err error) {
|
48
|
+
/* On linux, this calls ioctl(fd, TIOCGPTN, ...) */
|
49
|
+
var num int
|
50
|
+
|
51
|
+
/* Get the /dev/pts number */
|
52
|
+
err = ioctl(file, syscall.TIOCGPTN, &num)
|
53
|
+
if err != nil && err.Error() != "errno 0" {
|
54
|
+
return "", err
|
55
|
+
}
|
56
|
+
return fmt.Sprintf("/dev/pts/%d", num), nil
|
57
|
+
}
|
58
|
+
|
59
|
+
func grantpt(file *os.File) (err error) {
|
60
|
+
slave_name, err := ptsname(file)
|
61
|
+
if err != nil { return err }
|
62
|
+
err = os.Chown(slave_name, os.Getuid(), os.Getgid())
|
63
|
+
if err != nil { return err }
|
64
|
+
err = os.Chmod(slave_name, 0600)
|
65
|
+
if err != nil { return err }
|
66
|
+
return nil
|
67
|
+
}
|
68
|
+
|
69
|
+
func unlockpt(file *os.File) (err error) {
|
70
|
+
var val = 0
|
71
|
+
err = ioctl(file, syscall.TIOCSPTLCK, &val)
|
72
|
+
|
73
|
+
if err != nil && err.Error() != "errno 0" { return err }
|
74
|
+
return nil
|
75
|
+
}
|
76
|
+
|
77
|
+
/* Borrowed with modifications from github.com/kr/pty/pty_linux.go; MIT license */
|
78
|
+
func ioctl(file *os.File, command uint, data *int) (err syscall.Errno) {
|
79
|
+
_, _, err = syscall.Syscall(syscall.SYS_IOCTL, uintptr(file.Fd()),
|
80
|
+
uintptr(command), uintptr(unsafe.Pointer(data)))
|
81
|
+
if err != 0 {
|
82
|
+
return err
|
83
|
+
}
|
84
|
+
return syscall.Errno(0)
|
85
|
+
} /* ioctl */
|
86
|
+
|
87
|
+
func openpty() (master *os.File, slave *os.File, err error) {
|
88
|
+
master, err = getpt()
|
89
|
+
if err != nil { return nil, nil, err }
|
90
|
+
if err = grantpt(master); err != nil { return nil, nil, err }
|
91
|
+
if err = unlockpt(master); err != nil { return nil, nil, err }
|
92
|
+
|
93
|
+
slave_name, err := ptsname(master)
|
94
|
+
if err != nil { return nil, nil, err }
|
95
|
+
slave, err = os.OpenFile(slave_name, os.O_RDWR, 0)
|
96
|
+
|
97
|
+
return master, slave, nil
|
98
|
+
} /* openpty */
|
99
|
+
|
100
|
+
func dup(file *os.File, name string) (newfile *os.File, err error) {
|
101
|
+
fd, err := syscall.Dup(int(file.Fd()))
|
102
|
+
if err != nil { return nil, err }
|
103
|
+
|
104
|
+
return os.NewFile(uintptr(fd), "<stdin>"), nil
|
105
|
+
}
|
106
|
+
|
107
|
+
func forkpty(name string, argv []string, attr *os.ProcAttr) (master *os.File, command *exec.Cmd, err error) {
|
108
|
+
master, slave, err := openpty()
|
109
|
+
if err != nil { return nil, nil, err }
|
110
|
+
|
111
|
+
/* dup it up. */
|
112
|
+
|
113
|
+
fd := [3]*os.File{slave, slave, slave}
|
114
|
+
attr.Files = fd[:]
|
115
|
+
|
116
|
+
command = new(exec.Cmd)
|
117
|
+
//command.Path = name
|
118
|
+
//command.Args = argv[:]
|
119
|
+
command.Stdin, err = dup(slave, "<slave stdin>")
|
120
|
+
command.Stdout, err = dup(slave, "<slave stdout>")
|
121
|
+
command.Stderr, err = dup(slave, "<slave stderr>")
|
122
|
+
//command.Stdout = slave
|
123
|
+
//command.Stderr = slave
|
124
|
+
command.Process, err = os.StartProcess(name, argv, attr)
|
125
|
+
slave.Close()
|
126
|
+
if err != nil { return nil, nil, err }
|
127
|
+
|
128
|
+
/* Now in the parent */
|
129
|
+
command.Stdin, err = dup(master, "<stdin>")
|
130
|
+
command.Stdout, err = dup(master, "<stdout>")
|
131
|
+
command.Stderr, err = dup(master, "<stderr>")
|
132
|
+
//command.Stdin = master
|
133
|
+
//command.Stdout = master
|
134
|
+
//command.Stderr = master
|
135
|
+
//master.Close()
|
136
|
+
|
137
|
+
if err != nil { return nil, nil, err }
|
138
|
+
return master, command, nil
|
139
|
+
}
|
140
|
+
|
141
|
+
func main() {
|
142
|
+
master, command, err := forkpty("/bin/bash", []string{"/bin/bash", "-li"}, new(os.ProcAttr))
|
143
|
+
|
144
|
+
if err != nil { fmt.Printf("forkpty: %v\n", err); return }
|
145
|
+
fmt.Printf("%T/%v %s\n", master, master, master.Name())
|
146
|
+
|
147
|
+
go func() {
|
148
|
+
for {
|
149
|
+
data := make([]byte, 1024)
|
150
|
+
_, err := command.Stdout.(io.Reader).Read(data)
|
151
|
+
if err != nil { return }
|
152
|
+
//fmt.Printf("Read: %d '%#v'\n", size, fmt.Sprintf("%.*s", size, data))
|
153
|
+
os.Stdout.Write(data)
|
154
|
+
//time.Sleep(500 * time.Millisecond)
|
155
|
+
}
|
156
|
+
}()
|
157
|
+
|
158
|
+
time.Sleep(500 * time.Millisecond)
|
159
|
+
_, err = command.Stdin.(*os.File).WriteString("echo hello world\n")
|
160
|
+
if err != nil { fmt.Printf("Fprintf: %v\n", err); return }
|
161
|
+
time.Sleep(500 * time.Millisecond)
|
162
|
+
_, err = command.Stdin.(*os.File).WriteString("tty\n")
|
163
|
+
if err != nil { fmt.Printf("Fprintf: %v\n", err); return }
|
164
|
+
time.Sleep(500 * time.Millisecond)
|
165
|
+
//_, err = command.Stdin.(*os.File).WriteString("exit\n")
|
166
|
+
//if err != nil { fmt.Printf("Fprintf: %v\n", err); return }
|
167
|
+
master.Close()
|
168
|
+
|
169
|
+
/* It would be nice if this actually closed stdin for the subcommand */
|
170
|
+
command.Stdin.(*os.File).Close()
|
171
|
+
fmt.Printf("Wait: %v\n", command.Wait())
|
172
|
+
|
173
|
+
}
|
data/shatty.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
require "clamp"
|
2
|
+
require "pty"
|
3
|
+
require "ftw"
|
4
|
+
require "uuidtools"
|
5
|
+
|
6
|
+
class Shatty < Clamp::Command
|
7
|
+
subcommand "record", "Record a command" do
|
8
|
+
option ["-o", "--output"], "PATH_OR_URL",
|
9
|
+
"where to output the recording to (a path or url)"
|
10
|
+
option "--headless", :flag,
|
11
|
+
"headless mode; don't output anything to stdout."
|
12
|
+
parameter "COMMAND ...", "The command to run",
|
13
|
+
:attribute_name => :command
|
14
|
+
|
15
|
+
def execute
|
16
|
+
start = Time.now
|
17
|
+
|
18
|
+
if output.nil?
|
19
|
+
output = "http://r.logstash.net:8200/s/#{UUIDTools::UUID.random_create}"
|
20
|
+
puts "Sending output to: #{output}"
|
21
|
+
end
|
22
|
+
|
23
|
+
if output =~ /^https?:/
|
24
|
+
agent = FTW::Agent.new
|
25
|
+
stream, out = IO::pipe
|
26
|
+
Thread.new {
|
27
|
+
response = agent.post!(output, :body => stream)
|
28
|
+
# TODO(sissel): Shouldn't get here...
|
29
|
+
}
|
30
|
+
else
|
31
|
+
# no http/https, assume file output.
|
32
|
+
out = File.new(output, "w")
|
33
|
+
end
|
34
|
+
|
35
|
+
# binary mode.
|
36
|
+
buffer = ""
|
37
|
+
|
38
|
+
STDOUT.sync = true
|
39
|
+
terminal, keyboard, pid = PTY.spawn(*command)
|
40
|
+
system("stty raw -echo") # yeah, perhaps we should use termios instead.
|
41
|
+
|
42
|
+
# Dump stdin to the tty's keyboard
|
43
|
+
# We could use reopen here, but the 'keyboard' io has mode read/write.
|
44
|
+
Thread.new { STDIN.each_char { |c| keyboard.syswrite(c) } }
|
45
|
+
|
46
|
+
while true
|
47
|
+
# Read from the terminal output and compute the time offset
|
48
|
+
begin
|
49
|
+
terminal.sysread(16834, buffer)
|
50
|
+
rescue Errno::EIO => e
|
51
|
+
Process.waitpid(pid)
|
52
|
+
puts "Command exited with code: #{$?.exitstatus}"
|
53
|
+
break
|
54
|
+
end
|
55
|
+
|
56
|
+
time_offset = Time.now - start
|
57
|
+
|
58
|
+
# for each chunk of text read from tmux, record
|
59
|
+
# the timestamp (duration since 'start' of recording)
|
60
|
+
out.syswrite([time_offset.to_f, buffer.length, buffer].pack("GNA#{buffer.length}"))
|
61
|
+
|
62
|
+
$stdout.write(buffer) unless headless?
|
63
|
+
end
|
64
|
+
|
65
|
+
system("stty sane")
|
66
|
+
end # def execute
|
67
|
+
end # subcommand "record"
|
68
|
+
|
69
|
+
subcommand "play", "Play a recording" do
|
70
|
+
parameter "[PATH_OR_URL]",
|
71
|
+
"The recording to play. This can be a path or URL.",
|
72
|
+
:attribute_name => :path
|
73
|
+
|
74
|
+
def execute
|
75
|
+
# TODO(sissel): Don't abort :(
|
76
|
+
Thread.abort_on_exception = true
|
77
|
+
|
78
|
+
if path =~ /^https?:/
|
79
|
+
input, writer = IO::pipe
|
80
|
+
Thread.new do
|
81
|
+
agent = FTW::Agent.new
|
82
|
+
response = agent.get!(path)
|
83
|
+
response.read_http_body { |chunk| writer.syswrite(chunk) }
|
84
|
+
end
|
85
|
+
else
|
86
|
+
input = File.new(path, "w")
|
87
|
+
end
|
88
|
+
|
89
|
+
start = nil
|
90
|
+
|
91
|
+
$stdout.sync = true
|
92
|
+
headersize = [1,1].pack("GN").size
|
93
|
+
last_time = 0
|
94
|
+
while true
|
95
|
+
# Read the header
|
96
|
+
begin
|
97
|
+
buffer = input.read(headersize)
|
98
|
+
time, length = buffer.unpack("GN")
|
99
|
+
buffer = input.read(length)
|
100
|
+
rescue EOFError
|
101
|
+
break
|
102
|
+
end
|
103
|
+
|
104
|
+
# Sleep if necessary
|
105
|
+
#sleep(time - last_time) if last_time > 0
|
106
|
+
last_time = time
|
107
|
+
|
108
|
+
# output this frame.
|
109
|
+
$stdout.syswrite(buffer)
|
110
|
+
end
|
111
|
+
end # def execute
|
112
|
+
end # subcommand "play"
|
113
|
+
end # class Shatty
|
114
|
+
|
115
|
+
Shatty.run
|
data/web.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
require "ftw" # gem ftw
|
2
|
+
require "cabin" # gem cabin
|
3
|
+
require "thread"
|
4
|
+
|
5
|
+
ShutdownSignal = :shutdown
|
6
|
+
|
7
|
+
class Session
|
8
|
+
def initialize
|
9
|
+
@queue = Queue.new
|
10
|
+
@recent = []
|
11
|
+
end # def initialize
|
12
|
+
|
13
|
+
def <<(chunk)
|
14
|
+
@queue << chunk
|
15
|
+
@recent << chunk
|
16
|
+
@recent = @recent[0..100]
|
17
|
+
end # def push
|
18
|
+
|
19
|
+
def enumerator
|
20
|
+
return Enumerator.new do |y|
|
21
|
+
@recent.each { |chunk| y << chunk }
|
22
|
+
while true
|
23
|
+
chunk = @queue.pop
|
24
|
+
break if chunk == ShutdownSignal
|
25
|
+
y << chunk
|
26
|
+
end
|
27
|
+
end # Enumerator
|
28
|
+
end # def enumerator
|
29
|
+
|
30
|
+
def raw
|
31
|
+
return Enumerator.new do |y|
|
32
|
+
enumerator.each do |chunk|
|
33
|
+
puts decode(chunk).inspect
|
34
|
+
y << decode(chunk)
|
35
|
+
end
|
36
|
+
end # Enumerator
|
37
|
+
end # def enumerator
|
38
|
+
|
39
|
+
def decode(chunk)
|
40
|
+
return chunk[headersize .. -1]
|
41
|
+
end # def decode
|
42
|
+
|
43
|
+
def close
|
44
|
+
@queue << ShutdownSignal
|
45
|
+
end
|
46
|
+
end # class Session
|
47
|
+
|
48
|
+
sessions = {}
|
49
|
+
|
50
|
+
port = ENV.include?("PORT") ? ENV["PORT"].to_i : 8888
|
51
|
+
server = FTW::WebServer.new("0.0.0.0", port) do |request, response|
|
52
|
+
@logger = Cabin::Channel.get
|
53
|
+
if request.path =~ /^\/s\//
|
54
|
+
session = sessions[request.path] ||= Session.new
|
55
|
+
if request.method == "POST"
|
56
|
+
# TODO(sissel): Check if a session exists.
|
57
|
+
|
58
|
+
begin
|
59
|
+
request.read_http_body do |chunk|
|
60
|
+
session << chunk
|
61
|
+
end
|
62
|
+
rescue EOFError
|
63
|
+
end
|
64
|
+
session.close
|
65
|
+
sessions.delete(request.path)
|
66
|
+
elsif request.method == "GET"
|
67
|
+
response.status = 200
|
68
|
+
response["Content-Type"] = "text/plain"
|
69
|
+
if request["user-agent"] =~ /^curl\/[0-9]/
|
70
|
+
# Curl. Send raw text.
|
71
|
+
response.body = session.raw
|
72
|
+
else
|
73
|
+
response.body = session.enumerator
|
74
|
+
end
|
75
|
+
else
|
76
|
+
response.status = 400
|
77
|
+
response.body = "Invalid method '#{request.method}'\n"
|
78
|
+
end
|
79
|
+
else
|
80
|
+
response.status = 404
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
server.run
|
metadata
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: shatty
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Jordan Sissel
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-11-07 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: cabin
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>'
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>'
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
description: shatty
|
31
|
+
email:
|
32
|
+
- jls@semicomplete.com
|
33
|
+
executables: []
|
34
|
+
extensions: []
|
35
|
+
extra_rdoc_files: []
|
36
|
+
files:
|
37
|
+
- Gemfile
|
38
|
+
- Gemfile.lock
|
39
|
+
- Procfile
|
40
|
+
- README.md
|
41
|
+
- examples/blinkenlights.shatty
|
42
|
+
- examples/output.shatty
|
43
|
+
- misc/shatty.go
|
44
|
+
- shatty.rb
|
45
|
+
- web.rb
|
46
|
+
homepage:
|
47
|
+
licenses:
|
48
|
+
- none chosen yet
|
49
|
+
post_install_message:
|
50
|
+
rdoc_options: []
|
51
|
+
require_paths:
|
52
|
+
- lib
|
53
|
+
- lib
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
55
|
+
none: false
|
56
|
+
requirements:
|
57
|
+
- - ! '>='
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '0'
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
requirements: []
|
67
|
+
rubyforge_project:
|
68
|
+
rubygems_version: 1.8.24
|
69
|
+
signing_key:
|
70
|
+
specification_version: 3
|
71
|
+
summary: shatty
|
72
|
+
test_files: []
|