domain/architecture/system/deployment/nix_darwin/setting/ops.bend

near-v-framework@0.6.0.0 · 697 lines · 0 definitions

1import Base
2import bend-net-url@0.4.0.0/url.bend as Url
3import near-v-framework@0.6.0.0/domain/architecture/system/plan/type.bend as P
4import near-v-framework@0.6.0.0/domain/architecture/system/deployment/nix_darwin/brew/type.bend as Br
5import near-v-framework@0.6.0.0/domain/architecture/system/deployment/nix_darwin/brew/ops.bend as BrO
6import near-v-framework@0.6.0.0/domain/architecture/system/deployment/nix_darwin/setting/type.bend as St
7
8# ---- mac.nix ----
9def literal(k: St.ValueKind, v: String) -> String:
10  match k:
11    case St.KStr{}:
12      "\"" ++ v ++ "\""
13    case _:
14      v
15
16def line(s: St.Setting) -> String:
17  match s:
18    case St.Setting{o, k, v}:
19      "  " ++ o ++ " = " ++ literal(k, v) ++ ";\n"
20
21def lines(xs: List<&2, St.Setting>) -> String:
22  match xs:
23    case []:
24      ""
25    case s <> rest:
26      line(s) ++ lines(rest)
27
28def nix_bool(b: Bool) -> String:
29  Bool.pick(String, b, "true", "false")
30
31def firewall_nix(f: St.Firewall) -> String:
32  match f:
33    case St.FirewallOff{}:
34      "  networking.applicationFirewall.enable = false;\n"
35    case St.FirewallOn{a, b, c, d}:
36      "  networking.applicationFirewall.enable = true;\n" ++
37      "  networking.applicationFirewall.allowSigned = " ++ nix_bool(a) ++ ";\n" ++
38      "  networking.applicationFirewall.allowSignedApp = " ++ nix_bool(b) ++ ";\n" ++
39      "  networking.applicationFirewall.blockAllIncoming = " ++ nix_bool(c) ++ ";\n" ++
40      "  networking.applicationFirewall.enableStealthMode = " ++ nix_bool(d) ++ ";\n"
41
42# The nix-darwin module for the Mac: its settings, firewall and Homebrew. Homebrew uninstalls what is not
43# declared and never updates or upgrades on its own.
44def mac_nix(settings: List<&2, St.Setting>, fw: St.Firewall, brews: List<&2, Br.Brew>) -> String:
45  "# Generated by V from the system's nix-darwin settings. Do not edit.\n{ ... }: {\n" ++
46  lines(settings) ++
47  firewall_nix(fw) ++
48  "  homebrew.enable = true;\n  homebrew.onActivation.cleanup = \"uninstall\";\n  homebrew.onActivation.autoUpdate = false;\n  homebrew.onActivation.upgrade = false;\n" ++
49  BrO.nix_section(brews) ++ "}\n"
50
51def option_of(s: St.Setting) -> String:
52  match s:
53    case St.Setting{o, _, _}:
54      o
55
56def none_prefixed(xs: List<&2, St.Setting>, +p: String) -> Bool:
57  match xs:
58    case []:
59      True{}
60    case s <> rest:
61      Bool.not(String.starts_with(option_of(s), p)) && none_prefixed(rest, p)
62
63# The firewall is declared by its type, never as flat settings.
64def firewall_typed(settings: List<&2, St.Setting>) -> Bool:
65  none_prefixed(settings, "networking.applicationFirewall")
66
67# ---- comparing declared and observed ----
68def value_if(hit: Bool, s: St.Setting, other: String) -> String:
69  match s:
70    case St.Setting{_, _, v}:
71      Bool.pick(String, hit, v, other)
72
73# The first observed value of `option`, or "(unset)".
74def lookup(+option: String, now: List<&2, St.Setting>) -> String:
75  match now:
76    case []:
77      "(unset)"
78    case +s <> rest:
79      value_if(String.eq(option, option_of(s)), s, lookup(option, rest))
80
81def drop_zeros(cs: List<&2, Char>) -> List<&2, Char>:
82  match cs:
83    case []:
84      []
85    case +c <> +rest:
86      Bool.pick(List<&2, Char>, Char.is_eq(c, '0'), drop_zeros(rest), c <> rest)
87
88# "0.500000" -> "0.5", "0.000000" -> "0": numbers compare by value, not spelling.
89def trim_num(+s: String) -> String:
90  +t = String.reverse(String.from_list(drop_zeros(String.to_list(String.reverse(s)))))
91  Bool.pick(String, String.contains(s, "."), Bool.pick(String, String.ends_with(t, "."), String.take(t, Nat.sub(String.length(t), 1n)), t), s)
92
93def same(+now: String, +want: String) -> Bool:
94  String.eq(now, want) || String.eq(trim_num(now), trim_num(want))
95
96def change_if(+now: String, +option: String, +want: String) -> List<&2, P.Step>:
97  Bool.pick(List<&2, P.Step>, same(now, want), [], [P.Change{option, now, want}])
98
99def diff_one(s: St.Setting, +now: List<&2, St.Setting>) -> List<&2, P.Step>:
100  match s:
101    case St.Setting{+o, _, v}:
102      change_if(lookup(o, now), o, v)
103
104# Every declared setting whose observed value differs.
105def diff(want: List<&2, St.Setting>, +now: List<&2, St.Setting>) -> List<&2, P.Step>:
106  match want:
107    case []:
108      []
109    case s <> rest:
110      List.append(&2, P.Step, diff_one(s, now), diff(rest, now))
111
112def firewall_show(f: St.Firewall) -> String:
113  match f:
114    case St.FirewallOff{}:
115      "off"
116    case St.FirewallOn{a, b, c, d}:
117      "on (allowSigned " ++ nix_bool(a) ++ ", allowSignedApp " ++ nix_bool(b) ++ ", blockAllIncoming " ++ nix_bool(c) ++ ", stealth " ++ nix_bool(d) ++ ")"
118
119def firewall_change(want: St.Firewall, now: St.Firewall) -> List<&2, P.Step>:
120  +w = firewall_show(want)
121  +n = firewall_show(now)
122  Bool.pick(List<&2, P.Step>, String.eq(w, n), [], [P.Change{"networking.applicationFirewall", n, w}])
123
124# ---- words and lines ----
125def keep_if(keep: Bool, x: String, rest: List<&2, String>) -> List<&2, String>:
126  match keep:
127    case True{}:
128      x <> rest
129    case False{}:
130      rest
131
132def blank(cs: List<&2, Char>) -> List<&2, Char>:
133  match cs:
134    case []:
135      []
136    case +c <> rest:
137      Bool.pick(Char, Char.is_space(c), ' ', c) <> blank(rest)
138
139def words(s: String) -> List<&2, String>:
140  List.filter(~String, ~(w => Bool.not(String.is_empty(w))), String.split(String.from_list(blank(String.to_list(s))), ' '))
141
142def word(+s: String, n: Nat) -> String:
143  Maybe.default(&2, String, List.get(&2, String, words(s), n), "")
144
145def last_word(+s: String) -> String:
146  Maybe.default(&2, String, List.last(&2, String, words(s)), "")
147
148def filled_go(ls: List<&2, String>) -> List<&2, String>:
149  match ls:
150    case []:
151      []
152    case l <> rest:
153      +t = String.trim(l)
154      keep_if(Bool.not(String.is_empty(t)), t, filled_go(rest))
155
156# The trimmed, non-empty lines.
157def filled(s: String) -> List<&2, String>:
158  filled_go(String.lines(s))
159
160def distinct(xs: List<&2, String>) -> List<&2, String>:
161  match xs:
162    case []:
163      []
164    case +x <> +rest:
165      keep_if(Bool.not(List.contains(~String, ~String.eq, rest, x)), x, distinct(rest))
166
167def digits_of(cs: List<&2, Char>) -> String:
168  match cs:
169    case []:
170      ""
171    case +c <> rest:
172      Bool.pick(String, Char.is_digit(c), String.from_list([c]) ++ digits_of(rest), "")
173
174def digits(s: String) -> String:
175  digits_of(String.to_list(s))
176
177def nat_of(+s: String) -> Nat:
178  Maybe.default(&2, Nat, Nat.read(digits(String.trim(s))), 0n)
179
180def unquote(+s: String) -> String:
181  Bool.pick(String, String.starts_with(s, "\"") && String.ends_with(s, "\"") && Nat.is_gt(String.length(s), 1n),
182    String.take(String.drop(s, 1n), Nat.sub(String.length(s), 2n)), s)
183
184def nix_strs(xs: List<&2, String>) -> String:
185  match xs:
186    case []:
187      ""
188    case x <> rest:
189      " " ++ BrO.nix_str(x) ++ nix_strs(rest)
190
191# A list of strings as a Nix list.
192def nix_list(xs: List<&2, String>) -> String:
193  "[" ++ nix_strs(xs) ++ " ]"
194
195def nix_setting(o: String, v: String) -> St.Setting:
196  St.Setting{o, St.KNix{}, v}
197
198def bool_setting(o: String, b: Bool) -> St.Setting:
199  St.Setting{o, St.KBool{}, nix_bool(b)}
200
201def str_setting(o: String, v: String) -> St.Setting:
202  St.Setting{o, St.KStr{}, BrO.nix_esc(v)}
203
204def int_setting(o: String, v: String) -> St.Setting:
205  St.Setting{o, St.KInt{}, v}
206
207# The setting, when its observed value is not empty.
208def when_filled(+v: String, s: St.Setting) -> List<&2, St.Setting>:
209  Bool.pick(List<&2, St.Setting>, String.is_empty(String.trim(v)), [], [s])
210
211# ---- system.defaults: which options (nix/options.tsv, "option<TAB>type"), and where macOS keeps them ----
212# How an option's type reads from a plist; KNix for the types that are not one scalar (not read).
213def scalar_kind(+t: String) -> St.ValueKind:
214  Bool.pick(St.ValueKind, String.eq(t, "boolean"), St.KBool{},
215  Bool.pick(St.ValueKind, String.starts_with(t, "floating point"), St.KFloat{},
216  Bool.pick(St.ValueKind, String.starts_with(t, "signed integer") || String.starts_with(t, "(positive integer") || String.starts_with(t, "integer between") || String.starts_with(t, "one of 0") || String.starts_with(t, "one of 1") || String.starts_with(t, "value 1"), St.KInt{},
217  Bool.pick(St.ValueKind, String.eq(t, "string") || String.eq(t, "absolute path") || String.starts_with(t, "one of \"") || String.starts_with(t, "value \""), St.KStr{}, St.KNix{}))))
218
219def kind_of(+t: String) -> St.ValueKind:
220  Bool.pick(St.ValueKind, String.starts_with(t, "null or "), scalar_kind(String.drop(t, 8n)), St.KNix{})
221
222def option_line(+l: String) -> List<&2, St.Setting>:
223  +cols = String.split(l, '\t')
224  +o = Maybe.default(&2, String, List.head(&2, String, cols), "")
225  Bool.pick(List<&2, St.Setting>, String.is_empty(o), [],
226    [St.Setting{o, kind_of(Maybe.default(&2, String, List.get(&2, String, cols, 1n), "")), ""}])
227
228def option_lines(ls: List<&2, String>) -> List<&2, St.Setting>:
229  match ls:
230    case []:
231      []
232    case l <> rest:
233      List.append(&2, St.Setting, option_line(l), option_lines(rest))
234
235# nix-darwin's options with how each reads; each value is "" until read from the Mac.
236def options(tsv: String) -> List<&2, St.Setting>:
237  option_lines(String.lines(tsv))
238
239def after_defaults(+o: String) -> String:
240  Bool.pick(String, String.starts_with(o, "system.defaults."), String.drop(o, 16n), "")
241
242# `system.defaults.dock.tilesize` -> "dock"; `system.defaults.".GlobalPreferences".x` -> ".GlobalPreferences".
243def group_of(+o: String) -> String:
244  +r = after_defaults(o)
245  Bool.pick(String, String.starts_with(r, "\""),
246    Maybe.default(&2, String, List.get(&2, String, String.split(r, '"'), 1n), ""),
247    Maybe.default(&2, String, List.head(&2, String, String.split(r, '.')), ""))
248
249def key_of(+o: String) -> String:
250  +r = after_defaults(o)
251  unquote(Bool.pick(String, String.starts_with(r, "\""),
252    String.drop(String.join(List.drop(&2, String, String.split(r, '"'), 2n), "\""), 1n),
253    String.join(List.drop(&2, String, String.split(r, '.'), 1n), ".")))
254
255# The `defaults` domain of a group ("-currentHost d" for a per-host one), "" for groups not read.
256def domain(+g: String) -> String:
257  Bool.pick(String, String.eq(g, ".GlobalPreferences") || String.eq(g, "NSGlobalDomain"), "-g",
258  Bool.pick(String, String.eq(g, "LaunchServices"), "com.apple.LaunchServices",
259  Bool.pick(String, String.eq(g, "menuExtraClock"), "com.apple.menuextra.clock",
260  Bool.pick(String, String.eq(g, "dock"), "com.apple.dock",
261  Bool.pick(String, String.eq(g, "finder"), "com.apple.finder",
262  Bool.pick(String, String.eq(g, "hitoolbox"), "com.apple.HIToolbox",
263  Bool.pick(String, String.eq(g, "iCal"), "com.apple.iCal",
264  Bool.pick(String, String.eq(g, "magicmouse"), "com.apple.AppleMultitouchMouse",
265  Bool.pick(String, String.eq(g, "screencapture"), "com.apple.screencapture",
266  Bool.pick(String, String.eq(g, "screensaver"), "com.apple.screensaver",
267  Bool.pick(String, String.eq(g, "spaces"), "com.apple.spaces",
268  Bool.pick(String, String.eq(g, "trackpad"), "com.apple.AppleMultitouchTrackpad",
269  Bool.pick(String, String.eq(g, "universalaccess"), "com.apple.universalaccess",
270  Bool.pick(String, String.eq(g, "ActivityMonitor"), "com.apple.ActivityMonitor",
271  Bool.pick(String, String.eq(g, "WindowManager"), "com.apple.WindowManager",
272  Bool.pick(String, String.eq(g, "controlcenter"), "-currentHost com.apple.controlcenter",
273  Bool.pick(String, String.eq(g, "loginwindow"), "/Library/Preferences/com.apple.loginwindow",
274  Bool.pick(String, String.eq(g, "smb"), "/Library/Preferences/SystemConfiguration/com.apple.smb.server",
275  Bool.pick(String, String.eq(g, "SoftwareUpdate"), "/Library/Preferences/com.apple.SoftwareUpdate", "")))))))))))))))))))
276
277# `defaults` arguments that write a domain's plist XML to standard output.
278def export_args(+d: String) -> List<&2, String>:
279  Bool.pick(List<&2, String>, String.starts_with(d, "-currentHost "), ["-currentHost", "export", String.drop(d, 13n), "-"], ["export", d, "-"])
280
281def is_nix(k: St.ValueKind) -> Bool:
282  match k:
283    case St.KNix{}:
284      True{}
285    case _:
286      False{}
287
288# The domain an option is read from, "" when it is not read from one.
289def domain_of(s: St.Setting) -> String:
290  match s:
291    case St.Setting{+o, k, _}:
292      Bool.pick(String, is_nix(k), "", domain(group_of(o)))
293
294def domains_go(xs: List<&2, St.Setting>) -> List<&2, String>:
295  match xs:
296    case []:
297      []
298    case s <> rest:
299      +d = domain_of(s)
300      keep_if(Bool.not(String.is_empty(d)), d, domains_go(rest))
301
302# The domains the options are read from, each once.
303def domains(xs: List<&2, St.Setting>) -> List<&2, String>:
304  distinct(domains_go(xs))
305
306# ---- plist XML, as `defaults export` writes it (real characters, unlike `defaults read`) ----
307def text_lines_go(s: String, +cur: List<&2, Char>, +acc: List<&2, String>) -> List<&2, String>:
308  match s:
309    case SNil{}:
310      List.reverse(&2, String, String.from_list(List.reverse(&2, Char, cur)) <> acc)
311    case SCon{+h, t}:
312      +nl = Char.is_eq(h, '\n')
313      text_lines_go(t, Bool.pick(List<&2, Char>, nl, [], h <> cur), Bool.pick(List<&2, String>, nl, String.from_list(List.reverse(&2, Char, cur)) <> acc, acc))
314
315# String.lines in constant stack: Base's recurses as deep as the text is long, too deep for a large plist.
316def text_lines(s: String) -> List<&2, String>:
317  text_lines_go(s, [], [])
318
319def xml_entity(+seg: String) -> String:
320  Bool.pick(String, String.starts_with(seg, "amp;"), "&" ++ String.drop(seg, 4n),
321  Bool.pick(String, String.starts_with(seg, "lt;"), "<" ++ String.drop(seg, 3n),
322  Bool.pick(String, String.starts_with(seg, "gt;"), ">" ++ String.drop(seg, 3n),
323  Bool.pick(String, String.starts_with(seg, "quot;"), "\"" ++ String.drop(seg, 5n),
324  Bool.pick(String, String.starts_with(seg, "apos;"), "'" ++ String.drop(seg, 5n), "&" ++ seg)))))
325
326def xml_entities(segs: List<&2, String>) -> String:
327  match segs:
328    case []:
329      ""
330    case s <> rest:
331      xml_entity(s) ++ xml_entities(rest)
332
333def xml_unescape(+s: String) -> String:
334  +segs = String.split(s, '&')
335  Maybe.default(&2, String, List.head(&2, String, segs), "") ++ xml_entities(List.drop(&2, String, segs, 1n))
336
337def xml_escape_char(+c: Char) -> String:
338  Bool.pick(String, Char.is_eq(c, '&'), "&amp;", Bool.pick(String, Char.is_eq(c, '<'), "&lt;", Bool.pick(String, Char.is_eq(c, '>'), "&gt;", String.from_list([c]))))
339
340def xml_escape(cs: List<&2, Char>) -> String:
341  match cs:
342    case []:
343      ""
344    case c <> rest:
345      xml_escape_char(c) ++ xml_escape(rest)
346
347# "<true/>" -> "1", "<false/>" -> "0", "<integer>3</integer>" -> "3", "<string>a &amp; b</string>" -> "a & b";
348# "" when unset or not a scalar.
349def plist_scalar(+v: String) -> String:
350  +t = String.trim(v)
351  Bool.pick(String, String.starts_with(t, "<true/>"), "1",
352  Bool.pick(String, String.starts_with(t, "<false/>"), "0",
353  Bool.pick(String, String.starts_with(t, "<dict") || String.starts_with(t, "<array") || String.starts_with(t, "<data"), "",
354    xml_unescape(Maybe.default(&2, String, List.head(&2, String, String.split(Maybe.default(&2, String, List.get(&2, String, String.split(t, '>'), 1n), ""), '<')), "")))))
355
356def ends_scalar(+l: String) -> Bool:
357  String.contains(l, "</string>") || String.contains(l, "</integer>") || String.contains(l, "</real>") || String.contains(l, "/>") || String.contains(l, "<dict>") || String.contains(l, "<array>") || String.contains(l, "<data>")
358
359# The lines of a value, up to the one that ends it.
360def value_lines(ls: List<&2, String>, done: Bool) -> String:
361  match ls done:
362    case [] _:
363      ""
364    case _ True{}:
365      ""
366    case +l <> rest False{}:
367      l ++ "\n" ++ value_lines(rest, ends_scalar(l))
368
369def after_key(ls: List<&2, String>, +target: String, hit: Bool) -> String:
370  match ls hit:
371    case [] _:
372      ""
373    case xs True{}:
374      value_lines(xs, False{})
375    case +l <> rest False{}:
376      after_key(rest, target, String.eq(l, target))
377
378# The scalar of a top-level key in a plist's XML lines, "" when absent.
379def plist_value(+key: String, ls: List<&2, String>) -> String:
380  plist_scalar(after_key(ls, "\t<key>" ++ xml_escape(String.to_list(key)) ++ "</key>", False{}))
381
382# ---- values, written as nix-darwin writes them ----
383def normalize_kind(k: St.ValueKind, +v: String) -> String:
384  match k:
385    case St.KBool{}:
386      Bool.pick(String, String.eq(v, "1"), "true", Bool.pick(String, String.eq(v, "0"), "false", ""))
387    case St.KFloat{}:
388      Bool.pick(String, String.contains(v, "."), v, v ++ ".0")
389    case St.KStr{}:
390      BrO.nix_esc(v)
391    case _:
392      v
393
394def normalize(k: St.ValueKind, +v: String) -> String:
395  Bool.pick(String, String.is_empty(v), "", normalize_kind(k, v))
396
397def index_label(+raw: String, labels: List<&2, String>) -> String:
398  Maybe.default(&2, String, List.get(&2, String, labels, Maybe.default(&2, Nat, Nat.read(raw), 99n)), "")
399
400def finder_label(+c: String) -> String:
401  Bool.pick(String, String.eq(c, "PfCm"), "Computer", Bool.pick(String, String.eq(c, "PfVo"), "OS volume",
402  Bool.pick(String, String.eq(c, "PfHm"), "Home", Bool.pick(String, String.eq(c, "PfDe"), "Desktop",
403  Bool.pick(String, String.eq(c, "PfDo"), "Documents", Bool.pick(String, String.eq(c, "PfAF"), "Recents",
404  Bool.pick(String, String.eq(c, "PfID"), "iCloud Drive", Bool.pick(String, String.eq(c, "PfLo"), "Other", ""))))))))
405
406# An option whose stored value is a code for a label (nix-darwin maps label -> code with `apply`).
407def value_for(+g: String, +key: String, k: St.ValueKind, +raw: String) -> String:
408  Bool.pick(String, String.eq(g, "hitoolbox") && String.eq(key, "AppleFnUsageType"), index_label(raw, ["Do Nothing", "Change Input Source", "Show Emoji & Symbols", "Start Dictation"]),
409  Bool.pick(String, String.eq(g, "iCal") && String.eq(key, "first day of week"), index_label(raw, ["System Setting", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]),
410  Bool.pick(String, String.eq(g, "finder") && String.eq(key, "NewWindowTarget"), finder_label(raw),
411  Bool.pick(String, String.eq(g, "controlcenter"), Bool.pick(String, String.eq(raw, "18"), "true", Bool.pick(String, String.eq(raw, "24"), "false", "")),
412  normalize(k, raw)))))
413
414def read_one(s: St.Setting, +ls: List<&2, String>) -> List<&2, St.Setting>:
415  match s:
416    case St.Setting{+o, +k, _}:
417      +v = value_for(group_of(o), key_of(o), k, plist_value(key_of(o), ls))
418      Bool.pick(List<&2, St.Setting>, String.is_empty(v), [], [St.Setting{o, k, v}])
419
420# The options read from domain `d`, with their values in its exported XML lines.
421def read_domain(xs: List<&2, St.Setting>, +d: String, +ls: List<&2, String>) -> List<&2, St.Setting>:
422  match xs:
423    case []:
424      []
425    case +s <> rest:
426      List.append(&2, St.Setting, Bool.pick(List<&2, St.Setting>, String.eq(domain_of(s), d), read_one(s, ls), []), read_domain(rest, d, ls))
427
428# ---- power: `pmset -g custom` lines like " sleep  10" (0 minutes: never) ----
429def pm_hit(+ws: List<&2, String>, +key: String) -> String:
430  Bool.pick(String, Nat.is_eq(List.length(&2, String, ws), 2n) && String.eq(Maybe.default(&2, String, List.head(&2, String, ws), ""), key),
431    Maybe.default(&2, String, List.get(&2, String, ws, 1n), ""), "")
432
433def pm_get(ls: List<&2, String>, +key: String) -> String:
434  match ls:
435    case []:
436      ""
437    case l <> rest:
438      +hit = pm_hit(words(l), key)
439      Bool.pick(String, String.is_empty(hit), pm_get(rest, key), hit)
440
441def minutes(+v: String) -> String:
442  Bool.pick(String, String.eq(v, "0"), "\"never\"", v)
443
444def power_settings(+pm: String) -> List<&2, St.Setting>:
445  +ls = String.lines(pm)
446  List.concat(&2, St.Setting, [
447    when_filled(pm_get(ls, "sleep"), int_setting("power.sleep.computer", minutes(pm_get(ls, "sleep")))),
448    when_filled(pm_get(ls, "displaysleep"), int_setting("power.sleep.display", minutes(pm_get(ls, "displaysleep")))),
449    when_filled(pm_get(ls, "disksleep"), int_setting("power.sleep.harddisk", minutes(pm_get(ls, "disksleep")))),
450    when_filled(pm_get(ls, "autorestart"), bool_setting("power.restartAfterPowerFailure", String.eq(pm_get(ls, "autorestart"), "1"))),
451    when_filled(pm_get(ls, "womp"), bool_setting("networking.wakeOnLan.enable", String.eq(pm_get(ls, "womp"), "1")))])
452
453def button_line(ls: List<&2, String>) -> String:
454  match ls:
455    case []:
456      ""
457    case +l <> rest:
458      Bool.pick(String, String.contains(l, "Sleep On Power Button"), last_word(l), button_line(rest))
459
460# `pmset -g`'s "Sleep On Power Button 1".
461def button_setting(+pm: String) -> List<&2, St.Setting>:
462  +b = button_line(String.lines(pm))
463  when_filled(b, bool_setting("power.sleep.allowSleepByPowerButton", String.eq(b, "1")))
464
465# ---- the firewall, from socketfilterfw's four answers ----
466def firewall_on(on: Bool, +f: String) -> St.Firewall:
467  match on:
468    case True{}:
469      St.FirewallOn{String.contains(f, "built-in signed software ENABLED"), String.contains(f, "downloaded signed software ENABLED"),
470        String.contains(f, "block all state set to enabled"), String.contains(f, "stealth mode is on")}
471    case False{}:
472      St.FirewallOff{}
473
474def firewall_of(+f: String) -> St.Firewall:
475  firewall_on(String.contains(f, "Firewall is enabled"), f)
476
477# ---- Dock tiles: system.defaults.dock.persistent-apps / persistent-others ----
478# "file:///Applications/Google%20Chrome.app/" -> "/Applications/Google Chrome.app"; bend-net-url decodes.
479def url_path(+u: String) -> String:
480  +a = Bool.pick(String, String.starts_with(u, "file://"), String.drop(u, 7n), u)
481  +b = Bool.pick(String, String.ends_with(a, "/") && Nat.is_gt(String.length(a), 1n), String.take(a, Nat.sub(String.length(a), 1n)), a)
482  Maybe.default(&2, String, Url.pct.decode(b), b)
483
484def arrangement(+n: String) -> String:
485  Bool.pick(String, String.eq(n, "2"), "date-added", Bool.pick(String, String.eq(n, "3"), "date-modified",
486  Bool.pick(String, String.eq(n, "4"), "date-created", Bool.pick(String, String.eq(n, "5"), "kind", "name"))))
487
488def displayas(+n: String) -> String:
489  Bool.pick(String, String.eq(n, "1"), "folder", "stack")
490
491def showas(+n: String) -> String:
492  Bool.pick(String, String.eq(n, "1"), "fan", Bool.pick(String, String.eq(n, "2"), "grid", Bool.pick(String, String.eq(n, "3"), "list", "automatic")))
493
494def app_tile(+t: String, +p: String) -> String:
495  Bool.pick(String, String.eq(t, "spacer-tile"), "{ spacer = { small = false; }; }",
496  Bool.pick(String, String.eq(t, "small-spacer-tile"), "{ spacer = { small = true; }; }",
497  Bool.pick(String, String.eq(t, "directory-tile"), "{ folder = " ++ BrO.nix_str(p) ++ "; }",
498  Bool.pick(String, String.ends_with(p, ".app"), "{ app = " ++ BrO.nix_str(p) ++ "; }", "{ file = " ++ BrO.nix_str(p) ++ "; }"))))
499
500# A tile from its type, URL, arrangement, displayas and showas (plist fields; the last three for folders).
501def tile_nix(+key: String, +t: String, +url: String, +arr: String, +disp: String, +show: String) -> String:
502  Bool.pick(String, String.eq(key, "persistent-apps"), app_tile(t, url_path(url)),
503    Bool.pick(String, String.eq(t, "directory-tile"),
504      "{ folder = { path = " ++ BrO.nix_str(url_path(url)) ++ "; arrangement = \"" ++ arrangement(arr) ++ "\"; displayas = \"" ++ displayas(disp) ++ "\"; showas = \"" ++ showas(show) ++ "\"; }; }",
505      "{ file = " ++ BrO.nix_str(url_path(url)) ++ "; }"))
506
507def spaced(xs: List<&2, String>) -> String:
508  match xs:
509    case []:
510      ""
511    case x <> rest:
512      " " ++ x ++ spaced(rest)
513
514def dock_setting(+key: String, +count: String, tiles: List<&2, String>) -> List<&2, St.Setting>:
515  when_filled(count, nix_setting("system.defaults.dock." ++ key, "[" ++ spaced(tiles) ++ " ]"))
516
517# ---- network: nix-darwin gives every known service the same DNS servers and search domains; a Mac where
518# they differ cannot be declared ----
519# `networksetup -listallnetworkservices`: a header line, then the services ("*" marks a disabled one).
520def services(+out: String) -> List<&2, String>:
521  List.filter(~String, ~(s => Bool.not(String.starts_with(s, "*"))), List.drop(&2, String, filled(out), 1n))
522
523# One service's servers or domains, "" when it has none.
524def per_service(+out: String) -> String:
525  Bool.pick(String, String.contains(out, "aren't any"), "", String.join(words(out), " "))
526
527def same_for_all(+what: String, +outs: List<&2, String>) -> String:
528  Bool.pick(String, Nat.is_gt(List.length(&2, String, distinct(outs)), 1n), "throw \"V: " ++ what ++ " differ per network service\"",
529    nix_list(words(Maybe.default(&2, String, List.head(&2, String, outs), ""))))
530
531# ---- security ----
532def is_nix_link(+target: String) -> Bool:
533  String.starts_with(String.trim(target), "/etc/static/")
534
535def sudo_keep(+l: String) -> Bool:
536  Bool.not(String.is_empty(String.trim(l)) || String.starts_with(l, "#") || String.contains(l, "env_keep+=TERMINFO"))
537
538def sudo_line(l: String) -> Bool:
539  sudo_keep(l)
540
541# The sudoers lines nix-darwin writes from security.sudo.extraConfig (not its terminfo lines).
542def sudo_extra(+sudo: String) -> String:
543  String.join(List.filter(~String, ~sudo_line, String.lines(sudo)), "\n")
544
545# ---- keyboard: hidutil's UserKeyMapping ----
546# The number after `w =` for each `w` in the words.
547def after_word(ws: List<&2, String>, +w: String) -> List<&2, String>:
548  match ws:
549    case []:
550      []
551    case +x <> +rest:
552      List.append(&2, String, Bool.pick(List<&2, String>, String.eq(x, w), [digits(Maybe.default(&2, String, List.get(&2, String, rest, 1n), ""))], []), after_word(rest, w))
553
554def pairs(src: List<&2, String>, dst: List<&2, String>) -> String:
555  match src:
556    case []:
557      ""
558    case s <> srest:
559      match dst:
560        case []:
561          ""
562        case d <> drest:
563          " { HIDKeyboardModifierMappingSrc = " ++ s ++ "; HIDKeyboardModifierMappingDst = " ++ d ++ "; }" ++ pairs(srest, drest)
564
565def keyboard_settings(+km: String) -> List<&2, St.Setting>:
566  +none = String.eq(String.trim(km), "(null)") || String.eq(String.trim(km), "(\n)") || String.is_empty(String.trim(km))
567  [bool_setting("system.keyboard.enableKeyMapping", Bool.not(none)),
568   nix_setting("system.keyboard.userKeyMapping", "[" ++ pairs(after_word(words(km), "HIDKeyboardModifierMappingSrc"), after_word(words(km), "HIDKeyboardModifierMappingDst")) ++ " ]"),
569   bool_setting("system.keyboard.remapCapsLockToControl", False{}),
570   bool_setting("system.keyboard.remapCapsLockToEscape", False{}),
571   bool_setting("system.keyboard.swapCapsLockAndEscape", False{}),
572   bool_setting("system.keyboard.swapLeftCommandAndLeftAlt", False{}),
573   bool_setting("system.keyboard.swapLeftCtrlAndFn", False{}),
574   bool_setting("system.keyboard.swapRightCommandAndRightOption", False{}),
575   bool_setting("system.keyboard.nonUS.remapTilde", False{})]
576
577# ---- system ----
578def after_segment(segs: List<&2, String>, +s: String) -> String:
579  match segs:
580    case []:
581      ""
582    case +x <> +rest:
583      Bool.pick(String, String.eq(x, s), String.join(rest, "/"), after_segment(rest, s))
584
585# "/var/db/timezone/zoneinfo/America/Matamoros" -> "America/Matamoros".
586def time_zone(+link: String) -> String:
587  after_segment(String.split(String.trim(link), '/'), "zoneinfo")
588
589def extra_shell_keep(+l: String) -> Bool:
590  Bool.not(String.starts_with(l, "#") || List.contains(~String, ~String.eq, ["/bin/bash", "/bin/csh", "/bin/dash", "/bin/ksh", "/bin/sh", "/bin/tcsh", "/bin/zsh"], l))
591
592def extra_shell(l: String) -> Bool:
593  extra_shell_keep(l)
594
595# /etc/shells beyond macOS's own.
596def extra_shells(+shells: String) -> List<&2, String>:
597  List.filter(~String, ~extra_shell, filled(shells))
598
599# nvram's "StartupMute	%01" (muted); nothing when it is not set.
600def chime_setting(+mute: String) -> List<&2, St.Setting>:
601  when_filled(mute, bool_setting("system.startup.chime", Bool.not(String.eq(word(mute, 1n), "%01"))))
602
603def fonts_setting(fonts: List<&2, String>) -> St.Setting:
604  nix_setting("fonts.packages", Bool.pick(String, List.is_empty(&2, String, fonts), "[ ]", "throw \"V: /Library/Fonts/Nix Fonts has fonts from no declared package\""))
605
606# ---- users and groups (id >= 500, not system accounts) ----
607# `dscl . -list /Users UniqueID` lines "name   501" -> the names.
608def account(+l: String) -> List<&2, String>:
609  +n = word(l, 0n)
610  Bool.pick(List<&2, String>, Nat.is_ge(nat_of(word(l, 1n)), 500n) && Bool.not(String.starts_with(n, "_")) && Bool.not(String.is_empty(n)), [n], [])
611
612def accounts_go(ls: List<&2, String>) -> List<&2, String>:
613  match ls:
614    case []:
615      []
616    case l <> rest:
617      List.append(&2, String, account(l), accounts_go(rest))
618
619def accounts(+out: String) -> List<&2, String>:
620  accounts_go(String.lines(out))
621
622# `dscl . -read <record> Attr` answers "Attr: value" or "Attr:\n value".
623def dscl_value(+attr: String, +out: String) -> String:
624  +t = String.trim(out)
625  String.trim(String.join(String.lines(Bool.pick(String, String.starts_with(t, attr ++ ":"), String.drop(t, Nat.add(String.length(attr), 1n)), t)), " "))
626
627def user_settings(+n: String, +uid: String, +gid: String, +dir: String, +real: String, +shell: String, +hidden: String) -> List<&2, St.Setting>:
628  +u = "users.users.\"" ++ n ++ "\"."
629  [int_setting(u ++ "uid", uid), int_setting(u ++ "gid", gid), str_setting(u ++ "home", dir), str_setting(u ++ "description", real),
630   nix_setting(u ++ "shell", shell), bool_setting(u ++ "isHidden", String.eq(hidden, "1"))]
631
632def group_settings(+n: String, +gid: String, +real: String, +members: String) -> List<&2, St.Setting>:
633  +g = "users.groups.\"" ++ n ++ "\"."
634  [int_setting(g ++ "gid", gid), str_setting(g ++ "description", real), nix_setting(g ++ "members", nix_list(words(members)))]
635
636# ---- launchd files other than hidden ones, nix-darwin's own (org.nixos.*), the system's services and Determinate
637# Nix's; each is named by its content hash, as `./launch/<hash>-<file>` ----
638def plist_of(labels: List<&2, String>, +f: String) -> Bool:
639  match labels:
640    case []:
641      False{}
642    case l <> rest:
643      String.eq(l ++ ".plist", f) || plist_of(rest, f)
644
645def launch_kept(+f: String, +labels: List<&2, String>) -> Bool:
646  Bool.not(String.starts_with(f, ".") || String.starts_with(f, "org.nixos.") || String.starts_with(f, "systems.determinate.") || plist_of(labels, f))
647
648# `shasum -a 256` output -> the setting.
649def launch_setting(+opt: String, +f: String, +sum: String) -> St.Setting:
650  nix_setting(opt ++ ".\"" ++ f ++ "\".source", "./launch/" ++ String.take(String.trim(sum), 12n) ++ "-" ++ f)
651
652# ---- a settings file in V's types, for a system to import ----
653def kind_bend(k: St.ValueKind) -> String:
654  match k:
655    case St.KBool{}:
656      "St.KBool{}"
657    case St.KInt{}:
658      "St.KInt{}"
659    case St.KFloat{}:
660      "St.KFloat{}"
661    case St.KStr{}:
662      "St.KStr{}"
663    case St.KNix{}:
664      "St.KNix{}"
665
666def setting_bend(s: St.Setting) -> String:
667  match s:
668    case St.Setting{o, k, v}:
669      "    St.Setting{" ++ BrO.bend_str(o) ++ ", " ++ kind_bend(k) ++ ", " ++ BrO.bend_str(v) ++ "}"
670
671def settings_bend(xs: List<&2, St.Setting>) -> List<&2, String>:
672  match xs:
673    case []:
674      []
675    case s <> rest:
676      setting_bend(s) <> settings_bend(rest)
677
678def bool_bend(b: Bool) -> String:
679  Bool.pick(String, b, "True{}", "False{}")
680
681def firewall_bend(f: St.Firewall) -> String:
682  match f:
683    case St.FirewallOff{}:
684      "St.FirewallOff{}"
685    case St.FirewallOn{a, b, c, d}:
686      "St.FirewallOn{" ++ bool_bend(a) ++ ", " ++ bool_bend(b) ++ ", " ++ bool_bend(c) ++ ", " ++ bool_bend(d) ++ "}"
687
688# The V package whose types the file names.
689def v_package() -> String:
690  "near-v-framework@0.3.0.0/domain/architecture/system/deployment/nix-darwin"
691
692# Bend source for the Mac's settings, firewall and Homebrew entries in V's types.
693def import_text(settings: List<&2, St.Setting>, fw: St.Firewall, brews: List<&2, Br.Brew>) -> String:
694  "import Base\nimport " ++ v_package() ++ "/setting/type.bend as St\nimport " ++ v_package() ++ "/brew/type.bend as Br\n\n" ++
695  "# The Mac as V read it.\n\ndef settings() -> List<&2, St.Setting>:\n  [\n" ++ String.join(settings_bend(settings), ",\n") ++ "\n  ]\n\n" ++
696  "# The application firewall: its options exist only while it is on.\ndef firewall() -> St.Firewall:\n  " ++ firewall_bend(fw) ++ "\n\n" ++
697  "def brews() -> List<&2, Br.Brew>:\n" ++ BrO.bend_list(brews) ++ "\n"