mac-wifi 2.0.0 → 2.1.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,221 @@
1
+ require 'tempfile'
2
+ require_relative '../mac-wifi'
3
+
4
+ module MacWifi
5
+
6
+ class BaseModel
7
+
8
+ class OsCommandError < RuntimeError
9
+ attr_reader :exitstatus, :command, :text
10
+
11
+ def initialize(exitstatus, command, text)
12
+ @exitstatus = exitstatus
13
+ @command = command
14
+ @text = text
15
+ end
16
+ end
17
+
18
+
19
+ def initialize(verbose = false)
20
+ @verbose_mode = verbose
21
+ end
22
+
23
+
24
+ def run_os_command(command)
25
+ output = `#{command} 2>&1` # join stderr with stdout
26
+ if $?.exitstatus != 0
27
+ raise OsCommandError.new($?.exitstatus, command, output)
28
+ end
29
+ if @verbose_mode
30
+ puts "\n\n#{'-' * 79}\nCommand: #{command}\n\nOutput:\n#{output}#{'-' * 79}\n\n"
31
+ end
32
+ output
33
+ end
34
+ private :run_os_command
35
+
36
+
37
+ # This method returns whether or not there is a working Internet connection.
38
+ # Because of a Mac issue which causes a request to hang if the network is turned
39
+ # off during its lifetime, we give it only 5 seconds per try,
40
+ # and limit the number of tries to 3.
41
+ #
42
+ # This implementation will probably strike you as overly complex. The following
43
+ # code looks like it is all that should be necessary, but unfortunately
44
+ # this implementation often hangs when wifi is turned off while curl is active
45
+ #
46
+ # def connected_to_internet?
47
+ # script = "curl --silent --head http://www.google.com/ > /dev/null ; echo $?"
48
+ # result = `#{script}`.chomp
49
+ # puts result
50
+ # result == '0'
51
+ # end
52
+
53
+ # TODO Investigate using Curl options: --connect-timeout 1 --max-time 2 --retry 0
54
+ # to greatly simplify this method.
55
+ def connected_to_internet?
56
+
57
+ tempfile = Tempfile.open('mac-wifi-')
58
+
59
+ begin
60
+ start_status_script = -> do
61
+ script = "curl --silent --head http://www.google.com/ > /dev/null ; echo $? > #{tempfile.path} &"
62
+ pid = Process.spawn(script)
63
+ Process.detach(pid)
64
+ pid
65
+ end
66
+
67
+ process_is_running = ->(pid) do
68
+ script = %Q{ps -p #{pid} > /dev/null; echo $?}
69
+ output = `#{script}`.chomp
70
+ output == "0"
71
+ end
72
+
73
+ get_connected_state_from_curl = -> do
74
+ tempfile.close
75
+ File.read(tempfile.path).chomp == '0'
76
+ end
77
+
78
+ # Do one run, iterating during the timeout period to see if the command has completed
79
+ do_one_run = -> do
80
+ end_time = Time.now + 3
81
+ pid = start_status_script.()
82
+ while Time.now < end_time
83
+ if process_is_running.(pid)
84
+ sleep 0.5
85
+ else
86
+ return get_connected_state_from_curl.()
87
+ end
88
+ end
89
+ Process.kill('KILL', pid) if process_is_running.(pid)
90
+ :hung
91
+ end
92
+
93
+ 3.times do
94
+ connected = do_one_run.()
95
+ return connected if connected != :hung
96
+ end
97
+
98
+ raise "Could not determine Internet status."
99
+
100
+ ensure
101
+ tempfile.unlink
102
+ end
103
+
104
+ end
105
+
106
+
107
+ # Turns wifi off and then on, reconnecting to the originally connecting network.
108
+ def cycle_network
109
+ # TODO: Make this network name saving and restoring conditional on it not having a password.
110
+ # If the disabled code below is enabled, an error will be raised if a password is required,
111
+ # even though it is stored.
112
+ # network_name = current_network
113
+ wifi_off
114
+ wifi_on
115
+ # connect(network_name) if network_name
116
+ end
117
+
118
+
119
+ def connected_to?(network_name)
120
+ network_name == connected_network_name
121
+ end
122
+
123
+
124
+ # Connects to the passed network name, optionally with password.
125
+ # Turns wifi on first, in case it was turned off.
126
+ # Relies on subclass implementation of os_level_connect().
127
+ def connect(network_name, password = nil)
128
+ # Allow symbols and anything responding to to_s for user convenience
129
+ network_name = network_name.to_s if network_name
130
+ password = password.to_s if password
131
+
132
+ if network_name.nil? || network_name.empty?
133
+ raise "A network name is required but was not provided."
134
+ end
135
+ wifi_on
136
+ os_level_connect(network_name, password)
137
+
138
+ # Verify that the network is now connected:
139
+ actual_network_name = connected_network_name
140
+ unless actual_network_name == network_name
141
+ message = %Q{Expected to connect to "#{network_name}" but }
142
+ if actual_network_name
143
+ message << %Q{connected to "#{connected_network_name}" instead.}
144
+ else
145
+ message << "unable to connect to any network. Did you "
146
+ end
147
+ message << (password ? "provide the correct password?" : "need to provide a password?")
148
+ raise message
149
+ end
150
+ nil
151
+ end
152
+
153
+
154
+ # Removes the specified network(s) from the preferred network list.
155
+ # @param network_names names of networks to remove; may be empty or contain nonexistent networks
156
+ # @return names of the networks that were removed (excludes non-preexisting networks)
157
+ def remove_preferred_networks(*network_names)
158
+ networks_to_remove = network_names & preferred_networks # exclude any nonexistent networks
159
+ networks_to_remove.each { |name| remove_preferred_network(name) }
160
+ end
161
+
162
+
163
+ def preferred_network_password(preferred_network_name)
164
+ preferred_network_name = preferred_network_name.to_s
165
+ if preferred_networks.include?(preferred_network_name)
166
+ os_level_preferred_network_password(preferred_network_name)
167
+ else
168
+ raise "Network #{preferred_network_name} not in preferred networks list."
169
+ end
170
+ end
171
+
172
+
173
+ # Waits for the Internet connection to be in the desired state.
174
+ # @param target_status must be in [:conn, :disc, :off, :on]; waits for that state
175
+ # @param wait_interval_in_secs sleeps this interval between retries; if nil or absent,
176
+ # a default will be provided
177
+ #
178
+ def till(target_status, wait_interval_in_secs = nil)
179
+
180
+ # One might ask, why not just put the 0.5 up there as the default argument.
181
+ # We could do that, but we'd still need the line below in case nil
182
+ # was explicitly specified. The default argument of nil above emphasizes that
183
+ # the absence of an argument and a specification of nil will behave identically.
184
+ wait_interval_in_secs ||= 0.5
185
+
186
+ finished_predicates = {
187
+ conn: -> { connected_to_internet? },
188
+ disc: -> { ! connected_to_internet? },
189
+ on: -> { wifi_on? },
190
+ off: -> { ! wifi_on? }
191
+ }
192
+
193
+ finished_predicate = finished_predicates[target_status]
194
+
195
+ if finished_predicate.nil?
196
+ raise ArgumentError.new(
197
+ "Option must be one of #{finished_predicates.keys.inspect}. Was: #{target_status.inspect}")
198
+ end
199
+
200
+ loop do
201
+ return if finished_predicate.()
202
+ sleep(wait_interval_in_secs)
203
+ end
204
+ end
205
+
206
+
207
+ # Tries an OS command until the stop condition is true.
208
+ # @command the command to run in the OS
209
+ # @stop_condition a lambda taking the commands stdout as its sole parameter
210
+ # @return the stdout produced by the command
211
+ def try_os_command_until(command, stop_condition, max_tries = 100)
212
+ max_tries.times do
213
+ stdout = run_os_command(command)
214
+ if stop_condition.(stdout)
215
+ return stdout
216
+ end
217
+ end
218
+ nil
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,417 @@
1
+ require_relative 'mac_os_model'
2
+
3
+ module MacWifi
4
+
5
+ class CommandLineInterface
6
+
7
+ attr_reader :model, :interactive_mode, :options
8
+
9
+ class Command < Struct.new(:min_string, :max_string, :action); end
10
+
11
+
12
+ class BadCommandError < RuntimeError
13
+ def initialize(error_message)
14
+ super
15
+ end
16
+ end
17
+
18
+
19
+ # Help text to be used when requested by 'h' command, in case of unrecognized or nonexistent command, etc.
20
+ HELP_TEXT = "
21
+ Command Line Switches: [mac-wifi version #{MacWifi::VERSION}]
22
+
23
+ -o[i,j,p,y] - outputs data in inspect, JSON, puts, or YAML format when not in shell mode
24
+ -s - run in shell mode
25
+ -v - verbose mode (prints OS commands and their outputs)
26
+
27
+ Commands:
28
+
29
+ a[vail_nets] - array of names of the available networks
30
+ ci - connected to Internet (not just wifi on)?
31
+ co[nnect] network-name - turns wifi on, connects to network-name
32
+ cy[cle] - turns wifi off, then on, preserving network selection
33
+ d[isconnect] - disconnects from current network, does not turn off wifi
34
+ h[elp] - prints this help
35
+ i[nfo] - a hash of wifi-related information
36
+ l[s_avail_nets] - details about available networks
37
+ n[etwork_name] - name (SSID) of currently connected network
38
+ on - turns wifi on
39
+ of[f] - turns wifi off
40
+ pa[ssword] network-name - password for preferred network-name
41
+ pr[ef_nets] - preferred (not necessarily available) networks
42
+ q[uit] - exits this program (interactive shell mode only) (see also 'x')
43
+ r[m_pref_nets] network-name - removes network-name from the preferred networks list
44
+ (can provide multiple names separated by spaces)
45
+ s[hell] - opens an interactive pry shell (command line only)
46
+ t[ill] - returns when the desired Internet connection state is true. Options:
47
+ 1) 'on'/:on, 'off'/:off, 'conn'/:conn, or 'disc'/:disc
48
+ 2) wait interval, in seconds (optional, defaults to 0.5 seconds)
49
+ w[ifion] - is the wifi on?
50
+ x[it] - exits this program (interactive shell mode only) (see also 'q')
51
+
52
+ When in interactive shell mode:
53
+ * use quotes for string parameters such as method names.
54
+ * for pry commands, use prefix `%`.
55
+
56
+ "
57
+
58
+
59
+ def initialize(options)
60
+ @options = options
61
+ @model = MacOsModel.new(verbose_mode)
62
+ @interactive_mode = !!(options.interactive_mode)
63
+ run_shell if @interactive_mode
64
+ end
65
+
66
+
67
+ # Until command line option parsing is added, the only way to specify
68
+ # verbose mode is in the environment variable MAC_WIFI_OPTS.
69
+ def verbose_mode
70
+ options.verbose
71
+ end
72
+
73
+
74
+ def print_help
75
+ puts HELP_TEXT
76
+ end
77
+
78
+
79
+ # @return true if awesome_print is available (after requiring it), else false after requiring 'pp'.
80
+ # We'd like to use awesome_print if it is available, but not require it.
81
+ # So, we try to require it, but if that fails, we fall back to using pp (pretty print),
82
+ # which is included in Ruby distributions without the need to install a gem.
83
+ def awesome_print_available?
84
+ if @awesome_print_available.nil? # first time here
85
+ begin
86
+ require 'awesome_print'
87
+ @awesome_print_available = true
88
+ rescue LoadError
89
+ require 'pp'
90
+ @awesome_print_available = false
91
+ end
92
+ end
93
+
94
+ @awesome_print_available
95
+ end
96
+
97
+
98
+ def fancy_string(object)
99
+ awesome_print_available? ? object.ai : object.pretty_inspect
100
+ end
101
+
102
+
103
+ def fancy_puts(object)
104
+ puts fancy_string(object)
105
+ end
106
+ alias_method :fp, :fancy_puts
107
+
108
+
109
+ # Asserts that a command has been passed on the command line.
110
+ def validate_command_line
111
+ if ARGV.empty?
112
+ puts "Syntax is: #{__FILE__} [options] command [command_options]"
113
+ print_help
114
+ exit(-1)
115
+ end
116
+ end
117
+
118
+
119
+ # Pry will output the content of the method from which it was called.
120
+ # This small method exists solely to reduce the amount of pry's output
121
+ # that is not needed here.
122
+ def run_pry
123
+ binding.pry
124
+
125
+ # the seemingly useless line below is needed to avoid pry's exiting
126
+ # (see https://github.com/deivid-rodriguez/pry-byebug/issues/45)
127
+ _a = nil
128
+ end
129
+
130
+
131
+ # Runs a pry session in the context of this object.
132
+ # Commands and options specified on the command line can also be specified in the shell.
133
+ def run_shell
134
+ begin
135
+ require 'pry'
136
+ rescue LoadError
137
+ puts "The 'pry' gem and/or one of its prerequisites, required for running the shell, was not found." +
138
+ " Please `gem install pry`."
139
+ exit(-1)
140
+ end
141
+
142
+ print_help
143
+
144
+ # Enable the line below if you have any problems with pry configuration being loaded
145
+ # that is messing up this runtime use of pry:
146
+ # Pry.config.should_load_rc = false
147
+
148
+ # Strangely, this is the only thing I have found that successfully suppresses the
149
+ # code context output, which is not useful here. Anyway, this will differentiate
150
+ # a pry command from a DSL command, which _is_ useful here.
151
+ Pry.config.command_prefix = '%'
152
+
153
+ run_pry
154
+ end
155
+
156
+
157
+ # For use by the shell; when typing a command and options, it is passed to process_command_line
158
+ def method_missing(method_name, *options)
159
+ method_name = method_name.to_s
160
+ method_exists = !! find_command_action(method_name)
161
+ if method_exists
162
+ process_command_line(method_name, options)
163
+ else
164
+ puts(%Q{"#{method_name}" is not a valid command or option. If you intend for this to be a string literal, use quotes or %q/Q{}.})
165
+ end
166
+ end
167
+
168
+
169
+ # Processes the command (ARGV[0]) and any relevant options (ARGV[1..-1]).
170
+ #
171
+ # CAUTION! In interactive mode, any strings entered (e.g. a network name) MUST
172
+ # be in a form that the Ruby interpreter will recognize as a string,
173
+ # i.e. single or double quotes, %q, %Q, etc.
174
+ # Otherwise it will assume it's a method name and pass it to method_missing!
175
+ def process_command_line(command, options)
176
+ action = find_command_action(command)
177
+ if action
178
+ action.(*options)
179
+ else
180
+ print_help
181
+ raise BadCommandError.new(
182
+ %Q{Unrecognized command. Command was "#{command}" and options were #{options.inspect}.})
183
+ end
184
+ end
185
+
186
+
187
+ def quit
188
+ if interactive_mode
189
+ exit(0)
190
+ else
191
+ puts "This command can only be run in shell mode."
192
+ end
193
+ end
194
+
195
+
196
+ def cmd_a
197
+ info = model.available_network_names
198
+ if interactive_mode
199
+ info
200
+ else
201
+ if post_processor
202
+ puts post_processor.(info)
203
+ else
204
+ message = if model.wifi_on?
205
+ "Available networks are:\n\n#{fancy_string(info)}"
206
+ else
207
+ "Wifi is off, cannot see available networks."
208
+ end
209
+ puts(message)
210
+ end
211
+ end
212
+ end
213
+
214
+
215
+ def cmd_ci
216
+ connected = model.connected_to_internet?
217
+ if interactive_mode
218
+ connected
219
+ else
220
+ puts (post_processor ? post_processor.(connected) : "Connected to Internet: #{connected}")
221
+ end
222
+ end
223
+
224
+
225
+ def cmd_co(network, password = nil)
226
+ model.connect(network, password)
227
+ end
228
+
229
+
230
+ def cmd_cy
231
+ model.cycle_network
232
+ end
233
+
234
+
235
+ def cmd_d
236
+ model.disconnect
237
+ end
238
+
239
+
240
+ def cmd_h
241
+ print_help
242
+ end
243
+
244
+
245
+ def cmd_i
246
+ info = model.wifi_info
247
+ if interactive_mode
248
+ info
249
+ else
250
+ if post_processor
251
+ puts post_processor.(info)
252
+ else
253
+ puts fancy_string(info)
254
+ end
255
+ end
256
+ end
257
+
258
+
259
+ def cmd_lsa
260
+ info = model.available_network_info
261
+ if interactive_mode
262
+ info
263
+ else
264
+ if post_processor
265
+ puts post_processor.(info)
266
+ else
267
+ message = model.wifi_on? ? fancy_string(info) : "Wifi is off, cannot see available networks."
268
+ puts(message)
269
+ end
270
+ end
271
+ end
272
+
273
+
274
+ def cmd_n
275
+ name = model.connected_network_name
276
+ if interactive_mode
277
+ name
278
+ else
279
+ display_name = name ? name : '[none]'
280
+ puts (post_processor ? post_processor.(name) : %Q{Network (SSID) name: "#{display_name}"})
281
+ end
282
+ end
283
+
284
+
285
+ def cmd_of
286
+ model.wifi_off
287
+ end
288
+
289
+
290
+ def cmd_on
291
+ model.wifi_on
292
+ end
293
+
294
+
295
+ def cmd_pa(network)
296
+ password = model.preferred_network_password(network)
297
+
298
+ if interactive_mode
299
+ password
300
+ else
301
+ if post_processor
302
+ puts post_processor.(password)
303
+ else
304
+ output = %Q{Preferred network "#{model.connected_network_name}" }
305
+ output << (password ? %Q{stored password is "#{password}".} : "has no stored password.")
306
+ puts output
307
+ end
308
+ end
309
+ end
310
+
311
+
312
+ def cmd_pr
313
+ networks = model.preferred_networks
314
+ if interactive_mode
315
+ networks
316
+ else
317
+ puts (post_processor ? post_processor.(networks) : fancy_string(networks))
318
+ end
319
+ end
320
+
321
+
322
+ def cmd_q
323
+ quit
324
+ end
325
+
326
+
327
+ def cmd_r(*options)
328
+ removed_networks = model.remove_preferred_networks(*options)
329
+ if interactive_mode
330
+ removed_networks
331
+ else
332
+ puts (post_processor ? post_processor.(removed_networks) : "Removed networks: #{removed_networks.inspect}")
333
+ end
334
+ end
335
+
336
+
337
+ def cmd_t(*options)
338
+ target_status = options[0].to_sym
339
+ wait_interval_in_secs = (options[1] ? Float(options[1]) : nil)
340
+ model.till(target_status, wait_interval_in_secs)
341
+ end
342
+
343
+
344
+ def cmd_w
345
+ on = model.wifi_on?
346
+ if interactive_mode
347
+ on
348
+ else
349
+ puts (post_processor ? post_processor.(on) : "Wifi on: #{on}")
350
+ end
351
+ end
352
+
353
+
354
+ def cmd_x
355
+ quit
356
+ end
357
+
358
+
359
+ def commands
360
+ @commands_ ||= [
361
+ Command.new('a', 'avail_nets', -> (*_options) { cmd_a }),
362
+ Command.new('ci', 'ci', -> (*_options) { cmd_ci }),
363
+ Command.new('co', 'connect', -> (*options) { cmd_co(*options) }),
364
+ Command.new('cy', 'cycle', -> (*_options) { cmd_cy }),
365
+ Command.new('d', 'disconnect', -> (*_options) { cmd_d }),
366
+ Command.new('h', 'help', -> (*_options) { cmd_h }),
367
+ Command.new('i', 'info', -> (*_options) { cmd_i }),
368
+ Command.new('l', 'ls_avail_nets', -> (*_options) { cmd_lsa }),
369
+ Command.new('n', 'network_name', -> (*_options) { cmd_n }),
370
+ Command.new('of', 'off', -> (*_options) { cmd_of }),
371
+ Command.new('on', 'on', -> (*_options) { cmd_on }),
372
+ Command.new('pa', 'password', -> (*options) { cmd_pa(*options) }),
373
+ Command.new('pr', 'pref_nets', -> (*_options) { cmd_pr }),
374
+ Command.new('q', 'quit', -> (*_options) { cmd_q }),
375
+ Command.new('r', 'rm_pref_nets', -> (*options) { cmd_r(*options) }),
376
+ Command.new('t', 'till', -> (*options) { cmd_t(*options) }),
377
+ Command.new('w', 'wifion', -> (*_options) { cmd_w }),
378
+ Command.new('x', 'xit', -> (*_options) { cmd_x })
379
+ ]
380
+ end
381
+
382
+
383
+ def find_command_action(command_string)
384
+ result = commands.detect do |cmd|
385
+ cmd.max_string.start_with?(command_string) \
386
+ && \
387
+ command_string.length >= cmd.min_string.length # e.g. 'c' by itself should not work
388
+ end
389
+
390
+ result ? result.action : nil
391
+ end
392
+
393
+
394
+ # If a post-processor has been configured (e.g. YAML or JSON), use it.
395
+ def post_process(object)
396
+ post_processor ? post_processor.(object) : object
397
+ end
398
+
399
+
400
+
401
+ def post_processor
402
+ options.post_processor
403
+ end
404
+
405
+
406
+ def call
407
+ validate_command_line
408
+ begin
409
+ process_command_line(ARGV[0], ARGV[1..-1])
410
+ rescue BadCommandError => error
411
+ separator_line = "! #{'-' * 75} !\n"
412
+ puts '' << separator_line << error.to_s << "\n" << separator_line
413
+ exit(-1)
414
+ end
415
+ end
416
+ end
417
+ end