appear 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.doc-coverage +1 -0
- data/.gitignore +51 -0
- data/.rspec +2 -0
- data/.travis.yml +19 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +53 -0
- data/appear.gemspec +44 -0
- data/bin/appear +7 -0
- data/bin/console +14 -0
- data/bin/pparents +23 -0
- data/bin/setup +7 -0
- data/lib/appear.rb +14 -0
- data/lib/appear/command.rb +57 -0
- data/lib/appear/config.rb +24 -0
- data/lib/appear/constants.rb +17 -0
- data/lib/appear/instance.rb +54 -0
- data/lib/appear/join.rb +100 -0
- data/lib/appear/lsof.rb +153 -0
- data/lib/appear/mac_os.rb +42 -0
- data/lib/appear/output.rb +30 -0
- data/lib/appear/processes.rb +88 -0
- data/lib/appear/revealers.rb +183 -0
- data/lib/appear/runner.rb +101 -0
- data/lib/appear/service.rb +65 -0
- data/lib/appear/tmux.rb +77 -0
- data/screenshot.gif +0 -0
- data/tools/macOS-helper.js +349 -0
- metadata +152 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
module Appear
|
2
|
+
# all the adjustable options for a Appear::Instance
|
3
|
+
class Config
|
4
|
+
# if set, the Appear::Instance will log debug information to this file.
|
5
|
+
# @return [String, nil] default nil
|
6
|
+
attr_accessor :log_file
|
7
|
+
|
8
|
+
# if false, the Appear::Instance will log debug information to STDERR.
|
9
|
+
# @return [Boolean] default true
|
10
|
+
attr_accessor :silent
|
11
|
+
|
12
|
+
# Record everything executed by Runner service to spec/command_output.
|
13
|
+
# Intended for generating test cases.
|
14
|
+
#
|
15
|
+
# @return [Boolean] default false
|
16
|
+
attr_accessor :record_runs
|
17
|
+
|
18
|
+
# sets defaults
|
19
|
+
def initialize
|
20
|
+
self.silent = true
|
21
|
+
self.record_runs = false
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
|
3
|
+
module Appear
|
4
|
+
VERSION = '1.0.0'
|
5
|
+
|
6
|
+
# root error for our library; all other errors inherit from this one.
|
7
|
+
class Error < StandardError; end
|
8
|
+
|
9
|
+
# we also put common constants in here because it's a good spot.
|
10
|
+
# Should we rename this file to 'appear/constants' ?
|
11
|
+
|
12
|
+
# the root of the Appear project directory
|
13
|
+
MODULE_DIR = Pathname.new(__FILE__).realpath.join('../../..')
|
14
|
+
|
15
|
+
# where we look for os-specific helper files
|
16
|
+
TOOLS_DIR = MODULE_DIR.join('tools')
|
17
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'appear/constants'
|
3
|
+
require 'appear/service'
|
4
|
+
|
5
|
+
require 'appear/output'
|
6
|
+
require 'appear/processes'
|
7
|
+
require 'appear/runner'
|
8
|
+
require 'appear/lsof'
|
9
|
+
require 'appear/mac_os'
|
10
|
+
require 'appear/tmux'
|
11
|
+
require 'appear/revealers'
|
12
|
+
|
13
|
+
module Appear
|
14
|
+
class CannotRevealError < Error; end
|
15
|
+
class NoGuiError < CannotRevealError; end
|
16
|
+
|
17
|
+
# Instance is the main class in Appear. It constructs all the other services
|
18
|
+
# and co-ordinates the actual revealing process.
|
19
|
+
class Instance < Service
|
20
|
+
delegate :process_tree, :processes
|
21
|
+
|
22
|
+
def initialize(config)
|
23
|
+
@config = config
|
24
|
+
|
25
|
+
# provide a reference to this revealer instance so that sub-revealers can
|
26
|
+
# appear other process trees, if needed. Our Tmux revealer uses this
|
27
|
+
# service to appear the tmux client.
|
28
|
+
@all_services = { :revealer => self }
|
29
|
+
|
30
|
+
# instantiate all our various services
|
31
|
+
@all_services[:output] = Appear::Output.new(@config.log_file, @config.silent)
|
32
|
+
@all_services[:runner] = Appear::Runner.new(@all_services)
|
33
|
+
@all_services[:runner] = Appear::RunnerRecorder.new(@all_services) if @config.record_runs
|
34
|
+
@all_services[:processes] = Appear::Processes.new(@all_services)
|
35
|
+
@all_services[:lsof] = Appear::Lsof.new(@all_services)
|
36
|
+
@all_services[:mac_os] = Appear::MacOs.new(@all_services)
|
37
|
+
@all_services[:tmux] = Appear::Tmux.new(@all_services)
|
38
|
+
|
39
|
+
# make sure we can use our processes service, and log stuff.
|
40
|
+
super(@all_services)
|
41
|
+
end
|
42
|
+
|
43
|
+
def call(pid)
|
44
|
+
tree = process_tree(pid)
|
45
|
+
|
46
|
+
statuses = ::Appear::REVEALERS.map do |klass|
|
47
|
+
revealer = klass.new(@all_services)
|
48
|
+
revealer.call(tree)
|
49
|
+
end
|
50
|
+
|
51
|
+
statuses.any? { |status| !status.nil? }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
data/lib/appear/join.rb
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
module Appear
|
2
|
+
# join objects based on hash value or method value.
|
3
|
+
#
|
4
|
+
# example:
|
5
|
+
#
|
6
|
+
# ```
|
7
|
+
# foos = many_foos
|
8
|
+
# bars = many_bars
|
9
|
+
# foo_bars = Join.join(:common_attribute, foos, bars)
|
10
|
+
#
|
11
|
+
# # can still access all the properties on either a foo or a bar
|
12
|
+
# foo_bars.first.common_attribute
|
13
|
+
#
|
14
|
+
# # can access attributes by symbol, too
|
15
|
+
# foo_bars.first[:something_else]
|
16
|
+
# ```
|
17
|
+
#
|
18
|
+
# foo_bars is an array of Join instances. Reads from a foo_bar will read
|
19
|
+
# first from the foo, and then from the bar - this is based on the order of
|
20
|
+
# "tables" passed to Join.join().
|
21
|
+
class Join
|
22
|
+
# @param field [Symbol] the method or hash field name to join on.
|
23
|
+
# @param tables [Array<Any>] arrays of any sort of object, so long as it is
|
24
|
+
# either a hash, or implements the given field.
|
25
|
+
# @return [Array<Join>]
|
26
|
+
def self.join(field, *tables)
|
27
|
+
by_field = Hash.new { |h, k| h[k] = self.new }
|
28
|
+
|
29
|
+
tables.each do |table|
|
30
|
+
table.each do |row|
|
31
|
+
field_value = access(row, field)
|
32
|
+
joined = by_field[field_value]
|
33
|
+
joined.push!(row)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
by_field.values.select do |joined|
|
38
|
+
joined.joined_count >= tables.length
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.can_access?(obj, field)
|
43
|
+
if obj.respond_to?(field)
|
44
|
+
return true
|
45
|
+
elsif obj.respond_to?(:[])
|
46
|
+
return true
|
47
|
+
end
|
48
|
+
return false
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.access(obj, field)
|
52
|
+
if obj.respond_to?(field)
|
53
|
+
obj.send(field)
|
54
|
+
elsif obj.respond_to?(:[])
|
55
|
+
obj[field]
|
56
|
+
else
|
57
|
+
raise "cannot access #{field.inspect} on #{object.inspect}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# an instance of Join is a joined object containing all the data in all its
|
62
|
+
# parts. Joins are read from left to right, returning the first non-nil
|
63
|
+
# value encountered.
|
64
|
+
def initialize(*objs)
|
65
|
+
@objs = objs
|
66
|
+
end
|
67
|
+
|
68
|
+
def push!(obj, note = nil)
|
69
|
+
@objs << obj
|
70
|
+
end
|
71
|
+
|
72
|
+
def joined_count
|
73
|
+
@objs.length
|
74
|
+
end
|
75
|
+
|
76
|
+
def [](sym)
|
77
|
+
result = nil
|
78
|
+
|
79
|
+
@objs.each do |obj|
|
80
|
+
if self.class.can_access?(obj, sym)
|
81
|
+
result = self.class.access(obj, sym)
|
82
|
+
end
|
83
|
+
break unless result.nil?
|
84
|
+
end
|
85
|
+
|
86
|
+
result
|
87
|
+
end
|
88
|
+
|
89
|
+
def method_missing(method, *args, &block)
|
90
|
+
raise NoMethodError.new("Cannot access #{method.inspect}") unless respond_to?(method)
|
91
|
+
raise ArgumentError.new("Passed args to accessor") if args.length > 0
|
92
|
+
raise ArgumentError.new("Passed block to accessor") if block
|
93
|
+
self[method]
|
94
|
+
end
|
95
|
+
|
96
|
+
def respond_to?(sym, priv = false)
|
97
|
+
super(sym, priv) || (@objs.any? { |o| self.class.can_access?(o, sym) })
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
data/lib/appear/lsof.rb
ADDED
@@ -0,0 +1,153 @@
|
|
1
|
+
require 'appear/service'
|
2
|
+
require 'pry'
|
3
|
+
|
4
|
+
module Appear
|
5
|
+
# The LSOF service co-ordinates access to the `lsof` system utility. LSOF
|
6
|
+
# stands for "list open files". It can read the "connections" various
|
7
|
+
# programs have to a given file, eg, what programs have a file descriptor for
|
8
|
+
# a file.
|
9
|
+
class Lsof < Service
|
10
|
+
attr_reader :cache
|
11
|
+
|
12
|
+
delegate :run, :runner
|
13
|
+
|
14
|
+
# A connection of a process to a file.
|
15
|
+
# Created from one output row of `lsof`.
|
16
|
+
class Connection
|
17
|
+
attr_accessor :command_name, :pid, :user, :fd, :type, :device, :size, :node, :name, :file_name
|
18
|
+
def initialize(hash)
|
19
|
+
hash.each do |key, value|
|
20
|
+
send("#{key}=", value)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Represents a pane's connection to a TTY.
|
26
|
+
class PaneConnection
|
27
|
+
attr_reader :pane, :connection, :process
|
28
|
+
# @param pane [#tty] a pane in a terminal emulator
|
29
|
+
# @param connection [Appear::Lsof::Connection] a connection of a process
|
30
|
+
# to a file -- usually a TTY device.
|
31
|
+
# @param process [Appear::Processes::ProcessInfo] a process
|
32
|
+
def initialize(pane, connection, process)
|
33
|
+
@pane = pane
|
34
|
+
@connection = connection
|
35
|
+
@process = process
|
36
|
+
end
|
37
|
+
|
38
|
+
def tty
|
39
|
+
connection.file_name
|
40
|
+
end
|
41
|
+
|
42
|
+
def pid
|
43
|
+
connection.pid
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def initialize(*args)
|
48
|
+
super(*args)
|
49
|
+
@cache = {}
|
50
|
+
end
|
51
|
+
|
52
|
+
# find any intersections where a process in the given tree is present in
|
53
|
+
# one of the terminal emulator panes. Performs an LSOF lookup on the TTY of
|
54
|
+
# each pane. Returns cases where one of the panes' ttys also have a
|
55
|
+
# connection from a process in the process tree.
|
56
|
+
#
|
57
|
+
# This is much faster if each pane includes a .pids method that returns an
|
58
|
+
# array of PIDs that could be the PID of the terminal emulator with that pane
|
59
|
+
#
|
60
|
+
# @param tree [Array<Process>]
|
61
|
+
# @param panes [Array<Pane>]
|
62
|
+
# @return [Array<PaneConnection>]
|
63
|
+
def join_via_tty(tree, panes)
|
64
|
+
hitlist = {}
|
65
|
+
tree.each do |process|
|
66
|
+
hitlist[process.pid] = process
|
67
|
+
end
|
68
|
+
|
69
|
+
ttys = panes.map(&:tty)
|
70
|
+
if panes.all? {|p| p.respond_to?(:pids) }
|
71
|
+
puts "using pids in join_via_tty"
|
72
|
+
pids = hitlist.keys + panes.map(&:pids).flatten
|
73
|
+
# binding.pry
|
74
|
+
lsofs = lsofs(ttys, :pids => pids)
|
75
|
+
else
|
76
|
+
lsofs = lsofs(ttys)
|
77
|
+
end
|
78
|
+
|
79
|
+
hits = {}
|
80
|
+
panes.each do |pane|
|
81
|
+
connections = lsofs[pane.tty]
|
82
|
+
connections.each do |conn|
|
83
|
+
process = hitlist[conn.pid]
|
84
|
+
if process
|
85
|
+
hits[conn.pid] = PaneConnection.new(pane, conn, process)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
hits.values
|
91
|
+
end
|
92
|
+
|
93
|
+
# list connections to files
|
94
|
+
#
|
95
|
+
# @param files [Array<String>] files to query
|
96
|
+
# @return [Hash<String, Array<Connection>>] map of filename to connections
|
97
|
+
def lsofs(files, opts = {})
|
98
|
+
cached = files.select { |f| @cache[f] }
|
99
|
+
uncached = files.reject { |f| @cache[f] }
|
100
|
+
|
101
|
+
result = parallel_lsof(uncached, opts)
|
102
|
+
result.each do |file, data|
|
103
|
+
@cache[file] = data
|
104
|
+
end
|
105
|
+
|
106
|
+
cached.each do |f|
|
107
|
+
result[f] = @cache[f]
|
108
|
+
end
|
109
|
+
|
110
|
+
result
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
# lsof takes a really long time, so parallelize lookups when you can.
|
116
|
+
def parallel_lsof(files, opts = {})
|
117
|
+
results = {}
|
118
|
+
threads = files.map do |file|
|
119
|
+
Thread.new do
|
120
|
+
results[file] = lsof(file, opts)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
threads.each { |t| t.join }
|
124
|
+
results
|
125
|
+
end
|
126
|
+
|
127
|
+
def lsof(file, opts = {})
|
128
|
+
pids = opts[:pids]
|
129
|
+
if pids
|
130
|
+
output = run(['lsof', '-ap', pids.join(','), file])
|
131
|
+
else
|
132
|
+
output = run("lsof #{file.shellescape}")
|
133
|
+
end
|
134
|
+
rows = output.lines.map do |line|
|
135
|
+
command, pid, user, fd, type, device, size, node, name = line.strip.split(/\s+/)
|
136
|
+
Connection.new({
|
137
|
+
command_name: command,
|
138
|
+
pid: pid.to_i,
|
139
|
+
user: user,
|
140
|
+
fd: fd,
|
141
|
+
type: type,
|
142
|
+
device: device,
|
143
|
+
size: size,
|
144
|
+
node: node,
|
145
|
+
file_name: name
|
146
|
+
})
|
147
|
+
end
|
148
|
+
rows[1..-1]
|
149
|
+
rescue Appear::ExecutionFailure
|
150
|
+
[]
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
require 'appear/constants'
|
4
|
+
require 'appear/service'
|
5
|
+
|
6
|
+
module Appear
|
7
|
+
class MacToolError < Error
|
8
|
+
def initialize(message, stack)
|
9
|
+
super("Mac error #{message.inspect}\n#{stack}")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# The MacOs service handles macOS-specific concerns; mostly running our
|
14
|
+
# companion macOS helper tool.
|
15
|
+
class MacOs < Service
|
16
|
+
delegate :run, :runner
|
17
|
+
|
18
|
+
# the "realpath" part is basically an assertion that this file exists.
|
19
|
+
SCRIPT = Appear::TOOLS_DIR.join('macOS-helper.js').realpath.to_s
|
20
|
+
|
21
|
+
# call a method in our helper script. Communicates with JSON!
|
22
|
+
def call_method(method_name, data = nil)
|
23
|
+
command = [SCRIPT, method_name.to_s]
|
24
|
+
command << data.to_json unless data.nil?
|
25
|
+
output = run(command)
|
26
|
+
result = JSON.load(output.lines.last.strip)
|
27
|
+
|
28
|
+
if result["status"] == "error"
|
29
|
+
raise MacToolError.new(result["error"]["message"], result["error"]["stack"])
|
30
|
+
else
|
31
|
+
result["value"]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# TODO: ask Applescript if this a GUI application instead of just looking
|
36
|
+
# at the path
|
37
|
+
def has_gui?(process)
|
38
|
+
executable = process.command.first
|
39
|
+
executable =~ /\.app\/Contents\//
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'appear/service'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
module Appear
|
5
|
+
# The Output service encapsulates writing logging information to log files
|
6
|
+
# and STDERR, and writing output to STDOUT.
|
7
|
+
class Output
|
8
|
+
def initialize(log_file_name, silent)
|
9
|
+
@file_logger = Logger.new(log_file_name.to_s) if log_file_name
|
10
|
+
@stderr_logger = Logger.new(STDERR) unless silent
|
11
|
+
end
|
12
|
+
|
13
|
+
def log(*any)
|
14
|
+
@stderr_logger.debug(*any) if @stderr_logger
|
15
|
+
@file_logger.debug(*any) if @file_logger
|
16
|
+
end
|
17
|
+
|
18
|
+
def log_error(err)
|
19
|
+
log("Error #{err.inspect}: #{err.to_s.inspect}")
|
20
|
+
if err.backtrace
|
21
|
+
err.backtrace.each { |line| log(" " + line) }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def output(*any)
|
26
|
+
STDOUT.puts(*any)
|
27
|
+
@file_logger.debug(*any) if @file_logger
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'appear/constants'
|
2
|
+
require 'appear/service'
|
3
|
+
|
4
|
+
module Appear
|
5
|
+
# Raised if Processes tries to get info for a dead process, or a PID that is
|
6
|
+
# otherwise not found.
|
7
|
+
class DeadProcess < Error; end
|
8
|
+
|
9
|
+
# The Processes service handles looking up information about a system
|
10
|
+
# process. It mostly interacts with the `ps` system utility.
|
11
|
+
class Processes < Service
|
12
|
+
delegate :run, :runner
|
13
|
+
|
14
|
+
# contains information about a process. Returned by Processes#get_info and
|
15
|
+
# its derivatives.
|
16
|
+
class ProcessInfo
|
17
|
+
attr_accessor :pid, :command, :name, :parent_pid
|
18
|
+
def initialize(hash)
|
19
|
+
hash.each do |key, value|
|
20
|
+
send("#{key}=", value)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(*args)
|
26
|
+
super(*args)
|
27
|
+
@cache = {}
|
28
|
+
end
|
29
|
+
|
30
|
+
# Get info about a process by PID, including its command and parent_pid.
|
31
|
+
#
|
32
|
+
# @param pid [Integer]
|
33
|
+
# @return [ProcessInfo]
|
34
|
+
def get_info(pid)
|
35
|
+
result = @cache[pid]
|
36
|
+
unless result
|
37
|
+
result = fetch_info(pid)
|
38
|
+
@cache[pid] = result
|
39
|
+
end
|
40
|
+
result
|
41
|
+
end
|
42
|
+
|
43
|
+
# Is the given process alive?
|
44
|
+
#
|
45
|
+
# @param pid [Integer]
|
46
|
+
# @return [Boolean]
|
47
|
+
def alive?(pid)
|
48
|
+
begin
|
49
|
+
::Process.getpgid(pid)
|
50
|
+
true
|
51
|
+
rescue Errno::ESRCH
|
52
|
+
false
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# look up all the processes between the given pid and PID 1
|
57
|
+
# @param pid [Number]
|
58
|
+
# @return [Array<ProcessInfo>]
|
59
|
+
def process_tree(pid)
|
60
|
+
tree = [ get_info(pid) ]
|
61
|
+
while tree.last.pid > 1 && tree.last.parent_pid != 0
|
62
|
+
tree << get_info(tree.last.parent_pid)
|
63
|
+
end
|
64
|
+
tree
|
65
|
+
end
|
66
|
+
|
67
|
+
# @param pattern [String]
|
68
|
+
# @return [Array<Integer>] pids found
|
69
|
+
def pgrep(pattern)
|
70
|
+
output = run(['pgrep', '-lf', pattern])
|
71
|
+
output.lines.map do |line|
|
72
|
+
line.strip.split(/\s+/).first.to_i
|
73
|
+
end
|
74
|
+
rescue Appear::ExecutionFailure
|
75
|
+
[]
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def fetch_info(pid)
|
81
|
+
raise DeadProcess.new("cannot fetch info for dead PID #{pid}") unless alive?(pid)
|
82
|
+
output = run(['ps', '-p', pid.to_s, '-o', 'ppid=', '-o', 'command='])
|
83
|
+
ppid, *command = output.strip.split(/\s+/).reject(&:empty?)
|
84
|
+
name = File.basename(command.first)
|
85
|
+
ProcessInfo.new({:pid => pid.to_i, :parent_pid => ppid.to_i, :command => command, :name => name})
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|