1 import Base 2 import ../lib/effs/io.bend as X 3 import ../lib/vstr.bend as S 4 import ../lib/mac_types.bend as T 5 import ./project.bend as P 6 import ./vmac.bend as V 7 8 # `v system import`: read the Mac into mac.now.bend (and, the first time, mac.bend) in the project. 9 10 11 # ---- escaping (in vstr.bend) ---- 12 def esc(s: String) -> String: 13 S.esc(s) 14 15 def drop_if(yes: Bool, s: String) -> String: 16 match yes: 17 case True{}: 18 String.drop(s, 1n) 19 case False{}: 20 s 21 22 def unquote_end(+s: String) -> String: 23 String.reverse(drop_if(String.starts_with(String.reverse(s), "\""), String.reverse(s))) 24 25 def unquote(+s: String) -> String: 26 unquote_end(drop_if(String.starts_with(s, "\""), s)) 27 28 29 def pick(c: Bool, a: String, b: String) -> String: 30 S.pick(c, a, b) 31 32 # ---- which options, and how to read them ---- 33 def kind_of2(+t: String) -> String: 34 pick(String.eq(t, "boolean"), "KBool", 35 pick(String.starts_with(t, "floating point"), "KFloat", 36 pick(String.starts_with(t, "signed integer") || String.starts_with(t, "(positive integer") || String.starts_with(t, "one of 0") || String.starts_with(t, "one of 1") || String.starts_with(t, "value 1"), "KInt", 37 pick(String.eq(t, "string") || String.eq(t, "absolute path") || String.starts_with(t, "one of \"") || String.starts_with(t, "value \""), "KStr", "")))) 38 39 def kind_of(+t: String) -> String: 40 pick(String.starts_with(t, "null or "), kind_of2(String.drop(t, 8n)), "") 41 42 def rest_of(name: String) -> String: 43 String.drop(name, 16n) 44 45 def group_of(+name: String) -> String: 46 pick(String.starts_with(rest_of(name), "\""), S.nth(String.split(rest_of(name), '"'), 1n), S.nth(String.split(rest_of(name), '.'), 0n)) 47 48 def key_of(+name: String) -> String: 49 unquote(pick(String.starts_with(rest_of(name), "\""), 50 String.drop(String.join(List.drop(&2, String, String.split(rest_of(name), '"'), 2n), "\""), 1n), 51 String.join(List.drop(&2, String, String.split(rest_of(name), '.'), 1n), "."))) 52 53 # `defaults` arguments that read a group's domain; "" for groups not imported. 54 def domain(+g: String) -> String: 55 pick(String.eq(g, ".GlobalPreferences") || String.eq(g, "NSGlobalDomain"), "read -g", 56 pick(String.eq(g, "LaunchServices"), "read com.apple.LaunchServices", 57 pick(String.eq(g, "menuExtraClock"), "read com.apple.menuextra.clock", 58 pick(String.eq(g, "dock"), "read com.apple.dock", 59 pick(String.eq(g, "finder"), "read com.apple.finder", 60 pick(String.eq(g, "hitoolbox"), "read com.apple.HIToolbox", 61 pick(String.eq(g, "iCal"), "read com.apple.iCal", 62 pick(String.eq(g, "magicmouse"), "read com.apple.AppleMultitouchMouse", 63 pick(String.eq(g, "screencapture"), "read com.apple.screencapture", 64 pick(String.eq(g, "screensaver"), "read com.apple.screensaver", 65 pick(String.eq(g, "spaces"), "read com.apple.spaces", 66 pick(String.eq(g, "trackpad"), "read com.apple.AppleMultitouchTrackpad", 67 pick(String.eq(g, "universalaccess"), "read com.apple.universalaccess", 68 pick(String.eq(g, "ActivityMonitor"), "read com.apple.ActivityMonitor", 69 pick(String.eq(g, "WindowManager"), "read com.apple.WindowManager", 70 pick(String.eq(g, "controlcenter"), "-currentHost read com.apple.controlcenter", 71 pick(String.eq(g, "loginwindow"), "read /Library/Preferences/com.apple.loginwindow", 72 pick(String.eq(g, "smb"), "read /Library/Preferences/SystemConfiguration/com.apple.smb.server", 73 pick(String.eq(g, "SoftwareUpdate"), "read /Library/Preferences/com.apple.SoftwareUpdate", ""))))))))))))))))))) 74 75 # "read X" -> "export X", "-currentHost read X" -> "-currentHost export X". 76 def export_of(+d: String) -> String: 77 pick(String.starts_with(d, "read "), "export " ++ String.drop(d, 5n), "-currentHost export " ++ String.drop(d, 18n)) 78 79 # ---- values ---- 80 def nix_esc_chars(cs: List<&2, Char>) -> String: 81 S.nix_esc_chars(cs) 82 83 def normalize1(+k: String, +v: String) -> String: 84 pick(String.eq(k, "KBool"), pick(String.eq(v, "1"), "true", pick(String.eq(v, "0"), "false", "")), 85 pick(String.eq(k, "KFloat"), pick(String.contains(v, "."), v, v ++ ".0"), 86 pick(String.eq(k, "KStr"), nix_esc_chars(String.to_list(v)), v))) 87 88 def normalize(+k: String, +v: String) -> String: 89 pick(String.is_empty(v), "", normalize1(k, v)) 90 91 def one_if(b: Bool) -> Nat: 92 match b: 93 case True{}: 94 1n 95 case False{}: 96 0n 97 98 99 def nat_more(+raw: String) -> Nat: 100 Nat.add(one_if(String.eq(raw, "1")), Nat.add(Nat.mul(2n, one_if(String.eq(raw, "2"))), Nat.add(Nat.mul(3n, one_if(String.eq(raw, "3"))), Nat.add(Nat.mul(4n, one_if(String.eq(raw, "4"))), Nat.add(Nat.mul(5n, one_if(String.eq(raw, "5"))), Nat.add(Nat.mul(6n, one_if(String.eq(raw, "6"))), Nat.add(Nat.mul(7n, one_if(String.eq(raw, "7"))), Nat.mul(99n, one_if(String.is_empty(raw)))))))))) 101 102 103 def index_label(+raw: String, labels: List<&2, String>) -> String: 104 S.nth(labels, nat_more(raw)) 105 106 107 def finder_label(+c: String) -> String: 108 pick(String.eq(c, "PfCm"), "Computer", pick(String.eq(c, "PfVo"), "OS volume", pick(String.eq(c, "PfHm"), "Home", pick(String.eq(c, "PfDe"), "Desktop", 109 pick(String.eq(c, "PfDo"), "Documents", pick(String.eq(c, "PfAF"), "Recents", pick(String.eq(c, "PfID"), "iCloud Drive", pick(String.eq(c, "PfLo"), "Other", "")))))))) 110 111 # Options whose stored value is a code for a label (nix-darwin maps label -> code with `apply`). 112 def value_for(+g: String, +key: String, +k: String, +raw: String) -> String: 113 pick(String.eq(g, "hitoolbox") && String.eq(key, "AppleFnUsageType"), index_label(raw, ["Do Nothing", "Change Input Source", "Show Emoji & Symbols", "Start Dictation"]), 114 pick(String.eq(g, "iCal") && String.eq(key, "first day of week"), index_label(raw, ["System Setting", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]), 115 pick(String.eq(g, "finder") && String.eq(key, "NewWindowTarget"), finder_label(raw), 116 pick(String.eq(g, "controlcenter"), pick(String.eq(raw, "18"), "true", pick(String.eq(raw, "24"), "false", "")), 117 normalize(k, raw))))) 118 119 def setting_line(name: String, k: String, v: String) -> String: 120 S.setting_line(name, k, v) 121 122 def line_if(keep: Bool, l: String) -> List<&2, String>: 123 S.line_if(keep, l) 124 125 # ---- reading the Mac ---- 126 def read_value(r: Result<&1, &1, U32 & String, String>) -> String: 127 match r: 128 case Done{v}: 129 String.trim(v) 130 case Fail{_}: 131 "" 132 133 # One option line "nametype" -> its setting line, if the Mac has a value for it. 134 def option_read(+name: String, +k: String, +g: String) -> IO(List<&2, String>): 135 do IO>: 136 out : String <- X.sh(S.export_key(export_of(domain(g)), key_of(name))) 137 +v : String = value_for(g, key_of(name), k, S.plist_scalar(out)) 138 return line_if(Bool.not(String.is_empty(v)), setting_line(name, k, v)) 139 140 def option_skip(skip: Bool, +name: String, +k: String, +g: String) -> IO(List<&2, String>): 141 match skip: 142 case True{}: 143 IO.pure(List<&2, String>, []) 144 case False{}: 145 option_read(name, k, g) 146 147 def option_setting(+name: String, +k: String, +g: String) -> IO(List<&2, String>): 148 option_skip(String.is_empty(k) || String.is_empty(domain(g)), name, k, g) 149 150 def option_line(+l: String) -> IO(List<&2, String>): 151 option_setting(S.nth(String.split(l, '\t'), 0n), kind_of(S.nth(String.split(l, '\t'), 1n)), group_of(S.nth(String.split(l, '\t'), 0n))) 152 153 def option_lines(ls: List<&2, String>) -> IO(List<&2, String>): 154 match ls: 155 case []: 156 IO.pure(List<&2, String>, []) 157 case l <> rest: 158 do IO>: 159 a : List<&2, String> <- option_line(l) 160 b : List<&2, String> <- option_lines(rest) 161 return S.append(a, b) 162 163 # ---- power, names, firewall ---- 164 def pm_hit(+w: List<&2, String>, +key: String) -> String: 165 pick(Nat.is_eq(S.count(w), 2n) && String.eq(S.nth(w, 0n), key), S.nth(w, 1n), "") 166 167 def first_of(+a: String, b: String) -> String: 168 pick(String.is_empty(a), b, a) 169 170 171 def pm_get(ls: List<&2, String>, +key: String) -> String: 172 match ls: 173 case []: 174 "" 175 case l <> rest: 176 first_of(pm_hit(S.words(l), key), pm_get(rest, key)) 177 178 def minutes(+v: String) -> String: 179 pick(String.eq(v, "0"), "\"never\"", v) 180 181 def bool_of(+v: String) -> String: 182 pick(String.eq(v, "1"), "true", "false") 183 184 def power_lines(+pm: String) -> List<&2, String>: 185 S.append(line_if(Bool.not(String.is_empty(pm_get(String.lines(pm), "sleep"))), setting_line("power.sleep.computer", "KInt", minutes(pm_get(String.lines(pm), "sleep")))), 186 S.append(line_if(Bool.not(String.is_empty(pm_get(String.lines(pm), "displaysleep"))), setting_line("power.sleep.display", "KInt", minutes(pm_get(String.lines(pm), "displaysleep")))), 187 S.append(line_if(Bool.not(String.is_empty(pm_get(String.lines(pm), "disksleep"))), setting_line("power.sleep.harddisk", "KInt", minutes(pm_get(String.lines(pm), "disksleep")))), 188 S.append(line_if(Bool.not(String.is_empty(pm_get(String.lines(pm), "autorestart"))), setting_line("power.restartAfterPowerFailure", "KBool", bool_of(pm_get(String.lines(pm), "autorestart")))), 189 line_if(Bool.not(String.is_empty(pm_get(String.lines(pm), "womp"))), setting_line("networking.wakeOnLan.enable", "KBool", bool_of(pm_get(String.lines(pm), "womp")))))))) 190 191 def name_line(opt: String, r: Result<&1, &1, U32 & String, String>) -> List<&2, String>: 192 match r: 193 case Done{v}: 194 [setting_line(opt, "KStr", nix_esc_chars(String.to_list(String.trim(v))))] 195 case Fail{_}: 196 [] 197 198 def names() -> IO(List<&2, String>): 199 do IO>: 200 a : Result<&1, &1, U32 & String, String> <- X.Proc.run("scutil --get ComputerName 2>/dev/null") 201 b : Result<&1, &1, U32 & String, String> <- X.Proc.run("scutil --get LocalHostName 2>/dev/null") 202 c : Result<&1, &1, U32 & String, String> <- X.Proc.run("scutil --get HostName 2>/dev/null") 203 return S.append(name_line("networking.computerName", a), S.append(name_line("networking.localHostName", b), name_line("networking.hostName", c))) 204 205 # ---- Homebrew ---- 206 def count_prefix(ls: List<&2, String>, +p: String) -> Nat: 207 match ls: 208 case []: 209 0n 210 case l <> rest: 211 Nat.add(one_if(String.starts_with(l, p)), count_prefix(rest, p)) 212 213 # `mas "Numbers", id: 409203825` -> `"Numbers" = 409203825`; a name the Brewfile repeats gets its id added. 214 def mas_attr(+n: String, +id: String, +all: List<&2, String>) -> String: 215 S.nix_str(pick(Nat.is_gt(count_prefix(all, "mas \"" ++ n ++ "\","), 1n), n ++ " (" ++ id ++ ")", n)) ++ " = " ++ id 216 217 def brew_pick(+k: String, +n: String, +l: String, +all: List<&2, String>) -> List<&2, String>: 218 line_if(Bool.not(String.is_empty(k)), 219 pick(String.eq(k, "tap") || String.eq(k, "brew") || String.eq(k, "cask"), 220 " T.Brew{\"" ++ k ++ "\", \"" ++ esc(n) ++ "\"}", 221 pick(String.eq(k, "mas"), 222 " T.Brew{\"mas\", \"" ++ esc(mas_attr(n, S.last(S.words(l)), all)) ++ "\"}", 223 " T.Brew{\"other\", \"" ++ esc(l) ++ "\"}"))) 224 225 def brew_entry(+l: String, +all: List<&2, String>) -> List<&2, String>: 226 brew_pick(pick(String.starts_with(String.trim(l), "#"), "", S.nth(S.words(l), 0n)), S.nth(String.split(l, '"'), 1n), l, all) 227 228 def brew_entries(ls: List<&2, String>, +all: List<&2, String>) -> List<&2, String>: 229 match ls: 230 case []: 231 [] 232 case l <> rest: 233 S.append(brew_entry(l, all), brew_entries(rest, all)) 234 235 # ---- the files ---- 236 def mac_text(+vlib: String, title: String, settings: List<&2, String>, fw: String, brew: List<&2, String>) -> String: 237 "import Base\nimport " ++ vlib ++ "/mac_types.bend as T\n\n# " ++ title ++ "\n\ndef settings() -> List<&2, T.Setting>:\n [\n" ++ 238 String.join(settings, ",\n") ++ "\n ]\n\n# The application firewall (T.Firewall: its options exist only while it is on).\ndef firewall() -> T.Firewall:\n " ++ fw ++ 239 "\n\ndef brew() -> List<&2, T.Brew>:\n [\n" ++ String.join(brew, ",\n") ++ "\n ]\n" 240 241 # ---- keeping mac.bend: declared settings stay; options seen for the first time are added as observed ---- 242 def before_go(cs: List<&2, Char>, +pat: String) -> String: 243 match cs: 244 case []: 245 "" 246 case +c <> +rest: 247 pick(String.starts_with(String.from_list(c <> rest), pat), "", String.from_list([c]) ++ before_go(rest, pat)) 248 249 # A setting line's option part, which names it. 250 def head_of(l: String) -> String: 251 before_go(String.to_list(l), "\", T.K") 252 253 def heads(ls: List<&2, String>) -> List<&2, String>: 254 match ls: 255 case []: 256 [] 257 case l <> rest: 258 head_of(l) <> heads(rest) 259 260 def no_comma(+l: String) -> String: 261 pick(String.ends_with(l, ","), String.take(l, Nat.sub(String.length(l), 1n)), l) 262 263 def entries(ls: List<&2, String>, +prefix: String) -> List<&2, String>: 264 match ls: 265 case []: 266 [] 267 case +l <> rest: 268 S.append(line_if(String.starts_with(l, prefix), no_comma(l)), entries(rest, prefix)) 269 270 def unseen(obs: List<&2, String>, +known: List<&2, String>) -> List<&2, String>: 271 match obs: 272 case []: 273 [] 274 case +l <> rest: 275 S.append(line_if(Bool.not(T.has(head_of(l), known)), l), unseen(rest, known)) 276 277 # The declared firewall: the line after its def ("" if mac.bend has none yet). 278 def after_line(ls: List<&2, String>, +marker: String, prev: Bool) -> String: 279 match ls: 280 case []: 281 "" 282 case +l <> rest: 283 pick(prev, String.trim(l), after_line(rest, marker, String.eq(l, marker))) 284 285 def declared_title() -> String: 286 "The Mac as it should be. Edit this; `v system dryrun` shows what would change." 287 288 def write_declared(missing: Bool, +vlib: String, +settings: List<&2, String>, +fw: String, +brew: List<&2, String>) -> IO(Unit): 289 match missing: 290 case True{}: 291 do IO: 292 X.write_file("mac.bend", mac_text(vlib, declared_title(), settings, fw, brew)) 293 IO.print("wrote mac.bend (declared) from the Mac") 294 case False{}: 295 do IO: 296 text : String <- X.sh("cat mac.bend") 297 +ls : List<&2, String> = String.lines(text) 298 +declared : List<&2, String> = entries(ls, " T.Setting{") 299 +added : List<&2, String> = unseen(settings, heads(declared)) 300 +dfw : String = after_line(ls, "def firewall() -> T.Firewall:", False{}) 301 X.write_file("mac.bend", mac_text(vlib, declared_title(), S.append(declared, added), pick(String.is_empty(dfw), fw, dfw), entries(ls, " T.Brew{"))) 302 IO.print("mac.bend: " ++ Nat.show(S.count(added)) ++ " options declared for the first time (as observed)") 303 304 def import_mac() -> IO(Unit): 305 do IO: 306 +f : P.Facts <- P.read_facts() 307 P.when(Bool.not(P.has_target(f, "nix-darwin")), IO.die(Unit, 1, "import reads a Mac for a nix-darwin target; this project declares none")) 308 opts : String <- X.sh("cat nix/options.tsv") 309 prefs : List<&2, String> <- option_lines(String.lines(opts)) 310 pm : String <- X.sh("pmset -g custom") 311 nm : List<&2, String> <- names() 312 +fw : String <- V.firewall() 313 rest : List<&2, String> <- V.read_all(P.labels_of(f)) 314 bf : String <- X.sh("brew bundle dump --force --file=/tmp/v-Brewfile >/dev/null 2>&1; cat /tmp/v-Brewfile") 315 +settings : List<&2, String> = S.append(prefs, S.append(power_lines(pm), S.append(nm, rest))) 316 +bls : List<&2, String> = String.lines(bf) 317 +brew : List<&2, String> = brew_entries(bls, bls) 318 X.write_file("mac.now.bend", mac_text(P.vlib_of(f), "The Mac as it is, read by `v system import`. Generated; do not edit.", settings, fw, brew)) 319 exists : Bool <- X.ok("test -f mac.bend") 320 write_declared(Bool.not(exists), P.vlib_of(f), settings, fw, brew) 321 IO.print("import: " ++ Nat.show(S.count(settings)) ++ " settings, " ++ Nat.show(S.count(brew)) ++ " Homebrew entries")