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))))))))