Class: WifiWand::MacOsModel

Inherits:
BaseModel show all
Defined in:
lib/wifi-wand/models/mac_os_model.rb

Constant Summary collapse

AIRPORT_CMD =
'/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'

Instance Attribute Summary

Attributes inherited from BaseModel

#verbose_mode, #wifi_port

Instance Method Summary collapse

Methods inherited from BaseModel

#connect, #connected_to?, #connected_to_internet?, #cycle_network, #preferred_network_password, #public_ip_address_info, #random_mac_address, #remove_preferred_networks, #run_os_command, #till, #try_os_command_until

Constructor Details

#initialize(options = OpenStruct.new) ⇒ MacOsModel

Takes an OpenStruct containing options such as verbose mode and port name.



16
17
18
# File 'lib/wifi-wand/models/mac_os_model.rb', line 16

def initialize(options = OpenStruct.new)
  super
end

Instance Method Details

#airport_commandObject

Although at this time the airport command utility is predictable, allow putting it elsewhere in the path for overriding and easier fix if that location should change.



24
25
26
27
28
29
30
31
32
33
# File 'lib/wifi-wand/models/mac_os_model.rb', line 24

def airport_command
  airport_in_path = `which airport`.chomp
  if ! airport_in_path.empty?
    airport_in_path
  elsif File.exist?(AIRPORT_CMD)
    AIRPORT_CMD
  else
    raise Error.new("Airport command not found.")
  end
end

#available_network_infoObject

Returns data pertaining to available wireless networks. For some reason, this often returns no results, so I’ve put the operation in a loop. I was unable to detect a sort strategy in the airport utility’s output, so I sort the lines alphabetically, to show duplicates and for easier lookup.

Sample Output:

> [“SSID BSSID RSSI CHANNEL HT CC SECURITY (auth/unicast/group)”,

"ByCO-U00tRzUzMEg                 64:6c:b2:db:f3:0c -56  6       Y  -- NONE",
"Chancery                         0a:18:d6:0b:b9:c3 -82  11      Y  -- NONE",
"Chancery                         2a:a4:3c:03:33:99 -59  60,+1   Y  -- NONE",
"DIRECT-sq-BRAVIA                 02:71:cc:87:4a:8c -76  6       Y  -- WPA2(PSK/AES/AES) ",  #


74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/wifi-wand/models/mac_os_model.rb', line 74

def available_network_info
  return nil unless wifi_on? # no need to try
  command = "#{airport_command} -s | iconv -f macroman -t utf-8"
  max_attempts = 50

  reformat_line = ->(line) do
    ssid = line[0..31].strip
    "%-32.32s%s" % [ssid, line[32..-1]]
  end

  signal_strength = ->(line) { (line[50..54] || '').to_i }

  sort_in_place_by_signal_strength = ->(lines) do
    lines.sort! { |x,y| signal_strength.(y) <=> signal_strength.(x) }
  end

  process_tabular_data = ->(output) do
    lines = output.split("\n")
    header_line = lines[0]
    data_lines = lines[1..-1]
    data_lines.map! do |line|
      # Reformat the line so that the name is left instead of right justified
      reformat_line.(line)
    end
    sort_in_place_by_signal_strength.(data_lines)
    [reformat_line.(header_line)] + data_lines
  end

  output = try_os_command_until(command, ->(output) do
    ! ([nil, ''].include?(output))
  end)

  if output
    process_tabular_data.(output)
  else
    raise Error.new("Unable to get available network information after #{max_attempts} attempts.")
  end
end

#available_network_namesObject

The Mac OS airport utility (at /System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport) outputs the network names right padded with spaces so there is no way to differentiate a network name with leading space(s) from one without:

               SSID BSSID             RSSI CHANNEL HT CC SECURITY (auth/unicast/group)
ngHub_319442NL0293C 04:a1:51:58:5b:05 -65  11      Y  US WPA2(PSK/AES/AES)
    NETGEAR89_2GEXT 9c:3d:cf:11:69:b4 -67  8       Y  US NONE

To remedy this, they offer a “-x” option that outputs the information in (pseudo) XML. This XML has ‘dict’ elements that contain many elements. The SSID can be found in the XML element <string> which immediately follows an XML element whose text is “SSID_STR”. Unfortunately, since there is no way to connect the two other than their physical location, the key is rather useless for XML parsing.

I tried extracting the arrays of keys and strings, and finding the string element at the same position in the string array as the ‘SSID_STR’ was in the keys array. However, not all keys had string elements, so the index in the key array was the wrong index. Here is an excerpt from the XML output:

<key>RSSI</key> <integer>-91</integer> <key>SSID</key> <data> TkVUR0VBUjY1 </data> <key>SSID_STR</key> <string>NETGEAR65</string>

The kludge I came up with was that the ssid was always the 2nd value in the <string> element array, so that’s what is used here.

But now even that approach has been superseded by the XPath approach now used.

REXML is used here to avoid the need for the user to install Nokogiri.



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/wifi-wand/models/mac_os_model.rb', line 149

def available_network_names
  return nil unless wifi_on? # no need to try

  # For some reason, the airport command very often returns nothing, so we need to try until
  # we get data in the response:

  command = "#{airport_command} -s -x | iconv -f macroman -t utf-8"
  stop_condition = ->(response) { ! [nil, ''].include?(response) }
  output = try_os_command_until(command, stop_condition)
  doc = REXML::Document.new(output)
  xpath = '//key[text() = "SSID_STR"][1]/following-sibling::*[1]' # provided by @ScreenStaring on Twitter
  REXML::XPath.match(doc, xpath) \
      .map(&:text) \
      .sort { |x,y| x.casecmp(y) } \
      .uniq
end

#connected_network_nameObject

Returns the network currently connected to, or nil if none.



266
267
268
269
270
271
# File 'lib/wifi-wand/models/mac_os_model.rb', line 266

def connected_network_name
  return nil unless wifi_on? # no need to try
  lines = run_os_command("#{airport_command} -I").split("\n")
  ssid_lines = lines.grep(/ SSID:/)
  ssid_lines.empty? ? nil : ssid_lines.first.split('SSID: ').last.lstrip
end

#detect_wifi_portObject

Identifies the (first) wireless network hardware port in the system, e.g. en0 or en1 This may not detect wifi ports with nonstandard names, such as USB wifi devices.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/wifi-wand/models/mac_os_model.rb', line 38

def detect_wifi_port

  lines = run_os_command("networksetup -listallhardwareports").split("\n")
  # Produces something like this:
  # Hardware Port: Wi-Fi
  # Device: en0
  # Ethernet Address: ac:bc:32:b9:a9:9d
  #
  # Hardware Port: Bluetooth PAN
  # Device: en3
  # Ethernet Address: ac:bc:32:b9:a9:9e

  wifi_port_line_num = (0...lines.size).detect do |index|
    /: Wi-Fi$/.match(lines[index])
  end

  if wifi_port_line_num.nil?
    raise Error.new(%Q{Wifi port (e.g. "en0") not found in output of: networksetup -listallhardwareports})
  else
    lines[wifi_port_line_num + 1].split(': ').last
  end
end

#disconnectObject

Disconnects from the currently connected network. Does not turn off wifi.



275
276
277
278
279
# File 'lib/wifi-wand/models/mac_os_model.rb', line 275

def disconnect
  return nil unless wifi_on? # no need to try
  run_os_command("sudo #{airport_command} -z")
  nil
end

#ip_addressObject

Returns the IP address assigned to the wifi port, or nil if none.



244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/wifi-wand/models/mac_os_model.rb', line 244

def ip_address
  return nil unless wifi_on? # no need to try
  begin
    run_os_command("ipconfig getifaddr #{wifi_port}").chomp
  rescue OsCommandError => error
    if error.exitstatus == 1
      nil
    else
      raise
    end
  end
end

#is_wifi_port?(port) ⇒ Boolean

Returns whether or not the specified interface is a WiFi interfae.

Returns:

  • (Boolean)


183
184
185
186
187
# File 'lib/wifi-wand/models/mac_os_model.rb', line 183

def is_wifi_port?(port)
  run_os_command("networksetup -listpreferredwirelessnetworks #{port} 2>/dev/null")
  exit_status = $?.exitstatus
  exit_status != 10
end

#mac_addressObject

TODO: Add capability to change the MAC address using a command in the form of:

sudo ifconfig en0 ether aa:bb:cc:dd:ee:ff

However, the MAC address will be set to the real hardware address on restart. One way to implement this is to have an optional address argument, then this method returns the current address if none is provided, but sets to the specified address if it is.



288
289
290
# File 'lib/wifi-wand/models/mac_os_model.rb', line 288

def mac_address
  run_os_command("ifconfig #{wifi_port} | awk '/ether/{print $2}'").chomp
end

#nameservers_using_networksetupObject



401
402
403
404
405
406
407
# File 'lib/wifi-wand/models/mac_os_model.rb', line 401

def nameservers_using_networksetup
  output = run_os_command("networksetup -getdnsservers Wi-Fi")
  if output == "There aren't any DNS Servers set on Wi-Fi.\n"
    output = ''
  end
  output.split("\n")
end

#nameservers_using_resolv_confObject

Though this is strictly not OS-agnostic, it will be used by most OS’s, and can be overridden by subclasses (e.g. Windows).

Returns:

  • array of nameserver IP addresses from /etc/resolv.conf, or nil if not found



383
384
385
386
387
388
389
# File 'lib/wifi-wand/models/mac_os_model.rb', line 383

def nameservers_using_resolv_conf
  begin
    File.readlines('/etc/resolv.conf').grep(/^nameserver /).map { |line| line.split.last }
  rescue Errno::ENOENT
    nil
  end
end

#nameservers_using_scutilObject



392
393
394
395
396
397
398
# File 'lib/wifi-wand/models/mac_os_model.rb', line 392

def nameservers_using_scutil
  output = run_os_command('scutil --dns')
  nameserver_lines_scoped_and_unscoped = output.split("\n").grep(/^\s*nameserver\[/)
  unique_nameserver_lines = nameserver_lines_scoped_and_unscoped.uniq # take the union
  nameservers = unique_nameserver_lines.map { |line| line.split(' : ').last.strip }
  nameservers
end

#open_application(application_name) ⇒ Object



354
355
356
# File 'lib/wifi-wand/models/mac_os_model.rb', line 354

def open_application(application_name)
  run_os_command('open -a ' + application_name)
end

#open_resource(resource_url) ⇒ Object



359
360
361
# File 'lib/wifi-wand/models/mac_os_model.rb', line 359

def open_resource(resource_url)
  run_os_command('open ' + resource_url)
end

#os_level_connect(network_name, password = nil) ⇒ Object

This method is called by BaseModel#connect to do the OS-specific connection logic.



214
215
216
217
218
219
220
# File 'lib/wifi-wand/models/mac_os_model.rb', line 214

def os_level_connect(network_name, password = nil)
  command = "networksetup -setairportnetwork #{wifi_port} " + "#{Shellwords.shellescape(network_name)}"
  if password
    command << ' ' << Shellwords.shellescape(password)
  end
  run_os_command(command)
end

#os_level_preferred_network_password(preferred_network_name) ⇒ Object

@return:

If the network is in the preferred networks list
  If a password is associated w/this network, return the password
  If not, return nil
else
  raise an error


229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/wifi-wand/models/mac_os_model.rb', line 229

def os_level_preferred_network_password(preferred_network_name)
  command = %Q{security find-generic-password -D "AirPort network password" -a "#{preferred_network_name}" -w 2>&1}
  begin
    return run_os_command(command).chomp
  rescue OsCommandError => error
    if error.exitstatus == 44 # network has no password stored
      nil
    else
      raise
    end
  end
end

#preferred_networksObject

Returns data pertaining to “preferred” networks, many/most of which will probably not be available.



168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/wifi-wand/models/mac_os_model.rb', line 168

def preferred_networks
  lines = run_os_command("networksetup -listpreferredwirelessnetworks #{wifi_port}").split("\n")
  # Produces something like this, unsorted, and with leading tabs:
  # Preferred networks on en0:
  #         LibraryWiFi
  #         @thePAD/Magma

  lines.delete_at(0)                         # remove title line
  lines.map! { |line| line.gsub("\t", '') }  # remove leading tabs
  lines.sort! { |s1, s2| s1.casecmp(s2) }    # sort alphabetically, case insensitively
  lines
end

#remove_preferred_network(network_name) ⇒ Object



258
259
260
261
262
# File 'lib/wifi-wand/models/mac_os_model.rb', line 258

def remove_preferred_network(network_name)
  network_name = network_name.to_s
  run_os_command("sudo networksetup -removepreferredwirelessnetwork " +
                     "#{wifi_port} #{Shellwords.shellescape(network_name)}")
end

#set_nameservers(nameservers) ⇒ Object



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/wifi-wand/models/mac_os_model.rb', line 329

def set_nameservers(nameservers)
  arg = if nameservers == :clear
    'empty'
  else
    bad_addresses = nameservers.reject do |ns|
      begin
        IPAddr.new(ns).ipv4?
        true
      rescue => e
        puts e
        false
      end
    end

    unless bad_addresses.empty?
      raise Error.new("Bad IP addresses provided: #{bad_addresses.join(', ')}")
    end
    nameservers.join(' ')
  end # end assignment to arg variable

  run_os_command("networksetup -setdnsservers Wi-Fi #{arg}")
  nameservers
end

#wifi_infoObject

Returns some useful wifi-related information.



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/wifi-wand/models/mac_os_model.rb', line 294

def wifi_info

  connected = begin
    connected_to_internet?
  rescue
    false
  end

  info = {
      'wifi_on'     => wifi_on?,
      'internet_on' => connected,
      'port'        => wifi_port,
      'network'     => connected_network_name,
      'ip_address'  => ip_address,
      'mac_address' => mac_address,
      'nameservers' => nameservers_using_scutil,
      'timestamp'   => Time.now,
  }
  more_output = run_os_command(airport_command + " -I")
  more_info   = colon_output_to_hash(more_output)
  info.merge!(more_info)
  info.delete('AirPort') # will be here if off, but info is already in wifi_on key

  if info['internet_on']
    begin
      info['public_ip'] = public_ip_address_info
    rescue => e
      puts "Error obtaining public IP address info, proceeding with everything else:"
      puts e.to_s
    end
  end
  info
end

#wifi_offObject

Turns wifi off.



206
207
208
209
210
# File 'lib/wifi-wand/models/mac_os_model.rb', line 206

def wifi_off
  return unless wifi_on?
  run_os_command("networksetup -setairportpower #{wifi_port} off")
  wifi_on? ? Error.new(raise("Wifi could not be disabled.")) : nil
end

#wifi_onObject

Turns wifi on.



198
199
200
201
202
# File 'lib/wifi-wand/models/mac_os_model.rb', line 198

def wifi_on
  return if wifi_on?
  run_os_command("networksetup -setairportpower #{wifi_port} on")
  wifi_on? ? nil : Error.new(raise("Wifi could not be enabled."))
end

#wifi_on?Boolean

Returns true if wifi is on, else false.

Returns:

  • (Boolean)


191
192
193
194
# File 'lib/wifi-wand/models/mac_os_model.rb', line 191

def wifi_on?
  lines = run_os_command("#{airport_command} -I").split("\n")
  lines.grep("AirPort: Off").none?
end