- 1
//! Generated service units (docs/design/32-release-engineering.md §3). - 2
//! - 3
//! Units are rendered from [`SERVICES`] and diffed onto disk by - 4
//! [`services_sync`]; nothing here is hand-edited. Templates embed zero - 5
//! credentials: the binary self-sources the canonical Shared secret scope - 6
//! named by `vak_config::user_env_path` (`vak_config::credentials` — - 7
//! OS keychain, or an encrypted-file fallback), so regenerating - 8
//! units can never strand auth. Logs stay at the platform `logs_dir()` and - 9
//! user data remains under the canonical platform data home. - 10
//! Headless units always run from [`vak_config::paths::default_workspace`], - 11
//! so invoking `self services-sync` from a source checkout or another project - 12
//! can never silently rebind the gateway and channel bridges to that directory. - 13
//! - 14
//! All manager interaction goes through [`CommandRunner`], so tests inject a - 15
//! recorder instead of shelling out to launchctl/systemctl. - 16
- 17
use std::path::{Path, PathBuf}; - 18
use std::process::Command; - 19
- 20
/// Where units are installed for the current user. - 21
#[derive(Debug, Clone)] - 22
pub struct Paths { - 23
pub launch_agents_dir: PathBuf, - 24
pub systemd_unit_dir: PathBuf, - 25
} - 26
- 27
impl Default for Paths { - 28
fn default() -> Self { - 29
let home = super::home(); - 30
Paths { - 31
launch_agents_dir: home.join("Library/LaunchAgents"), - 32
systemd_unit_dir: home.join(".config/systemd/user"), - 33
} - 34
} - 35
} - 36
- 37
/// One resolvable service definition: binary placement plus arguments, - 38
/// mirroring the hand-made plists minus their embedded secrets. - 39
#[derive(Debug, Clone)] - 40
pub struct ServiceSpec { - 41
/// launchd label, also the plist stem (`com.vak.gateway`). Owned rather - 42
/// than `&'static str` because per-bot units (`com.vak.telegram-<id>`) - 43
/// are named dynamically from `bots.json`, not from the static - 44
/// [`SERVICES`] table. - 45
pub name: String, - 46
pub bin_path: PathBuf, - 47
pub args: Vec<String>, - 48
/// Stable log destination under the canonical platform logs directory. - 49
pub log_path: PathBuf, - 50
/// Workspace the service must load for config and its project-local - 51
/// secret scope. - 52
pub working_dir: PathBuf, - 53
/// User home required by platform path resolution in the sanitized - 54
/// service-manager environment. This is operational state, not a secret. - 55
pub home_dir: PathBuf, - 56
/// Non-secret executable search path captured when the unit is synced. - 57
/// MCP commands such as `npx` are resolved by the service manager, whose - 58
/// default PATH is usually smaller than the interactive shell's PATH. - 59
pub path_env: String, - 60
/// Whether the manager should resurrect the process when it exits - 61
/// (launchd `KeepAlive`, systemd `Restart=always`). False for GUI - 62
/// services, where an explicit user quit must actually quit. - 63
pub keep_alive: bool, - 64
/// Whether the process needs a real Aqua login session (a WindowServer - 65
/// connection and a LaunchServices check-in). See [`ServiceDef::gui`]. - 66
pub gui: bool, - 67
/// Whether the service manager should launch the process automatically - 68
/// at machine start / user login (launchd `RunAtLoad`, systemd `WantedBy`). - 69
pub run_at_load: bool, - 70
} - 71
- 72
/// Static template table behind [`ServiceSpec`]. - 73
#[derive(Debug, Clone, Copy)] - 74
pub struct ServiceDef { - 75
pub name: &'static str, - 76
/// Binary file name inside the install prefix's `bin` directory. - 77
pub bin_file: &'static str, - 78
pub args: &'static [&'static str], - 79
/// Log file name under `<vak-home>/logs`. - 80
pub log_file: &'static str, - 81
/// Manager-level resurrection (launchd `KeepAlive`, systemd - 82
/// `Restart=always`). - 83
pub keep_alive: bool, - 84
/// Whether the unit must be pinned to Vak's canonical default workspace. - 85
/// Headless servers load that workspace's config and project secret - 86
/// scope; a GUI app that picks its own project in-app instead runs from - 87
/// the account home and must not be silently bound to one directory. - 88
pub workspace_scoped: bool, - 89
/// The binary ships only when the build produced it (see `COMPONENTS` - 90
/// in the installer). A unit exec'ing a path that does not exist is - 91
/// worse than no unit, so these are skipped when absent. - 92
pub optional: bool, - 93
/// The unit draws on screen and must therefore be pinned to the Aqua - 94
/// login session (`LimitLoadToSessionType`). - 95
/// - 96
/// Without that key launchd runs the job in the plain background - 97
/// `gui/<uid>` domain: the process starts and stays up, but it never - 98
/// checks in with LaunchServices and gets no WindowServer (CGS) - 99
/// connection, so it can draw no menu-bar icon at all. `lsappinfo` - 100
/// reports the difference exactly — `bundle path=[NULL]`, - 101
/// `Arch=!!none`, `!cgsConnection` without the key, versus - 102
/// `type="Foreground"` with a real session token once it is set. - 103
/// Headless services must stay false: they have no UI to place, and - 104
/// pinning them to Aqua would stop them loading in a non-GUI session. - 105
pub gui: bool, - 106
} - 107
- 108
pub const SERVICES: &[ServiceDef] = &[ - 109
ServiceDef { - 110
name: "com.vak.gateway", - 111
bin_file: "vak", - 112
args: &["serve", "--gateway", "--trust"], - 113
log_file: "gateway.log", - 114
keep_alive: true, - 115
workspace_scoped: true, - 116
optional: false, - 117
gui: false, - 118
}, - 119
// The desktop app is what puts the menu-bar icon on screen; without a - 120
// unit nothing brings it back after a logout or reboot, so the tray — - 121
// the surface that starts and stops everything else — was the one - 122
// thing that did not survive one. - 123
// - 124
// KeepAlive is deliberately OFF, unlike the headless services above. - 125
// The tray menu's `Quit Vak` calls `app.exit(0)`; under KeepAlive - 126
// launchd would relaunch it a second later and Quit would visibly not - 127
// quit. RunAtLoad still gives the "back after login/reboot" behaviour - 128
// that is the whole point, and a genuinely crashed GUI app is better - 129
// left down than silently respawned in a loop the user cannot see. - 130
// - 131
// `--tray` starts with the window hidden: a login-launched app that - 132
// threw a 1440x900 window on screen at every boot would be a worse - 133
// regression than the missing persistence it fixes. - 134
ServiceDef { - 135
name: "com.vak.desktop", - 136
bin_file: "vak-desktop", - 137
args: &["--tray"], - 138
log_file: "desktop.log", - 139
keep_alive: false, - 140
workspace_scoped: false, - 141
optional: true, - 142
gui: true, - 143
}, - 144
]; - 145
- 146
// ------------------------------------------------------- multi-bot units - 147
// - 148
// docs/design/34 (multi-bot-per-channel): a user adds/removes Telegram, - 149
// Discord, and Slack bots at any time from the admin console, each getting - 150
// its own id and its own token env var recorded in `bots.json`. Unlike - 151
// [`SERVICES`] above, these units cannot be a static compile-time table — - 152
// there is no fixed number of bots, and the set changes at runtime as bots - 153
// are created, deleted, or renamed. Everything below reads `bots.json` - 154
// fresh each time and derives one unit per configured bot, named - 155
// `com.vak.<surface>-<id>` (e.g. `com.vak.telegram-VakBot`), each launched - 156
// with `--bot-id <id>` so it resolves that bot's own token env var - 157
// (`vak_server::gateway::bot_token_env_for_id`) instead of the legacy - 158
// single-bot slot. This crate cannot depend on vak-server (vak-server - 159
// already depends on vak-ops), so the tiny bit of `bots.json` schema it - 160
// needs is duplicated here rather than shared. - 161
- 162
/// Surfaces with a CLI bridge that takes `--server <url> --bot-id <id>` - 163
/// (see `vak telegram|discord|slack` in `crates/vak/src/cli.rs`). - 164
const BRIDGE_SURFACES: &[&str] = &["telegram", "discord", "slack"]; - 165
- 166
#[derive(Debug, Clone, serde::Deserialize)] - 167
struct BotRecord { - 168
id: String, - 169
surface: String, - 170
} - 171
- 172
#[derive(Debug, Default, serde::Deserialize)] - 173
struct BotsFile { - 174
#[serde(default)] - 175
bots: Vec<BotRecord>, - 176
} - 177
- 178
fn bots_json_path(data_home: &Path) -> PathBuf { - 179
data_home.join("gateway").join("bots.json") - 180
} - 181
- 182
/// Read the bots a user has configured. Missing file or parse failure reads - 183
/// as "no bots" rather than an error — a fresh install has no `bots.json` - 184
/// yet, and a corrupt one must not stop the gateway/desktop units from - 185
/// syncing. - 186
fn read_bots(data_home: &Path) -> Vec<BotRecord> { - 187
std::fs::read_to_string(bots_json_path(data_home)) - 188
.ok() - 189
.and_then(|raw| serde_json::from_str::<BotsFile>(&raw).ok()) - 190
.map(|f| f.bots) - 191
.unwrap_or_default() - 192
} - 193
- 194
pub fn configured_bot_service_names(data_home: &Path, surface: &str) -> Vec<String> { - 195
read_bots(data_home) - 196
.into_iter() - 197
.filter(|bot| bot.surface == surface && BRIDGE_SURFACES.contains(&bot.surface.as_str())) - 198
.map(|bot| bot_service_name(&bot.surface, &bot.id)) - 199
.collect() - 200
} - 201
- 202
/// Every configured bot's unit name, across all bridge surfaces. - 203
/// - 204
/// The per-surface form above answers "which telegram bridges?"; this one - 205
/// answers "everything that ought to be registered", which is what - 206
/// activation and drift detection both need. - 207
pub fn configured_bot_service_names_all(data_home: &Path) -> Vec<String> { - 208
read_bots(data_home) - 209
.into_iter() - 210
.filter(|bot| BRIDGE_SURFACES.contains(&bot.surface.as_str())) - 211
.map(|bot| bot_service_name(&bot.surface, &bot.id)) - 212
.collect() - 213
} - 214
- 215
/// True when `name`'s unit file execs a binary inside `prefix`. - 216
/// - 217
/// The question an uninstall has to ask before removing anything. A unit - 218
/// that execs a *different* install belongs to that install, and tearing - 219
/// it down because a throwaway prefix was being removed is how one - 220
/// uninstall stops somebody else's running services — which is exactly - 221
/// what happened: `vak self uninstall --prefix <tmp>` unregistered the - 222
/// operator's real launchd units, so simply running the test suite on a - 223
/// machine with vak installed silently stopped its gateway. - 224
pub fn unit_belongs_to_prefix(name: &str, prefix: &Path, paths: &Paths) -> bool { - 225
let Ok(unit) = std::fs::read_to_string(unit_file_path(name, paths)) else { - 226
return false; - 227
}; - 228
unit.contains(&prefix.to_string_lossy().into_owned()) - 229
} - 230
- 231
/// True when the service manager has a unit file for `name`. - 232
/// - 233
/// Presence of the unit file is what distinguishes "configured" from - 234
/// "activated": a bot can exist in `bots.json` with no unit, which is a - 235
/// deliberate state, not a fault. - 236
pub fn unit_is_registered(name: &str, paths: &Paths) -> bool { - 237
unit_file_path(name, paths).is_file() - 238
} - 239
- 240
/// launchd/systemd labels only tolerate a narrow character set; a bot id is - 241
/// operator-chosen (the admin console enforces alphanumeric/hyphen today, - 242
/// but this is a second, independent line of defense against a stray id - 243
/// producing a unit name the service manager rejects or a path that escapes - 244
/// the units directory). - 245
fn sanitize_for_unit_name(id: &str) -> String { - 246
id.chars() - 247
.map(|c| { - 248
if c.is_ascii_alphanumeric() || c == '-' { - 249
c - 250
} else { - 251
'_' - 252
} - 253
}) - 254
.collect() - 255
} - 256
- 257
/// The launchd label / systemd stem for one bot's bridge unit. - 258
pub fn bot_service_name(surface: &str, id: &str) -> String { - 259
format!("com.vak.{surface}-{}", sanitize_for_unit_name(id)) - 260
} - 261
- 262
/// One [`ServiceSpec`] per bot currently in `bots.json`, ready for - 263
/// [`sync_specs`]. Surfaces without a CLI bridge (unrecognized `surface` - 264
/// values) are skipped rather than producing a unit that can never run. - 265
pub fn bot_service_specs( - 266
bin_dir: &Path, - 267
home_dir: &Path, - 268
default_workspace: &Path, - 269
data_home: &Path, - 270
gateway_url: &str, - 271
) -> Vec<ServiceSpec> { - 272
read_bots(data_home) - 273
.into_iter() - 274
.filter(|b| BRIDGE_SURFACES.contains(&b.surface.as_str())) - 275
.map(|b| { - 276
let name = bot_service_name(&b.surface, &b.id); - 277
let log_file = format!("{}-{}.log", b.surface, sanitize_for_unit_name(&b.id)); - 278
ServiceSpec { - 279
name, - 280
bin_path: bin_dir.join("vak"), - 281
args: vec![ - 282
b.surface, - 283
"--server".to_string(), - 284
gateway_url.to_string(), - 285
"--bot-id".to_string(), - 286
b.id, - 287
], - 288
log_path: vak_config::paths::logs_dir().join(log_file), - 289
working_dir: default_workspace.to_path_buf(), - 290
home_dir: home_dir.to_path_buf(), - 291
path_env: std::env::var("PATH").unwrap_or_default(), - 292
keep_alive: true, - 293
gui: false, - 294
run_at_load: true, - 295
} - 296
}) - 297
.collect() - 298
} - 299
- 300
pub fn configured_bot_service_specs( - 301
bin_path: &Path, - 302
data_home: &Path, - 303
gateway_url: &str, - 304
) -> Vec<ServiceSpec> { - 305
let bin_dir = bin_path.parent().unwrap_or(Path::new("/")); - 306
bot_service_specs( - 307
bin_dir, - 308
&super::home(), - 309
&vak_config::paths::default_workspace(), - 310
data_home, - 311
gateway_url, - 312
) - 313
} - 314
- 315
/// Units matching `com.vak.<surface>-*` on disk that are no longer in - 316
/// `wanted` get stopped, deregistered, and their unit file removed — the - 317
/// counterpart to a bot being deleted or renamed in the admin console. - 318
/// Without this, a deleted bot's bridge process (and its stale token env - 319
/// reference) would keep running forever, invisible to `bots.json`. - 320
fn prune_stale_bot_units(wanted: &[String], paths: &Paths, runner: &dyn CommandRunner) { - 321
let dir = if cfg!(target_os = "macos") { - 322
&paths.launch_agents_dir - 323
} else { - 324
&paths.systemd_unit_dir - 325
}; - 326
let Ok(entries) = std::fs::read_dir(dir) else { - 327
return; - 328
}; - 329
for entry in entries.flatten() { - 330
let file_name = entry.file_name(); - 331
let file_name = file_name.to_string_lossy(); - 332
let is_bot_unit = BRIDGE_SURFACES - 333
.iter() - 334
.any(|s| file_name.starts_with(&format!("com.vak.{s}-"))); - 335
if !is_bot_unit { - 336
continue; - 337
} - 338
let stem = file_name - 339
.strip_suffix(".plist") - 340
.or_else(|| { - 341
file_name - 342
.strip_suffix(".service") - 343
.map(|s| s.trim_start_matches("vak-")) - 344
}) - 345
.unwrap_or(&file_name); - 346
// systemd stems are stripped of the "com.vak." prefix by - 347
// `short_name`; reconstruct the launchd-style label to compare. - 348
let label = if file_name.ends_with(".service") { - 349
format!("com.vak.{stem}") - 350
} else { - 351
stem.to_string() - 352
}; - 353
if !wanted.contains(&label) { - 354
let _ = services_uninstall(&[label.as_str()], paths, runner); - 355
} - 356
} - 357
} - 358
- 359
/// Reconcile every currently-configured bot's unit against the service - 360
/// manager: create units for new bots, update ones whose args changed - 361
/// (token env, surface), leave healthy ones alone, and remove units for - 362
/// bots that were deleted or renamed. Called after every bot create/update - 363
/// (surface change)/delete/token change so a user editing bots in the admin - 364
/// console never has to know a launchd/systemd unit is involved. - 365
pub fn sync_bots( - 366
bin_path: &Path, - 367
data_home: &Path, - 368
gateway_url: &str, - 369
paths: &Paths, - 370
runner: &dyn CommandRunner, - 371
) -> Vec<SyncOutcome> { - 372
let specs = configured_bot_service_specs(bin_path, data_home, gateway_url); - 373
let wanted: Vec<String> = specs.iter().map(|s| s.name.clone()).collect(); - 374
prune_stale_bot_units(&wanted, paths, runner); - 375
sync_specs(&specs, paths, runner) - 376
} - 377
- 378
/// Remove every per-bot bridge unit found on disk (`com.vak.<surface>-*`), - 379
/// regardless of what `bots.json` currently says. For use at uninstall - 380
/// time, where nothing should be left running — [`sync_bots`]'s normal - 381
/// diff-against-`bots.json` behaviour is the wrong shape there, since an - 382
/// uninstall wants "wanted = nothing", not "wanted = whatever's still - 383
/// configured". - 384
pub fn uninstall_bot_units(paths: &Paths, runner: &dyn CommandRunner) { - 385
prune_stale_bot_units(&[], paths, runner); - 386
} - 387
- 388
/// Bounce one bot's already-installed unit so its process re-reads the - 389
/// credential store. - 390
/// A token rotate or removal changes no unit *content* — the token itself - 391
/// is never embedded in the plist/unit, only its env var name is, and that - 392
/// name doesn't change — so [`sync_bots`]'s identity diff would see - 393
/// `Unchanged` and never restart the process. Call this alongside - 394
/// [`sync_bots`] whenever a bot's token is set or cleared. Best-effort: a - 395
/// bot with no unit yet (token set before the first sync) simply reports - 396
/// `false`, which is fine — [`sync_bots`] will create and start it fresh. - 397
pub fn restart_bot_unit(surface: &str, id: &str, runner: &dyn CommandRunner) -> bool { - 398
platform::restart(&bot_service_name(surface, id), runner) - 399
} - 400
- 401
impl ServiceDef { - 402
fn resolved_args(&self, port: u16) -> Vec<String> { - 403
let mut args: Vec<String> = self.args.iter().map(|a| (*a).to_string()).collect(); - 404
// Only the gateway takes a port; per-bot bridge units carry their - 405
// own `--bot-id` and base URL from `bot_service_specs`. - 406
if self.name == "com.vak.gateway" { - 407
args.extend(["--port".into(), port.to_string()]); - 408
} - 409
args - 410
} - 411
- 412
/// Resolve against an install prefix: `bin_dir` holds the release - 413
/// binaries; logs land in the canonical platform logs dir - 414
/// (`~/Library/Logs/vak` / XDG state) — never inside data. - 415
pub fn spec(&self, bin_dir: &Path, home_dir: &Path, default_workspace: &Path) -> ServiceSpec { - 416
ServiceSpec { - 417
name: self.name.to_string(), - 418
bin_path: bin_dir.join(self.bin_file), - 419
args: self.resolved_args(super::OpsConfig::detect().port), - 420
log_path: vak_config::paths::logs_dir().join(self.log_file), - 421
// Non-workspace-scoped services get the account home: they - 422
// choose their own project at runtime. Headless services use the - 423
// canonical default workspace, never the caller's current dir. - 424
working_dir: if self.workspace_scoped { - 425
default_workspace.to_path_buf() - 426
} else { - 427
home_dir.to_path_buf() - 428
}, - 429
home_dir: home_dir.to_path_buf(), - 430
path_env: std::env::var("PATH").unwrap_or_default(), - 431
keep_alive: self.keep_alive, - 432
gui: self.gui, - 433
run_at_load: if self.name == "com.vak.desktop" { - 434
is_autostart_configured() - 435
} else { - 436
true - 437
}, - 438
} - 439
} - 440
- 441
/// systemd unit stem derived from the launchd label - 442
/// (`com.vak.gateway` → `vak-gateway.service`). - 443
pub fn systemd_unit(&self) -> String { - 444
systemd_unit_name(self.name) - 445
} - 446
} - 447
- 448
fn short_name(name: &str) -> &str { - 449
name.strip_prefix("com.vak.").unwrap_or(name) - 450
} - 451
- 452
/// Unit file location for `name` under `paths` (pure; no filesystem IO). - 453
pub fn unit_file_path(name: &str, paths: &Paths) -> PathBuf { - 454
#[cfg(target_os = "macos")] - 455
{ - 456
paths.launch_agents_dir.join(format!("{name}.plist")) - 457
} - 458
#[cfg(not(target_os = "macos"))] - 459
{ - 460
paths - 461
.systemd_unit_dir - 462
.join(format!("vak-{}.service", short_name(name))) - 463
} - 464
} - 465
- 466
fn xml_escape(s: &str) -> String { - 467
s.replace('&', "&") - 468
.replace('<', "<") - 469
.replace('>', ">") - 470
} - 471
- 472
/// launchd property list: RunAtLoad always, KeepAlive per definition - 473
/// (off for GUI services so an explicit quit sticks), stdout/stderr to the stable - 474
/// log, with only non-secret HOME and PATH in the environment so canonical path - 475
/// resolution cannot mistake the workspace for the user home. Credentials - 476
/// still come from the user env file loaded by the binary itself. - 477
pub fn render_launchd_plist(spec: &ServiceSpec) -> String { - 478
let mut prog_args = String::new(); - 479
let bin = xml_escape(&spec.bin_path.to_string_lossy()); - 480
prog_args.push_str(&format!("\n\t\t<string>{bin}</string>")); - 481
for arg in &spec.args { - 482
prog_args.push_str(&format!("\n\t\t<string>{}</string>", xml_escape(arg))); - 483
} - 484
let log = xml_escape(&spec.log_path.to_string_lossy()); - 485
// GUI units must be pinned to the Aqua login session or launchd hands - 486
// them a background job with no WindowServer connection — the process - 487
// runs, logs nothing, and silently draws no menu-bar icon. - 488
let session_type = if spec.gui { - 489
"\n\t<key>LimitLoadToSessionType</key>\n\t<string>Aqua</string>" - 490
} else { - 491
"" - 492
}; - 493
format!( - 494
r#"<?xml version="1.0" encoding="UTF-8"?> - 495
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> - 496
<plist version="1.0"> - 497
<dict> - 498
<key>KeepAlive</key> - 499
<{}/>{} - 500
<key>Label</key> - 501
<string>{}</string> - 502
<key>ProgramArguments</key> - 503
<array>{} - 504
</array> - 505
<key>RunAtLoad</key> - 506
<{}/> - 507
<key>EnvironmentVariables</key> - 508
<dict> - 509
<key>HOME</key> - 510
<string>{}</string> - 511
<key>PATH</key> - 512
<string>{}</string> - 513
</dict> - 514
<key>WorkingDirectory</key> - 515
<string>{}</string> - 516
<key>StandardErrorPath</key> - 517
<string>{}</string> - 518
<key>StandardOutPath</key> - 519
<string>{}</string> - 520
</dict> - 521
</plist> - 522
"#, - 523
if spec.keep_alive { "true" } else { "false" }, - 524
session_type, - 525
xml_escape(&spec.name), - 526
prog_args, - 527
if spec.run_at_load { "true" } else { "false" }, - 528
xml_escape(&spec.home_dir.to_string_lossy()), - 529
xml_escape(&spec.path_env), - 530
xml_escape(&spec.working_dir.to_string_lossy()), - 531
log, - 532
log - 533
) - 534
} - 535
- 536
/// systemd user unit: Restart per definition, journald bypassed in favour of the same - 537
/// stable log files launchd uses, with only non-secret HOME and PATH. - 538
pub fn render_systemd_unit(spec: &ServiceSpec) -> String { - 539
let mut exec = spec.bin_path.to_string_lossy().into_owned(); - 540
for arg in &spec.args { - 541
exec.push(' '); - 542
exec.push_str(arg); - 543
} - 544
let log = spec.log_path.to_string_lossy(); - 545
let install_section = if spec.run_at_load { - 546
"[Install]\nWantedBy=default.target\n" - 547
} else { - 548
"" - 549
}; - 550
format!( - 551
"# Generated by vak-ops — regenerate with `self services-sync`.\n\ - 552
[Unit]\n\ - 553
Description=vak {}\n\ - 554
After=network-online.target\n\ - 555
Wants=network-online.target\n\ - 556
\n\ - 557
[Service]\n\ - 558
ExecStart={exec}\n\ - 559
Environment=HOME={}\n\ - 560
Environment=PATH={}\n\ - 561
WorkingDirectory={}\n\ - 562
Restart={}\n\ - 563
StandardOutput=append:{log}\n\ - 564
StandardError=append:{log}\n\ - 565
\n\ - 566
{install_section}", - 567
short_name(&spec.name), - 568
spec.home_dir.display(), - 569
spec.path_env, - 570
spec.working_dir.display(), - 571
if spec.keep_alive { "always" } else { "no" }, - 572
) - 573
} - 574
- 575
fn render(spec: &ServiceSpec) -> String { - 576
#[cfg(target_os = "macos")] - 577
{ - 578
render_launchd_plist(spec) - 579
} - 580
#[cfg(not(target_os = "macos"))] - 581
{ - 582
render_systemd_unit(spec) - 583
} - 584
} - 585
- 586
/// Shell-out seam so tests record commands instead of touching the real - 587
/// service manager. - 588
pub trait CommandRunner { - 589
/// Run a command; true when it exited successfully. - 590
fn success(&self, program: &str, args: &[String]) -> bool; - 591
/// Run a command capturing trimmed stdout; None when it failed. - 592
fn text(&self, program: &str, args: &[String]) -> Option<String>; - 593
} - 594
- 595
/// The real runner: quiet subprocess execution. - 596
#[derive(Debug, Default, Clone, Copy)] - 597
pub struct SystemRunner; - 598
- 599
impl CommandRunner for SystemRunner { - 600
fn success(&self, program: &str, args: &[String]) -> bool { - 601
Command::new(program) - 602
.args(args) - 603
.stdin(std::process::Stdio::null()) - 604
.stdout(std::process::Stdio::null()) - 605
.stderr(std::process::Stdio::null()) - 606
.status() - 607
.map(|s| s.success()) - 608
.unwrap_or(false) - 609
} - 610
- 611
fn text(&self, program: &str, args: &[String]) -> Option<String> { - 612
let out = Command::new(program) - 613
.args(args) - 614
.stdin(std::process::Stdio::null()) - 615
.stderr(std::process::Stdio::null()) - 616
.output() - 617
.ok()?; - 618
if !out.status.success() { - 619
return None; - 620
} - 621
Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) - 622
} - 623
} - 624
- 625
/// The systemd unit name for a launchd-style service label. - 626
/// - 627
/// One definition, shared by [`ServiceSpec::systemd_unit`] and the - 628
/// platform helpers below. They previously had a method and a free - 629
/// function of the same name, and only the method existed — so every - 630
/// `#[cfg(not(target_os = "macos"))]` branch referenced something that was - 631
/// not there and `vak-ops` did not compile on Linux at all. - 632
pub fn systemd_unit_name(label: &str) -> String { - 633
format!( - 634
"vak-{}.service", - 635
label.strip_prefix("com.vak.").unwrap_or(label) - 636
) - 637
} - 638
- 639
/// Read the persisted desktop autostart preference from `tray.json` if set. - 640
pub fn is_desktop_autostart_persisted() -> Option<bool> { - 641
let path = vak_config::paths::data_home().join("tray.json"); - 642
std::fs::read_to_string(path) - 643
.ok() - 644
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok()) - 645
.and_then(|value| value.get("autostart").and_then(|v| v.as_bool())) - 646
} - 647
- 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 {
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.