macos.rb 0.50.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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG +572 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE +21 -0
  5. data/README.md +129 -0
  6. data/Rakefile +9 -0
  7. data/lib/Array/all_but_last.rb +7 -0
  8. data/lib/MacOS/Apple80211.rb +68 -0
  9. data/lib/MacOS/Applications.rb +73 -0
  10. data/lib/MacOS/Defaults/ComAppleDock.rb +153 -0
  11. data/lib/MacOS/Defaults/NSGlobalDomain/NSUserKeyEquivalents.rb +109 -0
  12. data/lib/MacOS/Defaults.rb +126 -0
  13. data/lib/MacOS/DiskUtil.rb +63 -0
  14. data/lib/MacOS/Dmg.rb +99 -0
  15. data/lib/MacOS/HardwarePort.rb +79 -0
  16. data/lib/MacOS/IfConfig.rb +183 -0
  17. data/lib/MacOS/Keychain.rb +26 -0
  18. data/lib/MacOS/LaunchControl.rb +36 -0
  19. data/lib/MacOS/SystemProfiler.rb +119 -0
  20. data/lib/MacOS/USBFlashDrive.rb +26 -0
  21. data/lib/MacOS/Users.rb +16 -0
  22. data/lib/MacOS/VERSION.rb +6 -0
  23. data/lib/MacOS/VPN.rb +13 -0
  24. data/lib/MacOS/Voice.rb +40 -0
  25. data/lib/MacOS.rb +27 -0
  26. data/lib/Module/alias_methods.rb +7 -0
  27. data/lib/Ordinal/Array.rb +8 -0
  28. data/lib/Ordinal.rb +157 -0
  29. data/lib/Thoran/Array/AllButLast/all_but_last.rb +28 -0
  30. data/lib/Thoran/Array/LastX/lastX.rb +33 -0
  31. data/lib/Thoran/Module/AliasMethods/alias_methods.rb +23 -0
  32. data/lib/Thoran/String/Capture/capture.rb +28 -0
  33. data/lib/Version.rb +381 -0
  34. data/macos.rb.gemspec +59 -0
  35. data/test/MacOS/Apple80211_test.rb +80 -0
  36. data/test/MacOS/Applications_test.rb +82 -0
  37. data/test/MacOS/Defaults/ComAppleDock_test.rb +66 -0
  38. data/test/MacOS/Defaults/NSGlobalDomain/NSUserKeyEquivalents_test.rb +67 -0
  39. data/test/MacOS/Defaults_test.rb +144 -0
  40. data/test/MacOS/DiskUtil_test.rb +48 -0
  41. data/test/MacOS/Dmg_test.rb +69 -0
  42. data/test/MacOS/HardwarePort_test.rb +46 -0
  43. data/test/MacOS/IfConfig_test.rb +144 -0
  44. data/test/MacOS/Keychain_test.rb +22 -0
  45. data/test/MacOS/LaunchControl_test.rb +40 -0
  46. data/test/MacOS/SystemProfiler_test.rb +69 -0
  47. data/test/MacOS/USBFlashDrive_test.rb +20 -0
  48. data/test/MacOS/Users_test.rb +10 -0
  49. data/test/MacOS/VPN_test.rb +18 -0
  50. data/test/MacOS/Voice_test.rb +46 -0
  51. data/test/MacOS_test.rb +17 -0
  52. data/test/VERSION_test.rb +19 -0
  53. data/test/commands_test.rb +50 -0
  54. data/test/fixtures/airport-I-deprecated.txt +2 -0
  55. data/test/fixtures/airport-I.txt +17 -0
  56. data/test/fixtures/defaults-domains.txt +1 -0
  57. data/test/fixtures/defaults-export-com.apple.dock.xml +47 -0
  58. data/test/fixtures/defaults-read-NSUserKeyEquivalents.txt +4 -0
  59. data/test/fixtures/df-k.txt +6 -0
  60. data/test/fixtures/diskutil-list-plist.xml +104 -0
  61. data/test/fixtures/dscl-list-users.txt +8 -0
  62. data/test/fixtures/ifconfig-a.txt +53 -0
  63. data/test/fixtures/networksetup-listallhardwareports.txt +39 -0
  64. data/test/fixtures/say-v.txt +8 -0
  65. data/test/fixtures/system_profiler-SPAirPortDataType.txt +44 -0
  66. data/test/fixtures/system_profiler-SPSoftwareDataType.txt +14 -0
  67. data/test/fixtures/system_profiler-SPUSBDataType.txt +58 -0
  68. data/test/gemspec_test.rb +50 -0
  69. data/test/helper.rb +18 -0
  70. metadata +183 -0
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # macos.rb
2
+
3
+ Work with macOS from Ruby: network interfaces and hardware ports, the system's version, defaults, disks and disk images, USB flash drives, applications, users, voices, the keychain and launchd.
4
+
5
+ Each part wraps the command macOS provides for it, such as `ifconfig`, `networksetup`, `system_profiler`, `defaults` or `diskutil`, and gives back Ruby objects rather than text.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'macos.rb'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```
18
+ $ bundle install
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```
24
+ $ gem install macos.rb
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ruby
30
+ require 'MacOS'
31
+ ```
32
+
33
+ loads every part, or require only the parts you want, such as `require 'MacOS/IfConfig'`.
34
+
35
+ ### Network interfaces and hardware ports
36
+
37
+ ```ruby
38
+ MacOS::IfConfig.interface_names # => ["lo0", "en0", "utun4", ...]
39
+ MacOS::IfConfig.up_interfaces_names # those active, or flagged UP, and with an address
40
+ en0 = MacOS::IfConfig.find_by_interface('en0')
41
+ en0.ipv4_address # => "192.0.2.114"
42
+ en0.mac_address # => "00:00:5e:00:53:02"
43
+ MacOS::IfConfig.all_matching(/\Autun/) # a Regexp, or a String matched literally
44
+
45
+ MacOS::HardwarePort.find_by_name('Wi-Fi').interface # => "en0"
46
+
47
+ MacOS::VPN.up? # the first tunnel interface which is up, if any
48
+
49
+ MacOS::Apple80211.channel # the Wi-Fi connection's; also .ssid, .link_auth and .attributes
50
+ ```
51
+
52
+ Apple80211 reads `airport -I`, and from macOS 14.4 on, where `airport` reports nothing, `system_profiler SPAirPortDataType`. That gives the channel and security in its own form, `"36 (5GHz, 80MHz)"` and `"WPA2 Personal"`, rather than airport's, and no SSID: macOS redacts the network's name from programs without location access.
53
+
54
+ ### The system
55
+
56
+ ```ruby
57
+ MacOS.version # a Version, which compares with strings: MacOS.version >= '14.0'
58
+ MacOS.version.major # => "15"
59
+ MacOS::SystemProfiler.kernel_version.to_s # => "24.6.0"
60
+ MacOS::SystemProfiler.boot_mode # => "Normal"
61
+ MacOS::SystemProfiler.time_since_boot # => "6 days, 2 hours, 34 minutes"
62
+ ```
63
+
64
+ ### Defaults
65
+
66
+ ```ruby
67
+ MacOS::Defaults.domains # every domain defaults knows
68
+ MacOS::Defaults.new(domain: 'com.apple.dock').export # to a plist file, which #to_h then reads
69
+
70
+ MacOS::Defaults::ComAppleDock.read('autohide', 'tilesize') # => {"autohide" => "yes", "tilesize" => 48}
71
+ MacOS::Defaults::ComAppleDock.load('persistent-apps' => ['/Applications/Safari.app'], 'autohide' => true)
72
+
73
+ keys = MacOS::Defaults::NSGlobalDomain::NSUserKeyEquivalents
74
+ keys.assign_keys(%w{cmd alt left}, 'Previous Tab') # a keyboard shortcut for a menu item
75
+ keys.read_keys # => [{keys: [:cmd, :alt, :left], menu_items: ["Previous Tab"]}, ...]
76
+ ```
77
+
78
+ ### Disks, disk images and USB flash drives
79
+
80
+ ```ruby
81
+ MacOS::DiskUtil.volumes # => [{device_id: "disk4s1", mount_point: "/Volumes/EXAMPLE", name: "EXAMPLE", size: ...}, ...]
82
+ MacOS::DiskUtil.free_bytes_for_volume_name('EXAMPLE')
83
+
84
+ MacOS::Dmg.new(path_to_dmg: 'Example-1.0.dmg', disk_image_name: 'Example-1.0.dmg', package: 'Example').install
85
+
86
+ MacOS::USBFlashDrive.all_mount_points # => ["/Volumes/EXAMPLE", ...]
87
+ ```
88
+
89
+ ### Applications, users, voices, the keychain and launchd
90
+
91
+ ```ruby
92
+ MacOS::Applications.installed?('Safari') # in /System/Applications, /Applications or ~/Applications
93
+ MacOS::Applications.start('Safari')
94
+
95
+ MacOS::Users.all # the real users, without the system and service accounts
96
+
97
+ MacOS::Voice.list.map(&:name) # every voice say knows
98
+ MacOS::Voice.random_voice
99
+
100
+ MacOS::Keychain.wifi_password('ExampleNet') # nil where the keychain has none
101
+
102
+ MacOS::LaunchControl.new('/Library/LaunchAgents/com.example.agent.plist').load
103
+ ```
104
+
105
+ Some of these change the system, and some ask for `sudo`: bringing interfaces down, installing from a disk image, writing defaults and loading launchd plists. Reading never does.
106
+
107
+ ## Versions
108
+
109
+ Versions before 0.49.0 were reckoned when the library was put together, from its components' own versions; 0.50.0 is the first released as a gem. Each version carries the libraries it requires, at the versions current at its date.
110
+
111
+ ## Tests
112
+
113
+ ```
114
+ $ bundle exec rake test
115
+ ```
116
+
117
+ The tests never run the commands macOS provides. Each is given output captured from them, in `test/fixtures`, with its MAC addresses, IP addresses and names made anonymous.
118
+
119
+ ## Contributing
120
+
121
+ 1. Fork it (https://github.com/thoran/macos.rb/fork)
122
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
123
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
124
+ 4. Push to the branch (`git push origin my-new-feature`)
125
+ 5. Create a new pull request
126
+
127
+ ## License
128
+
129
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # Rakefile
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.test_files = FileList['test/**/*_test.rb']
7
+ end
8
+
9
+ task default: :test
@@ -0,0 +1,7 @@
1
+ # Array/all_but_last.rb
2
+ # Array#all_but_last
3
+
4
+ # 20180804
5
+ # 0.1.0 (The same version number as the current version of Thoran/Array/AllButLast.)
6
+
7
+ require 'Thoran/Array/AllButLast/all_but_last'
@@ -0,0 +1,68 @@
1
+ # MacOS/Apple80211.rb
2
+ # MacOS::Apple80211
3
+
4
+ module MacOS
5
+ class Apple80211
6
+ class << self
7
+
8
+ def airport_i
9
+ `/System/Library/PrivateFrameworks/Apple80211.framework/Resources/airport -I`
10
+ rescue Errno::ENOENT
11
+ ''
12
+ end
13
+
14
+ def system_profiler_airport
15
+ `system_profiler SPAirPortDataType`
16
+ end
17
+
18
+ # airport -I's attributes, or, where it gives none, as from macOS 14.4 on, system_profiler's.
19
+ def attributes
20
+ from_airport = airport_attributes
21
+ from_airport.empty? ? system_profiler_attributes : from_airport
22
+ end
23
+
24
+ def channel
25
+ attributes['channel']
26
+ end
27
+
28
+ def ssid
29
+ attributes['SSID']
30
+ end
31
+
32
+ def link_auth
33
+ attributes['link auth']
34
+ end
35
+
36
+ private
37
+
38
+ # The deprecation warning macOS 14.4 and later print, in place of the attributes, left out.
39
+ def airport_attributes
40
+ airport_i.split("\n").inject({}) do |a, line|
41
+ if line && line.match(/: .+/)
42
+ k, v = line.split(': ')
43
+ a[k.strip] = v.gsub('"', '')
44
+ end
45
+ a
46
+ end.reject{|k, _| k == 'WARNING'}
47
+ end
48
+
49
+ # The current network's fields, with its name as SSID where macOS doesn't redact it, and its Channel and Security as
50
+ # channel and link auth.
51
+ def system_profiler_attributes
52
+ lines = system_profiler_airport.lines
53
+ start = lines.index{|line| line.strip == 'Current Network Information:'} or return {}
54
+ name_line = lines[start + 1].to_s
55
+ return {} unless name_line.strip.end_with?(':')
56
+ depth = name_line[/\A */].size
57
+ fields = lines[(start + 2)..].take_while{|line| line[/\A */].size > depth && !line.strip.empty?}
58
+ attributes = fields.to_h{|line| line.strip.split(': ', 2)}
59
+ name = name_line.strip.chomp(':')
60
+ attributes['SSID'] = name unless name == '<redacted>'
61
+ attributes['channel'] = attributes['Channel'] if attributes['Channel']
62
+ attributes['link auth'] = attributes['Security'] if attributes['Security']
63
+ attributes
64
+ end
65
+
66
+ end # class << self
67
+ end
68
+ end
@@ -0,0 +1,73 @@
1
+ # MacOS/Applications.rb
2
+ # MacOS::Applications
3
+
4
+ # Description: Locates and detects installed .app application bundles across the system, local, and user
5
+ # file-system domains, in the manner of `which` for command-line executables.
6
+
7
+ require 'MacOS/Users'
8
+
9
+ module MacOS
10
+ module Applications
11
+ module_function
12
+
13
+ def paths(username = ENV['USER'])
14
+ system_paths +
15
+ system_utility_paths +
16
+ local_paths +
17
+ local_utility_paths +
18
+ user_paths(username)
19
+ end
20
+
21
+ def system_paths
22
+ in_directory('/System/Applications')
23
+ end
24
+
25
+ def system_utility_paths
26
+ in_directory('/System/Applications/Utilities')
27
+ end
28
+
29
+ def local_paths
30
+ in_directory('/Applications')
31
+ end
32
+
33
+ def local_utility_paths
34
+ in_directory('/Applications/Utilities')
35
+ end
36
+
37
+ def user_paths(username = ENV['USER'])
38
+ under_directory("/Users/#{username}/Applications")
39
+ end
40
+
41
+ def all_user_paths
42
+ MacOS::Users.all.collect{|username| user_paths(username)}.flatten
43
+ end
44
+
45
+ def in_directory(directory)
46
+ Dir["#{directory}/*.app"]
47
+ end
48
+
49
+ def under_directory(directory)
50
+ Dir["#{directory}/**/*.app"].reject{|path| path.sub(/\.app$/, '').match?(/\.app\//)}
51
+ end
52
+
53
+ def path_for(application_name)
54
+ paths.detect do |path|
55
+ File.basename(path) == application_name ||
56
+ File.basename(path, '.app') == application_name
57
+ end
58
+ end
59
+
60
+ def installed?(application_name)
61
+ path_for(application_name) ? true : false
62
+ end
63
+
64
+ def start(application_name)
65
+ return unless path = path_for(application_name)
66
+ system('open', path, exception: true)
67
+ end
68
+ alias_method :launch, :start
69
+ alias_method :open, :start
70
+ alias_method :run, :start
71
+ module_function :launch, :open, :run
72
+ end
73
+ end
@@ -0,0 +1,153 @@
1
+ # MacOS/Defaults/ComAppleDock.rb
2
+ # MacOS::Defaults::ComAppleDock
3
+
4
+ require 'plist'
5
+ require 'yaml'
6
+
7
+ module MacOS
8
+ class Defaults
9
+ module ComAppleDock
10
+ ARRAY_ATTRIBUTES = %w{
11
+ persistent-apps
12
+ persistent-others
13
+ recent-apps
14
+ }
15
+ BOOLEAN_ATTRIBUTES = %w{
16
+ autohide
17
+ expose-group-apps
18
+ launchanim
19
+ magnification
20
+ minimize-to-application
21
+ show-process-indicators
22
+ single-app
23
+ trash-full
24
+ }
25
+ INTEGER_ATTRIBUTES = %w{
26
+ tilesize
27
+ }
28
+ STRING_ATTRIBUTES = %w{
29
+ mineffect
30
+ orientation
31
+ }
32
+ ALL_ATTRIBUTES = (ARRAY_ATTRIBUTES + BOOLEAN_ATTRIBUTES + INTEGER_ATTRIBUTES + STRING_ATTRIBUTES).sort
33
+
34
+ class << self
35
+ def app_plist_data(urlified_app_string)
36
+ <<~APP_PLIST
37
+ <dict>
38
+ <key>tile-data</key>
39
+ <dict>
40
+ <key>file-data</key>
41
+ <dict>
42
+ <key>_CFURLString</key>
43
+ <string>#{urlified_app_string}</string>
44
+ <key>_CFURLStringType</key>
45
+ <integer>15</integer>
46
+ </dict>
47
+ </dict>
48
+ </dict>
49
+ APP_PLIST
50
+ end
51
+
52
+ def urlified_app_string(item)
53
+ "file://#{File.expand_path(item)}"
54
+ end
55
+
56
+ def persistent_app_write_command(app_plist_data)
57
+ "defaults write com.apple.dock persistent-apps -array-add '#{app_plist_data}'"
58
+ end
59
+
60
+ def add_persistent_app(persistent_app_path)
61
+ system(
62
+ persistent_app_write_command(
63
+ app_plist_data(
64
+ urlified_app_string(
65
+ persistent_app_path
66
+ )
67
+ )
68
+ )
69
+ )
70
+ end
71
+
72
+ def load_persistent_apps(persistent_app_paths)
73
+ persistent_app_paths.each do |persistent_app_path|
74
+ add_persistent_app(persistent_app_path)
75
+ end
76
+ end
77
+
78
+ def delete_all_items
79
+ delete_item('persistent-apps')
80
+ delete_item('persistent-others')
81
+ delete_item('recent-apps')
82
+ end
83
+
84
+ def delete_item(scope)
85
+ system("defaults delete com.apple.dock #{scope}")
86
+ end
87
+
88
+ def attribute_write_command(attr, value)
89
+ value = (
90
+ if BOOLEAN_ATTRIBUTES.include?(attr) # Without adding -bool it doesn't work.
91
+ "-bool #{value}"
92
+ elsif INTEGER_ATTRIBUTES.include?(attr) # Adding -int isn't strictly necessary for it to work, but is correct syntax.
93
+ "-int #{value}"
94
+ else
95
+ value
96
+ end
97
+ )
98
+ "defaults write com.apple.dock #{attr} #{value}"
99
+ end
100
+
101
+ def load_attribute(attr, value)
102
+ system(attribute_write_command(attr, value))
103
+ end
104
+
105
+ def load(dock_config)
106
+ dock_config.each do |attr, value|
107
+ if attr == 'persistent-apps'
108
+ load_persistent_apps(value)
109
+ else
110
+ load_attribute(attr, value)
111
+ end
112
+ end
113
+ end
114
+
115
+ def peristent_app_paths(plist)
116
+ plist['persistent-apps'].map do |persistent_app|
117
+ file_url = persistent_app['tile-data']['file-data']['_CFURLString']
118
+ file_url.sub(/file:\/\//, '').gsub(/%20/, ' ').sub(/\/Users\/.+?(\/.+)\//, '~\1')
119
+ end
120
+ end
121
+
122
+ def boolean_to_yes_no(boolean_string)
123
+ case boolean_string
124
+ when true; 'yes'
125
+ when false; 'no'
126
+ end
127
+ end
128
+
129
+ def read(*attributes)
130
+ return_value = {}
131
+ plist = Plist.parse_xml(`defaults export com.apple.dock -`)
132
+ attribute_list = (attributes.empty? ? ALL_ATTRIBUTES : attributes)
133
+ attribute_list.each do |attribute|
134
+ next if %w{persistent-others recent-apps}.include?(attribute)
135
+ if attribute == 'persistent-apps'
136
+ return_value[attribute] = peristent_app_paths(plist)
137
+ elsif BOOLEAN_ATTRIBUTES.include?(attribute)
138
+ return_value[attribute] = boolean_to_yes_no(plist[attribute])
139
+ else
140
+ return_value[attribute] = plist[attribute]
141
+ end
142
+ end
143
+ return_value
144
+ end
145
+
146
+ def to_yaml
147
+ YAML.dump(read).gsub(/['"]/, '')
148
+ end
149
+ alias_method :dump, :to_yaml
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,109 @@
1
+ # MacOS/Defaults/NSGlobalDomain/NSUserKeyEquivalents.rb
2
+ # MacOS::Defaults::NSGlobalDomain::NSUserKeyEquivalents
3
+
4
+ require 'set'
5
+ require 'yaml'
6
+
7
+ module MacOS
8
+ class Defaults
9
+ module NSGlobalDomain
10
+ class NSUserKeyEquivalents
11
+ KEY_MAP = {
12
+ [:cmd, :command, :command_key] => '@',
13
+ [:alt, :alt_key, :option, :option_key] => '~',
14
+ [:ctrl, :control, :control_key] => '^',
15
+ [:shift, :shift_key] => '$',
16
+ [:left, :left_key, :left_arrow, :left_arrow_key] => '\UF702', # Also '\U2190'?
17
+ [:right, :right_key, :right_arrow, :right_arrow_key] => '\UF703', # Also '\U2192'?
18
+ }
19
+
20
+ class << self
21
+ def key_code(*keys)
22
+ return_value = []
23
+ keys.flatten.each do |key|
24
+ if ('a'..'z').include?(key.to_s.downcase)
25
+ return_value << key.downcase
26
+ elsif matching_pair = KEY_MAP.find{|key_mapping| key_mapping.first.map(&:to_s).map(&:downcase).include?(key.to_s.downcase)}
27
+ return_value << matching_pair.last
28
+ else
29
+ raise "No key #{key} found."
30
+ end
31
+ end
32
+ return_value.join
33
+ end
34
+
35
+ def assign_key_code_command_string(key_code, menu_item)
36
+ "defaults write NSGlobalDomain NSUserKeyEquivalents -dict-add '#{menu_item}' '#{key_code}'"
37
+ end
38
+
39
+ def assign_key_code_command_strings(key_code, menu_item_or_items)
40
+ return_value = []
41
+ [menu_item_or_items].flatten.each do |menu_item|
42
+ return_value << assign_key_code_command_string(key_code, menu_item)
43
+ end
44
+ return_value
45
+ end
46
+
47
+ def assign_key_code(key_code, menu_item_or_items)
48
+ return_value = true
49
+ assign_key_code_command_strings(key_code, menu_item_or_items).each do |assign_key_code_command_string|
50
+ system(assign_key_code_command_string)
51
+ return_value = return_value && $?.success?
52
+ end
53
+ return_value
54
+ end
55
+
56
+ def assign_keys(key_or_keys, menu_item_or_items)
57
+ assign_key_code(key_code(key_or_keys), menu_item_or_items)
58
+ end
59
+
60
+ def load(shortcuts)
61
+ shortcuts.map do |shortcut|
62
+ assign_keys(shortcut[:keys], shortcut[:menu_items])
63
+ end
64
+ end
65
+
66
+ def parse_key_code(key_code)
67
+ key_code.split('\U')
68
+ end
69
+
70
+ def read_keys
71
+ return_value = []
72
+ plist_dict = `defaults read .GlobalPreferences NSUserKeyEquivalents -`
73
+ menu_item_to_keys_map = plist_dict.lines.grep(/=/).map{|e| e.split('=')}.map{|k,v| [k.strip, v.strip]}.map{|k,v| [k.tr('"',''), v.tr('"','')]}
74
+ menu_item_to_keys_map.each do |shortcut|
75
+ menu_item, key_code = shortcut.first, shortcut.last
76
+ md = key_code.match(/([\@~\^\$]+)(.+);/)
77
+ modifier_key_codes, key_code = md[1], md[2]
78
+ modifier_key_names = modifier_key_codes.split('').map{|e| KEY_MAP.find{|km| km.last == e}.first.first}
79
+ key_code = key_code.sub(/\\/, '')
80
+ key_name = (
81
+ if ('a'..'z').include?(key_code)
82
+ key_code
83
+ else
84
+ KEY_MAP.find do |km|
85
+ km.last == key_code.upcase
86
+ end.first.first
87
+ end
88
+ )
89
+ keys = [modifier_key_names, key_name].flatten
90
+ found_shortcut = return_value.find{|e| Set.new(e[:keys]) == Set.new(keys)}
91
+ if found_shortcut = return_value.find{|e| Set.new(e[:keys]) == Set.new(keys)}
92
+ index = return_value.index(found_shortcut)
93
+ return_value[index][:menu_items] << menu_item
94
+ else
95
+ return_value << {keys: keys, menu_items: [menu_item]}
96
+ end
97
+ end
98
+ return_value.reverse
99
+ end
100
+
101
+ def to_yaml
102
+ YAML.dump(read_keys)
103
+ end
104
+ alias_method :dump, :to_yaml
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,126 @@
1
+ # MacOS/Defaults.rb
2
+ # MacOS::Defaults
3
+
4
+ # Todo:
5
+ # 1. Handle compound/vector values in to_yaml(). Currently dumping hashes when it should be creating another level or more levels of hierarchy.
6
+
7
+ # Transformations:
8
+ # 1. database <--> plist
9
+ # 2. plist <--> hash
10
+ # 3. yaml <--> database
11
+
12
+
13
+ require 'cfpropertylist'
14
+ require 'yaml'
15
+
16
+ module MacOS
17
+ class Defaults
18
+
19
+ DEFAULT_PLIST_DIRECTORY = '/tmp'
20
+ DEFAULT_YAML_FILENAME = File.expand_path(File.join('~', '.config', 'osx_defaults.yml'))
21
+
22
+ class << self
23
+
24
+ def domains
25
+ `defaults domains`.chomp.split(', ')
26
+ end
27
+ alias_method :all_domains, :domains
28
+
29
+ def all
30
+ domains.collect do |domain|
31
+ self.new(domain: domain)
32
+ end
33
+ end
34
+
35
+ def bulk_export_to_plist_files
36
+ domains.each do |domain|
37
+ self.new(domain: domain).export
38
+ end
39
+ end
40
+
41
+ def bulk_import_from_plist_files
42
+ domains.each do |domain|
43
+ self.new(domain: domain).import
44
+ end
45
+ end
46
+
47
+ def from_yaml(yaml_filename = nil)
48
+ yaml_filename ||= DEFAULT_YAML_FILENAME
49
+ yaml_defaults = YAML.load_file(yaml_filename)
50
+ yaml_defaults.each do |domain, attributes|
51
+ self.new(domain: domain).from_yaml(attributes)
52
+ end
53
+ end
54
+
55
+ end # class << self
56
+
57
+ def initialize(**args)
58
+ @domain = args[:domain]
59
+ end
60
+
61
+ def export
62
+ if domain_valid?
63
+ `defaults export #{@domain} #{DEFAULT_PLIST_DIRECTORY}/#{@domain}.plist`
64
+ else
65
+ raise "Domain: #{@domain} was not found in the defaults database."
66
+ end
67
+ true
68
+ end
69
+
70
+ def import
71
+ if File.exist?("#{DEFAULT_PLIST_DIRECTORY}/#{@domain}.plist")
72
+ `defaults import #{@domain} #{DEFAULT_PLIST_DIRECTORY}/#{@domain}.plist`
73
+ else
74
+ raise "File: #{DEFAULT_PLIST_DIRECTORY}/#{@domain}.plist was not found in the filesystem."
75
+ end
76
+ true
77
+ end
78
+
79
+ def to_h
80
+ plist = CFPropertyList::List.new(file: "#{DEFAULT_PLIST_DIRECTORY}/#{@domain}.plist")
81
+ CFPropertyList.native_types(plist.value)
82
+ end
83
+
84
+ def to_yaml(yaml_parts = [], hash = to_h, recursion_depth = 1)
85
+ yaml_parts << "#{@domain}:" if recursion_depth == 1
86
+ hash.each do |k,v|
87
+ to_yaml(yaml_parts, v, recursion_depth += 1) if v.is_a?(Hash)
88
+ indent = ' ' * recursion_depth * 2
89
+ yaml_parts << "#{indent}- #{k}: #{v}"
90
+ end
91
+ yaml_parts.join("\n")
92
+ end
93
+ alias_method :to_yml, :to_yaml
94
+
95
+ # Writes each attribute, as to_yaml writes them, a list of single key: value pairs, true where every write succeeds.
96
+ def from_yaml(attributes)
97
+ attributes.map do |attribute|
98
+ key, value = attribute.to_a.first
99
+ system('defaults', 'write', @domain, key.to_s, *defaults_arguments(value))
100
+ end.all?
101
+ end
102
+
103
+ def domain_valid?
104
+ self.class.domains.include?(@domain) || global_domain?
105
+ end
106
+
107
+ def global_domain?
108
+ @domain == '-globalDomain'
109
+ end
110
+
111
+ private
112
+
113
+ # A value as defaults write takes it, its type given first.
114
+ def defaults_arguments(value)
115
+ case value
116
+ when true, false then ['-bool', value.to_s]
117
+ when Integer then ['-int', value.to_s]
118
+ when Float then ['-float', value.to_s]
119
+ when Array then ['-array', *value.map(&:to_s)]
120
+ when Hash then ['-dict', *value.flat_map{|k, v| [k.to_s, v.to_s]}]
121
+ else ['-string', value.to_s]
122
+ end
123
+ end
124
+
125
+ end
126
+ end