cli/vmac.bend

near-v-framework@0.2.0.0 · 329 lines · 0 definitions

1import Base
2import near-v-framework@0.2.0.0/lib/effs/io.bend as X
3import near-v-framework@0.2.0.0/lib/vstr.bend as S
4import bend-net-url@0.4.0.0/url.bend as Url
5
6# The Mac's nix-darwin-labeled state outside `system.defaults` scalars, read for `v system import`.
7# Each reader answers setting lines for mac.now.bend; values are Nix expressions (T.KNix) unless scalar.
8
9def nix_line(name: String, v: String) -> String:
10  S.setting_line(name, "KNix", v)
11
12def bool_line(name: String, b: Bool) -> String:
13  S.setting_line(name, "KBool", S.pick(b, "true", "false"))
14
15def str_line(name: String, v: String) -> String:
16  S.setting_line(name, "KStr", S.nix_esc_chars(String.to_list(v)))
17
18def int_line(name: String, v: String) -> String:
19  S.setting_line(name, "KInt", v)
20
21def nonempty_line(+v: String, l: String) -> List<&2, String>:
22  S.line_if(Bool.not(String.is_empty(v)), l)
23
24def lines_of(+s: String) -> List<&2, String>:
25  S.nonempty(String.lines(String.trim(s)))
26
27# ---- numbers ----
28def digit_nat(c: Char) -> Nat:
29  U32.to_nat(U32.sub(Char.to_u32(c), 48))
30
31def nat_go(cs: List<&2, Char>, acc: Nat) -> Nat:
32  match cs:
33    case []:
34      acc
35    case c <> rest:
36      nat_go(rest, Nat.add(Nat.mul(acc, 10n), digit_nat(c)))
37
38def nat_of(s: String) -> Nat:
39  nat_go(String.to_list(S.digits(s)), 0n)
40
41# ---- URLs as paths: "file:///Applications/Google%20Chrome.app/" -> "/Applications/Google Chrome.app" ----
42# Percent-decoding is bend-net-url's (UTF-8 aware); a malformed URL is kept as it is.
43def decoded_or(m: Maybe<&2, String>, raw: String) -> String:
44  match m:
45    case None{}:
46      raw
47    case Some{v}:
48      v
49
50def pct_decode(+s: String) -> String:
51  decoded_or(Url.pct.decode(s), s)
52
53def drop_prefix(+s: String, +p: String) -> String:
54  S.pick(String.starts_with(s, p), String.drop(s, String.length(p)), s)
55
56def drop_slash(+s: String) -> String:
57  S.pick(String.ends_with(s, "/") && Nat.is_gt(String.length(s), 1n), String.take(s, Nat.sub(String.length(s), 1n)), s)
58
59def url_path(+u: String) -> String:
60  pct_decode(drop_slash(drop_prefix(u, "file://")))
61
62# ---- Dock tiles: system.defaults.dock.persistent-apps / persistent-others ----
63def dock() -> String:
64  "\"$HOME/Library/Preferences/com.apple.dock.plist\""
65
66def tile_cmd(+key: String, +i: Nat) -> String:
67  "P=" ++ dock() ++ "; K=" ++ key ++ "." ++ Nat.show(i) ++
68  "; t=$(plutil -extract $K.tile-type raw $P 2>/dev/null) || exit 1" ++
69  "; printf '%s|%s|%s|%s|%s' \"$t\" \"$(plutil -extract $K.tile-data.file-data._CFURLString raw $P 2>/dev/null)\"" ++
70  " \"$(plutil -extract $K.tile-data.arrangement raw $P 2>/dev/null)\" \"$(plutil -extract $K.tile-data.displayas raw $P 2>/dev/null)\" \"$(plutil -extract $K.tile-data.showas raw $P 2>/dev/null)\""
71
72def arrangement(+n: String) -> String:
73  S.pick(String.eq(n, "2"), "date-added", S.pick(String.eq(n, "3"), "date-modified", S.pick(String.eq(n, "4"), "date-created", S.pick(String.eq(n, "5"), "kind", "name"))))
74
75def displayas(+n: String) -> String:
76  S.pick(String.eq(n, "1"), "folder", "stack")
77
78def showas(+n: String) -> String:
79  S.pick(String.eq(n, "1"), "fan", S.pick(String.eq(n, "2"), "grid", S.pick(String.eq(n, "3"), "list", "automatic")))
80
81def app_tile(+t: String, +p: String) -> String:
82  S.pick(String.eq(t, "spacer-tile"), "{ spacer = { small = false; }; }",
83  S.pick(String.eq(t, "small-spacer-tile"), "{ spacer = { small = true; }; }",
84  S.pick(String.eq(t, "directory-tile"), "{ folder = " ++ S.nix_str(p) ++ "; }",
85  S.pick(String.ends_with(p, ".app"), "{ app = " ++ S.nix_str(p) ++ "; }", "{ file = " ++ S.nix_str(p) ++ "; }"))))
86
87def other_tile(+t: String, +p: String, +f: List<&2, String>) -> String:
88  S.pick(String.eq(t, "directory-tile"),
89    "{ folder = { path = " ++ S.nix_str(p) ++ "; arrangement = \"" ++ arrangement(S.nth(f, 2n)) ++ "\"; displayas = \"" ++ displayas(S.nth(f, 3n)) ++ "\"; showas = \"" ++ showas(S.nth(f, 4n)) ++ "\"; }; }",
90    "{ file = " ++ S.nix_str(p) ++ "; }")
91
92def tile_nix(+key: String, +f: List<&2, String>) -> String:
93  S.pick(String.eq(key, "persistent-apps"), app_tile(S.nth(f, 0n), url_path(S.nth(f, 1n))), other_tile(S.nth(f, 0n), url_path(S.nth(f, 1n)), f))
94
95def tiles(n: Nat, +key: String, +i: Nat) -> IO(List<&2, String>):
96  match n:
97    case 0n:
98      IO.pure(List<&2, String>, [])
99    case 1n+m:
100      do IO<List<&2, String>>:
101        t : String <- X.sh(tile_cmd(key, i))
102        rest : List<&2, String> <- tiles(m, key, 1n+i)
103        return (" " ++ tile_nix(key, String.split(t, '|'))) <> rest
104
105def dock_list(+key: String) -> IO(List<&2, String>):
106  do IO<List<&2, String>>:
107    +n : String <- X.sh("plutil -extract " ++ key ++ " raw " ++ dock() ++ " 2>/dev/null")
108    ts : List<&2, String> <- tiles(nat_of(n), key, 0n)
109    return S.line_if(Bool.not(String.is_empty(String.trim(n))), nix_line("system.defaults.dock." ++ key, "[" ++ String.concat(ts) ++ " ]"))
110
111# ---- firewall, network, power ----
112# ---- the firewall, as a T.Firewall expression (its options exist only while it is on) ----
113def bend_bool(b: Bool) -> String:
114  S.pick(b, "True{}", "False{}")
115
116def firewall_expr(on: Bool, +f: String) -> String:
117  match on:
118    case True{}:
119      "T.FirewallOn{" ++ bend_bool(String.contains(f, "built-in signed software ENABLED")) ++ ", " ++
120      bend_bool(String.contains(f, "downloaded signed software ENABLED")) ++ ", " ++
121      bend_bool(String.contains(f, "block all state set to enabled")) ++ ", " ++
122      bend_bool(String.contains(f, "stealth mode is on")) ++ "}"
123    case False{}:
124      "T.FirewallOff{}"
125
126def firewall() -> IO(String):
127  do IO<String>:
128    +f : String <- X.sh("F=/usr/libexec/ApplicationFirewall/socketfilterfw; $F --getglobalstate; $F --getallowsigned; $F --getblockall; $F --getstealthmode")
129    return firewall_expr(String.contains(f, "Firewall is enabled"), f)
130
131# nix-darwin gives every known service the same DNS servers and search domains; a Mac where they differ cannot be declared.
132def same_for_all(+what: String, +outs: List<&2, String>) -> String:
133  S.pick(Nat.is_gt(S.count(outs), 1n), "throw \"V: " ++ what ++ " differ per network service\"", S.nix_list(S.words(S.nth(outs, 0n))))
134
135def per_service(+get: String) -> String:
136  "networksetup -listallnetworkservices | tail -n +2 | grep -v '^\\*' | while read -r s; do networksetup " ++ get ++ " \"$s\" | grep -v \"aren't any\" | tr '\\n' ' '; echo; done | sort -u"
137
138def network() -> IO(List<&2, String>):
139  do IO<List<&2, String>>:
140    svcs : String <- X.sh("networksetup -listallnetworkservices | tail -n +2 | grep -v '^\\*'")
141    dns : String <- X.sh(per_service("-getdnsservers"))
142    search : String <- X.sh(per_service("-getsearchdomains"))
143    return [nix_line("networking.knownNetworkServices", S.nix_list(lines_of(svcs))),
144            nix_line("networking.dns", same_for_all("DNS servers", String.lines(String.trim(dns)))),
145            nix_line("networking.search", same_for_all("search domains", String.lines(String.trim(search))))]
146
147def power() -> IO(List<&2, String>):
148  do IO<List<&2, String>>:
149    +b : String <- X.sh("pmset -g | grep 'Sleep On Power Button' | awk '{print $NF}'")
150    # power.restartAfterFreeze is left out: only `systemsetup` reports it, and only to an administrator.
151    return nonempty_line(String.trim(b), bool_line("power.sleep.allowSleepByPowerButton", String.eq(String.trim(b), "1")))
152
153# ---- security ----
154def is_nix_link(path: String) -> IO(Bool):
155  X.ok("readlink " ++ path ++ " | grep -q '^/etc/static/'")
156
157def sudo_extra(ls: List<&2, String>) -> List<&2, String>:
158  match ls:
159    case []:
160      []
161    case +l <> rest:
162      S.append(S.line_if(Bool.not(String.is_empty(String.trim(l)) || String.starts_with(l, "#") || String.contains(l, "env_keep+=TERMINFO")), l), sudo_extra(rest))
163
164def security() -> IO(List<&2, String>):
165  do IO<List<&2, String>>:
166    +pam : String <- X.sh("cat /etc/pam.d/sudo_local 2>/dev/null")
167    pam_nix : Bool <- is_nix_link("/etc/pam.d/sudo_local")
168    +sudo : String <- X.sh("cat /etc/sudoers.d/10-nix-darwin-extra-config 2>/dev/null")
169    pki : Bool <- is_nix_link("/etc/ssl/certs/ca-certificates.crt")
170    return [bool_line("security.pam.services.sudo_local.enable", pam_nix),
171            bool_line("security.pam.services.sudo_local.touchIdAuth", String.contains(pam, "pam_tid.so")),
172            bool_line("security.pam.services.sudo_local.watchIdAuth", String.contains(pam, "pam_watchid")),
173            bool_line("security.pam.services.sudo_local.reattach", String.contains(pam, "pam_reattach")),
174            bool_line("security.sudo.keepTerminfo", String.contains(sudo, "TERMINFO_DIRS")),
175            str_line("security.sudo.extraConfig", String.join(sudo_extra(String.lines(sudo)), "\n")),
176            bool_line("security.pki.installCACerts", pki)]
177
178# ---- keyboard: hidutil's UserKeyMapping ----
179def after_word(+w: String, ws: List<&2, String>) -> List<&2, String>:
180  match ws:
181    case []:
182      []
183    case +x <> +rest:
184      S.append(S.line_if(String.eq(x, w), S.digits(S.nth(rest, 1n))), after_word(w, rest))
185
186def pairs(src: List<&2, String>, dst: List<&2, String>) -> String:
187  match src:
188    case []:
189      ""
190    case s <> srest:
191      match dst:
192        case []:
193          ""
194        case d <> drest:
195          " { HIDKeyboardModifierMappingSrc = " ++ s ++ "; HIDKeyboardModifierMappingDst = " ++ d ++ "; }" ++ pairs(srest, drest)
196
197def keyboard() -> IO(List<&2, String>):
198  do IO<List<&2, String>>:
199    +km : String <- X.sh("hidutil property --get UserKeyMapping")
200    +none : Bool = String.eq(String.trim(km), "(null)") || String.eq(String.trim(km), "(\n)")
201    return [bool_line("system.keyboard.enableKeyMapping", Bool.not(none)),
202            nix_line("system.keyboard.userKeyMapping", "[" ++ pairs(after_word("HIDKeyboardModifierMappingSrc", S.words(String.join(String.lines(km), " "))), after_word("HIDKeyboardModifierMappingDst", S.words(String.join(String.lines(km), " ")))) ++ " ]"),
203            bool_line("system.keyboard.remapCapsLockToControl", False{}),
204            bool_line("system.keyboard.remapCapsLockToEscape", False{}),
205            bool_line("system.keyboard.swapCapsLockAndEscape", False{}),
206            bool_line("system.keyboard.swapLeftCommandAndLeftAlt", False{}),
207            bool_line("system.keyboard.swapLeftCtrlAndFn", False{}),
208            bool_line("system.keyboard.swapRightCommandAndRightOption", False{}),
209            bool_line("system.keyboard.nonUS.remapTilde", False{})]
210
211# ---- startup chime, time zone, primary user, Nix, shells, fonts, SSH ----
212def system() -> IO(List<&2, String>):
213  do IO<List<&2, String>>:
214    +mute : String <- X.sh("nvram StartupMute 2>/dev/null | awk '{print $2}'")
215    +tz : String <- X.sh("readlink /etc/localtime | sed 's|.*/zoneinfo/||'")
216    user : String <- X.sh("stat -f %Su /dev/console")
217    nix : Bool <- is_nix_link("/etc/nix/nix.conf")
218    shells : String <- X.sh("grep -v '^#' /etc/shells | grep -v -x -e /bin/bash -e /bin/csh -e /bin/dash -e /bin/ksh -e /bin/sh -e /bin/tcsh -e /bin/zsh")
219    +fonts : String <- X.sh("ls '/Library/Fonts/Nix Fonts' 2>/dev/null")
220    zsh : Bool <- is_nix_link("/etc/zshrc")
221    bash : Bool <- is_nix_link("/etc/bashrc")
222    fish : Bool <- is_nix_link("/etc/fish/config.fish")
223    ssh : Bool <- X.ok("launchctl print system/com.openssh.sshd >/dev/null 2>&1")
224    return S.append(nonempty_line(String.trim(mute), bool_line("system.startup.chime", Bool.not(String.eq(String.trim(mute), "%01")))),
225           [str_line("time.timeZone", String.trim(tz)),
226            str_line("system.primaryUser", String.trim(user)),
227            bool_line("nix.enable", nix),
228            nix_line("environment.shells", S.nix_list(lines_of(shells))),
229            nix_line("fonts.packages", S.pick(String.is_empty(String.trim(fonts)), "[ ]", "throw \"V: /Library/Fonts/Nix Fonts has fonts from no declared package\"")),
230            bool_line("programs.zsh.enable", zsh),
231            bool_line("programs.bash.enable", bash),
232            bool_line("programs.fish.enable", fish),
233            bool_line("services.openssh.enable", ssh)])
234
235# ---- users and groups (uid/gid >= 500, not system accounts) ----
236def dscl_get(+kind: String, +name: String, +attr: String) -> IO(String):
237  do IO<String>:
238    +v : String <- X.sh("dscl . -read '/" ++ kind ++ "/" ++ name ++ "' " ++ attr ++ " 2>/dev/null | sed -e '1s/^" ++ attr ++ "://' | tr '\\n' ' '")
239    return String.trim(v)
240
241def user_lines(names: List<&2, String>) -> IO(List<&2, String>):
242  match names:
243    case []:
244      IO.pure(List<&2, String>, [])
245    case +n <> rest:
246      do IO<List<&2, String>>:
247        uid : String <- dscl_get("Users", n, "UniqueID")
248        gid : String <- dscl_get("Users", n, "PrimaryGroupID")
249        dir : String <- dscl_get("Users", n, "NFSHomeDirectory")
250        real : String <- dscl_get("Users", n, "RealName")
251        shell : String <- dscl_get("Users", n, "UserShell")
252        +hidden : String <- dscl_get("Users", n, "IsHidden")
253        more : List<&2, String> <- user_lines(rest)
254        +u : String = "users.users.\"" ++ n ++ "\"."
255        return S.append([int_line(u ++ "uid", uid), int_line(u ++ "gid", gid), str_line(u ++ "home", dir), str_line(u ++ "description", real),
256                         nix_line(u ++ "shell", shell), bool_line(u ++ "isHidden", String.eq(hidden, "1"))], more)
257
258def group_lines(names: List<&2, String>) -> IO(List<&2, String>):
259  match names:
260    case []:
261      IO.pure(List<&2, String>, [])
262    case +n <> rest:
263      do IO<List<&2, String>>:
264        gid : String <- dscl_get("Groups", n, "PrimaryGroupID")
265        real : String <- dscl_get("Groups", n, "RealName")
266        members : String <- dscl_get("Groups", n, "GroupMembership")
267        more : List<&2, String> <- group_lines(rest)
268        +g : String = "users.groups.\"" ++ n ++ "\"."
269        return S.append([int_line(g ++ "gid", gid), str_line(g ++ "description", real), nix_line(g ++ "members", S.nix_list(S.words(members)))], more)
270
271def users() -> IO(List<&2, String>):
272  do IO<List<&2, String>>:
273    us : String <- X.sh("dscl . -list /Users UniqueID | awk '$2>=500 && $1 !~ /^_/ {print $1}'")
274    gs : String <- X.sh("dscl . -list /Groups PrimaryGroupID | awk '$2>=500 && $1 !~ /^_/ {print $1}'")
275    a : List<&2, String> <- user_lines(lines_of(us))
276    b : List<&2, String> <- group_lines(lines_of(gs))
277    return S.append([nix_line("users.knownUsers", "[ ]"), nix_line("users.knownGroups", "[ ]")], S.append(a, b))
278
279# ---- launchd files other than nix-darwin's own (org.nixos.*), the project's services and Determinate Nix's (nix.enable = false) ----
280# Each is kept in nix/launch/ under its content hash, so a changed file on the Mac shows up as a change.
281def launch_file(+opt: String, +dir: String, +f: String) -> IO(List<&2, String>):
282  do IO<List<&2, String>>:
283    +h : String <- X.sh("shasum -a 256 \"" ++ dir ++ "/" ++ f ++ "\" | cut -c1-12")
284    cp : Bool <- X.ok("mkdir -p nix/launch && cp \"" ++ dir ++ "/" ++ f ++ "\" nix/launch/" ++ String.trim(h) ++ "-" ++ f)
285    return [nix_line(opt ++ ".\"" ++ f ++ "\".source", "./launch/" ++ String.trim(h) ++ "-" ++ f)]
286
287def launch_files(+opt: String, +dir: String, fs: List<&2, String>) -> IO(List<&2, String>):
288  match fs:
289    case []:
290      IO.pure(List<&2, String>, [])
291    case +f <> rest:
292      do IO<List<&2, String>>:
293        a : List<&2, String> <- launch_file(opt, dir, f)
294        b : List<&2, String> <- launch_files(opt, dir, rest)
295        return S.append(a, b)
296
297def plists(xs: List<&2, String>) -> String:
298  match xs:
299    case []:
300      ""
301    case x <> rest:
302      " " ++ x ++ ".plist" ++ plists(rest)
303
304def launch_dir(+opt: String, +dir: String, +labels: List<&2, String>) -> IO(List<&2, String>):
305  do IO<List<&2, String>>:
306    fs : String <- X.sh("cd \"" ++ dir ++ "\" && for f in *; do [ -f \"$f\" ] || continue; case \" " ++ plists(labels) ++ " \" in *\" $f \"*) continue;; esac; case \"$f\" in org.nixos.*|systems.determinate.*) ;; *) echo \"$f\";; esac; done")
307    launch_files(opt, dir, lines_of(fs))
308
309def launch(+labels: List<&2, String>) -> IO(List<&2, String>):
310  do IO<List<&2, String>>:
311    a : List<&2, String> <- launch_dir("environment.launchDaemons", "/Library/LaunchDaemons", labels)
312    b : List<&2, String> <- launch_dir("environment.launchAgents", "/Library/LaunchAgents", labels)
313    c : List<&2, String> <- launch_dir("environment.userLaunchAgents", "$HOME/Library/LaunchAgents", labels)
314    return S.append(a, S.append(b, c))
315
316# ---- all of it ----
317# `labels`: the project's own launchd jobs (nix-darwin writes them; they are not imported).
318def read_all(+labels: List<&2, String>) -> IO(List<&2, String>):
319  do IO<List<&2, String>>:
320    apps : List<&2, String> <- dock_list("persistent-apps")
321    others : List<&2, String> <- dock_list("persistent-others")
322    net : List<&2, String> <- network()
323    pw : List<&2, String> <- power()
324    sec : List<&2, String> <- security()
325    kb : List<&2, String> <- keyboard()
326    sys : List<&2, String> <- system()
327    us : List<&2, String> <- users()
328    ld : List<&2, String> <- launch(labels)
329    return S.append(apps, S.append(others, S.append(net, S.append(pw, S.append(sec, S.append(kb, S.append(sys, S.append(us, ld))))))))