- 648
/// Persist the desktop autostart preference into `tray.json`. - 649
pub fn persist_desktop_autostart(enabled: bool) { - 650
let path = vak_config::paths::data_home().join("tray.json"); - 651
if let Some(parent) = path.parent() { - 652
let _ = std::fs::create_dir_all(parent); - 653
} - 654
let mut obj: serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&path) - 655
.ok() - 656
.and_then(|text| serde_json::from_str(&text).ok()) - 657
.unwrap_or_default(); - 658
obj.insert("autostart".to_string(), serde_json::Value::Bool(enabled)); - 659
let _ = std::fs::write(path, serde_json::to_string(&obj).unwrap_or_default()); - 660
} - 661
- 662
/// Whether desktop autostart is enabled (defaults to true if not configured). - 663
pub fn is_autostart_configured() -> bool { - 664
is_desktop_autostart_persisted().unwrap_or(true) - 665
} - 666
- 667
/// Query whether a service has autostart enabled in the platform manager. - 668
pub fn is_service_autostart_enabled(name: &str) -> bool { - 669
if name == "com.vak.desktop" - 670
&& let Some(persisted) = is_desktop_autostart_persisted() - 671
{ - 672
return persisted; - 673
} - 674
#[cfg(target_os = "macos")] - 675
{ - 676
let runner = SystemRunner; - 677
if let Some(stdout) = runner.text( - 678
"launchctl", - 679
&[ - 680
"print-disabled".to_string(), - 681
format!("gui/{}", platform::uid(&runner)), - 682
], - 683
) { - 684
let pattern = format!("\"{name}\" => disabled"); - 685
if stdout.contains(&pattern) { - 686
return false; - 687
} - 688
} - 689
} - 690
true - 691
} - 692
- 693
/// Enable or disable autostart for a service across OS supervisor and disk unit. - 694
pub fn set_service_autostart(name: &str, enabled: bool) -> Result<(), String> { - 695
if name == "com.vak.desktop" { - 696
persist_desktop_autostart(enabled); - 697
} - 698
let runner = SystemRunner; - 699
let ok = if enabled { - 700
platform::enable_autostart(name, &runner) - 701
} else { - 702
platform::disable_autostart(name, &runner) - 703
}; - 704
if !ok { - 705
return Err(format!("failed to toggle autostart for {name}")); - 706
} - 707
- 708
let paths = Paths::default(); - 709
let unit_path = unit_file_path(name, &paths); - 710
if let Ok(content) = std::fs::read_to_string(&unit_path) { - 711
#[cfg(target_os = "macos")] - 712
let replaced = if enabled { - 713
content.replace( - 714
"<key>RunAtLoad</key>\n\t<false/>", - 715
"<key>RunAtLoad</key>\n\t<true/>", - 716
) - 717
} else { - 718
content.replace( - 719
"<key>RunAtLoad</key>\n\t<true/>", - 720
"<key>RunAtLoad</key>\n\t<false/>", - 721
) - 722
}; - 723
#[cfg(not(target_os = "macos"))] - 724
let replaced = if enabled { - 725
if !content.contains("[Install]") { - 726
format!("{content}\n[Install]\nWantedBy=default.target\n") - 727
} else { - 728
content - 729
} - 730
} else { - 731
content.replace("[Install]\nWantedBy=default.target\n", "") - 732
}; - 733
let _ = write_atomic(&unit_path, &replaced); - 734
} - 735
Ok(()) - 736
} - 737
- 738
mod platform { - 739
use super::CommandRunner; - 740
#[cfg(not(target_os = "macos"))] - 741
use super::systemd_unit_name; - 742
use std::path::Path; - 743
- 744
#[cfg(target_os = "macos")] - 745
pub(crate) fn uid(runner: &dyn CommandRunner) -> String { - 746
runner - 747
.text("id", &["-u".to_string()]) - 748
.filter(|u| !u.is_empty()) - 749
.unwrap_or_else(|| "501".to_string()) - 750
} - 751
- 752
pub fn enable_autostart(name: &str, runner: &dyn CommandRunner) -> bool { - 753
#[cfg(target_os = "macos")] - 754
{ - 755
let target = format!("gui/{}/{}", uid(runner), name); - 756
runner.success("launchctl", &["enable".to_string(), target]) - 757
} - 758
#[cfg(not(target_os = "macos"))] - 759
{ - 760
runner.success( - 761
"systemctl", - 762
&[ - 763
"--user".to_string(), - 764
"enable".to_string(), - 765
systemd_unit_name(name), - 766
], - 767
) - 768
} - 769
} - 770
- 771
pub fn disable_autostart(name: &str, runner: &dyn CommandRunner) -> bool { - 772
#[cfg(target_os = "macos")] - 773
{ - 774
let target = format!("gui/{}/{}", uid(runner), name); - 775
runner.success("launchctl", &["disable".to_string(), target]) - 776
} - 777
#[cfg(not(target_os = "macos"))] - 778
{ - 779
runner.success( - 780
"systemctl", - 781
&[ - 782
"--user".to_string(), - 783
"disable".to_string(), - 784
systemd_unit_name(name), - 785
], - 786
) - 787
} - 788
} - 789
- 790
/// Stop + deregister. Best-effort: a not-loaded service fails here and - 791
/// that is fine. - 792
pub fn unload(name: &str, runner: &dyn CommandRunner) { - 793
#[cfg(target_os = "macos")] - 794
{ - 795
let target = format!("gui/{}/{}", uid(runner), name); - 796
runner.success("launchctl", &["bootout".into(), target]); - 797
} - 798
#[cfg(not(target_os = "macos"))] - 799
{ - 800
runner.success( - 801
"systemctl", - 802
&[ - 803
"--user".to_string(), - 804
"disable".to_string(), - 805
"--now".to_string(), - 806
systemd_unit_name(name), - 807
], - 808
); - 809
} - 810
} - 811
- 812
/// Register the freshly written unit; RunAtLoad/enable --now starts it. - 813
pub fn load(name: &str, unit_path: &Path, runner: &dyn CommandRunner) -> bool { - 814
#[cfg(target_os = "macos")] - 815
{ - 816
let _ = name; - 817
runner.success( - 818
"launchctl", - 819
&[ - 820
"bootstrap".to_string(), - 821
format!("gui/{}", uid(runner)), - 822
unit_path.display().to_string(), - 823
], - 824
) - 825
} - 826
#[cfg(not(target_os = "macos"))] - 827
{ - 828
let _ = unit_path; - 829
runner.success( - 830
"systemctl", - 831
&[ - 832
"--user".to_string(), - 833
"enable".to_string(), - 834
"--now".to_string(), - 835
systemd_unit_name(name), - 836
], - 837
) - 838
} - 839
} - 840
- 841
/// Start without touching registration (used when the unit is current - 842
/// but the process is down). - 843
pub fn start(name: &str, runner: &dyn CommandRunner) -> bool { - 844
#[cfg(target_os = "macos")] - 845
{ - 846
runner.success( - 847
"launchctl", - 848
&[ - 849
"kickstart".to_string(), - 850
format!("gui/{}/{}", uid(runner), name), - 851
], - 852
) - 853
} - 854
#[cfg(not(target_os = "macos"))] - 855
{ - 856
runner.success( - 857
"systemctl", - 858
&[ - 859
"--user".to_string(), - 860
"start".to_string(), - 861
systemd_unit_name(name), - 862
], - 863
) - 864
} - 865
} - 866
- 867
/// Kill + restart in one step (kickstart -k keeps launchd KeepAlive - 868
/// semantics; systemctl restart is the systemd analogue). Used when the - 869
/// unit is current but a live process predates the installed binary — - 870
/// it would otherwise keep executing the old image indefinitely. - 871
pub fn restart(name: &str, runner: &dyn CommandRunner) -> bool { - 872
#[cfg(target_os = "macos")] - 873
{ - 874
runner.success( - 875
"launchctl", - 876
&[ - 877
"kickstart".to_string(), - 878
"-k".to_string(), - 879
format!("gui/{}/{}", uid(runner), name), - 880
], - 881
) - 882
} - 883
#[cfg(not(target_os = "macos"))] - 884
{ - 885
runner.success( - 886
"systemctl", - 887
&[ - 888
"--user".to_string(), - 889
"restart".to_string(), - 890
systemd_unit_name(name), - 891
], - 892
) - 893
} - 894
} - 895
- 896
/// Live PID according to the manager, None when not running. - 897
pub fn running_pid(name: &str, runner: &dyn CommandRunner) -> Option<u32> { - 898
#[cfg(target_os = "macos")] - 899
{ - 900
let text = runner.text( - 901
"launchctl", - 902
&["print".to_string(), format!("gui/{}/{}", uid(runner), name)], - 903
)?; - 904
parse_launchd_pid(&text) - 905
} - 906
#[cfg(not(target_os = "macos"))] - 907
{ - 908
let text = runner.text( - 909
"systemctl", - 910
&[ - 911
"--user".to_string(), - 912
"show".to_string(), - 913
"-P".to_string(), - 914
"MainPID".to_string(), - 915
systemd_unit_name(name), - 916
], - 917
)?; - 918
text.parse::<u32>().ok().filter(|pid| *pid != 0) - 919
} - 920
} - 921
- 922
/// The status the service's process last exited with, when the manager - 923
/// knows one. Non-zero is a service that failed, not one that stopped. - 924
pub fn last_exit(name: &str, runner: &dyn CommandRunner) -> Option<i32> { - 925
#[cfg(target_os = "macos")] - 926
{ - 927
let text = runner.text( - 928
"launchctl", - 929
&["print".to_string(), format!("gui/{}/{}", uid(runner), name)], - 930
)?; - 931
parse_launchd_last_exit(&text) - 932
} - 933
#[cfg(not(target_os = "macos"))] - 934
{ - 935
let text = runner.text( - 936
"systemctl", - 937
&[ - 938
"--user".to_string(), - 939
"show".to_string(), - 940
"-P".to_string(), - 941
"ExecMainStatus".to_string(), - 942
systemd_unit_name(name), - 943
], - 944
)?; - 945
text.trim().parse::<i32>().ok() - 946
} - 947
} - 948
- 949
#[cfg(target_os = "macos")] - 950
pub(super) fn parse_launchd_last_exit(text: &str) -> Option<i32> { - 951
text.lines().find_map(|line| { - 952
let value = line.trim().strip_prefix("last exit code = ")?; - 953
let number: String = value - 954
.chars() - 955
.enumerate() - 956
.take_while(|(i, c)| c.is_ascii_digit() || (*i == 0 && *c == '-')) - 957
.map(|(_, c)| c) - 958
.collect(); - 959
number.parse::<i32>().ok() - 960
}) - 961
} - 962
- 963
#[cfg(target_os = "macos")] - 964
fn parse_launchd_pid(text: &str) -> Option<u32> { - 965
for line in text.lines() { - 966
if let Some(rest) = line.trim().strip_prefix("pid = ") { - 967
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); - 968
if let Ok(pid) = digits.parse::<u32>() { - 969
return Some(pid); - 970
} - 971
} - 972
} - 973
None - 974
} - 975
} - 976
- 977
use platform::{last_exit, load, running_pid, start, unload}; - 978
- 979
/// Outcome of syncing one service. - 980
#[derive(Debug, Clone, PartialEq, Eq)] - 981
pub enum SyncAction { - 982
/// Unit file did not exist; written and loaded. - 983
Created, - 984
/// Unit content drifted; unloaded, rewritten, reloaded. - 985
Updated, - 986
/// Unit identical and process live; untouched. - 987
Unchanged, - 988
/// Unit identical but process down; started without rewrite. - 989
Restarted, - 990
/// Unit identical but the running process predated the installed - 991
/// binary (started before the last `self install`); bounced so it - 992
/// executes the current image. Without this, an in-place upgrade - 993
/// leaves every service silently running stale code while `status` - 994
/// reports healthy pids (doc 32 invariant 4). - 995
Bounced, - 996
Failed(String), - 997
} - 998
- 999
#[derive(Debug, Clone)] - 1000
pub struct SyncOutcome { - 1001
/// The requested service name; unknown requests carry the raw string. - 1002
pub name: String, - 1003
pub action: SyncAction, - 1004
} - 1005
- 1006
/// A manager-level view of one service. - 1007
#[derive(Debug, Clone)] - 1008
pub struct ServiceRow { - 1009
pub name: String, - 1010
pub unit_path: PathBuf, - 1011
/// Whether a unit file exists at all. Distinguishes "never synced" - 1012
/// from "synced against the wrong binary": both leave - 1013
/// `unit_points_at_installed` false, but only the second is a - 1014
/// misconfiguration. A fresh install is the first, and reporting it - 1015
/// as the second sends the operator after a problem they do not have. - 1016
pub unit_present: bool, - 1017
/// False for missing units and for legacy units exec'ing outside the - 1018
/// install prefix (e.g. anything under `target/`) — the drift flag. - 1019
/// Read together with `unit_present` to tell the two cases apart. - 1020
pub unit_points_at_installed: bool, - 1021
/// True when a live process predates the installed binary (started - 1022
/// before the last install touched it) — stale-image drift. - 1023
pub binary_stale: bool, - 1024
pub running_pid: Option<u32>, - 1025
/// A non-zero status the service last exited with: a service that is - 1026
/// failing, whether it is down or restarting, not one that was stopped. - 1027
pub failed_exit: Option<i32>, - 1028
} - 1029
- 1030
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> { - 1031
let tmp = path.with_extension("tmp"); - 1032
std::fs::write(&tmp, contents).map_err(|e| format!("write {}: {e}", tmp.display()))?; - 1033
std::fs::rename(&tmp, path).map_err(|e| format!("rename into {}: {e}", path.display())) - 1034
} - 1035
- 1036
/// True when the installed binary changed after the unit was last written: - 1037
/// a live service synced against the older image is executing stale code. - 1038
/// Missing files never count as stale (handled by other branches). - 1039
fn binary_newer_than_unit(bin_path: &Path, unit_path: &Path) -> bool { - 1040
let (Ok(bin_meta), Ok(unit_meta)) = (std::fs::metadata(bin_path), std::fs::metadata(unit_path)) - 1041
else { - 1042
return false; - 1043
}; - 1044
let (Ok(bin_mtime), Ok(unit_mtime)) = (bin_meta.modified(), unit_meta.modified()) else { - 1045
return false; - 1046
}; - 1047
bin_mtime > unit_mtime - 1048
} - 1049
- 1050
fn sync_one(spec: &ServiceSpec, paths: &Paths, runner: &dyn CommandRunner) -> SyncOutcome { - 1051
let action = sync_one_inner(spec, paths, runner).unwrap_or_else(SyncAction::Failed); - 1052
SyncOutcome { - 1053
name: spec.name.to_string(), - 1054
action, - 1055
} - 1056
} - 1057
- 1058
/// Bootstrap can lose a transient race — e.g. the manager hasn't finished - 1059
/// tearing down the just-booted-out registration yet — and fail once even - 1060
/// though the unit is fine. A single failure here used to be terminal: the - 1061
/// caller fell back to restoring the previous unit's *content* but left the - 1062
/// service unloaded, silently, with nothing to notice or retry it (the - 1063
/// telegram bridge going dark across a `self update` traced back to exactly - 1064
/// this). Retry a few times with a short backoff before giving up. - 1065
const LOAD_RETRIES: u32 = 3; - 1066
const LOAD_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300); - 1067
- 1068
fn load_with_retries(name: &str, unit_path: &Path, runner: &dyn CommandRunner) -> bool { - 1069
for attempt in 0..LOAD_RETRIES { - 1070
if load(name, unit_path, runner) { - 1071
return true; - 1072
} - 1073
if attempt + 1 < LOAD_RETRIES { - 1074
std::thread::sleep(LOAD_RETRY_DELAY); - 1075
} - 1076
} - 1077
false - 1078
} - 1079
- 1080
fn sync_one_inner( - 1081
spec: &ServiceSpec, - 1082
paths: &Paths, - 1083
runner: &dyn CommandRunner, - 1084
) -> Result<SyncAction, String> { - 1085
let rendered = render(spec); - 1086
let unit_path = unit_file_path(&spec.name, paths); - 1087
if let Some(parent) = unit_path.parent() { - 1088
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?; - 1089
} - 1090
let previous = std::fs::read_to_string(&unit_path).ok(); - 1091
let was_running = running_pid(&spec.name, runner).is_some(); - 1092
- 1093
if previous.as_deref() == Some(rendered.as_str()) { - 1094
return if !was_running { - 1095
if start(&spec.name, runner) { - 1096
if binary_newer_than_unit(&spec.bin_path, &unit_path) { - 1097
write_atomic(&unit_path, &rendered)?; - 1098
} - 1099
Ok(SyncAction::Restarted) - 1100
} else { - 1101
Err(format!("start {} failed", spec.name)) - 1102
} - 1103
} else if binary_newer_than_unit(&spec.bin_path, &unit_path) { - 1104
// The live process predates the installed binary. Bounce it and - 1105
// re-stamp the unit so staleness converges — without the stamp, - 1106
// every later sync would bounce again forever. - 1107
if platform::restart(&spec.name, runner) && write_atomic(&unit_path, &rendered).is_ok() - 1108
{ - 1109
Ok(SyncAction::Bounced) - 1110
} else { - 1111
Err(format!( - 1112
"restart {} failed: process predates the installed binary", - 1113
spec.name - 1114
)) - 1115
} - 1116
} else { - 1117
Ok(SyncAction::Unchanged) - 1118
}; - 1119
} - 1120
- 1121
// Content drift: take the old instance down before replacing its unit. - 1122
// Bootout unconditionally — a crashed-but-loaded service (pid absent, - 1123
// registration alive) would otherwise fail the later bootstrap with - 1124
// "already bootstrapped" and roll back a perfectly good unit. - 1125
unload(&spec.name, runner); - 1126
write_atomic(&unit_path, &rendered)?; - 1127
if load_with_retries(&spec.name, &unit_path, runner) { - 1128
Ok(if previous.is_some() { - 1129
SyncAction::Updated - 1130
} else { - 1131
SyncAction::Created - 1132
}) - 1133
} else { - 1134
// A failed sync leaves the previous healthy unit in place. - 1135
match previous { - 1136
Some(old) => { - 1137
let _ = write_atomic(&unit_path, &old); - 1138
} - 1139
None => { - 1140
let _ = std::fs::remove_file(&unit_path); - 1141
} - 1142
} - 1143
Err(format!("loading {} failed", unit_path.display())) - 1144
} - 1145
} - 1146
- 1147
/// Render → diff → reload each spec. Idempotent: identical content plus a - 1148
/// live process is a no-op. - 1149
pub fn sync_specs( - 1150
specs: &[ServiceSpec], - 1151
paths: &Paths, - 1152
runner: &dyn CommandRunner, - 1153
) -> Vec<SyncOutcome> { - 1154
specs - 1155
.iter() - 1156
.map(|spec| sync_one(spec, paths, runner)) - 1157
.collect() - 1158
} - 1159
- 1160
/// Manager status per spec: does the on-disk unit reference the installed - 1161
/// binary, and what PID is the manager reporting? - 1162
pub fn status_specs( - 1163
specs: &[ServiceSpec], - 1164
paths: &Paths, - 1165
runner: &dyn CommandRunner, - 1166
) -> Vec<ServiceRow> { - 1167
specs - 1168
.iter() - 1169
.map(|spec| { - 1170
let unit_path = unit_file_path(&spec.name, paths); - 1171
let on_disk = std::fs::read_to_string(&unit_path).ok(); - 1172
let wanted = spec.bin_path.to_string_lossy().into_owned(); - 1173
let pid = running_pid(&spec.name, runner); - 1174
let unit_present = on_disk.is_some(); - 1175
let points_at_installed = on_disk.is_some_and(|t| t.contains(wanted.as_str())); - 1176
ServiceRow { - 1177
name: spec.name.to_string(), - 1178
unit_present, - 1179
unit_points_at_installed: points_at_installed, - 1180
// Stale-image drift: a live process synced against an - 1181
// older binary. Only meaningful when the unit itself is - 1182
// current, otherwise the legacy-path flag covers it. - 1183
binary_stale: pid.is_some() - 1184
&& points_at_installed - 1185
&& binary_newer_than_unit(&spec.bin_path, &unit_path), - 1186
failed_exit: unit_present - 1187
.then(|| last_exit(&spec.name, runner)) - 1188
.flatten() - 1189
.filter(|status| *status != 0), - 1190
unit_path, - 1191
running_pid: pid, - 1192
} - 1193
}) - 1194
.collect() - 1195
} - 1196
- 1197
/// Stop + unload + delete the named units. Missing units are already - 1198
/// uninstalled; nothing under the canonical platform data home is touched. - 1199
pub fn services_uninstall( - 1200
names: &[&str], - 1201
paths: &Paths, - 1202
runner: &dyn CommandRunner, - 1203
) -> Result<(), String> { - 1204
let mut failures = Vec::new(); - 1205
for name in names { - 1206
unload(name, runner); - 1207
let unit_path = unit_file_path(name, paths); - 1208
if let Err(e) = std::fs::remove_file(&unit_path) - 1209
&& e.kind() != std::io::ErrorKind::NotFound - 1210
{ - 1211
failures.push(format!("remove {}: {e}", unit_path.display())); - 1212
} - 1213
} - 1214
if failures.is_empty() { - 1215
Ok(()) - 1216
} else { - 1217
Err(failures.join("; ")) - 1218
} - 1219
} - 1220
- 1221
/// Every service that should be synced for an install rooted at - 1222
/// `bin_path`. Optional services whose binary the build did not produce - 1223
/// are left out: writing a unit that exec's a missing path only buys a - 1224
/// permanently-failing service the operator has to go and delete. - 1225
pub fn default_service_names(bin_path: &Path) -> Vec<&'static str> { - 1226
let bin_dir = bin_path.parent().unwrap_or(Path::new("/")); - 1227
SERVICES - 1228
.iter() - 1229
.filter(|def| !def.optional || bin_dir.join(def.bin_file).exists()) - 1230
.map(|def| def.name) - 1231
.collect() - 1232
} - 1233
- 1234
/// Resolve `names` against [`SERVICES`], preserving order. Unknown names land - 1235
/// as Err entries so callers can report them individually. - 1236
pub fn resolve_specs(bin_path: &Path, names: &[&str]) -> Vec<Result<ServiceSpec, String>> { - 1237
let bin_dir = bin_path.parent().unwrap_or(Path::new("/")); - 1238
let home_dir = super::home(); - 1239
let default_workspace = vak_config::paths::default_workspace(); - 1240
names - 1241
.iter() - 1242
.map(|name| { - 1243
SERVICES - 1244
.iter() - 1245
.find(|def| def.name == *name) - 1246
.map(|def| def.spec(bin_dir, &home_dir, &default_workspace)) - 1247
.ok_or_else(|| format!("unknown service: {name}")) - 1248
}) - 1249
.collect() - 1250
} - 1251
- 1252
/// [`sync_specs`] over [`SERVICES`], rooted at the installed `bin_path`. - 1253
/// Unknown names yield `Failed` outcomes without touching anything. - 1254
pub fn services_sync( - 1255
bin_path: &Path, - 1256
names: &[&str], - 1257
paths: &Paths, - 1258
runner: &dyn CommandRunner, - 1259
) -> Vec<SyncOutcome> { - 1260
resolve_specs(bin_path, names) - 1261
.into_iter() - 1262
.zip(names.iter()) - 1263
.map(|(resolved, name)| match resolved { - 1264
Ok(spec) => sync_one(&spec, paths, runner), - 1265
Err(msg) => SyncOutcome { - 1266
name: (*name).to_string(), - 1267
action: SyncAction::Failed(msg), - 1268
}, - 1269
}) - 1270
.collect() - 1271
} - 1272
- 1273
/// [`status_specs`] over [`SERVICES`], rooted at the installed `bin_path`. - 1274
pub fn services_status( - 1275
bin_path: &Path, - 1276
names: &[&str], - 1277
paths: &Paths, - 1278
runner: &dyn CommandRunner, - 1279
) -> Vec<ServiceRow> { - 1280
let specs: Vec<ServiceSpec> = resolve_specs(bin_path, names) - 1281
.into_iter() - 1282
.flatten() - 1283
.collect(); - 1284
status_specs(&specs, paths, runner) - 1285
} - 1286
- 1287
#[cfg(test)] - 1288
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 1289
mod tests { - 1290
use super::*; - 1291
use std::sync::Mutex; - 1292
- 1293
struct Fake { - 1294
cmds: Mutex<Vec<(String, Vec<String>)>>, - 1295
pid_text: Option<String>, - 1296
fail_load: bool, - 1297
} - 1298
impl Fake { - 1299
fn new() -> Self { - 1300
Fake { - 1301
cmds: Mutex::new(Vec::new()), - 1302
pid_text: None, - 1303
fail_load: false, - 1304
} - 1305
} - 1306
fn with_pid(pid: u32) -> Self { - 1307
Fake { - 1308
pid_text: Some(format!("com.vak.x = {{\n\tpid = {pid}\n}}")), - 1309
..Self::new() - 1310
} - 1311
} - 1312
} - 1313
impl CommandRunner for Fake { - 1314
fn success(&self, program: &str, args: &[String]) -> bool { - 1315
self.cmds - 1316
.lock() - 1317
.unwrap() - 1318
.push((program.to_string(), args.to_vec())); - 1319
!(self.fail_load - 1320
&& program == "launchctl" - 1321
&& args.first().map(String::as_str) == Some("bootstrap")) - 1322
} - 1323
fn text(&self, program: &str, args: &[String]) -> Option<String> { - 1324
if program == "id" { - 1325
return Some("501".to_string()); - 1326
} - 1327
self.success(program, args); - 1328
self.pid_text.clone() - 1329
} - 1330
} - 1331
- 1332
fn tmp_paths(tag: &str) -> (tempfile::TempDir, Paths) { - 1333
let dir = tempfile::tempdir().unwrap(); - 1334
let paths = Paths { - 1335
launch_agents_dir: dir.path().join(format!("{tag}-agents")), - 1336
systemd_unit_dir: dir.path().join(format!("{tag}-units")), - 1337
}; - 1338
(dir, paths) - 1339
} - 1340
- 1341
fn spec_for(def: &ServiceDef, bin_dir: &Path, log_dir: &Path) -> ServiceSpec { - 1342
ServiceSpec { - 1343
name: def.name.to_string(), - 1344
bin_path: bin_dir.join(def.bin_file), - 1345
args: def.args.iter().map(|a| (*a).to_string()).collect(), - 1346
log_path: log_dir.join(def.log_file), - 1347
working_dir: PathBuf::from("/workspace"), - 1348
home_dir: PathBuf::from("/Users/x"), - 1349
path_env: "/usr/bin:/bin".into(), - 1350
keep_alive: def.keep_alive, - 1351
gui: def.gui, - 1352
run_at_load: true, - 1353
} - 1354
} - 1355
- 1356
fn def_named(name: &str) -> &'static ServiceDef { - 1357
SERVICES - 1358
.iter() - 1359
.find(|d| d.name == name) - 1360
.expect("service must exist") - 1361
} - 1362
- 1363
/// The desktop app is a GUI service, and the two ways it differs from - 1364
/// the headless ones are both load-bearing: - 1365
/// - 1366
/// * `RunAtLoad` is the whole point — nothing else brings the menu-bar - 1367
/// icon back after a logout or reboot. - 1368
/// * `KeepAlive` must be off. The tray's `Quit Vak` calls - 1369
/// `app.exit(0)`; with KeepAlive on, launchd relaunches a second - 1370
/// later and Quit visibly does not quit. - 1371
#[test] - 1372
fn desktop_unit_launches_at_login_hidden_and_is_not_kept_alive() { - 1373
let def = def_named("com.vak.desktop"); - 1374
let spec = spec_for(def, Path::new("/opt/vak/bin"), Path::new("/l")); - 1375
- 1376
let plist = render_launchd_plist(&spec); - 1377
assert!(plist.contains("<string>/opt/vak/bin/vak-desktop</string>")); - 1378
assert!( - 1379
plist.contains("<key>RunAtLoad</key>\n\t<true/>"), - 1380
"the desktop service exists to come back at login: {plist}" - 1381
); - 1382
assert!( - 1383
plist.contains("<key>KeepAlive</key>\n\t<false/>"), - 1384
"KeepAlive would defeat the tray's Quit Vak: {plist}" - 1385
); - 1386
assert!( - 1387
plist.contains("<string>--tray</string>"), - 1388
"a login launch must not throw a window on screen: {plist}" - 1389
); - 1390
assert!(plist.contains("/l/desktop.log")); - 1391
// Without this key launchd runs the app in the background gui/<uid> - 1392
// domain: it starts, logs nothing, never checks in with - 1393
// LaunchServices, gets no WindowServer connection, and therefore - 1394
// draws no menu-bar icon — the exact failure this unit exists to - 1395
// prevent. - 1396
assert!( - 1397
plist.contains("<key>LimitLoadToSessionType</key>\n\t<string>Aqua</string>"), - 1398
"a GUI unit needs an Aqua session or it can draw no tray icon: {plist}" - 1399
); - 1400
- 1401
let unit = render_systemd_unit(&spec); - 1402
assert_eq!(def.systemd_unit(), "vak-desktop.service"); - 1403
assert!( - 1404
unit.contains("ExecStart=/opt/vak/bin/vak-desktop --tray"), - 1405
"{unit}" - 1406
); - 1407
assert!( - 1408
unit.contains("Restart=no"), - 1409
"the systemd analogue of KeepAlive=false: {unit}" - 1410
); - 1411
assert!(unit.contains("WantedBy=default.target"), "{unit}"); - 1412
} - 1413
- 1414
#[test] - 1415
fn desktop_unit_honors_disabled_autostart() { - 1416
let def = def_named("com.vak.desktop"); - 1417
let mut spec = spec_for(def, Path::new("/opt/vak/bin"), Path::new("/l")); - 1418
spec.run_at_load = false; - 1419
- 1420
let plist = render_launchd_plist(&spec); - 1421
assert!( - 1422
plist.contains("<key>RunAtLoad</key>\n\t<false/>"), - 1423
"disabled autostart renders RunAtLoad false: {plist}" - 1424
); - 1425
- 1426
let systemd = render_systemd_unit(&spec); - 1427
assert!( - 1428
!systemd.contains("WantedBy=default.target"), - 1429
"disabled autostart omits WantedBy: {systemd}" - 1430
); - 1431
} - 1432
- 1433
/// Headless services keep the resurrecting behaviour they have always - 1434
/// had — the GUI exception must not leak into them. - 1435
#[test] - 1436
fn headless_services_are_still_kept_alive() { - 1437
// Per-bot bridge units are generated from bots.json rather than - 1438
// living in SERVICES, and are covered by the multi-bot tests below. - 1439
let name = "com.vak.gateway"; - 1440
let spec = spec_for(def_named(name), Path::new("/b"), Path::new("/l")); - 1441
assert!(render_launchd_plist(&spec).contains("<key>KeepAlive</key>\n\t<true/>")); - 1442
assert!(render_systemd_unit(&spec).contains("Restart=always")); - 1443
// Aqua-pinning a headless daemon would stop it loading in any - 1444
// session without a logged-in GUI user. - 1445
assert!( - 1446
!render_launchd_plist(&spec).contains("LimitLoadToSessionType"), - 1447
"{name} has no UI and must not be pinned to a GUI session" - 1448
); - 1449
} - 1450
- 1451
/// Headless services use the canonical default workspace regardless of - 1452
/// the caller's cwd. The desktop app picks its project in its own UI, so - 1453
/// its service starts from the account home instead. - 1454
#[test] - 1455
fn workspace_scoped_services_use_the_canonical_default() { - 1456
let home = Path::new("/Users/x"); - 1457
let default_workspace = Path::new("/Users/x/vak-home"); - 1458
for def in SERVICES { - 1459
let spec = def.spec(Path::new("/b"), home, default_workspace); - 1460
let expected = if def.workspace_scoped { - 1461
default_workspace - 1462
} else { - 1463
home - 1464
}; - 1465
assert_eq!(spec.working_dir, expected, "{}", def.name); - 1466
} - 1467
} - 1468
- 1469
#[test] - 1470
fn service_templates_follow_the_resolved_port() { - 1471
let gateway = def_named("com.vak.gateway").resolved_args(9123); - 1472
assert_eq!(gateway.last().map(String::as_str), Some("9123")); - 1473
assert_eq!( - 1474
gateway.get(gateway.len() - 2).map(String::as_str), - 1475
Some("--port") - 1476
); - 1477
} - 1478
- 1479
/// An optional component the build never produced must not get a unit - 1480
/// pointing at a path that does not exist. - 1481
#[test] - 1482
fn optional_services_are_skipped_when_their_binary_is_absent() { - 1483
let dir = tempfile::tempdir().unwrap(); - 1484
let cli = dir.path().join("vak"); - 1485
std::fs::write(&cli, b"cli").unwrap(); - 1486
- 1487
let without = default_service_names(&cli); - 1488
assert!(without.contains(&"com.vak.gateway")); - 1489
assert!( - 1490
!without.contains(&"com.vak.desktop"), - 1491
"no vak-desktop binary shipped, so no unit: {without:?}" - 1492
); - 1493
- 1494
std::fs::write(dir.path().join("vak-desktop"), b"gui").unwrap(); - 1495
assert!(default_service_names(&cli).contains(&"com.vak.desktop")); - 1496
} - 1497
- 1498
#[test] - 1499
fn launchd_plist_renders_paths_args_and_zero_secrets() { - 1500
let def = &SERVICES[0]; - 1501
let spec = spec_for( - 1502
def, - 1503
Path::new("/opt/vak/bin"), - 1504
Path::new("/Users/x/.vak/logs"), - 1505
); - 1506
let plist = render_launchd_plist(&spec); - 1507
assert!(plist.contains("/opt/vak/bin/vak")); - 1508
for a in def.args { - 1509
assert!(plist.contains(a), "missing arg {a}"); - 1510
} - 1511
assert!(plist.contains("/Users/x/.vak/logs/gateway.log")); - 1512
assert!(plist.contains("KeepAlive")); - 1513
assert!(plist.contains("RunAtLoad")); - 1514
assert!(plist.contains("<key>HOME</key>")); - 1515
assert!(plist.contains("<string>/Users/x</string>")); - 1516
assert!(plist.contains("<key>PATH</key>")); - 1517
assert!(plist.contains("/usr/bin:/bin")); - 1518
// Update safety (doc 32): units never embed credentials. - 1519
for secret in ["TOKEN", "SECRET", "BOT_TOKEN"] { - 1520
assert!(!plist.contains(secret), "unit must not contain {secret}"); - 1521
} - 1522
} - 1523
- 1524
/// Canonical-layout invariant (doc 32): specs derived from the real - 1525
/// resolver never point logs into the legacy dotdir nor binaries at - 1526
/// a build tree. This is the regression guard for the 0.7 incident - 1527
/// where services silently executed stale images from ad-hoc paths. - 1528
#[test] - 1529
fn resolved_specs_never_reference_legacy_dotdir_or_build_trees() { - 1530
let specs: Vec<ServiceSpec> = - 1531
resolve_specs(Path::new("/Applications/vak.app/Contents/MacOS/vak"), &[]) - 1532
.into_iter() - 1533
.flatten() - 1534
.collect(); - 1535
for spec in specs { - 1536
let log = spec.log_path.to_string_lossy(); - 1537
let bin = spec.bin_path.to_string_lossy(); - 1538
assert!( - 1539
!log.contains("/.vak/"), - 1540
"log path must use the canonical logs dir, got {log}" - 1541
); - 1542
assert!( - 1543
!bin.contains("/target/"), - 1544
"binaries must come from the managed install, got {bin}" - 1545
); - 1546
assert!( - 1547
bin.starts_with("/Applications/vak.app/"), - 1548
"binaries must live inside the installed bundle, got {bin}" - 1549
); - 1550
assert!( - 1551
log.contains("Library/Logs/vak"), - 1552
"logs must land in Library/Logs on macOS, got {log}" - 1553
); - 1554
} - 1555
} - 1556
- 1557
#[test] - 1558
fn systemd_unit_renders_restart_and_exec() { - 1559
let def = &SERVICES[0]; - 1560
let spec = spec_for(def, Path::new("/opt/vak/bin"), Path::new("/h/.vak")); - 1561
let unit = render_systemd_unit(&spec); - 1562
assert!( - 1563
unit.contains("ExecStart="), - 1564
"unit missing ExecStart: {unit}" - 1565
); - 1566
assert!(unit.contains("/opt/vak/bin/vak"), "{unit}"); - 1567
assert!(unit.contains("--gateway"), "{unit}"); - 1568
assert!(unit.contains("Restart=always")); - 1569
} - 1570
- 1571
#[test] - 1572
fn sync_is_created_then_unchanged_when_running() { - 1573
let (_d, paths) = tmp_paths("sync1"); - 1574
let fake = Fake::with_pid(4242); - 1575
let specs: Vec<ServiceSpec> = SERVICES - 1576
.iter() - 1577
.map(|d| spec_for(d, Path::new("/opt/vak/bin"), Path::new("/tmp/logs"))) - 1578
.collect(); - 1579
- 1580
let first = sync_specs(&specs, &paths, &fake); - 1581
assert!( - 1582
matches!(first[0].action, SyncAction::Created), - 1583
"{:?}", - 1584
first[0] - 1585
); - 1586
let unit = std::fs::read_to_string(unit_file_path(SERVICES[0].name, &paths)).unwrap(); - 1587
assert!(unit.contains("/opt/vak/bin/vak")); - 1588
- 1589
let second = sync_specs(&specs, &paths, &fake); - 1590
assert!( - 1591
matches!(second[0].action, SyncAction::Unchanged), - 1592
"{:?}", - 1593
second[0] - 1594
); - 1595
} - 1596
- 1597
#[test] - 1598
fn sync_restarts_when_unit_current_but_process_down() { - 1599
let (_d, paths) = tmp_paths("sync2"); - 1600
let down = Fake::new(); // no pid text => not running - 1601
let up = Fake::with_pid(7); - 1602
let specs: Vec<ServiceSpec> = SERVICES - 1603
.iter() - 1604
.map(|d| spec_for(d, Path::new("/opt/vak/bin"), Path::new("/tmp/logs"))) - 1605
.collect(); - 1606
let _ = sync_specs(&specs, &paths, &up); // create while "running" - 1607
let out = sync_specs(&specs, &paths, &down); - 1608
assert!( - 1609
matches!(out[0].action, SyncAction::Restarted), - 1610
"{:?}", - 1611
out[0] - 1612
); - 1613
} - 1614
- 1615
#[test] - 1616
fn sync_bounces_live_process_predating_installed_binary() { - 1617
let dir = tempfile::tempdir().unwrap(); - 1618
let bin = dir.path().join("vak"); - 1619
std::fs::write(&bin, b"binary").unwrap(); - 1620
let (_d, paths) = tmp_paths("sync3"); - 1621
let fake = Fake::with_pid(99); - 1622
let specs: Vec<ServiceSpec> = SERVICES - 1623
.iter() - 1624
.map(|d| spec_for(d, dir.path(), Path::new("/tmp/logs"))) - 1625
.collect(); - 1626
- 1627
assert!(matches!( - 1628
sync_specs(&specs, &paths, &fake)[0].action, - 1629
SyncAction::Created - 1630
)); - 1631
- 1632
// Simulate `self install` replacing the binary AFTER the unit was - 1633
// written: push the unit's mtime into the past relative to the - 1634
// binary so the running pid predates the current image. - 1635
let now = std::time::SystemTime::now(); - 1636
let unit_path = unit_file_path(SERVICES[0].name, &paths); - 1637
// Unit 10s in the past, binary at now → running pid predates image. - 1638
for (path, age) in [(&unit_path, 10u64), (&bin, 0u64)] { - 1639
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); - 1640
f.set_modified(now - std::time::Duration::from_secs(age)) - 1641
.unwrap(); - 1642
drop(f); - 1643
} - 1644
- 1645
let second = sync_specs(&specs, &paths, &fake); - 1646
assert!( - 1647
matches!(second[0].action, SyncAction::Bounced),
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.