- 1
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - 2
- 3
//! vak-desktop: native shell around the same HTTP+SSE contract every other - 4
//! surface (tui/exec/serve) speaks. The webview gets a loopback bearer token - 5
//! for the embedded `vak-server` router; nothing about the agent protocol is - 6
//! re-implemented here. - 7
- 8
mod pty; - 9
- 10
use std::path::PathBuf; - 11
use std::sync::atomic::{AtomicBool, Ordering}; - 12
use std::sync::{Arc, Mutex}; - 13
use std::time::Duration; - 14
- 15
use serde::{Deserialize, Serialize}; - 16
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}; - 17
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; - 18
use tauri::{AppHandle, Emitter, Manager, RunEvent, State, WindowEvent}; - 19
- 20
const TRAY_ID: &str = "vak"; - 21
const TRAY_OPEN_ID: &str = "desktop.open"; - 22
const TRAY_HOME_ID: &str = "desktop.home"; - 23
const TRAY_APP_ID: &str = "desktop.app"; - 24
const TRAY_ADMIN_ID: &str = "desktop.admin"; - 25
const TRAY_WATCHDOG_ID: &str = "desktop.watchdog"; - 26
const TRAY_AUTOSTART_ID: &str = "desktop.autostart"; - 27
const TRAY_QUIT_ID: &str = "desktop.quit"; - 28
const GATEWAY: usize = 0; - 29
const BRIDGES: usize = 1; - 30
- 31
struct TrayState { - 32
watchdog: Arc<AtomicBool>, - 33
autostart: Arc<AtomicBool>, - 34
last_rendered: Mutex<Option<([vak_ops::State; 2], bool, bool)>>, - 35
} - 36
- 37
/// Start with the menu-bar icon only, leaving the main window hidden. - 38
/// - 39
/// The `com.vak.desktop` / `vak-desktop.service` unit passes this so a - 40
/// login launch restores the tray without throwing a window on screen at - 41
/// every boot. Every other way in — double-click, Dock, `Open Vakyartha`, a - 42
/// second launch handed over by the single-instance plugin — reveals the - 43
/// window, so the flag only suppresses the one startup nobody asked for. - 44
const TRAY_FLAG: &str = "--tray"; - 45
- 46
/// The window is created hidden (`visible: false` in `tauri.conf.json`) - 47
/// and revealed here, rather than created visible and hidden again: the - 48
/// latter flashes a full-size window on screen before the setup hook can - 49
/// run. - 50
fn tray_only_start() -> bool { - 51
is_tray_launch(std::env::args_os()) - 52
} - 53
- 54
/// Whether an argv asks for a tray-only launch. - 55
/// - 56
/// Split out from [`tray_only_start`] so it can also classify the argv the - 57
/// single-instance plugin hands over from a *second* process, which is not - 58
/// this process's own `std::env::args`. - 59
fn is_tray_launch<S: AsRef<std::ffi::OsStr>>(args: impl IntoIterator<Item = S>) -> bool { - 60
args.into_iter() - 61
.any(|arg| arg.as_ref() == std::ffi::OsStr::new(TRAY_FLAG)) - 62
} - 63
- 64
fn requested_project<S: AsRef<std::ffi::OsStr>>( - 65
args: impl IntoIterator<Item = S>, - 66
) -> Option<PathBuf> { - 67
args.into_iter() - 68
.map(|arg| PathBuf::from(arg.as_ref())) - 69
.find(|path| path.is_dir()) - 70
} - 71
- 72
fn show_main_window(app: &AppHandle) { - 73
if let Some(window) = app.get_webview_window("main") { - 74
let _ = window.unminimize(); - 75
let _ = window.show(); - 76
let _ = window.set_focus(); - 77
} - 78
} - 79
- 80
/// The desktop process is Vakyartha's only GUI lifecycle owner. Keeping the - 81
/// tray here means a Dock/Finder activation and a tray activation target the - 82
/// same process and always have a window to reveal. - 83
fn service(index: usize) -> vak_ops::Service { - 84
if index == GATEWAY { - 85
vak_ops::Service::Gateway - 86
} else { - 87
vak_ops::Service::Bridges - 88
} - 89
} - 90
- 91
fn states_now() -> [vak_ops::State; 2] { - 92
let config = vak_ops::OpsConfig::detect(); - 93
[ - 94
vak_ops::status(vak_ops::Service::Gateway, &config), - 95
vak_ops::status(vak_ops::Service::Bridges, &config), - 96
] - 97
} - 98
- 99
fn status_tooltip(states: &[vak_ops::State; 2]) -> String { - 100
format!( - 101
"Vakyartha — gateway {}, chat bridges {}", - 102
states[GATEWAY], states[BRIDGES] - 103
) - 104
} - 105
- 106
fn service_menu( - 107
app: &tauri::AppHandle, - 108
index: usize, - 109
state: vak_ops::State, - 110
) -> tauri::Result<Vec<tauri::menu::MenuItem<tauri::Wry>>> { - 111
let prefix = if index == GATEWAY { - 112
"gateway" - 113
} else { - 114
// One label for every transport: a bridge belongs to a bot, and - 115
// bots name their own surface (AGENTS.md invariant 23). - 116
"bridges" - 117
}; - 118
let dot = match state { - 119
vak_ops::State::Running => "●", - 120
vak_ops::State::Stopped => "○", - 121
vak_ops::State::NotInstalled => "×", - 122
vak_ops::State::Unknown => "?", - 123
}; - 124
let header = MenuItem::with_id( - 125
app, - 126
format!("desktop.{prefix}.status"), - 127
format!("{dot} {} — {state}", service(index).label()), - 128
false, - 129
None::<&str>, - 130
)?; - 131
let mut items = vec![header]; - 132
if state == vak_ops::State::NotInstalled { - 133
items.push(MenuItem::with_id( - 134
app, - 135
format!("desktop.{prefix}.install"), - 136
"Install service", - 137
true, - 138
None::<&str>, - 139
)?); - 140
} else { - 141
let running = state == vak_ops::State::Running; - 142
items.push(MenuItem::with_id( - 143
app, - 144
format!( - 145
"desktop.{prefix}.{}", - 146
if running { "stop" } else { "start" } - 147
), - 148
if running { "Stop" } else { "Start" }, - 149
true, - 150
None::<&str>, - 151
)?); - 152
items.push(MenuItem::with_id( - 153
app, - 154
format!("desktop.{prefix}.restart"), - 155
"Restart", - 156
running, - 157
None::<&str>, - 158
)?); - 159
items.push(MenuItem::with_id( - 160
app, - 161
format!("desktop.{prefix}.uninstall"), - 162
"Uninstall service", - 163
true, - 164
None::<&str>, - 165
)?); - 166
} - 167
items.push(MenuItem::with_id( - 168
app, - 169
format!("desktop.{prefix}.log"), - 170
"Open log", - 171
true, - 172
None::<&str>, - 173
)?); - 174
Ok(items) - 175
} - 176
- 177
fn build_tray_menu( - 178
app: &tauri::AppHandle, - 179
states: &[vak_ops::State; 2], - 180
watchdog_on: bool, - 181
autostart_on: bool, - 182
) -> tauri::Result<Menu<tauri::Wry>> { - 183
let open = MenuItem::with_id(app, TRAY_OPEN_ID, "Open Vakyartha", true, None::<&str>)?; - 184
// The same three destinations the landing page offers, in the same - 185
// order and with the same names. Two entries that both opened the admin - 186
// console at different hash routes was the tray describing one page as - 187
// if it were two products. - 188
let home = MenuItem::with_id(app, TRAY_HOME_ID, "Home", true, None::<&str>)?; - 189
let workspace = MenuItem::with_id(app, TRAY_APP_ID, "Workspace", true, None::<&str>)?; - 190
let admin = MenuItem::with_id(app, TRAY_ADMIN_ID, "Operations", true, None::<&str>)?; - 191
let separator_top = PredefinedMenuItem::separator(app)?; - 192
let separator = PredefinedMenuItem::separator(app)?; - 193
let gateway = service_menu(app, GATEWAY, states[GATEWAY])?; - 194
let separator_gateway = PredefinedMenuItem::separator(app)?; - 195
let bridges = service_menu(app, BRIDGES, states[BRIDGES])?; - 196
let separator_bridges = PredefinedMenuItem::separator(app)?; - 197
let watchdog = CheckMenuItem::with_id( - 198
app, - 199
TRAY_WATCHDOG_ID, - 200
"Watchdog: alert on service failures", - 201
true, - 202
watchdog_on, - 203
None::<&str>, - 204
)?; - 205
let autostart = CheckMenuItem::with_id( - 206
app, - 207
TRAY_AUTOSTART_ID, - 208
"Launch at login", - 209
true, - 210
autostart_on, - 211
None::<&str>, - 212
)?; - 213
let separator_watchdog = PredefinedMenuItem::separator(app)?; - 214
let quit = MenuItem::with_id(app, TRAY_QUIT_ID, "Quit Vakyartha", true, None::<&str>)?; - 215
let mut items: Vec<&dyn tauri::menu::IsMenuItem<tauri::Wry>> = - 216
vec![&open, &separator_top, &home, &workspace, &admin, &separator]; - 217
items.extend( - 218
gateway - 219
.iter() - 220
.map(|item| item as &dyn tauri::menu::IsMenuItem<tauri::Wry>), - 221
); - 222
items.push(&separator_gateway); - 223
items.extend( - 224
bridges - 225
.iter() - 226
.map(|item| item as &dyn tauri::menu::IsMenuItem<tauri::Wry>), - 227
); - 228
items.extend([ - 229
&separator_bridges as &dyn tauri::menu::IsMenuItem<tauri::Wry>, - 230
&watchdog, - 231
&autostart, - 232
&separator_watchdog, - 233
&quit, - 234
]); - 235
Menu::with_items(app, &items) - 236
} - 237
- 238
fn refresh_tray(app: &AppHandle) { - 239
let states = states_now(); - 240
let tray_state = app.state::<TrayState>(); - 241
let watchdog_on = tray_state.watchdog.load(Ordering::SeqCst); - 242
let autostart_on = tray_state.autostart.load(Ordering::SeqCst); - 243
let snapshot = (states, watchdog_on, autostart_on); - 244
let mut last = tray_state - 245
.last_rendered - 246
.lock() - 247
.unwrap_or_else(std::sync::PoisonError::into_inner); - 248
if *last == Some(snapshot) { - 249
return; - 250
} - 251
if let (Some(tray), Ok(menu)) = ( - 252
app.tray_by_id(TRAY_ID), - 253
build_tray_menu(app, &states, watchdog_on, autostart_on), - 254
) { - 255
let _ = tray.set_menu(Some(menu)); - 256
let _ = tray.set_tooltip(Some(status_tooltip(&states))); - 257
*last = Some(snapshot); - 258
} - 259
} - 260
- 261
fn persist_watchdog(on: bool) { - 262
let path = vak_config::paths::data_home().join("tray.json"); - 263
if let Some(parent) = path.parent() { - 264
let _ = std::fs::create_dir_all(parent); - 265
} - 266
let _ = std::fs::write(path, serde_json::json!({ "auto_restart": on }).to_string()); - 267
} - 268
- 269
fn load_watchdog() -> bool { - 270
std::fs::read_to_string(vak_config::paths::data_home().join("tray.json")) - 271
.ok() - 272
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok()) - 273
.and_then(|value| value["auto_restart"].as_bool()) - 274
.unwrap_or(true) - 275
} - 276
- 277
fn notify(title: &str, body: &str) { - 278
let _ = notify_rust::Notification::new() - 279
.summary(title) - 280
.body(body) - 281
.show(); - 282
} - 283
- 284
fn pinned_gateway_token() -> Option<String> { - 285
vak_config::read_env_file_var(&vak_config::user_env_path()?, "VAK_GATEWAY_TOKEN") - 286
.filter(|value| !value.trim().is_empty()) - 287
} - 288
- 289
/// Open one of the gateway's web surfaces in a browser. - 290
/// - 291
/// `path` is a whole path (`/`, `/app`, `/admin`), not an admin-relative - 292
/// fragment: the tray used to know only about the admin console and - 293
/// addressed it by hash route, which is why it grew two entries pointing - 294
/// into the same page instead of one entry per surface. - 295
/// - 296
/// The token still rides along for a server that predates loopback - 297
/// auto-login, or one where an operator turned it off; the client consumes - 298
/// it once and strips it from the address bar. - 299
fn open_web_path(path: &str) { - 300
let config = vak_ops::OpsConfig::detect(); - 301
if vak_ops::status(vak_ops::Service::Gateway, &config) != vak_ops::State::Running { - 302
notify("Vakyartha", "Start the gateway service first."); - 303
return; - 304
} - 305
let base = format!("http://127.0.0.1:{}{path}", config.port); - 306
let url = match pinned_gateway_token() { - 307
Some(token) => format!("{base}?token={token}"), - 308
None => base, - 309
}; - 310
if let Err(error) = std::process::Command::new("open").arg(url).spawn() { - 311
notify("Vakyartha", &format!("Could not open that page: {error}")); - 312
} - 313
} - 314
- 315
fn run_service_action(app: &AppHandle, service: vak_ops::Service, action: &str) { - 316
let config = vak_ops::OpsConfig::detect(); - 317
let problem = match action { - 318
"start" if !vak_ops::start(service, &config) => Some("could not start service".to_string()), - 319
"stop" if !vak_ops::stop(service, &config) => Some("could not stop service".to_string()), - 320
"restart" if !vak_ops::restart(service, &config) => { - 321
Some("could not restart service".to_string()) - 322
} - 323
"install" => vak_ops::install(service, &config).err(), - 324
"uninstall" => vak_ops::uninstall(service, &config).err(), - 325
"log" => { - 326
vak_ops::open_log(service); - 327
None - 328
} - 329
_ => None, - 330
}; - 331
if let Some(problem) = problem { - 332
notify("Vakyartha", &format!("{}: {problem}", service.label())); - 333
} - 334
refresh_tray(app); - 335
} - 336
- 337
fn handle_tray_menu(app: &AppHandle, id: &str) { - 338
match id { - 339
TRAY_OPEN_ID => show_main_window(app), - 340
TRAY_HOME_ID => open_web_path("/"), - 341
TRAY_APP_ID => open_web_path("/app"), - 342
TRAY_ADMIN_ID => open_web_path("/admin"), - 343
TRAY_WATCHDOG_ID => { - 344
let tray = app.state::<TrayState>(); - 345
let new_value = !tray.watchdog.load(Ordering::SeqCst); - 346
tray.watchdog.store(new_value, Ordering::SeqCst); - 347
persist_watchdog(new_value); - 348
refresh_tray(app); - 349
} - 350
TRAY_AUTOSTART_ID => { - 351
let tray = app.state::<TrayState>(); - 352
let new_value = !tray.autostart.load(Ordering::SeqCst); - 353
tray.autostart.store(new_value, Ordering::SeqCst); - 354
if let Err(err) = vak_ops::services::set_service_autostart("com.vak.desktop", new_value) - 355
{ - 356
notify( - 357
"Vakyartha", - 358
&format!("Could not update autostart setting: {err}"), - 359
); - 360
} - 361
refresh_tray(app); - 362
} - 363
TRAY_QUIT_ID => app.exit(0), - 364
_ => { - 365
for (prefix, service) in [ - 366
("desktop.gateway.", vak_ops::Service::Gateway), - 367
("desktop.bridges.", vak_ops::Service::Bridges), - 368
] { - 369
if let Some(action) = id.strip_prefix(prefix) { - 370
run_service_action(app, service, action); - 371
break; - 372
} - 373
} - 374
} - 375
} - 376
} - 377
- 378
fn start_tray_monitor(app: AppHandle) { - 379
std::thread::spawn(move || { - 380
let mut last_running = [false; 2]; - 381
loop { - 382
let states = states_now(); - 383
let tray = app.state::<TrayState>(); - 384
if tray.watchdog.load(Ordering::SeqCst) { - 385
for index in 0..2 { - 386
let running = states[index] == vak_ops::State::Running; - 387
if last_running[index] && !running { - 388
notify( - 389
"Vakyartha watchdog", - 390
&format!( - 391
"{} is down — launchd will recover it", - 392
service(index).label() - 393
), - 394
); - 395
} - 396
last_running[index] = running; - 397
} - 398
} else { - 399
for index in 0..2 { - 400
last_running[index] = states[index] == vak_ops::State::Running; - 401
} - 402
} - 403
refresh_tray(&app); - 404
std::thread::sleep(Duration::from_secs(3)); - 405
} - 406
}); - 407
} - 408
- 409
fn install_tray(app: &tauri::App) -> tauri::Result<()> { - 410
let states = states_now(); - 411
let tray_state = app.state::<TrayState>(); - 412
let watchdog_on = tray_state.watchdog.load(Ordering::SeqCst); - 413
let autostart_on = tray_state.autostart.load(Ordering::SeqCst); - 414
let menu = build_tray_menu(app.handle(), &states, watchdog_on, autostart_on)?; - 415
let mut tray = TrayIconBuilder::with_id("vak") - 416
.menu(&menu) - 417
.tooltip(status_tooltip(&states)) - 418
.show_menu_on_left_click(false) - 419
.on_menu_event(|app, event| handle_tray_menu(app, event.id().as_ref())) - 420
.on_tray_icon_event(|tray, event| { - 421
if matches!( - 422
event, - 423
TrayIconEvent::Click { - 424
button: MouseButton::Left, - 425
button_state: MouseButtonState::Up, - 426
.. - 427
} - 428
) { - 429
show_main_window(tray.app_handle()); - 430
} - 431
}); - 432
// AppKit renders this alpha mask at 18pt and supplies the current menu-bar - 433
// colour. Other platforms keep the complete colour tile. - 434
#[cfg(target_os = "macos")] - 435
let icon_bytes = include_bytes!("../icons/tray-template.png").as_slice(); - 436
#[cfg(not(target_os = "macos"))] - 437
let icon_bytes = include_bytes!("../icons/tray-color.png").as_slice(); - 438
let icon = tauri::image::Image::from_bytes(icon_bytes)?; - 439
tray = tray.icon(icon).icon_as_template(cfg!(target_os = "macos")); - 440
tray.build(app)?; - 441
start_tray_monitor(app.handle().clone()); - 442
Ok(()) - 443
} - 444
- 445
#[derive(Clone)] - 446
struct Backend { - 447
shutdown: tokio::sync::watch::Sender<bool>, - 448
} - 449
- 450
struct BackendState { - 451
running: Mutex<Option<Running>>, - 452
switching: tokio::sync::Mutex<()>, - 453
} - 454
- 455
struct Running { - 456
info: BackendInfo, - 457
backend: Backend, - 458
} - 459
- 460
/// How the main window draws its title bar. On macOS the window controls - 461
/// overlay the webview (`titleBarStyle: Overlay` in tauri.conf.json), so the - 462
/// client leaves room for them; elsewhere the system draws a title bar. - 463
#[derive(Serialize, Clone, Copy)] - 464
#[serde(rename_all = "snake_case")] - 465
enum WindowChrome { - 466
Overlay, - 467
Native, - 468
} - 469
- 470
impl Default for WindowChrome { - 471
fn default() -> Self { - 472
if cfg!(target_os = "macos") { - 473
Self::Overlay - 474
} else { - 475
Self::Native - 476
} - 477
} - 478
} - 479
- 480
#[derive(Serialize, Clone, Default)] - 481
struct BackendInfo { - 482
version: String, - 483
window_chrome: WindowChrome, - 484
ready: bool, - 485
#[serde(skip_serializing_if = "Option::is_none")] - 486
base_url: Option<String>, - 487
#[serde(skip_serializing_if = "Option::is_none")] - 488
token: Option<String>, - 489
#[serde(skip_serializing_if = "Option::is_none")] - 490
cwd: Option<String>, - 491
/// Why the last boot attempt failed, if it did. The webview reads this - 492
/// so a silent launch failure never traps the user on the project gate. - 493
#[serde(skip_serializing_if = "Option::is_none")] - 494
boot_error: Option<String>, - 495
recent_workspaces: Vec<String>, - 496
} - 497
- 498
#[derive(Deserialize, Serialize, Default)] - 499
#[serde(default)] - 500
struct DesktopPrefs { - 501
last_project: Option<String>, - 502
recent_workspaces: Vec<String>, - 503
} - 504
- 505
/// The same canonical data home the CLI, TUI, and wizard use - 506
/// (`vak_config::paths::data_home`, doc 32) — never a hand-rolled path. - 507
/// - 508
/// This used to hardcode `~/.vak`, the pre-canonical-layout location. - 509
/// `Core::set_provider_key` — the wizard, and the TUI's `/key` command — - 510
/// write credentials through `Core::user_env_file()`, which resolves to - 511
/// the canonical home. A desktop launch reading `.env` from the old - 512
/// dotdir could therefore never see a key saved anywhere else: every run - 513
/// failed `Core::provider()`, and with the silent-503 bug this fix's - 514
/// sibling change addresses, that failure was invisible. It also - 515
/// explains the "home migration skipped: both ... exist" warning — this - 516
/// function kept writing `desktop.json` and profile notes into the - 517
/// legacy dir, so it could never go away. - 518
fn vak_home() -> PathBuf { - 519
vak_config::paths::data_home() - 520
} - 521
- 522
fn prefs_path() -> PathBuf { - 523
vak_home().join("desktop.json") - 524
} - 525
- 526
fn desktop_prefs() -> DesktopPrefs { - 527
std::fs::read_to_string(prefs_path()) - 528
.ok() - 529
.and_then(|text| serde_json::from_str(&text).ok()) - 530
.unwrap_or_default() - 531
} - 532
- 533
fn last_project() -> Option<PathBuf> { - 534
let cwd = PathBuf::from(desktop_prefs().last_project?); - 535
cwd.is_dir().then_some(cwd) - 536
} - 537
- 538
fn startup_workspace(explicit: Option<PathBuf>, remembered: Option<PathBuf>) -> PathBuf { - 539
explicit - 540
.or(remembered) - 541
.unwrap_or_else(vak_config::paths::default_workspace) - 542
} - 543
- 544
/// The workspaces this shell offers, from the ONE store every surface - 545
/// shares (`vak_core::workspaces`). - 546
/// - 547
/// The desktop used to keep its own list in `desktop.json`, so a workspace - 548
/// removed in the browser stayed in the desktop's sidebar and vice versa — - 549
/// two lists of the same thing, guaranteed to disagree. `desktop.json` - 550
/// still holds `last_project` (which workspace to reopen at launch), which - 551
/// is genuinely this shell's own business. - 552
fn recent_workspaces() -> Vec<String> { - 553
vak_core::workspaces::visible( - 554
desktop_prefs() - 555
.recent_workspaces - 556
.into_iter() - 557
.map(PathBuf::from), - 558
) - 559
.into_iter() - 560
.map(|p| p.to_string_lossy().into_owned()) - 561
.collect() - 562
} - 563
- 564
fn save_project(cwd: &str) { - 565
// The shared store is what every surface reads; this also un-forgets a - 566
// workspace, so reopening one is how it comes back anywhere. - 567
if let Err(e) = vak_core::workspaces::remember(std::path::Path::new(cwd)) { - 568
eprintln!("warning: could not record the workspace: {e}"); - 569
} - 570
let mut prefs = desktop_prefs(); - 571
let previous = prefs.last_project.clone(); - 572
prefs.last_project = Some(cwd.to_string()); - 573
prefs - 574
.recent_workspaces - 575
.retain(|path| path != cwd && previous.as_deref() != Some(path)); - 576
prefs.recent_workspaces.insert(0, cwd.to_string()); - 577
if let Some(previous) = previous.filter(|path| path != cwd && PathBuf::from(path).is_dir()) { - 578
prefs.recent_workspaces.insert(1, previous); - 579
} - 580
prefs.recent_workspaces.truncate(8); - 581
let _ = std::fs::create_dir_all(vak_home()); - 582
if let Ok(json) = serde_json::to_string(&prefs) { - 583
let _ = std::fs::write(prefs_path(), json); - 584
} - 585
} - 586
- 587
/// Load the Shared secret scope, and the project's own only when the - 588
/// workspace is trusted. - 589
/// - 590
/// A project secret scope can inject `VAK_*_BASE_URL` and other privileged - 591
/// values, so loading it is part of the trust decision — not a consequence - 592
/// of having opened a folder (doc 46 security invariant 2). - 593
fn load_workspace_env(cwd: &std::path::Path, trusted: bool) { - 594
let user = vak_home().join(".env"); - 595
if trusted { - 596
let project = cwd.join(".env"); - 597
vak_config::replace_env_files(&[user.as_path(), project.as_path()]); - 598
} else { - 599
vak_config::replace_env_files(&[user.as_path()]); - 600
} - 601
} - 602
- 603
/// Boot the embedded agent server on an ephemeral loopback port. - 604
/// - 605
/// `trusted` is the operator's recorded decision about *this* folder, not - 606
/// an assumption drawn from having opened it. Selecting a directory used - 607
/// to imply full consent to whatever its `.vak/config.toml` asked for — - 608
/// hooks, MCP servers, a redirected provider endpoint — which is the - 609
/// hole doc 46 Step 2 exists to close. - 610
async fn boot_backend(cwd: PathBuf, trusted: bool) -> Result<Running, String> { - 611
vak_config::ensure_project_config(&cwd).map_err(|e| e.to_string())?; - 612
let core = vak_core::Core::new_with_trust(cwd.clone(), trusted) - 613
.map(|c| c.with_surface(vak_core::Surface::Desktop)) - 614
.map_err(|e| e.to_string())?; - 615
- 616
let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - 617
.await - 618
.map_err(|e| format!("bind failed: {e}"))?; - 619
let addr = listener.local_addr().map_err(|e| e.to_string())?; - 620
let (router, token) = vak_server::secured_router(core); - 621
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); - 622
- 623
let join = tauri::async_runtime::spawn(async move { - 624
let _ = axum::serve(listener, router) - 625
.with_graceful_shutdown(async move { - 626
let _ = shutdown_rx.changed().await; - 627
}) - 628
.await; - 629
}); - 630
// Keep the handle alive without blocking state access. - 631
tauri::async_runtime::spawn(async move { - 632
let _ = join.await; - 633
}); - 634
- 635
let info = BackendInfo { - 636
version: env!("CARGO_PKG_VERSION").to_string(), - 637
window_chrome: WindowChrome::default(), - 638
ready: true, - 639
base_url: Some(format!("http://{addr}")), - 640
token: Some(token), - 641
cwd: Some(cwd.to_string_lossy().into_owned()), - 642
boot_error: None, - 643
recent_workspaces: Vec::new(), - 644
}; - 645
Ok(Running { - 646
info, - 647
backend: Backend { - 648
shutdown: shutdown_tx, - 649
}, - 650
}) - 651
} - 652
- 653
fn install_backend( - 654
app: &AppHandle, - 655
state: &BackendState, - 656
mut running: Running, - 657
persist: bool, - 658
) -> BackendInfo { - 659
let cwd = running.info.cwd.clone().unwrap_or_default(); - 660
if persist { - 661
save_project(&cwd); - 662
} - 663
running.info.recent_workspaces = recent_workspaces(); - 664
let info = running.info.clone(); - 665
let previous = state - 666
.running - 667
.lock() - 668
.unwrap_or_else(std::sync::PoisonError::into_inner) - 669
.replace(running); - 670
if let Some(previous) = previous { - 671
let _ = previous.backend.shutdown.send(true); - 672
} - 673
let _ = app.emit("backend-ready", &info); - 674
info - 675
} - 676
- 677
/// Start the embedded backend for `cwd`. - 678
/// - 679
/// `trust` is the operator's answer when they have just been asked, and - 680
/// `None` when nobody is being asked — reopening a remembered project, for - 681
/// instance — in which case the decision already on record governs. - 682
/// Opening safely is the *absence* of a decision and is the default, so - 683
/// there is nothing to record for it. - 684
async fn start_project_backend( - 685
app: AppHandle, - 686
state: &BackendState, - 687
cwd: String, - 688
persist: bool, - 689
trust: Option<bool>, - 690
) -> Result<BackendInfo, String> { - 691
let path = PathBuf::from(&cwd); - 692
let path = match path.canonicalize() { - 693
Ok(path) if path.is_dir() => path, - 694
_ => { - 695
let msg = format!("not a directory: {cwd}"); - 696
set_boot_error(state, Some(msg.clone())); - 697
return Err(msg); - 698
} - 699
}; - 700
let _switch = state.switching.lock().await; - 701
let canonical = path.to_string_lossy().into_owned(); - 702
if let Some(info) = state - 703
.running - 704
.lock() - 705
.unwrap_or_else(std::sync::PoisonError::into_inner) - 706
.as_ref() - 707
.filter(|running| running.info.cwd.as_deref() == Some(canonical.as_str())) - 708
.map(|running| running.info.clone()) - 709
{ - 710
return Ok(info); - 711
} - 712
let previous_cwd = state - 713
.running - 714
.lock() - 715
.unwrap_or_else(std::sync::PoisonError::into_inner) - 716
.as_ref() - 717
.and_then(|running| running.info.cwd.clone()); - 718
if trust == Some(true) - 719
&& let Err(e) = vak_core::trust::record(&path) - 720
{ - 721
eprintln!("warning: could not record the trust decision: {e}"); - 722
} - 723
let trusted = trust.unwrap_or_else(|| vak_core::trust::is_trusted(&path)); - 724
load_workspace_env(&path, trusted); - 725
match boot_backend(path, trusted).await { - 726
Ok(running) => { - 727
let info = install_backend(&app, state, running, persist); - 728
set_boot_error(state, None); - 729
Ok(info) - 730
} - 731
Err(e) => { - 732
if let Some(previous_cwd) = previous_cwd { - 733
let previous = std::path::Path::new(&previous_cwd); - 734
load_workspace_env(previous, vak_core::trust::is_trusted(previous)); - 735
} else { - 736
vak_config::replace_env_files(&[vak_home().join(".env").as_path()]); - 737
} - 738
set_boot_error(state, Some(e.clone())); - 739
Err(e) - 740
} - 741
} - 742
} - 743
- 744
#[tauri::command] - 745
async fn start_backend( - 746
app: AppHandle, - 747
state: State<'_, BackendState>, - 748
cwd: String, - 749
trust: Option<bool>, - 750
) -> Result<BackendInfo, String> { - 751
start_project_backend(app, &state, cwd, true, trust).await - 752
} - 753
- 754
fn set_boot_error(state: &BackendState, error: Option<String>) { - 755
if let Ok(mut guard) = state.running.lock() { - 756
if error.is_some() && guard.as_ref().is_some_and(|r| r.info.ready) { - 757
return; // a live backend outranks a stale failure note - 758
} - 759
if let Some(running) = guard.as_mut() { - 760
running.info.boot_error = error; - 761
} else if let Some(err) = error { - 762
// No live backend yet: remember the failure so the gate can - 763
// render it instead of spinning forever. - 764
*guard = Some(Running { - 765
info: BackendInfo { - 766
ready: false, - 767
boot_error: Some(err), - 768
..BackendInfo::default() - 769
}, - 770
backend: Backend { - 771
shutdown: tokio::sync::watch::channel(true).0, - 772
}, - 773
}); - 774
} - 775
} - 776
} - 777
- 778
/// Persist a file the person saves (a transcript, a document) to the path - 779
/// they explicitly chose in a native save dialog. The webview has no fs - 780
/// plugin, so this is the one sanctioned write-out path. The bytes arrive as - 781
/// the raw request body; the chosen path, percent-encoded, in the - 782
/// `vak-save-path` header. - 783
#[tauri::command] - 784
async fn export_file(request: tauri::ipc::Request<'_>) -> Result<usize, String> { - 785
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { - 786
return Err("expected the file's bytes".into()); - 787
}; - 788
let path = request - 789
.headers() - 790
.get("vak-save-path") - 791
.and_then(|value| value.to_str().ok()) - 792
.and_then(percent_decode) - 793
.ok_or("expected the chosen path")?; - 794
tokio::fs::write(&path, bytes) - 795
.await - 796
.map(|_| bytes.len()) - 797
.map_err(|e| format!("could not write {path}: {e}")) - 798
} - 799
- 800
fn percent_decode(encoded: &str) -> Option<String> { - 801
let mut bytes = Vec::with_capacity(encoded.len()); - 802
let mut input = encoded.bytes(); - 803
while let Some(byte) = input.next() { - 804
if byte == b'%' { - 805
let high = (input.next()? as char).to_digit(16)?; - 806
let low = (input.next()? as char).to_digit(16)?; - 807
bytes.push(u8::try_from(high * 16 + low).ok()?); - 808
} else { - 809
bytes.push(byte); - 810
} - 811
} - 812
String::from_utf8(bytes).ok() - 813
} - 814
- 815
/// "Open with…" (docs/design/72, P4): hands a workspace document to the - 816
/// application the operating system associates with it. Scoped to a regular - 817
/// Word, Excel, PowerPoint or Visio file inside the open workspace, named - 818
/// relative to it; a macro-enabled file is refused, so Vakyartha never hands over - 819
/// a file whose macros could run. Vakyartha does not read or run the file here. - 820
#[tauri::command] - 821
fn open_workspace_file(state: State<'_, BackendState>, path: String) -> Result<(), String> { - 822
let cwd = state - 823
.running - 824
.lock() - 825
.unwrap_or_else(std::sync::PoisonError::into_inner) - 826
.as_ref() - 827
.and_then(|running| running.info.cwd.clone()) - 828
.ok_or("no workspace is open")?; - 829
let workspace = std::path::Path::new(&cwd) - 830
.canonicalize() - 831
.map_err(|e| format!("workspace unavailable: {e}"))?; - 832
let target = workspace - 833
.join(&path) - 834
.canonicalize() - 835
.map_err(|_| format!("{path} was not found"))?; - 836
if !target.starts_with(&workspace) || !target.is_file() { - 837
return Err(format!("{path} is not a file in this workspace")); - 838
} - 839
let format = target - 840
.extension() - 841
.and_then(|extension| extension.to_str()) - 842
.and_then(vak_ooxml::Format::from_extension) - 843
.ok_or_else(|| format!("{path} is not a Word, Excel, PowerPoint or Visio file"))?; - 844
if format.macro_enabled { - 845
return Err(format!( - 846
"{path} can contain macros, so Vakyartha does not open it; open it yourself if you trust it" - 847
)); - 848
} - 849
#[cfg(target_os = "macos")] - 850
let mut command = std::process::Command::new("open"); - 851
#[cfg(target_os = "windows")] - 852
let mut command = std::process::Command::new("explorer"); - 853
#[cfg(all(unix, not(target_os = "macos")))] - 854
let mut command = std::process::Command::new("xdg-open"); - 855
command - 856
.arg(&target) - 857
.spawn() - 858
.map(|_| ()) - 859
.map_err(|e| format!("could not open {path}: {e}")) - 860
} - 861
- 862
/// What a folder would ask for, before anything opens it. - 863
/// - 864
/// The same facts `POST /onboarding/workspace-review` reports, but reached - 865
/// without a server: this runs *before* a backend exists, which is exactly - 866
/// when the operator needs to decide. Reads section headers as text and - 867
/// never through the config loader — describing a project's privileged - 868
/// settings by parsing them normally would activate the very thing being - 869
/// asked about. - 870
#[derive(serde::Serialize)] - 871
struct WorkspaceReview { - 872
path: String, - 873
git: bool, - 874
requests_privilege: bool, - 875
privileges: Vec<&'static str>, - 876
trusted: bool, - 877
} - 878
- 879
#[tauri::command] - 880
fn review_workspace(cwd: String) -> WorkspaceReview { - 881
let path = std::path::PathBuf::from(&cwd); - 882
WorkspaceReview { - 883
git: path.join(".git").exists(), - 884
requests_privilege: vak_core::trust::requests_privilege(&path), - 885
privileges: vak_core::trust::requested_privileges(&path), - 886
trusted: vak_core::trust::is_trusted(&path), - 887
path: cwd, - 888
} - 889
} - 890
- 891
/// Open the admin console at a route, from the UI. - 892
/// - 893
/// Chat bots are created and credentialed there, not in desktop Settings: - 894
/// a credential belongs to a bot, and several bots can share a transport - 895
/// (AGENTS.md invariant 23), so a per-surface field here could only ever - 896
/// describe one of them. One place owns that, and this is how the desktop - 897
/// hands the operator over to it. - 898
#[tauri::command] - 899
fn open_admin(route: String) { - 900
// Route is chosen by our own UI, never by remote content; the admin - 901
// fragment is appended to a loopback URL built here. - 902
open_web_path(&format!("/admin{route}")); - 903
} - 904
- 905
#[tauri::command] - 906
fn backend_info(state: State<'_, BackendState>) -> BackendInfo { - 907
let guard = state - 908
.running - 909
.lock() - 910
.unwrap_or_else(std::sync::PoisonError::into_inner); - 911
let mut info = guard - 912
.as_ref() - 913
.map_or_else(BackendInfo::default, |running| running.info.clone()); - 914
info.recent_workspaces = recent_workspaces(); - 915
info - 916
} - 917
- 918
#[tauri::command] - 919
fn forget_workspace_desktop(cwd: String) { - 920
let path = std::path::Path::new(&cwd); - 921
let _ = vak_core::workspaces::forget(path); - 922
let mut prefs = desktop_prefs(); - 923
prefs.recent_workspaces.retain(|p| p != &cwd); - 924
if prefs.last_project.as_deref() == Some(&cwd) { - 925
prefs.last_project = prefs.recent_workspaces.first().cloned(); - 926
} - 927
let _ = std::fs::create_dir_all(vak_home()); - 928
if let Ok(json) = serde_json::to_string(&prefs) { - 929
let _ = std::fs::write(prefs_path(), json); - 930
} - 931
} - 932
- 933
#[tauri::command] - 934
fn get_desktop_autostart(app: AppHandle) -> bool { - 935
let tray = app.state::<TrayState>(); - 936
tray.autostart.load(Ordering::SeqCst) - 937
} - 938
- 939
#[tauri::command] - 940
fn set_desktop_autostart(app: AppHandle, enabled: bool) -> Result<(), String> { - 941
let tray = app.state::<TrayState>(); - 942
tray.autostart.store(enabled, Ordering::SeqCst); - 943
vak_ops::services::set_service_autostart("com.vak.desktop", enabled)?; - 944
refresh_tray(&app); - 945
Ok(()) - 946
} - 947
- 948
fn main() { - 949
// Augment GUI process PATH with canonical toolchain paths so brokers and MCP servers resolve node/python/etc. - 950
#[allow(unsafe_code)] - 951
unsafe { - 952
std::env::set_var("PATH", vak_config::paths::augmented_process_path()); - 953
} - 954
- 955
// The tray is installed before the async project/backend bootstrap. Load - 956
// the user environment synchronously so its service status and admin - 957
// links use a configured VAK_PORT from the first paint onward. - 958
vak_config::load_env_file(&vak_home().join(".env")); - 959
let internal = std::env::args_os().nth(1); - 960
#[cfg(target_os = "linux")] - 961
{ - 962
if internal.as_deref() - 963
== Some(std::ffi::OsStr::new( - 964
vak_tools::landlock::SANDBOX_SUBCOMMAND, - 965
)) - 966
{ - 967
std::process::exit(vak_tools::landlock::runner_main( - 968
std::env::args_os().skip(2), - 969
)); - 970
} - 971
} - 972
if internal.as_deref() == Some(std::ffi::OsStr::new(vak_tools::broker::WORKER_SUBCOMMAND)) { - 973
let runtime = match tokio::runtime::Builder::new_current_thread() - 974
.enable_all() - 975
.build() - 976
{ - 977
Ok(runtime) => runtime, - 978
Err(_) => std::process::exit(125), - 979
}; - 980
std::process::exit(runtime.block_on(vak_tools::broker::worker_main())); - 981
} - 982
if internal.as_deref() - 983
== Some(std::ffi::OsStr::new( - 984
vak_tools::broker::PERSISTENT_WORKER_SUBCOMMAND, - 985
)) - 986
{ - 987
let runtime = match tokio::runtime::Builder::new_current_thread() - 988
.enable_all() - 989
.build() - 990
{ - 991
Ok(runtime) => runtime, - 992
Err(_) => std::process::exit(125), - 993
}; - 994
std::process::exit(runtime.block_on(vak_tools::broker::persistent_worker_main())); - 995
} - 996
if internal.as_deref() - 997
== Some(std::ffi::OsStr::new( - 998
vak_delivery::worker::WORKER_SUBCOMMAND, - 999
)) - 1000
{
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.