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.
- checksums.yaml +7 -0
- data/CHANGELOG +572 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +129 -0
- data/Rakefile +9 -0
- data/lib/Array/all_but_last.rb +7 -0
- data/lib/MacOS/Apple80211.rb +68 -0
- data/lib/MacOS/Applications.rb +73 -0
- data/lib/MacOS/Defaults/ComAppleDock.rb +153 -0
- data/lib/MacOS/Defaults/NSGlobalDomain/NSUserKeyEquivalents.rb +109 -0
- data/lib/MacOS/Defaults.rb +126 -0
- data/lib/MacOS/DiskUtil.rb +63 -0
- data/lib/MacOS/Dmg.rb +99 -0
- data/lib/MacOS/HardwarePort.rb +79 -0
- data/lib/MacOS/IfConfig.rb +183 -0
- data/lib/MacOS/Keychain.rb +26 -0
- data/lib/MacOS/LaunchControl.rb +36 -0
- data/lib/MacOS/SystemProfiler.rb +119 -0
- data/lib/MacOS/USBFlashDrive.rb +26 -0
- data/lib/MacOS/Users.rb +16 -0
- data/lib/MacOS/VERSION.rb +6 -0
- data/lib/MacOS/VPN.rb +13 -0
- data/lib/MacOS/Voice.rb +40 -0
- data/lib/MacOS.rb +27 -0
- data/lib/Module/alias_methods.rb +7 -0
- data/lib/Ordinal/Array.rb +8 -0
- data/lib/Ordinal.rb +157 -0
- data/lib/Thoran/Array/AllButLast/all_but_last.rb +28 -0
- data/lib/Thoran/Array/LastX/lastX.rb +33 -0
- data/lib/Thoran/Module/AliasMethods/alias_methods.rb +23 -0
- data/lib/Thoran/String/Capture/capture.rb +28 -0
- data/lib/Version.rb +381 -0
- data/macos.rb.gemspec +59 -0
- data/test/MacOS/Apple80211_test.rb +80 -0
- data/test/MacOS/Applications_test.rb +82 -0
- data/test/MacOS/Defaults/ComAppleDock_test.rb +66 -0
- data/test/MacOS/Defaults/NSGlobalDomain/NSUserKeyEquivalents_test.rb +67 -0
- data/test/MacOS/Defaults_test.rb +144 -0
- data/test/MacOS/DiskUtil_test.rb +48 -0
- data/test/MacOS/Dmg_test.rb +69 -0
- data/test/MacOS/HardwarePort_test.rb +46 -0
- data/test/MacOS/IfConfig_test.rb +144 -0
- data/test/MacOS/Keychain_test.rb +22 -0
- data/test/MacOS/LaunchControl_test.rb +40 -0
- data/test/MacOS/SystemProfiler_test.rb +69 -0
- data/test/MacOS/USBFlashDrive_test.rb +20 -0
- data/test/MacOS/Users_test.rb +10 -0
- data/test/MacOS/VPN_test.rb +18 -0
- data/test/MacOS/Voice_test.rb +46 -0
- data/test/MacOS_test.rb +17 -0
- data/test/VERSION_test.rb +19 -0
- data/test/commands_test.rb +50 -0
- data/test/fixtures/airport-I-deprecated.txt +2 -0
- data/test/fixtures/airport-I.txt +17 -0
- data/test/fixtures/defaults-domains.txt +1 -0
- data/test/fixtures/defaults-export-com.apple.dock.xml +47 -0
- data/test/fixtures/defaults-read-NSUserKeyEquivalents.txt +4 -0
- data/test/fixtures/df-k.txt +6 -0
- data/test/fixtures/diskutil-list-plist.xml +104 -0
- data/test/fixtures/dscl-list-users.txt +8 -0
- data/test/fixtures/ifconfig-a.txt +53 -0
- data/test/fixtures/networksetup-listallhardwareports.txt +39 -0
- data/test/fixtures/say-v.txt +8 -0
- data/test/fixtures/system_profiler-SPAirPortDataType.txt +44 -0
- data/test/fixtures/system_profiler-SPSoftwareDataType.txt +14 -0
- data/test/fixtures/system_profiler-SPUSBDataType.txt +58 -0
- data/test/gemspec_test.rb +50 -0
- data/test/helper.rb +18 -0
- 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,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
|