1use crate::server::{ServerConfig, WorkspaceInit};
2use crate::workspace::{generate_token, PersistHook, WorkspaceFlags, WorkspaceRegistry};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock};
6
7#[cfg(unix)]
11fn restrict_user_only(path: &Path) {
12 use std::os::unix::fs::PermissionsExt;
13 match std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
14 Ok(()) => tracing::debug!(path = %path.display(), "settings.json chmod 0600 applied"),
15 Err(e) => tracing::debug!(
16 path = %path.display(),
17 "settings.json chmod 0600 failed: {e} — proceeding"
18 ),
19 }
20}
21
22#[cfg(not(unix))]
27fn restrict_user_only(_path: &Path) {}
28
29fn warn_sensitive_secret_persisted_once(path: &Path) {
34 static WARNED: OnceLock<()> = OnceLock::new();
35 WARNED.get_or_init(|| {
36 tracing::warn!(
37 path = %path.display(),
38 "chat provider api_key persisted in plaintext at this path; \
39 restrict access to your user account only"
40 );
41 });
42}
43
44fn default_true() -> bool {
45 true
46}
47fn default_stable() -> String {
48 "stable".to_string()
49}
50fn default_auto() -> String {
51 "auto".to_string()
52}
53fn default_in_page() -> String {
54 "in_page".to_string()
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum PortMode {
60 Auto,
61 #[default]
62 Spec,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct WorkspaceSettings {
67 pub path: String,
68 #[serde(flatten, default)]
69 pub flags: WorkspaceFlags,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct ChatProviderSettings {
76 #[serde(default)]
77 pub api_key: String,
78 #[serde(default)]
80 pub model: String,
81 #[serde(default)]
83 pub base_url: String,
84 #[serde(default)]
88 pub models: Vec<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ChatSettings {
96 #[serde(default = "default_provider")]
98 pub provider: String,
99 #[serde(default)]
100 pub anthropic: ChatProviderSettings,
101 #[serde(default)]
102 pub openai: ChatProviderSettings,
103}
104
105fn default_provider() -> String {
106 "anthropic".to_string()
107}
108
109impl Default for ChatSettings {
110 fn default() -> Self {
111 Self {
112 provider: default_provider(),
113 anthropic: ChatProviderSettings::default(),
114 openai: ChatProviderSettings::default(),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(default)]
121pub struct AppSettings {
122 pub port_mode: PortMode,
123 pub port: u16,
124 pub host: String,
125 pub theme: String,
126 #[serde(default = "default_auto")]
127 pub language: String,
128 #[serde(default = "default_auto")]
129 pub web_theme: String,
130 #[serde(default = "default_auto")]
131 pub web_language: String,
132 pub db_path: Option<String>,
133 #[serde(default)]
138 pub salt: String,
139 pub workspaces: Vec<WorkspaceSettings>,
140 #[serde(default = "default_true")]
141 pub tray_resident: bool,
142 #[serde(default = "default_true")]
143 pub default_search: bool,
144 #[serde(default = "default_true")]
145 pub default_viewed: bool,
146 #[serde(default)]
147 pub default_live: bool,
148 #[serde(default)]
149 pub default_edit: bool,
150 #[serde(default)]
151 pub default_chat: bool,
152 #[serde(default = "default_in_page")]
160 pub default_chat_mode: String,
161 #[serde(default)]
162 pub default_shared_annotation: bool,
163 #[serde(default)]
167 pub print_collapsed_content: bool,
168 #[serde(default)]
169 pub chat: ChatSettings,
170 #[serde(default)]
171 pub web_styles: std::collections::HashMap<String, String>,
172 #[serde(default)]
173 pub shortcuts: std::collections::HashMap<String, serde_json::Value>,
174 #[serde(default = "default_true")]
175 pub auto_update: bool,
176 #[serde(default = "default_stable")]
177 pub update_channel: String,
178 #[serde(default)]
179 pub window_width: Option<u32>,
180 #[serde(default)]
181 pub window_height: Option<u32>,
182}
183
184impl Default for AppSettings {
185 fn default() -> Self {
186 Self {
187 port_mode: PortMode::Spec,
188 port: 6419,
189 host: "127.0.0.1".to_string(),
190 theme: "auto".to_string(),
191 language: "auto".to_string(),
192 web_theme: "auto".to_string(),
193 web_language: "auto".to_string(),
194 db_path: None,
195 salt: String::new(),
196 workspaces: vec![],
197 tray_resident: true,
198 default_search: true,
199 default_viewed: true,
200 default_live: false,
201 default_edit: false,
202 default_chat: false,
203 default_chat_mode: default_in_page(),
204 default_shared_annotation: false,
205 print_collapsed_content: false,
206 chat: ChatSettings::default(),
207 web_styles: std::collections::HashMap::new(),
208 shortcuts: std::collections::HashMap::new(),
209 auto_update: true,
210 update_channel: "stable".to_string(),
211 window_width: None,
212 window_height: None,
213 }
214 }
215}
216
217impl AppSettings {
218 pub(crate) fn settings_path_at(home: &Path) -> PathBuf {
221 home.join(".markon").join("settings.json")
222 }
223
224 #[allow(dead_code)]
225 pub(crate) fn settings_path() -> PathBuf {
226 let home = dirs::home_dir().expect("HOME directory required");
227 Self::settings_path_at(&home)
228 }
229
230 pub(crate) fn load_at(home: &Path) -> Self {
233 let p = Self::settings_path_at(home);
234 let mut s = if let Ok(c) = std::fs::read_to_string(&p) {
235 serde_json::from_str::<Self>(&c).unwrap_or_default()
236 } else {
237 Self::default()
238 };
239 s.normalize();
240 if s.salt.is_empty() {
241 s.salt = generate_token();
242 let _ = s.save_at(home);
243 }
244 s
245 }
246
247 pub fn load() -> Self {
248 let home = dirs::home_dir().expect("HOME directory required");
249 Self::load_at(&home)
250 }
251
252 fn normalize(&mut self) {
258 use std::collections::HashSet;
259 let mut seen = HashSet::new();
260 self.workspaces.retain(|w| seen.insert(w.path.clone()));
261 for field in [
262 &mut self.language,
263 &mut self.web_theme,
264 &mut self.web_language,
265 ] {
266 if field.is_empty() {
267 *field = "auto".to_string();
268 }
269 }
270 if self.default_chat_mode != "popout" {
271 self.default_chat_mode = "in_page".to_string();
272 }
273 }
274 pub(crate) fn save_at(&self, home: &Path) -> Result<(), String> {
275 let p = Self::settings_path_at(home);
276 if let Some(parent) = p.parent() {
277 let _ = std::fs::create_dir_all(parent);
278 }
279 let c = serde_json::to_string_pretty(self).unwrap();
280 std::fs::write(&p, c).map_err(|e| e.to_string())?;
281 restrict_user_only(&p);
287 if self.has_sensitive_provider_secret() {
288 warn_sensitive_secret_persisted_once(&p);
289 }
290 Ok(())
291 }
292
293 fn has_sensitive_provider_secret(&self) -> bool {
297 !self.chat.anthropic.api_key.trim().is_empty()
298 || !self.chat.openai.api_key.trim().is_empty()
299 }
300
301 pub fn save(&self) -> Result<(), String> {
302 let home = dirs::home_dir().expect("HOME directory required");
303 self.save_at(&home)
304 }
305 pub fn to_server_config(&self, port: u16) -> ServerConfig {
306 let initial_workspaces: Vec<WorkspaceInit> = self
307 .workspaces
308 .iter()
309 .filter(|w| !w.path.is_empty())
310 .map(|w| WorkspaceInit {
311 path: PathBuf::from(&w.path),
312 flags: w.flags,
313 initial_path: None,
314 })
315 .collect();
316 ServerConfig {
317 host: self.host.clone(),
318 port,
319 theme: if self.web_theme == "auto" {
320 self.theme.clone()
321 } else {
322 self.web_theme.clone()
323 },
324 qr: None,
325 open_browser: None,
326 shared_annotation: initial_workspaces.iter().any(|w| w.flags.shared_annotation),
327 salt: Some(self.salt.clone()),
328 initial_workspaces,
329 bound_listener: None,
330 registry: None,
331 management_token: None,
332 language: if self.web_language == "auto" {
333 Some(self.language.clone())
334 } else {
335 Some(self.web_language.clone())
336 },
337 styles_css: self.render_styles_css(),
338 shortcuts_json: self.render_shortcuts_json(),
339 default_chat_mode: self.default_chat_mode.clone(),
340 print_collapsed_content: self.print_collapsed_content,
341 }
342 }
343 pub fn effective_web_language(&self) -> Option<String> {
344 let l = if self.web_language == "auto" {
345 &self.language
346 } else {
347 &self.web_language
348 };
349 if l == "auto" || l.is_empty() {
350 None
351 } else {
352 Some(l.clone())
353 }
354 }
355 pub fn persist_hook(settings: Arc<Mutex<AppSettings>>) -> PersistHook {
360 Arc::new(move |reg| {
361 let mut s = settings.lock().unwrap();
362 s.sync_from_registry(reg);
363 let _ = s.save();
364 })
365 }
366
367 pub fn render_shortcuts_json(&self) -> Option<String> {
368 if self.shortcuts.is_empty() {
369 None
370 } else {
371 serde_json::to_string(&self.shortcuts).ok()
372 }
373 }
374 pub(crate) fn sync_from_registry(&mut self, registry: &WorkspaceRegistry) {
378 self.workspaces = registry
379 .info_list()
380 .into_iter()
381 .filter(|info| !info.ephemeral)
382 .map(|info| WorkspaceSettings {
383 path: info.path,
384 flags: info.flags,
385 })
386 .collect();
387 }
388
389 pub fn render_styles_css(&self) -> Option<String> {
411 if self.web_styles.is_empty() {
412 return None;
413 }
414 let mut root: Vec<String> = Vec::new();
415 let mut dark: Vec<String> = Vec::new();
416 for (k, v) in &self.web_styles {
417 if let Some(base) = k.strip_suffix(".light") {
418 root.push(format!("--markon-{base}: {v};"));
419 } else if let Some(base) = k.strip_suffix(".dark") {
420 dark.push(format!("--markon-{base}: {v};"));
421 } else {
422 root.push(format!("--markon-{k}: {v};"));
426 }
427 }
428 if root.is_empty() && dark.is_empty() {
429 return None;
430 }
431 let mut out = String::new();
432 if !root.is_empty() {
433 out.push_str(":root { ");
434 out.push_str(&root.join(" "));
435 out.push_str(" }");
436 }
437 if !dark.is_empty() {
438 if !out.is_empty() {
439 out.push(' ');
440 }
441 out.push_str("html[data-theme=\"dark\"] { ");
442 out.push_str(&dark.join(" "));
443 out.push_str(" }");
444 }
445 Some(out)
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::workspace::WorkspaceConfig;
453
454 struct TempHome(PathBuf);
457 impl TempHome {
458 fn new(label: &str) -> Self {
459 let base = std::env::temp_dir().join(format!(
460 "markon-test-{}-{}",
461 label,
462 uuid::Uuid::new_v4().simple()
463 ));
464 std::fs::create_dir_all(&base).expect("create tempdir");
465 TempHome(base)
466 }
467 fn path(&self) -> &Path {
468 &self.0
469 }
470 }
471 impl Drop for TempHome {
472 fn drop(&mut self) {
473 let _ = std::fs::remove_dir_all(&self.0);
474 }
475 }
476
477 #[test]
480 fn load_generates_salt_and_persists_it() {
481 let home = TempHome::new("salt");
482 let s1 = AppSettings::load_at(home.path());
483 assert!(!s1.salt.is_empty());
484
485 assert!(AppSettings::settings_path_at(home.path()).exists());
486
487 let s2 = AppSettings::load_at(home.path());
488 assert_eq!(s1.salt, s2.salt, "salt must be stable across load() calls");
489 }
490
491 #[test]
492 fn load_missing_file_returns_default() {
493 let home = TempHome::new("missing");
494 let s = AppSettings::load_at(home.path());
495 let d = AppSettings::default();
496 assert_eq!(s.port, d.port);
497 assert_eq!(s.host, d.host);
498 assert_eq!(s.language, "auto");
499 assert_eq!(s.web_theme, "auto");
500 assert_eq!(s.default_chat_mode, "in_page");
501 }
502
503 #[cfg(unix)]
504 #[test]
505 fn save_chmods_settings_file_to_0600() {
506 use std::os::unix::fs::PermissionsExt;
507 let home = TempHome::new("chmod");
508 let mut s = AppSettings::load_at(home.path());
509 s.chat.anthropic.api_key = "sk-ant-test-key-1234567890".to_string();
510 s.save_at(home.path()).expect("save");
511 let p = AppSettings::settings_path_at(home.path());
512 let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
513 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
514 }
515
516 #[test]
517 fn load_corrupt_file_returns_default() {
518 let home = TempHome::new("corrupt");
519 let p = AppSettings::settings_path_at(home.path());
520 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
521 std::fs::write(&p, b"{garbage").unwrap();
522
523 let s = AppSettings::load_at(home.path());
524 assert_eq!(s.port, AppSettings::default().port);
525 assert_eq!(s.default_chat_mode, "in_page");
526 }
527
528 #[test]
529 fn normalize_dedup_and_coerce() {
530 let mut s = AppSettings {
531 workspaces: vec![
532 WorkspaceSettings {
533 path: "/a".to_string(),
534 flags: WorkspaceFlags::default(),
535 },
536 WorkspaceSettings {
537 path: "/b".to_string(),
538 flags: WorkspaceFlags::default(),
539 },
540 WorkspaceSettings {
541 path: "/a".to_string(),
542 flags: WorkspaceFlags::default(),
543 },
544 ],
545 language: String::new(),
546 web_theme: String::new(),
547 web_language: String::new(),
548 default_chat_mode: "sidebar".to_string(),
549 ..AppSettings::default()
550 };
551
552 s.normalize();
553
554 assert_eq!(s.workspaces.len(), 2);
555 assert_eq!(s.workspaces[0].path, "/a");
556 assert_eq!(s.workspaces[1].path, "/b");
557 assert_eq!(s.language, "auto");
558 assert_eq!(s.web_theme, "auto");
559 assert_eq!(s.web_language, "auto");
560 assert_eq!(s.default_chat_mode, "in_page");
561
562 s.default_chat_mode = "popout".to_string();
563 s.normalize();
564 assert_eq!(s.default_chat_mode, "popout");
565 }
566
567 #[test]
568 fn sync_from_registry_skips_ephemeral() {
569 let reg = WorkspaceRegistry::new("testsalt".to_string());
570 let home = TempHome::new("reg");
571 let ws_path = home.path().to_path_buf();
572
573 reg.add(WorkspaceConfig {
574 path: ws_path.clone(),
575 flags: WorkspaceFlags::default(),
576 single_file: None,
577 });
578 reg.add(WorkspaceConfig {
579 path: ws_path.clone(),
580 flags: WorkspaceFlags::default(),
581 single_file: Some("note.md".to_string()),
582 });
583
584 let mut s = AppSettings::default();
585 s.sync_from_registry(®);
586
587 assert_eq!(s.workspaces.len(), 1, "ephemeral entry must be excluded");
588 assert_eq!(s.workspaces[0].path, ws_path.to_string_lossy());
589 }
590
591 fn settings_with_styles(pairs: &[(&str, &str)]) -> AppSettings {
593 let mut s = AppSettings::default();
594 for (k, v) in pairs {
595 s.web_styles.insert((*k).to_string(), (*v).to_string());
596 }
597 s
598 }
599
600 #[test]
601 fn render_styles_css_empty_returns_none() {
602 let s = AppSettings::default();
603 assert!(s.web_styles.is_empty());
604 assert!(s.render_styles_css().is_none());
605 }
606
607 #[test]
608 fn render_styles_css_light_only_emits_root_block() {
609 let s = settings_with_styles(&[("primary.light", "#0969da"), ("muted.light", "#656d76")]);
610 let css = s.render_styles_css().expect("should render");
611 assert!(css.starts_with(":root { "), "got: {css}");
612 assert!(css.contains("--markon-primary: #0969da;"), "got: {css}");
613 assert!(css.contains("--markon-muted: #656d76;"), "got: {css}");
614 assert!(
615 !css.contains("html[data-theme=\"dark\"]"),
616 "dark block must be absent when no dark keys: {css}"
617 );
618 assert!(!css.contains("primary.light:"), "leaked dotted key: {css}");
620 assert!(css.contains("--markon-"), "must carry token prefix: {css}");
621 }
622
623 #[test]
624 fn render_styles_css_dark_only_emits_dark_block_only() {
625 let s = settings_with_styles(&[("primary.dark", "#58a6ff")]);
626 let css = s.render_styles_css().expect("should render");
627 assert!(
628 !css.contains(":root {"),
629 "no :root block when no light/single-value keys: {css}"
630 );
631 assert!(
632 css.contains("html[data-theme=\"dark\"] { --markon-primary: #58a6ff; }"),
633 "got: {css}"
634 );
635 assert!(!css.contains("primary.dark:"), "leaked dotted key: {css}");
636 assert!(css.contains("--markon-"), "must carry token prefix: {css}");
637 }
638
639 #[test]
640 fn render_styles_css_mixed_routes_keys_correctly() {
641 let s = settings_with_styles(&[
642 ("primary.light", "#0969da"),
643 ("primary.dark", "#58a6ff"),
644 ("muted.light", "#656d76"),
645 ("ui-font", "Inter"),
646 ("ui-font-size", "0.95"),
647 ("panel-opacity", "0.85"),
648 ]);
649 let css = s.render_styles_css().expect("should render");
650
651 let root_idx = css.find(":root {").expect("root block present");
653 let dark_idx = css
654 .find("html[data-theme=\"dark\"] {")
655 .expect("dark block present");
656 assert!(
657 root_idx < dark_idx,
658 "selector block order must be :root before dark: {css}"
659 );
660
661 let root_block = &css[root_idx..dark_idx];
664 assert!(
665 root_block.contains("--markon-primary: #0969da;"),
666 "got: {root_block}"
667 );
668 assert!(
669 root_block.contains("--markon-muted: #656d76;"),
670 "got: {root_block}"
671 );
672 assert!(
673 root_block.contains("--markon-ui-font: Inter;"),
674 "got: {root_block}"
675 );
676 assert!(
677 root_block.contains("--markon-ui-font-size: 0.95;"),
678 "got: {root_block}"
679 );
680 assert!(
681 root_block.contains("--markon-panel-opacity: 0.85;"),
682 "got: {root_block}"
683 );
684
685 let dark_block = &css[dark_idx..];
688 assert!(
689 dark_block.contains("--markon-primary: #58a6ff;"),
690 "got: {dark_block}"
691 );
692 assert!(
693 !dark_block.contains("--markon-ui-font"),
694 "single-value token leaked into dark block: {dark_block}"
695 );
696 assert!(
697 !dark_block.contains("--markon-panel-opacity"),
698 "single-value token leaked into dark block: {dark_block}"
699 );
700
701 assert!(!css.contains("primary.light:"), "leaked dotted key: {css}");
703 assert!(!css.contains("primary.dark:"), "leaked dotted key: {css}");
704 assert!(
705 !css.contains(" ui-font:") && !css.starts_with("ui-font:"),
706 "bare (un-prefixed) property leaked: {css}"
707 );
708 assert!(css.contains("--markon-"), "must carry token prefix: {css}");
709 }
710
711 #[test]
712 fn render_styles_css_unknown_suffix_falls_back_to_root() {
713 let s = settings_with_styles(&[("primary.weird", "#abcdef")]);
716 let css = s.render_styles_css().expect("should render");
717 assert!(
718 css.contains(":root { --markon-primary.weird: #abcdef; }"),
719 "got: {css}"
720 );
721 assert!(!css.contains("html[data-theme=\"dark\"]"), "got: {css}");
722 }
723
724 #[test]
725 fn to_server_config_propagates_salt_and_workspaces() {
726 let s = AppSettings {
727 salt: "mysalt".to_string(),
728 workspaces: vec![WorkspaceSettings {
729 path: "/docs".to_string(),
730 flags: WorkspaceFlags::default(),
731 }],
732 ..AppSettings::default()
733 };
734
735 let cfg = s.to_server_config(7777);
736
737 assert_eq!(cfg.salt, Some("mysalt".to_string()));
738 assert_eq!(cfg.port, 7777);
739 assert_eq!(cfg.initial_workspaces.len(), 1);
740 assert_eq!(cfg.initial_workspaces[0].path, Path::new("/docs"));
741 }
742}