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.
@@ -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
@@ -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
@@ -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