1use anyhow::{anyhow, Context, Result};
27use directories::{ProjectDirs, UserDirs};
28use parking_lot::Mutex;
29use serde::{Deserialize, Serialize};
30use std::path::{Path, PathBuf};
31
32const TRACE_TARGET: &str = "studio_worker::config";
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Config {
38 pub api_base_url: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub worker_id: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub auth_token: Option<String>,
49 pub vram_threshold_gb: f32,
51 pub auto_start: bool,
53 #[serde(default = "default_start_minimised")]
58 pub start_minimised: bool,
59 #[serde(default = "default_auto_update_enabled")]
62 pub auto_update_enabled: bool,
63 #[serde(default = "default_auto_update_interval")]
65 pub auto_update_interval_secs: u64,
66 #[serde(default = "default_auto_update_feed")]
68 pub auto_update_feed: String,
69 #[serde(default)]
71 pub auto_update_prerelease: bool,
72 #[serde(default = "default_models_root_persisted")]
76 pub models_root: PathBuf,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub ws_reconnect_attempts: Option<u32>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub install_id: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub registration_request_id: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub registration_secret: Option<String>,
95}
96
97fn default_auto_update_enabled() -> bool {
98 true
99}
100fn default_start_minimised() -> bool {
101 true
102}
103fn default_auto_update_interval() -> u64 {
104 1800
105}
106fn default_auto_update_feed() -> String {
107 "https://api.github.com/repos/webbertakken/studio-worker/releases".into()
108}
109
110pub fn default_models_root() -> PathBuf {
114 models_root_from(home_dir())
115}
116
117fn home_dir() -> Option<PathBuf> {
121 UserDirs::new().map(|d| d.home_dir().to_path_buf())
122}
123
124fn models_root_from(home: Option<PathBuf>) -> PathBuf {
128 match home {
129 Some(home) => home.join("models"),
130 None => std::env::temp_dir().join("studio-worker-models"),
131 }
132}
133
134fn default_models_root_persisted() -> PathBuf {
135 default_models_root()
136}
137
138fn expand_home(path: PathBuf) -> PathBuf {
143 expand_home_with(path, home_dir())
144}
145
146fn expand_home_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
150 let s = path.to_string_lossy();
151 if s == "~" {
152 return home.unwrap_or(path);
153 }
154 if let Some(rest) = s.strip_prefix("~/") {
155 if let Some(home) = home {
156 return home.join(rest);
157 }
158 }
159 path
160}
161
162impl Default for Config {
163 fn default() -> Self {
164 Self {
165 api_base_url: "https://studio.minis.gg/".into(),
166 worker_id: None,
167 auth_token: None,
168 vram_threshold_gb: 12.0,
169 auto_start: true,
170 start_minimised: default_start_minimised(),
171 auto_update_enabled: default_auto_update_enabled(),
172 auto_update_interval_secs: default_auto_update_interval(),
173 auto_update_feed: default_auto_update_feed(),
174 auto_update_prerelease: false,
175 models_root: default_models_root(),
176 ws_reconnect_attempts: None,
177 install_id: None,
178 registration_request_id: None,
179 registration_secret: None,
180 }
181 }
182}
183
184fn default_config_path() -> Result<PathBuf> {
185 let dirs = ProjectDirs::from("gg", "minis", "minis-studio-worker")
186 .ok_or_else(|| anyhow!("cannot resolve config directory"))?;
187 Ok(dirs.config_dir().join("config.toml"))
188}
189
190pub fn default_catalog_path() -> Result<PathBuf> {
193 let dirs = ProjectDirs::from("gg", "minis", "minis-studio-worker")
194 .ok_or_else(|| anyhow!("cannot resolve config directory"))?;
195 Ok(dirs.config_dir().join("models.json"))
196}
197
198pub fn resolve_path(override_path: Option<&str>) -> Result<PathBuf> {
199 if let Some(p) = override_path {
200 Ok(PathBuf::from(p))
201 } else {
202 default_config_path()
203 }
204}
205
206pub fn load(override_path: Option<&str>) -> Result<(Config, PathBuf)> {
207 let path = resolve_path(override_path)?;
208 if !path.exists() {
209 let cfg = Config::default();
210 save(&cfg, &path)?;
211 tracing::info!(
212 target: TRACE_TARGET,
213 op = "load",
214 source = "default_created",
215 config_path = %path.display(),
216 api_base_url = %cfg.api_base_url,
217 vram_threshold_gb = cfg.vram_threshold_gb,
218 auto_start = cfg.auto_start,
219 models_root = %cfg.models_root.display(),
220 "config file missing — bootstrapped defaults"
221 );
222 return Ok((cfg, path));
223 }
224 let text = match std::fs::read_to_string(&path) {
225 Ok(text) => text,
226 Err(e) => {
227 tracing::warn!(
231 target: TRACE_TARGET,
232 op = "load",
233 config_path = %path.display(),
234 error = %e,
235 "failed to read config file"
236 );
237 return Err(e).with_context(|| format!("reading {}", path.display()));
238 }
239 };
240 let mut cfg: Config = match toml::from_str(&text) {
241 Ok(cfg) => cfg,
242 Err(e) => {
243 tracing::warn!(
249 target: TRACE_TARGET,
250 op = "load",
251 config_path = %path.display(),
252 "config file is not valid TOML"
253 );
254 return Err(e).context("parsing config.toml");
255 }
256 };
257 cfg.models_root = expand_home(std::mem::take(&mut cfg.models_root));
258 tracing::debug!(
259 target: TRACE_TARGET,
260 op = "load",
261 source = "existing_file",
262 config_path = %path.display(),
263 api_base_url = %cfg.api_base_url,
264 vram_threshold_gb = cfg.vram_threshold_gb,
265 auto_start = cfg.auto_start,
266 models_root = %cfg.models_root.display(),
267 worker_id = cfg.worker_id.as_deref().unwrap_or("(unregistered)"),
268 has_auth_token = cfg.auth_token.is_some(),
269 "loaded config from disk"
270 );
271 Ok((cfg, path))
272}
273
274pub fn save(cfg: &Config, path: &Path) -> Result<()> {
275 match write_config(cfg, path) {
276 Ok(bytes) => {
277 tracing::debug!(
278 target: TRACE_TARGET,
279 op = "save",
280 config_path = %path.display(),
281 vram_threshold_gb = cfg.vram_threshold_gb,
282 auto_start = cfg.auto_start,
283 models_root = %cfg.models_root.display(),
284 bytes = bytes,
285 "persisted config to disk"
286 );
287 Ok(())
288 }
289 Err(e) => {
290 tracing::warn!(
297 target: TRACE_TARGET,
298 op = "save",
299 config_path = %path.display(),
300 error = %e,
301 "failed to persist config to disk"
302 );
303 Err(e)
304 }
305 }
306}
307
308fn write_config(cfg: &Config, path: &Path) -> Result<usize> {
313 if let Some(parent) = path.parent() {
314 std::fs::create_dir_all(parent)
315 .with_context(|| format!("creating {}", parent.display()))?;
316 }
317 let text = toml::to_string_pretty(cfg).with_context(|| "serialising config")?;
318 let bytes = text.len();
319 write_atomic(path, text.as_bytes())?;
320 Ok(bytes)
321}
322
323fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
340 use std::io::Write as _;
341 let dir = match path.parent() {
342 Some(p) if !p.as_os_str().is_empty() => p,
343 _ => Path::new("."),
344 };
345 let mut tmp = tempfile::NamedTempFile::new_in(dir)
346 .with_context(|| format!("creating temp file in {}", dir.display()))?;
347 tmp.write_all(bytes)
348 .with_context(|| "writing temp config")?;
349 tmp.as_file()
350 .sync_all()
351 .with_context(|| "flushing temp config to disk")?;
352 tmp.persist(path)
353 .map_err(|e| anyhow!("atomically replacing {}: {}", path.display(), e.error))?;
354 Ok(())
355}
356
357pub type SharedConfig = std::sync::Arc<Mutex<Config>>;
359
360pub fn shared(cfg: Config) -> SharedConfig {
361 std::sync::Arc::new(Mutex::new(cfg))
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use tempfile::tempdir;
368
369 #[test]
370 fn start_minimised_defaults_true_for_configs_predating_the_field() {
371 let cfg: Config = toml::from_str(
374 r#"
375 api_base_url = "https://studio.minis.gg/"
376 vram_threshold_gb = 12.0
377 auto_start = true
378 "#,
379 )
380 .unwrap();
381 assert!(cfg.start_minimised);
382 }
383
384 #[test]
385 fn default_values_are_sensible() {
386 let cfg = Config::default();
387 assert_eq!(cfg.api_base_url, "https://studio.minis.gg/");
388 assert!(cfg.auto_start);
389 assert!(
390 cfg.start_minimised,
391 "the UI must start minimised by default"
392 );
393 assert!(cfg.auto_update_enabled);
394 assert_eq!(cfg.auto_update_interval_secs, 1800);
395 assert!(!cfg.auto_update_prerelease);
396 assert!(cfg.auto_update_feed.contains("webbertakken/studio-worker"));
397 assert_eq!(cfg.vram_threshold_gb, 12.0);
398 assert!(cfg.worker_id.is_none());
399 assert!(cfg.auth_token.is_none());
400 let m = cfg.models_root.to_string_lossy().to_string();
403 assert!(m.ends_with("models") || m.contains("studio-worker-models"));
404 }
405
406 #[test]
407 fn resolve_path_uses_override_when_provided() {
408 let path = resolve_path(Some("/tmp/test-config.toml")).unwrap();
409 assert_eq!(path, PathBuf::from("/tmp/test-config.toml"));
410 }
411
412 #[test]
413 fn resolve_path_defaults_when_no_override() {
414 let path = resolve_path(None).unwrap();
415 let s = path.to_string_lossy();
416 assert!(
417 s.contains("minis-studio-worker") || s.contains("minis.gg.minis-studio-worker"),
418 "unexpected default path: {s}"
419 );
420 assert!(s.ends_with("config.toml"));
421 }
422
423 #[test]
424 fn load_creates_default_when_file_missing() {
425 let dir = tempdir().unwrap();
426 let path = dir.path().join("sub").join("config.toml");
427 let path_str = path.to_string_lossy().to_string();
428 let (cfg, returned_path) = load(Some(&path_str)).unwrap();
429 assert_eq!(returned_path, path);
430 assert_eq!(cfg.api_base_url, "https://studio.minis.gg/");
431 assert!(path.exists());
433 }
434
435 #[test]
436 fn round_trip_via_save_and_load_preserves_fields() {
437 let dir = tempdir().unwrap();
438 let path = dir.path().join("config.toml");
439 let cfg = Config {
440 worker_id: Some("w-123".into()),
441 auth_token: Some("tok-xyz".into()),
442 vram_threshold_gb: 24.0,
443 auto_update_prerelease: true,
444 models_root: PathBuf::from("/tmp/test-models"),
445 ..Config::default()
446 };
447 save(&cfg, &path).unwrap();
448
449 let path_str = path.to_string_lossy().to_string();
450 let (loaded, _) = load(Some(&path_str)).unwrap();
451 assert_eq!(loaded.api_base_url, cfg.api_base_url);
452 assert_eq!(loaded.worker_id, cfg.worker_id);
453 assert_eq!(loaded.auth_token, cfg.auth_token);
454 assert_eq!(loaded.vram_threshold_gb, cfg.vram_threshold_gb);
455 assert_eq!(loaded.auto_update_prerelease, cfg.auto_update_prerelease);
456 assert_eq!(loaded.models_root, cfg.models_root);
457 }
458
459 #[test]
460 fn shared_wraps_in_arc_mutex() {
461 let cfg = Config::default();
462 let shared = shared(cfg.clone());
463 let guard = shared.lock();
464 assert_eq!(guard.api_base_url, cfg.api_base_url);
465 }
466
467 #[test]
468 fn load_returns_error_on_malformed_toml() {
469 let dir = tempdir().unwrap();
470 let path = dir.path().join("config.toml");
471 std::fs::write(&path, "this :: is = not = toml = :").unwrap();
472 let path_str = path.to_string_lossy().to_string();
473 let err = load(Some(&path_str)).unwrap_err();
474 assert!(err.to_string().contains("parsing config.toml"));
475 }
476
477 #[test]
478 fn load_strips_legacy_engine_fields_silently() {
479 let dir = tempdir().unwrap();
483 let path = dir.path().join("config.toml");
484 let legacy = r#"
485 api_base_url = "https://example.invalid"
486 vram_threshold_gb = 8.0
487 auto_start = true
488 engine = "multi"
489 engines = ["llama", "synthetic"]
490 auto_enabled = false
491 label = "alice's rig"
492 "#;
493 std::fs::write(&path, legacy).unwrap();
494 let (cfg, _) = load(Some(&path.to_string_lossy())).unwrap();
495 assert_eq!(cfg.api_base_url, "https://example.invalid");
496 assert_eq!(cfg.vram_threshold_gb, 8.0);
497 }
498
499 #[test]
500 fn load_expands_leading_tilde_in_models_root() {
501 let dir = tempdir().unwrap();
504 let path = dir.path().join("config.toml");
505 let raw = r#"
506 api_base_url = "https://x.invalid"
507 vram_threshold_gb = 4.0
508 auto_start = true
509 auto_update_enabled = false
510 auto_update_interval_secs = 1
511 auto_update_feed = "https://x.invalid"
512 auto_update_prerelease = false
513 models_root = "~/models-test"
514 "#;
515 std::fs::write(&path, raw).unwrap();
516 let (cfg, _) = load(Some(&path.to_string_lossy())).unwrap();
517 assert!(
518 cfg.models_root.is_absolute(),
519 "~/ should expand to an absolute path, got {}",
520 cfg.models_root.display()
521 );
522 assert!(cfg.models_root.ends_with("models-test"));
523 }
524
525 #[test]
526 fn expand_home_leaves_absolute_paths_alone() {
527 let p = PathBuf::from("/tmp/anywhere");
528 assert_eq!(expand_home(p.clone()), p);
529 }
530
531 #[test]
532 fn expand_home_handles_bare_tilde() {
533 let expanded = expand_home(PathBuf::from("~"));
534 assert!(
535 expanded.is_absolute() || expanded == Path::new("~"),
536 "bare ~ expands to home (or stays put on weird boxes), got {}",
537 expanded.display()
538 );
539 }
540
541 #[test]
547 fn models_root_from_uses_home_when_available() {
548 let home = PathBuf::from("/home/someuser");
549 assert_eq!(models_root_from(Some(home.clone())), home.join("models"));
550 }
551
552 #[test]
553 fn models_root_from_falls_back_to_tmp_without_home() {
554 assert_eq!(
555 models_root_from(None),
556 std::env::temp_dir().join("studio-worker-models")
557 );
558 }
559
560 #[test]
561 fn expand_home_with_bare_tilde_uses_injected_home() {
562 let home = PathBuf::from("/home/x");
563 assert_eq!(
564 expand_home_with(PathBuf::from("~"), Some(home.clone())),
565 home
566 );
567 }
568
569 #[test]
570 fn expand_home_with_bare_tilde_without_home_stays_put() {
571 assert_eq!(
572 expand_home_with(PathBuf::from("~"), None),
573 PathBuf::from("~")
574 );
575 }
576
577 #[test]
578 fn expand_home_with_prefix_joins_injected_home() {
579 let home = PathBuf::from("/home/x");
580 assert_eq!(
581 expand_home_with(PathBuf::from("~/models"), Some(home.clone())),
582 home.join("models")
583 );
584 }
585
586 #[test]
587 fn expand_home_with_prefix_without_home_stays_unexpanded() {
588 let p = PathBuf::from("~/models");
589 assert_eq!(expand_home_with(p.clone(), None), p);
590 }
591
592 #[test]
593 fn expand_home_with_leaves_absolute_paths_alone() {
594 let p = PathBuf::from("/tmp/anywhere");
595 assert_eq!(
596 expand_home_with(p.clone(), Some(PathBuf::from("/home/x"))),
597 p
598 );
599 }
600
601 #[cfg(unix)]
602 #[test]
603 fn save_writes_config_owner_only_because_it_holds_secrets() {
604 use std::os::unix::fs::PermissionsExt;
610 let dir = tempdir().unwrap();
611 let path = dir.path().join("config.toml");
612 let cfg = Config {
613 auth_token: Some("super-secret-token".into()),
614 registration_secret: Some("reg-secret".into()),
615 ..Config::default()
616 };
617 save(&cfg, &path).unwrap();
618 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
619 assert_eq!(
620 mode & 0o077,
621 0,
622 "secrets-bearing config must not be group/world-accessible; got mode {mode:o}"
623 );
624 }
625
626 #[test]
627 fn save_atomically_replaces_existing_config_without_temp_litter() {
628 let dir = tempdir().unwrap();
632 let path = dir.path().join("config.toml");
633
634 let big = Config {
635 api_base_url: "https://a-very-long-host-name.example.invalid/studio/".into(),
636 worker_id: Some("worker-with-a-longish-id-000000".into()),
637 ..Config::default()
638 };
639 save(&big, &path).unwrap();
640
641 let small = Config {
642 api_base_url: "https://x/".into(),
643 ..Config::default()
644 };
645 save(&small, &path).unwrap();
646
647 let (loaded, _) = load(Some(&path.to_string_lossy())).unwrap();
648 assert_eq!(loaded.api_base_url, "https://x/");
649 assert!(
650 loaded.worker_id.is_none(),
651 "a replacing save must not leave the previous worker_id behind"
652 );
653
654 let names: Vec<String> = std::fs::read_dir(dir.path())
655 .unwrap()
656 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
657 .collect();
658 assert_eq!(
659 names,
660 vec!["config.toml".to_string()],
661 "atomic save must leave only the target file, found: {names:?}"
662 );
663 }
664}