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.
- checksums.yaml +5 -5
- data/README.md +67 -59
- data/RELEASE_NOTES.md +8 -0
- data/exe/mac-wifi +1 -1052
- data/lib/mac-wifi/base_model.rb +221 -0
- data/lib/mac-wifi/command_line_interface.rb +417 -0
- data/lib/mac-wifi/mac_os_model.rb +297 -0
- data/lib/mac-wifi/main.rb +69 -0
- data/lib/mac-wifi/version.rb +5 -0
- data/lib/mac-wifi.rb +3 -0
- data/mac-wifi.gemspec +8 -4
- metadata +9 -3
data/exe/mac-wifi
CHANGED
@@ -19,1057 +19,6 @@
|
|
19
19
|
# License: MIT License
|
20
20
|
|
21
21
|
|
22
|
-
|
23
|
-
require 'yaml'
|
24
|
-
|
25
|
-
require 'shellwords'
|
26
|
-
require 'tempfile'
|
27
|
-
|
28
|
-
module MacWifi
|
29
|
-
|
30
|
-
# This version must be kept in sync with the version in the gemspec file.
|
31
|
-
VERSION = '2.0.0'
|
32
|
-
|
33
|
-
|
34
|
-
class BaseModel
|
35
|
-
|
36
|
-
class OsCommandError < RuntimeError
|
37
|
-
attr_reader :exitstatus, :command, :text
|
38
|
-
|
39
|
-
def initialize(exitstatus, command, text)
|
40
|
-
@exitstatus = exitstatus
|
41
|
-
@command = command
|
42
|
-
@text = text
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
|
47
|
-
def initialize(verbose = false)
|
48
|
-
@verbose_mode = verbose
|
49
|
-
end
|
50
|
-
|
51
|
-
|
52
|
-
def run_os_command(command)
|
53
|
-
output = `#{command} 2>&1` # join stderr with stdout
|
54
|
-
if $?.exitstatus != 0
|
55
|
-
raise OsCommandError.new($?.exitstatus, command, output)
|
56
|
-
end
|
57
|
-
if @verbose_mode
|
58
|
-
puts "\n\n#{'-' * 79}\nCommand: #{command}\n\nOutput:\n#{output}#{'-' * 79}\n\n"
|
59
|
-
end
|
60
|
-
output
|
61
|
-
end
|
62
|
-
private :run_os_command
|
63
|
-
|
64
|
-
|
65
|
-
# This method returns whether or not there is a working Internet connection.
|
66
|
-
# Because of a Mac issue which causes a request to hang if the network is turned
|
67
|
-
# off during its lifetime, we give it only 5 seconds per try,
|
68
|
-
# and limit the number of tries to 3.
|
69
|
-
#
|
70
|
-
# This implementation will probably strike you as overly complex. The following
|
71
|
-
# code looks like it is all that should be necessary, but unfortunately
|
72
|
-
# this implementation often hangs when wifi is turned off while curl is active
|
73
|
-
#
|
74
|
-
# def connected_to_internet?
|
75
|
-
# script = "curl --silent --head http://www.google.com/ > /dev/null ; echo $?"
|
76
|
-
# result = `#{script}`.chomp
|
77
|
-
# puts result
|
78
|
-
# result == '0'
|
79
|
-
# end
|
80
|
-
|
81
|
-
# TODO Investigate using Curl options: --connect-timeout 1 --max-time 2 --retry 0
|
82
|
-
# to greatly simplify this method.
|
83
|
-
def connected_to_internet?
|
84
|
-
|
85
|
-
tempfile = Tempfile.open('mac-wifi-')
|
86
|
-
|
87
|
-
begin
|
88
|
-
start_status_script = -> do
|
89
|
-
script = "curl --silent --head http://www.google.com/ > /dev/null ; echo $? > #{tempfile.path} &"
|
90
|
-
pid = Process.spawn(script)
|
91
|
-
Process.detach(pid)
|
92
|
-
pid
|
93
|
-
end
|
94
|
-
|
95
|
-
process_is_running = ->(pid) do
|
96
|
-
script = %Q{ps -p #{pid} > /dev/null; echo $?}
|
97
|
-
output = `#{script}`.chomp
|
98
|
-
output == "0"
|
99
|
-
end
|
100
|
-
|
101
|
-
get_connected_state_from_curl = -> do
|
102
|
-
tempfile.close
|
103
|
-
File.read(tempfile.path).chomp == '0'
|
104
|
-
end
|
105
|
-
|
106
|
-
# Do one run, iterating during the timeout period to see if the command has completed
|
107
|
-
do_one_run = -> do
|
108
|
-
end_time = Time.now + 3
|
109
|
-
pid = start_status_script.()
|
110
|
-
while Time.now < end_time
|
111
|
-
if process_is_running.(pid)
|
112
|
-
sleep 0.5
|
113
|
-
else
|
114
|
-
return get_connected_state_from_curl.()
|
115
|
-
end
|
116
|
-
end
|
117
|
-
Process.kill('KILL', pid) if process_is_running.(pid)
|
118
|
-
:hung
|
119
|
-
end
|
120
|
-
|
121
|
-
3.times do
|
122
|
-
connected = do_one_run.()
|
123
|
-
return connected if connected != :hung
|
124
|
-
end
|
125
|
-
|
126
|
-
raise "Could not determine Internet status."
|
127
|
-
|
128
|
-
ensure
|
129
|
-
tempfile.unlink
|
130
|
-
end
|
131
|
-
|
132
|
-
end
|
133
|
-
|
134
|
-
|
135
|
-
# Turns wifi off and then on, reconnecting to the originally connecting network.
|
136
|
-
def cycle_network
|
137
|
-
# TODO: Make this network name saving and restoring conditional on it not having a password.
|
138
|
-
# If the disabled code below is enabled, an error will be raised if a password is required,
|
139
|
-
# even though it is stored.
|
140
|
-
# network_name = current_network
|
141
|
-
wifi_off
|
142
|
-
wifi_on
|
143
|
-
# connect(network_name) if network_name
|
144
|
-
end
|
145
|
-
|
146
|
-
|
147
|
-
def connected_to?(network_name)
|
148
|
-
network_name == connected_network_name
|
149
|
-
end
|
150
|
-
|
151
|
-
|
152
|
-
# Connects to the passed network name, optionally with password.
|
153
|
-
# Turns wifi on first, in case it was turned off.
|
154
|
-
# Relies on subclass implementation of os_level_connect().
|
155
|
-
def connect(network_name, password = nil)
|
156
|
-
# Allow symbols and anything responding to to_s for user convenience
|
157
|
-
network_name = network_name.to_s if network_name
|
158
|
-
password = password.to_s if password
|
159
|
-
|
160
|
-
if network_name.nil? || network_name.empty?
|
161
|
-
raise "A network name is required but was not provided."
|
162
|
-
end
|
163
|
-
wifi_on
|
164
|
-
os_level_connect(network_name, password)
|
165
|
-
|
166
|
-
# Verify that the network is now connected:
|
167
|
-
actual_network_name = connected_network_name
|
168
|
-
unless actual_network_name == network_name
|
169
|
-
message = %Q{Expected to connect to "#{network_name}" but }
|
170
|
-
if actual_network_name
|
171
|
-
message << %Q{connected to "#{connected_network_name}" instead.}
|
172
|
-
else
|
173
|
-
message << "unable to connect to any network. Did you "
|
174
|
-
end
|
175
|
-
message << (password ? "provide the correct password?" : "need to provide a password?")
|
176
|
-
raise message
|
177
|
-
end
|
178
|
-
nil
|
179
|
-
end
|
180
|
-
|
181
|
-
|
182
|
-
# Removes the specified network(s) from the preferred network list.
|
183
|
-
# @param network_names names of networks to remove; may be empty or contain nonexistent networks
|
184
|
-
# @return names of the networks that were removed (excludes non-preexisting networks)
|
185
|
-
def remove_preferred_networks(*network_names)
|
186
|
-
networks_to_remove = network_names & preferred_networks # exclude any nonexistent networks
|
187
|
-
networks_to_remove.each { |name| remove_preferred_network(name) }
|
188
|
-
end
|
189
|
-
|
190
|
-
|
191
|
-
def preferred_network_password(preferred_network_name)
|
192
|
-
preferred_network_name = preferred_network_name.to_s
|
193
|
-
if preferred_networks.include?(preferred_network_name)
|
194
|
-
os_level_preferred_network_password(preferred_network_name)
|
195
|
-
else
|
196
|
-
raise "Network #{preferred_network_name} not in preferred networks list."
|
197
|
-
end
|
198
|
-
end
|
199
|
-
|
200
|
-
|
201
|
-
# Waits for the Internet connection to be in the desired state.
|
202
|
-
# @param target_status must be in [:conn, :disc, :off, :on]; waits for that state
|
203
|
-
# @param wait_interval_in_secs sleeps this interval between retries; if nil or absent,
|
204
|
-
# a default will be provided
|
205
|
-
#
|
206
|
-
def till(target_status, wait_interval_in_secs = nil)
|
207
|
-
|
208
|
-
# One might ask, why not just put the 0.5 up there as the default argument.
|
209
|
-
# We could do that, but we'd still need the line below in case nil
|
210
|
-
# was explicitly specified. The default argument of nil above emphasizes that
|
211
|
-
# the absence of an argument and a specification of nil will behave identically.
|
212
|
-
wait_interval_in_secs ||= 0.5
|
213
|
-
|
214
|
-
finished_predicates = {
|
215
|
-
conn: -> { connected_to_internet? },
|
216
|
-
disc: -> { ! connected_to_internet? },
|
217
|
-
on: -> { wifi_on? },
|
218
|
-
off: -> { ! wifi_on? }
|
219
|
-
}
|
220
|
-
|
221
|
-
finished_predicate = finished_predicates[target_status]
|
222
|
-
|
223
|
-
if finished_predicate.nil?
|
224
|
-
raise ArgumentError.new(
|
225
|
-
"Option must be one of #{finished_predicates.keys.inspect}. Was: #{target_status.inspect}")
|
226
|
-
end
|
227
|
-
|
228
|
-
loop do
|
229
|
-
return if finished_predicate.()
|
230
|
-
sleep(wait_interval_in_secs)
|
231
|
-
end
|
232
|
-
end
|
233
|
-
|
234
|
-
|
235
|
-
# Tries an OS command until the stop condition is true.
|
236
|
-
# @command the command to run in the OS
|
237
|
-
# @stop_condition a lambda taking the commands stdout as its sole parameter
|
238
|
-
# @return the stdout produced by the command
|
239
|
-
def try_os_command_until(command, stop_condition, max_tries = 100)
|
240
|
-
max_tries.times do
|
241
|
-
stdout = run_os_command(command)
|
242
|
-
if stop_condition.(stdout)
|
243
|
-
return stdout
|
244
|
-
end
|
245
|
-
end
|
246
|
-
nil
|
247
|
-
end
|
248
|
-
end
|
249
|
-
|
250
|
-
|
251
|
-
class MacOsModel < BaseModel
|
252
|
-
|
253
|
-
AIRPORT_CMD = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'
|
254
|
-
|
255
|
-
def initialize(verbose = false)
|
256
|
-
super
|
257
|
-
end
|
258
|
-
|
259
|
-
|
260
|
-
# This is determined by whether or not a line like the following appears in the output of `netstat -nr`:
|
261
|
-
# 0/1 10.137.0.41 UGSc 15 0 utun1
|
262
|
-
def vpn_running?
|
263
|
-
run_os_command('netstat -nr').split("\n").grep(/^0\/1.*utun1/).any?
|
264
|
-
end
|
265
|
-
|
266
|
-
|
267
|
-
# Identifies the (first) wireless network hardware port in the system, e.g. en0 or en1
|
268
|
-
def wifi_hardware_port
|
269
|
-
@wifi_hardware_port ||= begin
|
270
|
-
lines = run_os_command("networksetup -listallhardwareports").split("\n")
|
271
|
-
# Produces something like this:
|
272
|
-
# Hardware Port: Wi-Fi
|
273
|
-
# Device: en0
|
274
|
-
# Ethernet Address: ac:bc:32:b9:a9:9d
|
275
|
-
#
|
276
|
-
# Hardware Port: Bluetooth PAN
|
277
|
-
# Device: en3
|
278
|
-
# Ethernet Address: ac:bc:32:b9:a9:9e
|
279
|
-
wifi_port_line_num = (0...lines.size).detect do |index|
|
280
|
-
/: Wi-Fi$/.match(lines[index])
|
281
|
-
end
|
282
|
-
if wifi_port_line_num.nil?
|
283
|
-
raise %Q{Wifi port (e.g. "en0") not found in output of: networksetup -listallhardwareports}
|
284
|
-
else
|
285
|
-
lines[wifi_port_line_num + 1].split(': ').last
|
286
|
-
end
|
287
|
-
end
|
288
|
-
end
|
289
|
-
|
290
|
-
|
291
|
-
# Returns data pertaining to available wireless networks.
|
292
|
-
# For some reason, this often returns no results, so I've put the operation in a loop.
|
293
|
-
# I was unable to detect a sort strategy in the airport utility's output, so I sort
|
294
|
-
# the lines alphabetically, to show duplicates and for easier lookup.
|
295
|
-
def available_network_info
|
296
|
-
return nil unless wifi_on? # no need to try
|
297
|
-
command = "#{AIRPORT_CMD} -s"
|
298
|
-
max_attempts = 50
|
299
|
-
|
300
|
-
|
301
|
-
reformat_line = ->(line) do
|
302
|
-
ssid = line[0..31].strip
|
303
|
-
"%-32.32s%s" % [ssid, line[32..-1]]
|
304
|
-
end
|
305
|
-
|
306
|
-
|
307
|
-
process_tabular_data = ->(output) do
|
308
|
-
lines = output.split("\n")
|
309
|
-
header_line = lines[0]
|
310
|
-
data_lines = lines[1..-1]
|
311
|
-
data_lines.map! do |line|
|
312
|
-
# Reformat the line so that the name is left instead of right justified
|
313
|
-
reformat_line.(line)
|
314
|
-
end
|
315
|
-
data_lines.sort!
|
316
|
-
[reformat_line.(header_line)] + data_lines
|
317
|
-
end
|
318
|
-
|
319
|
-
|
320
|
-
output = try_os_command_until(command, ->(output) do
|
321
|
-
! ([nil, ''].include?(output))
|
322
|
-
end)
|
323
|
-
|
324
|
-
if output
|
325
|
-
process_tabular_data.(output)
|
326
|
-
else
|
327
|
-
raise "Unable to get available network information after #{max_attempts} attempts."
|
328
|
-
end
|
329
|
-
end
|
330
|
-
|
331
|
-
|
332
|
-
def parse_network_names(info)
|
333
|
-
if info.nil?
|
334
|
-
nil
|
335
|
-
else
|
336
|
-
info[1..-1] \
|
337
|
-
.map { |line| line[0..32].rstrip } \
|
338
|
-
.uniq \
|
339
|
-
.sort { |s1, s2| s1.casecmp(s2) }
|
340
|
-
end
|
341
|
-
end
|
342
|
-
|
343
|
-
# @return an array of unique available network names only, sorted alphabetically
|
344
|
-
# Kludge alert: the tabular data does not differentiate between strings with and without leading whitespace
|
345
|
-
# Therefore, we get the data once in tabular format, and another time in XML format.
|
346
|
-
# The XML element will include any leading whitespace. However, it includes all <string> elements,
|
347
|
-
# many of which are not network names.
|
348
|
-
# As an improved approximation of the correct result, for each network name found in tabular mode,
|
349
|
-
# we look to see if there is a corresponding string element with leading whitespace, and, if so,
|
350
|
-
# replace it.
|
351
|
-
#
|
352
|
-
# This will not behave correctly if a given name has occurrences with different amounts of whitespace,
|
353
|
-
# e.g. ' x' and ' x'.
|
354
|
-
#
|
355
|
-
# The reason we don't use an XML parser to get the exactly correct result is that we don't want
|
356
|
-
# users to need to install any external dependencies in order to run this script.
|
357
|
-
def available_network_names
|
358
|
-
|
359
|
-
# Parses the XML text (using grep, not XML parsing) to find
|
360
|
-
# <string> elements, and extracts the network name candidates
|
361
|
-
# containing leading spaces from it.
|
362
|
-
get_leading_space_names = ->(text) do
|
363
|
-
text.split("\n") \
|
364
|
-
.grep(%r{<string>}) \
|
365
|
-
.sort \
|
366
|
-
.uniq \
|
367
|
-
.map { |line| line.gsub("<string>", '').gsub('</string>', '').gsub("\t", '') } \
|
368
|
-
.select { |s| s[0] == ' ' }
|
369
|
-
end
|
370
|
-
|
371
|
-
|
372
|
-
output_is_valid = ->(output) { ! ([nil, ''].include?(output)) }
|
373
|
-
tabular_data = try_os_command_until("#{AIRPORT_CMD} -s", output_is_valid)
|
374
|
-
xml_data = try_os_command_until("#{AIRPORT_CMD} -s -x", output_is_valid)
|
375
|
-
|
376
|
-
if tabular_data.nil? || xml_data.nil?
|
377
|
-
raise "Unable to get available network information; please try again."
|
378
|
-
end
|
379
|
-
|
380
|
-
tabular_data_lines = tabular_data[1..-1] # omit header line
|
381
|
-
names_no_spaces = parse_network_names(tabular_data_lines.split("\n")).map(&:strip)
|
382
|
-
names_maybe_spaces = get_leading_space_names.(xml_data)
|
383
|
-
|
384
|
-
names = names_no_spaces.map do |name_no_spaces|
|
385
|
-
match = names_maybe_spaces.detect do |name_maybe_spaces|
|
386
|
-
%r{[ \t]?#{name_no_spaces}$}.match(name_maybe_spaces)
|
387
|
-
end
|
388
|
-
|
389
|
-
match ? match : name_no_spaces
|
390
|
-
end
|
391
|
-
|
392
|
-
names.sort { |s1, s2| s1.casecmp(s2) } # sort alphabetically, case insensitively
|
393
|
-
end
|
394
|
-
|
395
|
-
|
396
|
-
# Returns data pertaining to "preferred" networks, many/most of which will probably not be available.
|
397
|
-
def preferred_networks
|
398
|
-
lines = run_os_command("networksetup -listpreferredwirelessnetworks #{wifi_hardware_port}").split("\n")
|
399
|
-
# Produces something like this, unsorted, and with leading tabs:
|
400
|
-
# Preferred networks on en0:
|
401
|
-
# LibraryWiFi
|
402
|
-
# @thePAD/Magma
|
403
|
-
|
404
|
-
lines.delete_at(0) # remove title line
|
405
|
-
lines.map! { |line| line.gsub("\t", '') } # remove leading tabs
|
406
|
-
lines.sort! { |s1, s2| s1.casecmp(s2) } # sort alphabetically, case insensitively
|
407
|
-
lines
|
408
|
-
end
|
409
|
-
|
410
|
-
|
411
|
-
# Returns true if wifi is on, else false.
|
412
|
-
def wifi_on?
|
413
|
-
lines = run_os_command("#{AIRPORT_CMD} -I").split("\n")
|
414
|
-
lines.grep("AirPort: Off").none?
|
415
|
-
end
|
416
|
-
|
417
|
-
|
418
|
-
# Turns wifi on.
|
419
|
-
def wifi_on
|
420
|
-
return if wifi_on?
|
421
|
-
run_os_command("networksetup -setairportpower #{wifi_hardware_port} on")
|
422
|
-
wifi_on? ? nil : raise("Wifi could not be enabled.")
|
423
|
-
end
|
424
|
-
|
425
|
-
|
426
|
-
# Turns wifi off.
|
427
|
-
def wifi_off
|
428
|
-
return unless wifi_on?
|
429
|
-
run_os_command("networksetup -setairportpower #{wifi_hardware_port} off")
|
430
|
-
wifi_on? ? raise("Wifi could not be disabled.") : nil
|
431
|
-
end
|
432
|
-
|
433
|
-
|
434
|
-
def connected_network_name
|
435
|
-
wifi_info['SSID']
|
436
|
-
end
|
437
|
-
|
438
|
-
|
439
|
-
# This method is called by BaseModel#connect to do the OS-specific connection logic.
|
440
|
-
def os_level_connect(network_name, password = nil)
|
441
|
-
command = "networksetup -setairportnetwork #{wifi_hardware_port} " + "#{Shellwords.shellescape(network_name)}"
|
442
|
-
if password
|
443
|
-
command << ' ' << Shellwords.shellescape(password)
|
444
|
-
end
|
445
|
-
run_os_command(command)
|
446
|
-
end
|
447
|
-
|
448
|
-
|
449
|
-
# @return:
|
450
|
-
# If the network is in the preferred networks list
|
451
|
-
# If a password is associated w/this network, return the password
|
452
|
-
# If not, return nil
|
453
|
-
# else
|
454
|
-
# raise an error
|
455
|
-
def os_level_preferred_network_password(preferred_network_name)
|
456
|
-
command = %Q{security find-generic-password -D "AirPort network password" -a "#{preferred_network_name}" -w 2>&1}
|
457
|
-
begin
|
458
|
-
return run_os_command(command).chomp
|
459
|
-
rescue OsCommandError => error
|
460
|
-
if error.exitstatus == 44 # network has no password stored
|
461
|
-
nil
|
462
|
-
else
|
463
|
-
raise
|
464
|
-
end
|
465
|
-
end
|
466
|
-
end
|
467
|
-
|
468
|
-
|
469
|
-
# Returns the IP address assigned to the wifi port, or nil if none.
|
470
|
-
def ip_address
|
471
|
-
begin
|
472
|
-
run_os_command("ipconfig getifaddr #{wifi_hardware_port}").chomp
|
473
|
-
rescue OsCommandError => error
|
474
|
-
if error.exitstatus == 1
|
475
|
-
nil
|
476
|
-
else
|
477
|
-
raise
|
478
|
-
end
|
479
|
-
end
|
480
|
-
end
|
481
|
-
|
482
|
-
|
483
|
-
def remove_preferred_network(network_name)
|
484
|
-
network_name = network_name.to_s
|
485
|
-
run_os_command("sudo networksetup -removepreferredwirelessnetwork " +
|
486
|
-
"#{wifi_hardware_port} #{Shellwords.shellescape(network_name)}")
|
487
|
-
end
|
488
|
-
|
489
|
-
|
490
|
-
# Returns the network currently connected to, or nil if none.
|
491
|
-
def current_network
|
492
|
-
lines = run_os_command("#{AIRPORT_CMD} -I").split("\n")
|
493
|
-
ssid_lines = lines.grep(/ SSID:/)
|
494
|
-
ssid_lines.empty? ? nil : ssid_lines.first.split('SSID: ').last.strip
|
495
|
-
end
|
496
|
-
|
497
|
-
|
498
|
-
# Disconnects from the currently connected network. Does not turn off wifi.
|
499
|
-
def disconnect
|
500
|
-
run_os_command("sudo #{AIRPORT_CMD} -z")
|
501
|
-
nil
|
502
|
-
end
|
503
|
-
|
504
|
-
|
505
|
-
# Returns some useful wifi-related information.
|
506
|
-
def wifi_info
|
507
|
-
|
508
|
-
info = {
|
509
|
-
wifi_on: wifi_on?,
|
510
|
-
internet_on: connected_to_internet?,
|
511
|
-
vpn_on: vpn_running?,
|
512
|
-
port: wifi_hardware_port,
|
513
|
-
network: current_network,
|
514
|
-
ip_address: ip_address,
|
515
|
-
timestamp: Time.now,
|
516
|
-
}
|
517
|
-
more_output = run_os_command(AIRPORT_CMD + " -I")
|
518
|
-
more_info = colon_output_to_hash(more_output)
|
519
|
-
info.merge!(more_info)
|
520
|
-
info.delete('AirPort') # will be here if off, but info is already in wifi_on key
|
521
|
-
info
|
522
|
-
end
|
523
|
-
|
524
|
-
|
525
|
-
# Parses output like the text below into a hash:
|
526
|
-
# SSID: Pattara211
|
527
|
-
# MCS: 5
|
528
|
-
# channel: 7
|
529
|
-
def colon_output_to_hash(output)
|
530
|
-
lines = output.split("\n")
|
531
|
-
lines.each_with_object({}) do |line, new_hash|
|
532
|
-
key, value = line.split(': ')
|
533
|
-
key = key.strip
|
534
|
-
value.strip! if value
|
535
|
-
new_hash[key] = value
|
536
|
-
end
|
537
|
-
end
|
538
|
-
private :colon_output_to_hash
|
539
|
-
end
|
540
|
-
|
541
|
-
|
542
|
-
class CommandLineInterface
|
543
|
-
|
544
|
-
attr_reader :model, :interactive_mode, :options
|
545
|
-
|
546
|
-
class Command < Struct.new(:min_string, :max_string, :action); end
|
547
|
-
|
548
|
-
|
549
|
-
class BadCommandError < RuntimeError
|
550
|
-
def initialize(error_message)
|
551
|
-
super
|
552
|
-
end
|
553
|
-
end
|
554
|
-
|
555
|
-
|
556
|
-
# Help text to be used when requested by 'h' command, in case of unrecognized or nonexistent command, etc.
|
557
|
-
HELP_TEXT = "
|
558
|
-
Command Line Switches: [mac-wifi version #{VERSION}]
|
559
|
-
|
560
|
-
-o[i,j,p,y] - outputs data in inspect, JSON, puts, or YAML format when not in shell mode
|
561
|
-
-s - run in shell mode
|
562
|
-
-v - verbose mode (prints OS commands and their outputs)
|
563
|
-
|
564
|
-
Commands:
|
565
|
-
|
566
|
-
a[vail_nets] - array of names of the available networks
|
567
|
-
ci - connected to Internet (not just wifi on)?
|
568
|
-
co[nnect] network-name - turns wifi on, connects to network-name
|
569
|
-
cy[cle] - turns wifi off, then on, preserving network selection
|
570
|
-
d[isconnect] - disconnects from current network, does not turn off wifi
|
571
|
-
h[elp] - prints this help
|
572
|
-
i[nfo] - a hash of wifi-related information
|
573
|
-
l[s_avail_nets] - details about available networks
|
574
|
-
n[etwork_name] - name (SSID) of currently connected network
|
575
|
-
on - turns wifi on
|
576
|
-
of[f] - turns wifi off
|
577
|
-
pa[ssword] network-name - password for preferred network-name
|
578
|
-
pr[ef_nets] - preferred (not necessarily available) networks
|
579
|
-
q[uit] - exits this program (interactive shell mode only) (see also 'x')
|
580
|
-
r[m_pref_nets] network-name - removes network-name from the preferred networks list
|
581
|
-
(can provide multiple names separated by spaces)
|
582
|
-
s[hell] - opens an interactive pry shell (command line only)
|
583
|
-
t[ill] - returns when the desired Internet connection state is true. Options:
|
584
|
-
1) 'on'/:on, 'off'/:off, 'conn'/:conn, or 'disc'/:disc
|
585
|
-
2) wait interval, in seconds (optional, defaults to 0.5 seconds)
|
586
|
-
w[ifion] - is the wifi on?
|
587
|
-
x[it] - exits this program (interactive shell mode only) (see also 'q')
|
588
|
-
|
589
|
-
When in interactive shell mode:
|
590
|
-
* use quotes for string parameters such as method names.
|
591
|
-
* for pry commands, use prefix `%`.
|
592
|
-
|
593
|
-
"
|
594
|
-
|
595
|
-
|
596
|
-
def initialize(options)
|
597
|
-
@options = options
|
598
|
-
@model = MacOsModel.new(verbose_mode)
|
599
|
-
@interactive_mode = !!(options.interactive_mode)
|
600
|
-
run_shell if @interactive_mode
|
601
|
-
end
|
602
|
-
|
603
|
-
|
604
|
-
# Until command line option parsing is added, the only way to specify
|
605
|
-
# verbose mode is in the environment variable MAC_WIFI_OPTS.
|
606
|
-
def verbose_mode
|
607
|
-
options.verbose
|
608
|
-
end
|
609
|
-
|
610
|
-
|
611
|
-
def print_help
|
612
|
-
puts HELP_TEXT
|
613
|
-
end
|
614
|
-
|
615
|
-
|
616
|
-
# @return true if awesome_print is available (after requiring it), else false after requiring 'pp'.
|
617
|
-
# We'd like to use awesome_print if it is available, but not require it.
|
618
|
-
# So, we try to require it, but if that fails, we fall back to using pp (pretty print),
|
619
|
-
# which is included in Ruby distributions without the need to install a gem.
|
620
|
-
def awesome_print_available?
|
621
|
-
if @awesome_print_available.nil? # first time here
|
622
|
-
begin
|
623
|
-
require 'awesome_print'
|
624
|
-
@awesome_print_available = true
|
625
|
-
rescue LoadError
|
626
|
-
require 'pp'
|
627
|
-
@awesome_print_available = false
|
628
|
-
end
|
629
|
-
end
|
630
|
-
|
631
|
-
@awesome_print_available
|
632
|
-
end
|
633
|
-
|
634
|
-
|
635
|
-
def fancy_string(object)
|
636
|
-
awesome_print_available? ? object.ai : object.pretty_inspect
|
637
|
-
end
|
638
|
-
|
639
|
-
|
640
|
-
def fancy_puts(object)
|
641
|
-
puts fancy_string(object)
|
642
|
-
end
|
643
|
-
alias_method :fp, :fancy_puts
|
644
|
-
|
645
|
-
|
646
|
-
def puts_unless_interactive(string)
|
647
|
-
puts(string) unless interactive_mode
|
648
|
-
end
|
649
|
-
|
650
|
-
|
651
|
-
# Asserts that a command has been passed on the command line.
|
652
|
-
def validate_command_line
|
653
|
-
if ARGV.empty?
|
654
|
-
puts "Syntax is: #{__FILE__} [options] command [command_options]"
|
655
|
-
print_help
|
656
|
-
exit(-1)
|
657
|
-
end
|
658
|
-
end
|
659
|
-
|
660
|
-
|
661
|
-
# Pry will output the content of the method from which it was called.
|
662
|
-
# This small method exists solely to reduce the amount of pry's output
|
663
|
-
# that is not needed here.
|
664
|
-
def run_pry
|
665
|
-
binding.pry
|
666
|
-
|
667
|
-
# the seemingly useless line below is needed to avoid pry's exiting
|
668
|
-
# (see https://github.com/deivid-rodriguez/pry-byebug/issues/45)
|
669
|
-
_a = nil
|
670
|
-
end
|
671
|
-
|
672
|
-
|
673
|
-
# Runs a pry session in the context of this object.
|
674
|
-
# Commands and options specified on the command line can also be specified in the shell.
|
675
|
-
def run_shell
|
676
|
-
begin
|
677
|
-
require 'pry'
|
678
|
-
rescue LoadError
|
679
|
-
puts "The 'pry' gem and/or one of its prerequisites, required for running the shell, was not found." +
|
680
|
-
" Please `gem install pry`."
|
681
|
-
exit(-1)
|
682
|
-
end
|
683
|
-
|
684
|
-
print_help
|
685
|
-
|
686
|
-
# Enable the line below if you have any problems with pry configuration being loaded
|
687
|
-
# that is messing up this runtime use of pry:
|
688
|
-
# Pry.config.should_load_rc = false
|
689
|
-
|
690
|
-
# Strangely, this is the only thing I have found that successfully suppresses the
|
691
|
-
# code context output, which is not useful here. Anyway, this will differentiate
|
692
|
-
# a pry command from a DSL command, which _is_ useful here.
|
693
|
-
Pry.config.command_prefix = '%'
|
694
|
-
|
695
|
-
run_pry
|
696
|
-
end
|
697
|
-
|
698
|
-
|
699
|
-
# For use by the shell; when typing a command and options, it is passed to process_command_line
|
700
|
-
def method_missing(method_name, *options)
|
701
|
-
method_name = method_name.to_s
|
702
|
-
method_exists = !! find_command_action(method_name)
|
703
|
-
if method_exists
|
704
|
-
process_command_line(method_name, options)
|
705
|
-
else
|
706
|
-
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{}.})
|
707
|
-
end
|
708
|
-
end
|
709
|
-
|
710
|
-
|
711
|
-
# Processes the command (ARGV[0]) and any relevant options (ARGV[1..-1]).
|
712
|
-
#
|
713
|
-
# CAUTION! In interactive mode, any strings entered (e.g. a network name) MUST
|
714
|
-
# be in a form that the Ruby interpreter will recognize as a string,
|
715
|
-
# i.e. single or double quotes, %q, %Q, etc.
|
716
|
-
# Otherwise it will assume it's a method name and pass it to method_missing!
|
717
|
-
def process_command_line(command, options)
|
718
|
-
action = find_command_action(command)
|
719
|
-
if action
|
720
|
-
action.(*options)
|
721
|
-
else
|
722
|
-
print_help
|
723
|
-
raise BadCommandError.new(
|
724
|
-
%Q{Unrecognized command. Command was "#{command}" and options were #{options.inspect}.})
|
725
|
-
end
|
726
|
-
end
|
727
|
-
|
728
|
-
|
729
|
-
def quit
|
730
|
-
if interactive_mode
|
731
|
-
exit(0)
|
732
|
-
else
|
733
|
-
puts "This command can only be run in shell mode."
|
734
|
-
end
|
735
|
-
end
|
736
|
-
|
737
|
-
|
738
|
-
def cmd_a
|
739
|
-
info = model.available_network_names
|
740
|
-
if interactive_mode
|
741
|
-
info
|
742
|
-
else
|
743
|
-
if post_processor
|
744
|
-
puts post_processor.(info)
|
745
|
-
else
|
746
|
-
message = if model.wifi_on?
|
747
|
-
"Available networks are:\n\n#{fancy_string(info)}"
|
748
|
-
else
|
749
|
-
"Wifi is off, cannot see available networks."
|
750
|
-
end
|
751
|
-
puts(message)
|
752
|
-
end
|
753
|
-
end
|
754
|
-
end
|
755
|
-
|
756
|
-
|
757
|
-
def cmd_ci
|
758
|
-
connected = model.connected_to_internet?
|
759
|
-
if interactive_mode
|
760
|
-
connected
|
761
|
-
else
|
762
|
-
puts (post_processor ? post_processor.(connected) : "Connected to Internet: #{connected}")
|
763
|
-
end
|
764
|
-
end
|
765
|
-
|
766
|
-
|
767
|
-
def cmd_co(network, password = nil)
|
768
|
-
model.connect(network, password)
|
769
|
-
end
|
770
|
-
|
771
|
-
|
772
|
-
def cmd_cy
|
773
|
-
model.cycle_network
|
774
|
-
end
|
775
|
-
|
776
|
-
|
777
|
-
def cmd_d
|
778
|
-
model.disconnect
|
779
|
-
end
|
780
|
-
|
781
|
-
|
782
|
-
def cmd_h
|
783
|
-
print_help
|
784
|
-
end
|
785
|
-
|
786
|
-
|
787
|
-
def cmd_i
|
788
|
-
info = model.wifi_info
|
789
|
-
if interactive_mode
|
790
|
-
info
|
791
|
-
else
|
792
|
-
if post_processor
|
793
|
-
puts post_processor.(info)
|
794
|
-
else
|
795
|
-
puts fancy_string(info)
|
796
|
-
end
|
797
|
-
end
|
798
|
-
end
|
799
|
-
|
800
|
-
|
801
|
-
def cmd_lsa
|
802
|
-
info = model.available_network_info
|
803
|
-
if interactive_mode
|
804
|
-
info
|
805
|
-
else
|
806
|
-
if post_processor
|
807
|
-
puts post_processor.(info)
|
808
|
-
else
|
809
|
-
message = model.wifi_on? ? fancy_string(info) : "Wifi is off, cannot see available networks."
|
810
|
-
puts(message)
|
811
|
-
end
|
812
|
-
end
|
813
|
-
end
|
814
|
-
|
815
|
-
|
816
|
-
def cmd_n
|
817
|
-
name = model.connected_network_name
|
818
|
-
if interactive_mode
|
819
|
-
name
|
820
|
-
else
|
821
|
-
display_name = name ? name : '[none]'
|
822
|
-
puts (post_processor ? post_processor.(name) : %Q{Network (SSID) name: "#{display_name}"})
|
823
|
-
end
|
824
|
-
end
|
825
|
-
|
826
|
-
|
827
|
-
def cmd_of
|
828
|
-
model.wifi_off
|
829
|
-
end
|
830
|
-
|
831
|
-
|
832
|
-
def cmd_on
|
833
|
-
model.wifi_on
|
834
|
-
end
|
835
|
-
|
836
|
-
|
837
|
-
def cmd_pa(network)
|
838
|
-
password = model.preferred_network_password(network)
|
839
|
-
|
840
|
-
if interactive_mode
|
841
|
-
password
|
842
|
-
else
|
843
|
-
if post_processor
|
844
|
-
puts post_processor.(password)
|
845
|
-
else
|
846
|
-
output = %Q{Preferred network "#{model.connected_network_name}" }
|
847
|
-
output << (password ? %Q{stored password is "#{password}".} : "has no stored password.")
|
848
|
-
puts output
|
849
|
-
end
|
850
|
-
end
|
851
|
-
end
|
852
|
-
|
853
|
-
|
854
|
-
def cmd_pr
|
855
|
-
networks = model.preferred_networks
|
856
|
-
if interactive_mode
|
857
|
-
networks
|
858
|
-
else
|
859
|
-
puts (post_processor ? post_processor.(networks) : fancy_string(networks))
|
860
|
-
end
|
861
|
-
end
|
862
|
-
|
863
|
-
|
864
|
-
def cmd_q
|
865
|
-
quit
|
866
|
-
end
|
867
|
-
|
868
|
-
|
869
|
-
def cmd_r(*options)
|
870
|
-
removed_networks = model.remove_preferred_networks(*options)
|
871
|
-
if interactive_mode
|
872
|
-
removed_networks
|
873
|
-
else
|
874
|
-
puts (post_processor ? post_processor.(removed_networks) : "Removed networks: #{removed_networks.inspect}")
|
875
|
-
end
|
876
|
-
end
|
877
|
-
|
878
|
-
|
879
|
-
def cmd_t(*options)
|
880
|
-
target_status = options[0].to_sym
|
881
|
-
wait_interval_in_secs = (options[1] ? Float(options[1]) : nil)
|
882
|
-
model.till(target_status, wait_interval_in_secs)
|
883
|
-
end
|
884
|
-
|
885
|
-
|
886
|
-
def cmd_w
|
887
|
-
on = model.wifi_on?
|
888
|
-
if interactive_mode
|
889
|
-
on
|
890
|
-
else
|
891
|
-
puts (post_processor ? post_processor.(on) : "Wifi on: #{on}")
|
892
|
-
end
|
893
|
-
end
|
894
|
-
|
895
|
-
|
896
|
-
def cmd_x
|
897
|
-
quit
|
898
|
-
end
|
899
|
-
|
900
|
-
|
901
|
-
def commands
|
902
|
-
@commands_ ||= [
|
903
|
-
Command.new('a', 'avail_nets', -> (*_options) { cmd_a }),
|
904
|
-
Command.new('ci', 'ci', -> (*_options) { cmd_ci }),
|
905
|
-
Command.new('co', 'connect', -> (*options) { cmd_co(*options) }),
|
906
|
-
Command.new('cy', 'cycle', -> (*_options) { cmd_cy }),
|
907
|
-
Command.new('d', 'disconnect', -> (*_options) { cmd_d }),
|
908
|
-
Command.new('h', 'help', -> (*_options) { cmd_h }),
|
909
|
-
Command.new('i', 'info', -> (*_options) { cmd_i }),
|
910
|
-
Command.new('l', 'ls_avail_nets', -> (*_options) { cmd_lsa }),
|
911
|
-
Command.new('n', 'network_name', -> (*_options) { cmd_n }),
|
912
|
-
Command.new('of', 'off', -> (*_options) { cmd_of }),
|
913
|
-
Command.new('on', 'on', -> (*_options) { cmd_on }),
|
914
|
-
Command.new('pa', 'password', -> (*options) { cmd_pa(*options) }),
|
915
|
-
Command.new('pr', 'pref_nets', -> (*_options) { cmd_pr }),
|
916
|
-
Command.new('q', 'quit', -> (*_options) { cmd_q }),
|
917
|
-
Command.new('r', 'rm_pref_nets', -> (*options) { cmd_r(*options) }),
|
918
|
-
Command.new('t', 'till', -> (*options) { cmd_t(*options) }),
|
919
|
-
Command.new('w', 'wifion', -> (*_options) { cmd_w }),
|
920
|
-
Command.new('x', 'xit', -> (*_options) { cmd_x })
|
921
|
-
]
|
922
|
-
end
|
923
|
-
|
924
|
-
|
925
|
-
def find_command_action(command_string)
|
926
|
-
result = commands.detect do |cmd|
|
927
|
-
cmd.max_string.start_with?(command_string) \
|
928
|
-
&& \
|
929
|
-
command_string.length >= cmd.min_string.length # e.g. 'c' by itself should not work
|
930
|
-
end
|
931
|
-
|
932
|
-
result ? result.action : nil
|
933
|
-
end
|
934
|
-
|
935
|
-
|
936
|
-
# If a post-processor has been configured (e.g. YAML or JSON), use it.
|
937
|
-
def post_process(object)
|
938
|
-
post_processor ? post_processor.(object) : object
|
939
|
-
end
|
940
|
-
|
941
|
-
|
942
|
-
|
943
|
-
def post_processor
|
944
|
-
options.post_processor
|
945
|
-
end
|
946
|
-
|
947
|
-
|
948
|
-
def call
|
949
|
-
validate_command_line
|
950
|
-
begin
|
951
|
-
process_command_line(ARGV[0], ARGV[1..-1])
|
952
|
-
rescue BadCommandError => error
|
953
|
-
separator_line = "! #{'-' * 75} !\n"
|
954
|
-
puts '' << separator_line << error.to_s << "\n" << separator_line
|
955
|
-
exit(-1)
|
956
|
-
end
|
957
|
-
end
|
958
|
-
end
|
959
|
-
|
960
|
-
|
961
|
-
require 'optparse'
|
962
|
-
require 'ostruct'
|
963
|
-
|
964
|
-
class Main
|
965
|
-
|
966
|
-
# @return true if this file is being run as a script, else false
|
967
|
-
#
|
968
|
-
# This file could be called as a script in either of these two ways:
|
969
|
-
#
|
970
|
-
# 1) by loading this file directly, or
|
971
|
-
# 2) by running as a gem executable's binstub, in (relatively) '../../../exe'
|
972
|
-
#
|
973
|
-
# For example, I get this when running this file as a gem and running:
|
974
|
-
#
|
975
|
-
# puts "$0: #{$0}"
|
976
|
-
# puts "__FILE__: #{__FILE__}"
|
977
|
-
# puts "GEM_PATH: #{ENV['GEM_PATH']}"
|
978
|
-
#
|
979
|
-
# $0: /Users/kbennett/.rvm/gems/ruby-2.4.1/bin/mac-wifi
|
980
|
-
# __FILE__: /Users/kbennett/.rvm/gems/ruby-2.4.1/gems/mac-wifi-1.2.0/exe/mac-wifi
|
981
|
-
# GEM_PATH: /Users/kbennett/.rvm/gems/ruby-2.4.1:/Users/kbennett/.rvm/gems/ruby-2.4.1@global
|
982
|
-
|
983
|
-
|
984
|
-
def running_as_script?
|
985
|
-
|
986
|
-
this_source_file = File.expand_path(__FILE__)
|
987
|
-
called_file = File.expand_path($0)
|
988
|
-
return true if called_file == this_source_file
|
989
|
-
|
990
|
-
this_source_file_basename = File.basename(this_source_file)
|
991
|
-
called_file_basename = File.basename(called_file)
|
992
|
-
return false if called_file_basename != this_source_file_basename
|
993
|
-
|
994
|
-
# If here, then filespecs are different but have the same basename.
|
995
|
-
gem_roots = ENV['GEM_PATH'].split(File::PATH_SEPARATOR)
|
996
|
-
as_script = gem_roots.any? do |gem_root|
|
997
|
-
binstub_candidate_name = File.join(gem_root, 'bin', called_file_basename)
|
998
|
-
up_4_from_this_source_file = File.expand_path(File.join(this_source_file, '..', '..', '..', '..'))
|
999
|
-
found = ($0 == binstub_candidate_name) && (gem_root == up_4_from_this_source_file)
|
1000
|
-
found
|
1001
|
-
end
|
1002
|
-
|
1003
|
-
as_script
|
1004
|
-
end
|
1005
|
-
|
1006
|
-
|
1007
|
-
def assert_os_is_mac_os
|
1008
|
-
host_os = RbConfig::CONFIG["host_os"]
|
1009
|
-
unless /darwin/.match(host_os) # e.g. "darwin16.4.0"
|
1010
|
-
raise "This program currently works only on Mac OS. Platform is '#{host_os}'."
|
1011
|
-
end
|
1012
|
-
end
|
1013
|
-
|
1014
|
-
|
1015
|
-
# Parses the command line with Ruby's internal 'optparse'.
|
1016
|
-
# Looks for "-v" flag to set verbosity to true.
|
1017
|
-
# optparse removes what it processes from ARGV, which simplifies our command parsing.
|
1018
|
-
def parse_command_line
|
1019
|
-
options = OpenStruct.new
|
1020
|
-
OptionParser.new do |parser|
|
1021
|
-
parser.on("-v", "--[no-]verbose", "Run verbosely") do |v|
|
1022
|
-
options.verbose = v
|
1023
|
-
end
|
1024
|
-
|
1025
|
-
parser.on("-s", "--shell", "Start interactive shell") do |v|
|
1026
|
-
options.interactive_mode = true
|
1027
|
-
end
|
1028
|
-
|
1029
|
-
parser.on("-o", "--output_format FORMAT", "Format output data") do |v|
|
1030
|
-
|
1031
|
-
transformers = {
|
1032
|
-
'i' => ->(object) { object.inspect },
|
1033
|
-
'j' => ->(object) { JSON.pretty_generate(object) },
|
1034
|
-
'p' => ->(object) { sio = StringIO.new; sio.puts(object); sio.string },
|
1035
|
-
'y' => ->(object) { object.to_yaml }
|
1036
|
-
}
|
1037
|
-
|
1038
|
-
choice = v[0].downcase
|
1039
|
-
|
1040
|
-
unless transformers.keys.include?(choice)
|
1041
|
-
raise %Q{Output format "#{choice}" not in list of available formats} +
|
1042
|
-
" (#{transformers.keys.inspect})."
|
1043
|
-
end
|
1044
|
-
|
1045
|
-
options.post_processor = transformers[choice]
|
1046
|
-
end
|
1047
|
-
|
1048
|
-
parser.on("-h", "--help", "Show help") do |_help_requested|
|
1049
|
-
ARGV << 'h' # pass on the request to the command processor
|
1050
|
-
end
|
1051
|
-
end.parse!
|
1052
|
-
options
|
1053
|
-
end
|
1054
|
-
|
1055
|
-
|
1056
|
-
def call
|
1057
|
-
assert_os_is_mac_os
|
1058
|
-
|
1059
|
-
options = parse_command_line
|
1060
|
-
|
1061
|
-
# If this file is being called as a script, run it.
|
1062
|
-
# Else, it may be loaded to use the model in a different way.
|
1063
|
-
if running_as_script?
|
1064
|
-
begin
|
1065
|
-
MacWifi::CommandLineInterface.new(options).call
|
1066
|
-
end
|
1067
|
-
end
|
1068
|
-
end
|
1069
|
-
end
|
1070
|
-
|
1071
|
-
|
1072
|
-
end # module MacWifi
|
1073
|
-
|
22
|
+
require_relative '../lib/mac-wifi/main'
|
1074
23
|
|
1075
24
|
MacWifi::Main.new.call
|