state_mate 0.0.1
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/.gitignore +22 -0
- data/.rspec +2 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +29 -0
- data/Rakefile +6 -0
- data/ansible/library/state +59 -0
- data/lib/state_mate.rb +366 -0
- data/lib/state_mate/adapters/defaults.rb +330 -0
- data/lib/state_mate/adapters/git_config.rb +35 -0
- data/lib/state_mate/adapters/json.rb +66 -0
- data/lib/state_mate/adapters/launchd.rb +117 -0
- data/lib/state_mate/adapters/nvram.rb +44 -0
- data/lib/state_mate/adapters/time_machine.rb +60 -0
- data/lib/state_mate/version.rb +3 -0
- data/notes/state-set-steps.md +26 -0
- data/spec/spec_helper.rb +44 -0
- data/spec/state_mate/adapters/defaults/hardware_uuid_spec.rb +15 -0
- data/spec/state_mate/adapters/defaults/hash_deep_write_spec.rb +33 -0
- data/spec/state_mate/adapters/defaults/read_defaults_spec.rb +57 -0
- data/spec/state_mate/adapters/defaults/read_spec.rb +32 -0
- data/spec/state_mate/adapters/defaults/read_type_spec.rb +27 -0
- data/spec/state_mate/adapters/defaults/write_spec.rb +29 -0
- data/spec/state_mate/adapters/git_config/read_spec.rb +36 -0
- data/spec/state_mate/adapters/git_config/write_spec.rb +17 -0
- data/spec/state_mate/adapters/json/read_spec.rb +56 -0
- data/spec/state_mate/adapters/json/write_spec.rb +54 -0
- data/spec/state_mate_spec.rb +7 -0
- data/state_mate.gemspec +25 -0
- data/test/ansible/ansible.cfg +5 -0
- data/test/ansible/clear +2 -0
- data/test/ansible/hosts +1 -0
- data/test/ansible/play +2 -0
- data/test/ansible/playbook.yml +38 -0
- data/test/ansible/read +2 -0
- metadata +167 -0
@@ -0,0 +1,330 @@
|
|
1
|
+
require 'shellwords'
|
2
|
+
require 'rexml/document'
|
3
|
+
require 'base64'
|
4
|
+
require 'time'
|
5
|
+
require 'pp'
|
6
|
+
require 'tempfile'
|
7
|
+
|
8
|
+
require 'CFPropertyList'
|
9
|
+
|
10
|
+
require 'nrser'
|
11
|
+
require 'nrser/exec'
|
12
|
+
|
13
|
+
using NRSER
|
14
|
+
|
15
|
+
module StateMate; end
|
16
|
+
module StateMate::Adapters; end
|
17
|
+
|
18
|
+
module StateMate::Adapters::Defaults
|
19
|
+
KEY_SEP = ':'
|
20
|
+
DEFAULTS_CMD = '/usr/bin/defaults'
|
21
|
+
|
22
|
+
# convert a ruby object to a `REXML::Element` for a plist
|
23
|
+
def self.to_xml_element obj
|
24
|
+
case obj
|
25
|
+
when String
|
26
|
+
REXML::Element.new("string").add_text obj
|
27
|
+
when Fixnum
|
28
|
+
REXML::Element.new('integer').add_text obj.to_s
|
29
|
+
when Float
|
30
|
+
REXML::Element.new('real').add_text obj.to_s
|
31
|
+
when Hash
|
32
|
+
dict = REXML::Element.new('dict')
|
33
|
+
obj.each {|dict_key, dict_obj|
|
34
|
+
dict.add_element REXML::Element.new('key').add_text(dict_key)
|
35
|
+
dict.add_element to_xml_element(dict_obj)
|
36
|
+
}
|
37
|
+
dict
|
38
|
+
when Array
|
39
|
+
array = REXML::Element.new('array')
|
40
|
+
obj.each {|array_entry|
|
41
|
+
array.add_element to_xml_element(array_entry)
|
42
|
+
}
|
43
|
+
array
|
44
|
+
when TrueClass, FalseClass
|
45
|
+
REXML::Element.new obj.to_s
|
46
|
+
when Time
|
47
|
+
REXML::Element.new('date').add_text obj.utc.iso8601
|
48
|
+
else
|
49
|
+
raise "can't handle type: #{ obj.inspect }"
|
50
|
+
end
|
51
|
+
end # ::to_xml_element
|
52
|
+
|
53
|
+
def self.prefs_path user
|
54
|
+
if user == 'root'
|
55
|
+
'/Library/Preferences'
|
56
|
+
else
|
57
|
+
"/Users/#{ user }/Library/Preferences"
|
58
|
+
end
|
59
|
+
end # ::prefs_path
|
60
|
+
|
61
|
+
def self.domain_to_filepath domain, user = ENV['USER'], current_host = false
|
62
|
+
# there are a few cases:
|
63
|
+
#
|
64
|
+
# 1.) absolute file path
|
65
|
+
if domain.start_with? '/'
|
66
|
+
domain
|
67
|
+
#
|
68
|
+
# 2.) home-based path
|
69
|
+
elsif domain.start_with? '~/'
|
70
|
+
if user == 'root'
|
71
|
+
"/var/root/#{ domain[2..-1] }"
|
72
|
+
else
|
73
|
+
"/Users/#{ user }/#{ domain[2..-1] }"
|
74
|
+
end
|
75
|
+
#
|
76
|
+
# global domain
|
77
|
+
elsif domain == "NSGlobalDomain"
|
78
|
+
if current_host
|
79
|
+
"#{ prefs_path user }/.GlobalPreferences.#{ hardware_uuid }.plist"
|
80
|
+
else
|
81
|
+
"#{ prefs_path user }/.GlobalPreferences.plist"
|
82
|
+
end
|
83
|
+
#
|
84
|
+
# 3.) domain with corresponding plist
|
85
|
+
else
|
86
|
+
if current_host
|
87
|
+
"#{ prefs_path user }/ByHost/#{ domain }.#{ hardware_uuid }.plist"
|
88
|
+
else
|
89
|
+
"#{ prefs_path user }/#{ domain }.plist"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end # ::domain_to_filepath
|
93
|
+
|
94
|
+
def self.parse_key key
|
95
|
+
domain, *key_segs = case key
|
96
|
+
when Array
|
97
|
+
key
|
98
|
+
when String
|
99
|
+
key.split KEY_SEP
|
100
|
+
else
|
101
|
+
raise "must be string or array, not #{ key.inspect }"
|
102
|
+
end # case
|
103
|
+
[domain, key_segs]
|
104
|
+
end # ::parse_key
|
105
|
+
|
106
|
+
# Converts a CFType hiercharchy to native Ruby types
|
107
|
+
#
|
108
|
+
# customized to use the Base64 encoding of binary blobs since
|
109
|
+
# JSON pukes on the raw ones
|
110
|
+
def self.native_types(object,keys_as_symbols=false)
|
111
|
+
return if object.nil?
|
112
|
+
|
113
|
+
if (object.is_a?(CFPropertyList::CFDate) ||
|
114
|
+
object.is_a?(CFPropertyList::CFString) ||
|
115
|
+
object.is_a?(CFPropertyList::CFInteger) ||
|
116
|
+
object.is_a?(CFPropertyList::CFReal) ||
|
117
|
+
object.is_a?(CFPropertyList::CFBoolean)) ||
|
118
|
+
object.is_a?(CFPropertyList::CFUid) then
|
119
|
+
return object.value
|
120
|
+
elsif(object.is_a?(CFPropertyList::CFData)) then
|
121
|
+
return CFPropertyList::Blob.new(object.encoded_value)
|
122
|
+
elsif(object.is_a?(CFPropertyList::CFArray)) then
|
123
|
+
ary = []
|
124
|
+
object.value.each do
|
125
|
+
|v|
|
126
|
+
ary.push native_types(v)
|
127
|
+
end
|
128
|
+
|
129
|
+
return ary
|
130
|
+
elsif(object.is_a?(CFPropertyList::CFDictionary)) then
|
131
|
+
hsh = {}
|
132
|
+
object.value.each_pair do
|
133
|
+
|k,v|
|
134
|
+
k = k.to_sym if keys_as_symbols
|
135
|
+
hsh[k] = native_types(v)
|
136
|
+
end
|
137
|
+
|
138
|
+
return hsh
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def self.read_defaults domain, current_host = false
|
143
|
+
file = Tempfile.new('read_defaults')
|
144
|
+
begin
|
145
|
+
cmd_parts = ['%{cmd}']
|
146
|
+
cmd_parts << '-currentHost' if current_host
|
147
|
+
cmd_parts << 'export'
|
148
|
+
cmd_parts << '%{domain}'
|
149
|
+
cmd_parts << '%{filepath}'
|
150
|
+
cmd = NRSER::Exec.sub cmd_parts.join(' '), cmd: DEFAULTS_CMD,
|
151
|
+
domain: domain,
|
152
|
+
filepath: file.path
|
153
|
+
NRSER::Exec.run cmd
|
154
|
+
|
155
|
+
plist = CFPropertyList::List.new file: file.path
|
156
|
+
data = native_types plist.value
|
157
|
+
ensure
|
158
|
+
file.close
|
159
|
+
file.unlink # deletes the temp file
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def self.read_type domain, key, current_host
|
164
|
+
cmd_parts = ['%{cmd}']
|
165
|
+
cmd_parts << '-currentHost' if current_host
|
166
|
+
cmd_parts << 'read-type'
|
167
|
+
cmd_parts << '%{domain}'
|
168
|
+
cmd_parts << '%{key}'
|
169
|
+
|
170
|
+
cmd = NRSER::Exec.sub cmd_parts.join(' '), cmd: DEFAULTS_CMD,
|
171
|
+
domain: domain,
|
172
|
+
key: key
|
173
|
+
|
174
|
+
out = NRSER::Exec.run(cmd).chomp
|
175
|
+
|
176
|
+
case out
|
177
|
+
when "Type is string"
|
178
|
+
:string
|
179
|
+
when "Type is data"
|
180
|
+
:data
|
181
|
+
when "Type is integer"
|
182
|
+
:int
|
183
|
+
when "Type is float"
|
184
|
+
:float
|
185
|
+
when "Type is boolean"
|
186
|
+
:bool
|
187
|
+
when "Type is date"
|
188
|
+
:date
|
189
|
+
when "Type is array"
|
190
|
+
:array
|
191
|
+
when "Type is dictionary"
|
192
|
+
:dict
|
193
|
+
else
|
194
|
+
raise "unknown output: #{ out.inspect }"
|
195
|
+
end
|
196
|
+
end # ::read_type
|
197
|
+
|
198
|
+
def self.read key, options = {}
|
199
|
+
options = {
|
200
|
+
'current_host' => false,
|
201
|
+
}.merge options
|
202
|
+
|
203
|
+
domain, key_segs = parse_key key
|
204
|
+
|
205
|
+
value = read_defaults domain, options['current_host']
|
206
|
+
|
207
|
+
key_segs.each do |seg|
|
208
|
+
value = if (value.is_a?(Hash) && value.key?(seg))
|
209
|
+
value[seg]
|
210
|
+
else
|
211
|
+
nil
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# when 0 or 1 are returned they might actually be true or false
|
216
|
+
# case value
|
217
|
+
# when 0, 1
|
218
|
+
value
|
219
|
+
end # ::read
|
220
|
+
|
221
|
+
# def self.read_type
|
222
|
+
|
223
|
+
def self.write key, value, options = {}
|
224
|
+
options = {
|
225
|
+
'current_host' => false,
|
226
|
+
}.merge options
|
227
|
+
|
228
|
+
domain, key_segs = parse_key key
|
229
|
+
|
230
|
+
if key_segs.length > 1
|
231
|
+
deep_write domain,
|
232
|
+
key_segs[0],
|
233
|
+
key_segs.drop(1),
|
234
|
+
value,
|
235
|
+
options['current_host']
|
236
|
+
else
|
237
|
+
basic_write domain,
|
238
|
+
key_segs[0],
|
239
|
+
value,
|
240
|
+
options['current_host']
|
241
|
+
end
|
242
|
+
end # ::write
|
243
|
+
|
244
|
+
def self.basic_delete domain, key, current_host
|
245
|
+
cmd_parts = ['%{cmd}']
|
246
|
+
cmd_parts << '-currentHost' if current_host
|
247
|
+
cmd_parts << 'delete'
|
248
|
+
cmd_parts << '%{domain}'
|
249
|
+
cmd_parts << '%{key}' unless key.empty?
|
250
|
+
|
251
|
+
result = NRSER::Exec.result cmd_parts.join(' '), cmd: DEFAULTS_CMD,
|
252
|
+
domain: domain,
|
253
|
+
key: key
|
254
|
+
|
255
|
+
result.check_error
|
256
|
+
result
|
257
|
+
end
|
258
|
+
|
259
|
+
def self.basic_write domain, key, value, current_host
|
260
|
+
return basic_delete(domain, key, current_host) if value.nil?
|
261
|
+
|
262
|
+
xml = to_xml_element(value).to_s
|
263
|
+
|
264
|
+
cmd_parts = ['%{cmd}']
|
265
|
+
cmd_parts << '-currentHost' if current_host
|
266
|
+
cmd_parts << 'write'
|
267
|
+
cmd_parts << '%{domain}'
|
268
|
+
cmd_parts << '%{key}' unless key.empty?
|
269
|
+
cmd_parts << '%{xml}'
|
270
|
+
|
271
|
+
cmd = NRSER::Exec.sub cmd_parts.join(' '), cmd: DEFAULTS_CMD,
|
272
|
+
domain: domain,
|
273
|
+
key: key,
|
274
|
+
xml: xml
|
275
|
+
|
276
|
+
NRSER::Exec.run cmd
|
277
|
+
end # ::basic_write
|
278
|
+
|
279
|
+
def self.hash_deep_write! hash, key, value
|
280
|
+
segment = key.first
|
281
|
+
rest = key[1..-1]
|
282
|
+
|
283
|
+
# terminating case: we are at the last segment
|
284
|
+
if rest.empty?
|
285
|
+
hash[segment] = value
|
286
|
+
else
|
287
|
+
case hash[segment]
|
288
|
+
when Hash
|
289
|
+
# go deeper
|
290
|
+
hash_deep_write! hash[segment], rest, value
|
291
|
+
else
|
292
|
+
hash[segment] = {}
|
293
|
+
hash_deep_write! hash[segment], rest, value
|
294
|
+
end
|
295
|
+
end
|
296
|
+
value
|
297
|
+
end # hash_deep_write!
|
298
|
+
|
299
|
+
def self.deep_write domain, key, deep_segs, value, current_host
|
300
|
+
root = read [domain, key], current_host: current_host
|
301
|
+
# handle the root not being there
|
302
|
+
root = {} if root.nil?
|
303
|
+
hash_deep_write! root, deep_segs, value
|
304
|
+
basic_write domain, key, root, current_host
|
305
|
+
end # ::deep_write
|
306
|
+
|
307
|
+
# get the "by host" / "current host" id, also called the "hardware uuid".
|
308
|
+
# adapted from
|
309
|
+
#
|
310
|
+
# <http://stackoverflow.com/questions/933460/unique-hardware-id-in-mac-os-x>
|
311
|
+
#
|
312
|
+
def self.hardware_uuid
|
313
|
+
plist_xml_str = NRSER::Exec.run "ioreg -r -d 1 -c IOPlatformExpertDevice -a"
|
314
|
+
plist = CFPropertyList::List.new data: plist_xml_str
|
315
|
+
dict = CFPropertyList.native_types(plist.value).first
|
316
|
+
dict['IOPlatformUUID']
|
317
|
+
end # ::hardware_uuid
|
318
|
+
|
319
|
+
# `defaults` will return `true` as `1` and `false` as `0` :/
|
320
|
+
def self.values_equal? current, desired
|
321
|
+
case desired
|
322
|
+
when true
|
323
|
+
current == true || current == 1
|
324
|
+
when false
|
325
|
+
current == false || current == 0
|
326
|
+
else
|
327
|
+
current == desired
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'nrser'
|
2
|
+
require 'nrser/exec'
|
3
|
+
|
4
|
+
using NRSER
|
5
|
+
|
6
|
+
module StateMate; end
|
7
|
+
module StateMate::Adapters; end
|
8
|
+
|
9
|
+
module StateMate::Adapters::GitConfig
|
10
|
+
def self.read key, options = {}
|
11
|
+
result = NRSER::Exec.result "git config --global --get %{key}", key: key
|
12
|
+
|
13
|
+
if result.success?
|
14
|
+
result.output.chomp
|
15
|
+
elsif result.output == ''
|
16
|
+
nil
|
17
|
+
else
|
18
|
+
result.raise_error
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.write key, value, options = {}
|
23
|
+
action = if read(key, options).nil?
|
24
|
+
'--add'
|
25
|
+
else
|
26
|
+
'--replace'
|
27
|
+
end
|
28
|
+
|
29
|
+
result = NRSER::Exec.result(
|
30
|
+
"git config --global #{ action } %{key} %{value}",
|
31
|
+
key: key,
|
32
|
+
value: value
|
33
|
+
)
|
34
|
+
end
|
35
|
+
end # GitConfig
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
require 'nrser'
|
4
|
+
require 'nrser/exec'
|
5
|
+
|
6
|
+
require 'state_mate'
|
7
|
+
require 'state_mate/adapters/defaults'
|
8
|
+
|
9
|
+
using NRSER
|
10
|
+
|
11
|
+
module StateMate::Adapters::JSON
|
12
|
+
def self.parse_key key
|
13
|
+
# use the same key seperation as Defaults
|
14
|
+
StateMate::Adapters::Defaults.parse_key key
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.read key, options = {}
|
18
|
+
filepath, key_segs = parse_key key
|
19
|
+
|
20
|
+
contents = File.read(File.expand_path(filepath))
|
21
|
+
|
22
|
+
value = JSON.load contents
|
23
|
+
|
24
|
+
key_segs.each do |seg|
|
25
|
+
value = if (value.is_a?(Hash) && value.key?(seg))
|
26
|
+
value[seg]
|
27
|
+
else
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
value
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.write key, value, options = {}
|
36
|
+
options = {
|
37
|
+
'pretty' => true,
|
38
|
+
}.merge options
|
39
|
+
|
40
|
+
filepath, key_segs = parse_key key
|
41
|
+
|
42
|
+
new_root = if key_segs.length > 1
|
43
|
+
root = read filepath
|
44
|
+
|
45
|
+
StateMate::Adapters::Defaults.hash_deep_write!(
|
46
|
+
root,
|
47
|
+
key_segs,
|
48
|
+
value
|
49
|
+
)
|
50
|
+
|
51
|
+
root
|
52
|
+
else
|
53
|
+
value
|
54
|
+
end
|
55
|
+
|
56
|
+
content = if options['pretty']
|
57
|
+
JSON.pretty_generate new_root
|
58
|
+
else
|
59
|
+
JSON.dump new_root
|
60
|
+
end
|
61
|
+
|
62
|
+
File.open(filepath, 'w') do |f|
|
63
|
+
f.write content
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end # JSON
|
@@ -0,0 +1,117 @@
|
|
1
|
+
require 'pp'
|
2
|
+
|
3
|
+
require 'CFPropertyList'
|
4
|
+
|
5
|
+
require 'nrser'
|
6
|
+
require 'nrser/exec'
|
7
|
+
|
8
|
+
require 'state_mate'
|
9
|
+
require 'state_mate/adapters/defaults'
|
10
|
+
|
11
|
+
using NRSER
|
12
|
+
|
13
|
+
# very useful:
|
14
|
+
#
|
15
|
+
# <http://launchd.info/>
|
16
|
+
# 46
|
17
|
+
|
18
|
+
module StateMate::Adapters::LaunchD
|
19
|
+
|
20
|
+
EXE = '/bin/launchctl'
|
21
|
+
|
22
|
+
def self.truncate_values hash, length
|
23
|
+
hash.map {|k, v|
|
24
|
+
case v
|
25
|
+
when String
|
26
|
+
[k, v.truncate(length)]
|
27
|
+
when Hash
|
28
|
+
[k, truncate_values(v, length)]
|
29
|
+
else
|
30
|
+
[k ,v]
|
31
|
+
end
|
32
|
+
}.to_h
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.user_overrides_db_path user = ENV['USER']
|
36
|
+
if user == 'root'
|
37
|
+
"/var/db/launchd.db/com.apple.launchd/overrides.plist"
|
38
|
+
else
|
39
|
+
user_id = NRSER::Exec.run("id -u %{user}", user: user).chomp.to_i
|
40
|
+
"/var/db/launchd.db/com.apple.launchd.peruser.#{ user_id }/overrides.plist"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.user_overrides_db user = ENV['USER']
|
45
|
+
plist = CFPropertyList::List.new file: user_overrides_db_path(user)
|
46
|
+
CFPropertyList.native_types plist.value
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.disabled? label, user = ENV['USER']
|
50
|
+
db = user_overrides_db(user)
|
51
|
+
return false unless db.key?(label) && db[label].key?('Disabled')
|
52
|
+
db[label]['Disabled']
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.loaded? label
|
56
|
+
begin
|
57
|
+
NRSER::Exec.run "%{exe} list -x %{label}", exe: EXE, label: label
|
58
|
+
rescue SystemCallError => e
|
59
|
+
false
|
60
|
+
else
|
61
|
+
true
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.parse_key key
|
66
|
+
# use the same key seperation as Defaults
|
67
|
+
StateMate::Adapters::Defaults.parse_key key
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.load file_path
|
71
|
+
NRSER::Exec.run "%{exe} load -w %{file_path}", exe: EXE,
|
72
|
+
file_path: file_path
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.unload file_path
|
76
|
+
NRSER::Exec.run "%{exe} unload -w %{file_path}", exe: EXE,
|
77
|
+
file_path: file_path
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.read key, options = {}
|
81
|
+
file_path, key_segs = parse_key key
|
82
|
+
|
83
|
+
# get the hash of the plist at the file path and use that to get the label
|
84
|
+
plist = CFPropertyList::List.new file: file_path
|
85
|
+
data = CFPropertyList.native_types plist.value
|
86
|
+
label = data["Label"]
|
87
|
+
|
88
|
+
case key_segs
|
89
|
+
# the only thing we can handle right now
|
90
|
+
when ['Disabled']
|
91
|
+
disabled? label
|
92
|
+
else
|
93
|
+
raise "unprocessable key: #{ key.inspect }"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def self.write key, value, options = {}
|
98
|
+
file_path, key_segs = parse_key key
|
99
|
+
|
100
|
+
case key_segs
|
101
|
+
when ['Disabled']
|
102
|
+
case value
|
103
|
+
when true
|
104
|
+
unload file_path
|
105
|
+
|
106
|
+
when false
|
107
|
+
load file_path
|
108
|
+
|
109
|
+
else
|
110
|
+
raise StateMate::Error::TypeError value, "expected true or false"
|
111
|
+
|
112
|
+
end
|
113
|
+
else
|
114
|
+
raise "unprocessable key: #{ key.inspect }"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end # LaunchD
|