1 import Base 2 import ../lib/effs/io.bend as X 3 import ../lib/vstr.bend as S 4 import 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 9 def nix_line(name: String, v: String) -> String: 10 S.setting_line(name, "KNix", v) 11 12 def bool_line(name: String, b: Bool) -> String: 13 S.setting_line(name, "KBool", S.pick(b, "true", "false")) 14 15 def str_line(name: String, v: String) -> String: 16 S.setting_line(name, "KStr", S.nix_esc_chars(String.to_list(v))) 17 18 def int_line(name: String, v: String) -> String: 19 S.setting_line(name, "KInt", v) 20 21 def nonempty_line(+v: String, l: String) -> List<&2, String>: 22 S.line_if(Bool.not(String.is_empty(v)), l) 23 24 def lines_of(+s: String) -> List<&2, String>: 25 S.nonempty(String.lines(String.trim(s))) 26 27 # ---- numbers ---- 28 def digit_nat(c: Char) -> Nat: 29 U32.to_nat(U32.sub(Char.to_u32(c), 48)) 30 31 def 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 38 def 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. 43 def decoded_or(m: Maybe<&2, String>, raw: String) -> String: 44 match m: 45 case None{}: 46 raw 47 case Some{v}: 48 v 49 50 def pct_decode(+s: String) -> String: 51 decoded_or(Url.pct.decode(s), s) 52 53 def drop_prefix(+s: String, +p: String) -> String: 54 S.pick(String.starts_with(s, p), String.drop(s, String.length(p)), s) 55 56 def 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 59 def 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 ---- 63 def dock() -> String: 64 "\"$HOME/Library/Preferences/com.apple.dock.plist\"" 65 66 def 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 72 def 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 75 def displayas(+n: String) -> String: 76 S.pick(String.eq(n, "1"), "folder", "stack") 77 78 def 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 81 def 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 87 def 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 92 def 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 95 def 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>: 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 105 def dock_list(+key: String) -> IO(List<&2, String>): 106 do IO>: 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) ---- 113 def bend_bool(b: Bool) -> String: 114 S.pick(b, "True{}", "False{}") 115 116 def 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 126 def firewall() -> IO(String): 127 do IO: 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. 132 def 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 135 def 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 138 def network() -> IO(List<&2, String>): 139 do IO>: 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 147 def power() -> IO(List<&2, String>): 148 do IO>: 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 ---- 154 def is_nix_link(path: String) -> IO(Bool): 155 X.ok("readlink " ++ path ++ " | grep -q '^/etc/static/'") 156 157 def 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 164 def security() -> IO(List<&2, String>): 165 do IO>: 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 ---- 179 def 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 186 def 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 197 def keyboard() -> IO(List<&2, String>): 198 do IO>: 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 ---- 212 def system() -> IO(List<&2, String>): 213 do IO>: 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) ---- 236 def dscl_get(+kind: String, +name: String, +attr: String) -> IO(String): 237 do IO: 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 241 def 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>: 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 258 def 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>: 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 271 def users() -> IO(List<&2, String>): 272 do IO>: 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. 281 def launch_file(+opt: String, +dir: String, +f: String) -> IO(List<&2, String>): 282 do IO>: 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 287 def 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>: 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 297 def plists(xs: List<&2, String>) -> String: 298 match xs: 299 case []: 300 "" 301 case x <> rest: 302 " " ++ x ++ ".plist" ++ plists(rest) 303 304 def launch_dir(+opt: String, +dir: String, +labels: List<&2, String>) -> IO(List<&2, String>): 305 do IO>: 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 309 def launch(+labels: List<&2, String>) -> IO(List<&2, String>): 310 do IO>: 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). 318 def read_all(+labels: List<&2, String>) -> IO(List<&2, String>): 319 do IO>: 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))))))))