mac-wifi 2.0.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,297 @@
1
+ require 'shellwords'
2
+
3
+ require_relative 'base_model'
4
+
5
+ module MacWifi
6
+
7
+ class MacOsModel < BaseModel
8
+
9
+ AIRPORT_CMD = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'
10
+
11
+ def initialize(verbose = false)
12
+ super
13
+ end
14
+
15
+
16
+ # This is determined by whether or not a line like the following appears in the output of `netstat -nr`:
17
+ # 0/1 10.137.0.41 UGSc 15 0 utun1
18
+ def vpn_running?
19
+ run_os_command('netstat -nr').split("\n").grep(/^0\/1.*utun1/).any?
20
+ end
21
+
22
+
23
+ # Identifies the (first) wireless network hardware port in the system, e.g. en0 or en1
24
+ def wifi_hardware_port
25
+ @wifi_hardware_port ||= begin
26
+ lines = run_os_command("networksetup -listallhardwareports").split("\n")
27
+ # Produces something like this:
28
+ # Hardware Port: Wi-Fi
29
+ # Device: en0
30
+ # Ethernet Address: ac:bc:32:b9:a9:9d
31
+ #
32
+ # Hardware Port: Bluetooth PAN
33
+ # Device: en3
34
+ # Ethernet Address: ac:bc:32:b9:a9:9e
35
+ wifi_port_line_num = (0...lines.size).detect do |index|
36
+ /: Wi-Fi$/.match(lines[index])
37
+ end
38
+ if wifi_port_line_num.nil?
39
+ raise %Q{Wifi port (e.g. "en0") not found in output of: networksetup -listallhardwareports}
40
+ else
41
+ lines[wifi_port_line_num + 1].split(': ').last
42
+ end
43
+ end
44
+ end
45
+
46
+
47
+ # Returns data pertaining to available wireless networks.
48
+ # For some reason, this often returns no results, so I've put the operation in a loop.
49
+ # I was unable to detect a sort strategy in the airport utility's output, so I sort
50
+ # the lines alphabetically, to show duplicates and for easier lookup.
51
+ def available_network_info
52
+ return nil unless wifi_on? # no need to try
53
+ command = "#{AIRPORT_CMD} -s"
54
+ max_attempts = 50
55
+
56
+
57
+ reformat_line = ->(line) do
58
+ ssid = line[0..31].strip
59
+ "%-32.32s%s" % [ssid, line[32..-1]]
60
+ end
61
+
62
+
63
+ process_tabular_data = ->(output) do
64
+ lines = output.split("\n")
65
+ header_line = lines[0]
66
+ data_lines = lines[1..-1]
67
+ data_lines.map! do |line|
68
+ # Reformat the line so that the name is left instead of right justified
69
+ reformat_line.(line)
70
+ end
71
+ data_lines.sort!
72
+ [reformat_line.(header_line)] + data_lines
73
+ end
74
+
75
+
76
+ output = try_os_command_until(command, ->(output) do
77
+ ! ([nil, ''].include?(output))
78
+ end)
79
+
80
+ if output
81
+ process_tabular_data.(output)
82
+ else
83
+ raise "Unable to get available network information after #{max_attempts} attempts."
84
+ end
85
+ end
86
+
87
+
88
+ def parse_network_names(info)
89
+ if info.nil?
90
+ nil
91
+ else
92
+ info[1..-1] \
93
+ .map { |line| line[0..32].rstrip } \
94
+ .uniq \
95
+ .sort { |s1, s2| s1.casecmp(s2) }
96
+ end
97
+ end
98
+
99
+
100
+ # @return an array of unique available network names only, sorted alphabetically
101
+ # Kludge alert: the tabular data does not differentiate between strings with and without leading whitespace
102
+ # Therefore, we get the data once in tabular format, and another time in XML format.
103
+ # The XML element will include any leading whitespace. However, it includes all <string> elements,
104
+ # many of which are not network names.
105
+ # As an improved approximation of the correct result, for each network name found in tabular mode,
106
+ # we look to see if there is a corresponding string element with leading whitespace, and, if so,
107
+ # replace it.
108
+ #
109
+ # This will not behave correctly if a given name has occurrences with different amounts of whitespace,
110
+ # e.g. ' x' and ' x'.
111
+ #
112
+ # The reason we don't use an XML parser to get the exactly correct result is that we don't want
113
+ # users to need to install any external dependencies in order to run this script.
114
+ def available_network_names
115
+
116
+ # Parses the XML text (using grep, not XML parsing) to find
117
+ # <string> elements, and extracts the network name candidates
118
+ # containing leading spaces from it.
119
+ get_leading_space_names = ->(text) do
120
+ text.split("\n") \
121
+ .grep(%r{<string>}) \
122
+ .sort \
123
+ .uniq \
124
+ .map { |line| line.gsub("<string>", '').gsub('</string>', '').gsub("\t", '') } \
125
+ .select { |s| s[0] == ' ' }
126
+ end
127
+
128
+
129
+ output_is_valid = ->(output) { ! ([nil, ''].include?(output)) }
130
+ tabular_data = try_os_command_until("#{AIRPORT_CMD} -s", output_is_valid)
131
+ xml_data = try_os_command_until("#{AIRPORT_CMD} -s -x", output_is_valid)
132
+
133
+ if tabular_data.nil? || xml_data.nil?
134
+ raise "Unable to get available network information; please try again."
135
+ end
136
+
137
+ tabular_data_lines = tabular_data[1..-1] # omit header line
138
+ names_no_spaces = parse_network_names(tabular_data_lines.split("\n")).map(&:strip)
139
+ names_maybe_spaces = get_leading_space_names.(xml_data)
140
+
141
+ names = names_no_spaces.map do |name_no_spaces|
142
+ match = names_maybe_spaces.detect do |name_maybe_spaces|
143
+ %r{[ \t]?#{name_no_spaces}$}.match(name_maybe_spaces)
144
+ end
145
+
146
+ match ? match : name_no_spaces
147
+ end
148
+
149
+ names.sort { |s1, s2| s1.casecmp(s2) } # sort alphabetically, case insensitively
150
+ end
151
+
152
+
153
+ # Returns data pertaining to "preferred" networks, many/most of which will probably not be available.
154
+ def preferred_networks
155
+ lines = run_os_command("networksetup -listpreferredwirelessnetworks #{wifi_hardware_port}").split("\n")
156
+ # Produces something like this, unsorted, and with leading tabs:
157
+ # Preferred networks on en0:
158
+ # LibraryWiFi
159
+ # @thePAD/Magma
160
+
161
+ lines.delete_at(0) # remove title line
162
+ lines.map! { |line| line.gsub("\t", '') } # remove leading tabs
163
+ lines.sort! { |s1, s2| s1.casecmp(s2) } # sort alphabetically, case insensitively
164
+ lines
165
+ end
166
+
167
+
168
+ # Returns true if wifi is on, else false.
169
+ def wifi_on?
170
+ lines = run_os_command("#{AIRPORT_CMD} -I").split("\n")
171
+ lines.grep("AirPort: Off").none?
172
+ end
173
+
174
+
175
+ # Turns wifi on.
176
+ def wifi_on
177
+ return if wifi_on?
178
+ run_os_command("networksetup -setairportpower #{wifi_hardware_port} on")
179
+ wifi_on? ? nil : raise("Wifi could not be enabled.")
180
+ end
181
+
182
+
183
+ # Turns wifi off.
184
+ def wifi_off
185
+ return unless wifi_on?
186
+ run_os_command("networksetup -setairportpower #{wifi_hardware_port} off")
187
+ wifi_on? ? raise("Wifi could not be disabled.") : nil
188
+ end
189
+
190
+
191
+ def connected_network_name
192
+ wifi_info['SSID']
193
+ end
194
+
195
+
196
+ # This method is called by BaseModel#connect to do the OS-specific connection logic.
197
+ def os_level_connect(network_name, password = nil)
198
+ command = "networksetup -setairportnetwork #{wifi_hardware_port} " + "#{Shellwords.shellescape(network_name)}"
199
+ if password
200
+ command << ' ' << Shellwords.shellescape(password)
201
+ end
202
+ run_os_command(command)
203
+ end
204
+
205
+
206
+ # @return:
207
+ # If the network is in the preferred networks list
208
+ # If a password is associated w/this network, return the password
209
+ # If not, return nil
210
+ # else
211
+ # raise an error
212
+ def os_level_preferred_network_password(preferred_network_name)
213
+ command = %Q{security find-generic-password -D "AirPort network password" -a "#{preferred_network_name}" -w 2>&1}
214
+ begin
215
+ return run_os_command(command).chomp
216
+ rescue OsCommandError => error
217
+ if error.exitstatus == 44 # network has no password stored
218
+ nil
219
+ else
220
+ raise
221
+ end
222
+ end
223
+ end
224
+
225
+
226
+ # Returns the IP address assigned to the wifi port, or nil if none.
227
+ def ip_address
228
+ begin
229
+ run_os_command("ipconfig getifaddr #{wifi_hardware_port}").chomp
230
+ rescue OsCommandError => error
231
+ if error.exitstatus == 1
232
+ nil
233
+ else
234
+ raise
235
+ end
236
+ end
237
+ end
238
+
239
+
240
+ def remove_preferred_network(network_name)
241
+ network_name = network_name.to_s
242
+ run_os_command("sudo networksetup -removepreferredwirelessnetwork " +
243
+ "#{wifi_hardware_port} #{Shellwords.shellescape(network_name)}")
244
+ end
245
+
246
+
247
+ # Returns the network currently connected to, or nil if none.
248
+ def current_network
249
+ lines = run_os_command("#{AIRPORT_CMD} -I").split("\n")
250
+ ssid_lines = lines.grep(/ SSID:/)
251
+ ssid_lines.empty? ? nil : ssid_lines.first.split('SSID: ').last.strip
252
+ end
253
+
254
+
255
+ # Disconnects from the currently connected network. Does not turn off wifi.
256
+ def disconnect
257
+ run_os_command("sudo #{AIRPORT_CMD} -z")
258
+ nil
259
+ end
260
+
261
+
262
+ # Returns some useful wifi-related information.
263
+ def wifi_info
264
+
265
+ info = {
266
+ wifi_on: wifi_on?,
267
+ internet_on: connected_to_internet?,
268
+ vpn_on: vpn_running?,
269
+ port: wifi_hardware_port,
270
+ network: current_network,
271
+ ip_address: ip_address,
272
+ timestamp: Time.now,
273
+ }
274
+ more_output = run_os_command(AIRPORT_CMD + " -I")
275
+ more_info = colon_output_to_hash(more_output)
276
+ info.merge!(more_info)
277
+ info.delete('AirPort') # will be here if off, but info is already in wifi_on key
278
+ info
279
+ end
280
+
281
+
282
+ # Parses output like the text below into a hash:
283
+ # SSID: Pattara211
284
+ # MCS: 5
285
+ # channel: 7
286
+ def colon_output_to_hash(output)
287
+ lines = output.split("\n")
288
+ lines.each_with_object({}) do |line, new_hash|
289
+ key, value = line.split(': ')
290
+ key = key.strip
291
+ value.strip! if value
292
+ new_hash[key] = value
293
+ end
294
+ end
295
+ private :colon_output_to_hash
296
+ end
297
+ end
@@ -0,0 +1,69 @@
1
+ require_relative 'command_line_interface'
2
+
3
+ module MacWifi
4
+
5
+ require 'json'
6
+ require 'optparse'
7
+ require 'ostruct'
8
+ require 'yaml'
9
+
10
+ class Main
11
+
12
+ def assert_os_is_mac_os
13
+ host_os = RbConfig::CONFIG["host_os"]
14
+ unless /darwin/.match(host_os) # e.g. "darwin16.4.0"
15
+ raise "This program currently works only on Mac OS. Platform is '#{host_os}'."
16
+ end
17
+ end
18
+
19
+
20
+ # Parses the command line with Ruby's internal 'optparse'.
21
+ # Looks for "-v" flag to set verbosity to true.
22
+ # optparse removes what it processes from ARGV, which simplifies our command parsing.
23
+ def parse_command_line
24
+ options = OpenStruct.new
25
+ OptionParser.new do |parser|
26
+ parser.on("-v", "--[no-]verbose", "Run verbosely") do |v|
27
+ options.verbose = v
28
+ end
29
+
30
+ parser.on("-s", "--shell", "Start interactive shell") do |v|
31
+ options.interactive_mode = true
32
+ end
33
+
34
+ parser.on("-o", "--output_format FORMAT", "Format output data") do |v|
35
+
36
+ transformers = {
37
+ 'i' => ->(object) { object.inspect },
38
+ 'j' => ->(object) { JSON.pretty_generate(object) },
39
+ 'p' => ->(object) { sio = StringIO.new; sio.puts(object); sio.string },
40
+ 'y' => ->(object) { object.to_yaml }
41
+ }
42
+
43
+ choice = v[0].downcase
44
+
45
+ unless transformers.keys.include?(choice)
46
+ raise %Q{Output format "#{choice}" not in list of available formats} +
47
+ " (#{transformers.keys.inspect})."
48
+ end
49
+
50
+ options.post_processor = transformers[choice]
51
+ end
52
+
53
+ parser.on("-h", "--help", "Show help") do |_help_requested|
54
+ ARGV << 'h' # pass on the request to the command processor
55
+ end
56
+ end.parse!
57
+ options
58
+ end
59
+
60
+
61
+ def call
62
+ assert_os_is_mac_os
63
+
64
+ options = parse_command_line
65
+
66
+ MacWifi::CommandLineInterface.new(options).call
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ module MacWifi
2
+
3
+ VERSION = '2.1.0'
4
+
5
+ end
data/lib/mac-wifi.rb ADDED
@@ -0,0 +1,3 @@
1
+ require_relative 'mac-wifi/version'
2
+
3
+ require_relative 'mac-wifi/main' # recursively requires the other files
data/mac-wifi.gemspec CHANGED
@@ -1,10 +1,12 @@
1
1
  # coding: utf-8
2
- # When changing the version, also change the version and the help text in the README.
3
- VERSION = '2.0.0'
2
+
3
+ require_relative 'lib/mac-wifi/version'
4
+
5
+ puts MacWifi::VERSION
4
6
 
5
7
  Gem::Specification.new do |spec|
6
8
  spec.name = "mac-wifi"
7
- spec.version = VERSION
9
+ spec.version = MacWifi::VERSION
8
10
  spec.authors = ["Keith Bennett"]
9
11
  spec.email = ["keithrbennett@gmail.com"]
10
12
  spec.description = %q{A command line interface for managing wifi on a Mac.}
@@ -15,8 +17,10 @@ Gem::Specification.new do |spec|
15
17
  spec.files = `git ls-files`.split($/)
16
18
  spec.bindir = 'exe'
17
19
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+
21
+ spec.require_paths = ["lib"]
22
+
18
23
  # spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
- # spec.require_paths = ['lib']
20
24
 
21
25
  spec.add_development_dependency "bundler", "~> 1.16"
22
26
  spec.add_development_dependency "rake", "~> 10.0"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mac-wifi
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Bennett
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-01-14 00:00:00.000000000 Z
11
+ date: 2018-01-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -66,6 +66,12 @@ files:
66
66
  - README.md
67
67
  - RELEASE_NOTES.md
68
68
  - exe/mac-wifi
69
+ - lib/mac-wifi.rb
70
+ - lib/mac-wifi/base_model.rb
71
+ - lib/mac-wifi/command_line_interface.rb
72
+ - lib/mac-wifi/mac_os_model.rb
73
+ - lib/mac-wifi/main.rb
74
+ - lib/mac-wifi/version.rb
69
75
  - mac-wifi.gemspec
70
76
  - sample-avail-network-data.xml
71
77
  - sample-available-networks.json
@@ -91,7 +97,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
97
  version: '0'
92
98
  requirements: []
93
99
  rubyforge_project:
94
- rubygems_version: 2.6.14
100
+ rubygems_version: 2.7.3
95
101
  signing_key:
96
102
  specification_version: 4
97
103
  summary: Mac wifi utility