diff --git a/openvtc-core/src/diagnostics.rs b/openvtc-core/src/diagnostics.rs index 032db0e..ee47f0b 100644 --- a/openvtc-core/src/diagnostics.rs +++ b/openvtc-core/src/diagnostics.rs @@ -87,11 +87,21 @@ impl Diagnosis { /// /// Best-effort: a profile whose directory is unwritable is already having a /// bad day, and failing to save the explanation must not replace the - /// explanation. Overwrites any previous report — the current failure is the - /// one being asked about. + /// explanation. Overwrites any previous report *for this profile* — the + /// current failure is the one being asked about. + /// + /// The filename carries the profile (`last-startup-failure-{profile}.txt`, + /// unsuffixed for `default`) because the profile directory is shared across + /// profiles; a fixed name would let one profile's crash report overwrite + /// another's. #[must_use] pub fn write_report(&self, profile: &str) -> Option { - let path = profile_dir(profile).ok()?.join("last-startup-failure.txt"); + let name = if profile.is_empty() || profile == "default" { + "last-startup-failure.txt".to_string() + } else { + format!("last-startup-failure-{profile}.txt") + }; + let path = profile_dir(profile).ok()?.join(name); std::fs::write(&path, self.render_plain()).ok()?; Some(path) } diff --git a/openvtc/src/main.rs b/openvtc/src/main.rs index d201a6f..226878a 100644 --- a/openvtc/src/main.rs +++ b/openvtc/src/main.rs @@ -365,6 +365,62 @@ fn open_debug_log(path: &std::path::Path) -> std::io::Result { Ok(file) } +/// Resolve the config profile from the `--profile` flag and the +/// `OPENVTC_CONFIG_PROFILE` env var (the env wins, with a warning on a +/// mismatch), then validate it. +/// +/// The name is interpolated into lock-file, config and theme paths and used as +/// the OS keyring account, so path separators and traversal are rejected before +/// it reaches the filesystem. Factored out of `main` so it can run *before* the +/// theme is chosen — the theme choice is scoped to the profile, so the profile +/// has to be known first. +fn resolve_profile(matches: &clap::ArgMatches) -> Result { + let cli_profile = matches + .get_one::("profile") + .cloned() + .unwrap_or_else(|| "default".to_string()); + + let profile = if let Ok(env_profile) = env::var("OPENVTC_CONFIG_PROFILE") { + // ENV Profile will override the CLI Argument + if cli_profile != "default" && cli_profile != env_profile { + println!("{}", + style("WARNING: Using both ENV OPENVTC_CONFIG_PROFILE and CLI profile! These do not match!").themed(CLI_CAUTION) + ); + println!( + "{} {}", + style("WARNING: Using CLI Profile:").themed(CLI_CAUTION), + style(&cli_profile).themed(CLI_EXAMPLE) + ); + cli_profile + } else { + println!( + "{}{}{}", + style("Using profile (").themed(CLI_INFO), + style(&env_profile).themed(CLI_EXAMPLE), + style(") from OPENVTC_CONFIG_PROFILE ENV variable").themed(CLI_INFO) + ); + env_profile + } + } else { + cli_profile + }; + + if profile.is_empty() + || !profile + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + || profile.contains("..") + { + eprintln!( + "{} {}", + style("ERROR: Invalid profile name:").themed(CLI_ERROR), + style(&profile).themed(CLI_CAUTION) + ); + bail!("Profile name may only contain [A-Za-z0-9._-] and must not contain '..'"); + } + Ok(profile) +} + // **************************************************************************** // MAIN Function // **************************************************************************** @@ -420,21 +476,21 @@ async fn main() -> Result<()> { // the unlock-code passed into `load_fast`). Unknown subcommands and // `--help`/`--version` are handled here by clap (process exits). let matches = cli().get_matches(); - // The theme colours everything printed from here on — prompts and errors - // as well as the TUI — so it is chosen first. Under `auto` this asks the - // terminal for its background, which only works before the TUI starts. - let theme_roots = theme::catalog::Roots::from_env(); + // Which configuration profile to use? Resolved before the theme so the theme + // choice can be scoped to it: the choice used to live in a single shared + // `tui.toml`, so a theme change under one profile repainted every other. Its + // informational messages print with the default palette, since the theme is + // not chosen yet — a rare, acceptable trade for per-profile isolation. + let profile = resolve_profile(&matches)?; + // The theme colours everything printed from here on — prompts and errors as + // well as the TUI — so it is chosen next, per profile. Under `auto` this asks + // the terminal for its background, which only works before the TUI starts. + let theme_roots = theme::catalog::Roots::from_env_for_profile(&profile); let theme_in_use = theme::init(&theme_roots); - // `theme` needs no profile: how the TUI looks is the person's, not an - // account's, so it runs before any profile is resolved or opened. if let Some(("theme", theme_args)) = matches.subcommand() { - return theme_cmd::run(theme_args); + return theme_cmd::run(theme_args, &profile); } let theme_watcher = theme::live::Watcher::new(theme_roots, &theme_in_use); - let cli_profile = matches - .get_one::("profile") - .cloned() - .unwrap_or_else(|| "default".to_string()); // `--unlock-code-file` (a path, or `-` for standard input) takes the place of // `--unlock-code`, which clap refuses alongside it. Read eagerly so a bad // path fails here, with a clear message, rather than after the profile has @@ -474,49 +530,6 @@ async fn main() -> Result<()> { None => None, }; - // Which configuration profile to use? - let profile = if let Ok(env_profile) = env::var("OPENVTC_CONFIG_PROFILE") { - // ENV Profile will override the CLI Argument - if cli_profile != "default" && cli_profile != env_profile { - println!("{}", - style("WARNING: Using both ENV OPENVTC_CONFIG_PROFILE and CLI profile! These do not match!").themed(CLI_CAUTION) - ); - println!( - "{} {}", - style("WARNING: Using CLI Profile:").themed(CLI_CAUTION), - style(&cli_profile).themed(CLI_EXAMPLE) - ); - cli_profile - } else { - println!( - "{}{}{}", - style("Using profile (").themed(CLI_INFO), - style(&env_profile).themed(CLI_EXAMPLE), - style(") from OPENVTC_CONFIG_PROFILE ENV variable").themed(CLI_INFO) - ); - env_profile - } - } else { - cli_profile - }; - - // The profile name is interpolated into lock-file and config paths and - // used as the OS keyring account identifier; reject path separators and - // traversal sequences before it reaches the filesystem. - if profile.is_empty() - || !profile - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') - || profile.contains("..") - { - eprintln!( - "{} {}", - style("ERROR: Invalid profile name:").themed(CLI_ERROR), - style(&profile).themed(CLI_CAUTION) - ); - bail!("Profile name may only contain [A-Za-z0-9._-] and must not contain '..'"); - } - // Register the platform's keyring-core credential store. keyring-core 1.0 // doesn't auto-pick a backend — every binary registers exactly one at // startup. This runs *after* profile resolution because the durable diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index a2f00bb..c8b105f 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -947,14 +947,26 @@ pub(crate) async fn dispatch( begin_reconnect_status(state); return SettingsOutcome::ReconnectMediator; } - SettingsAction::ThemeOpen => handle_theme_open(state, &Roots::from_env()), + // The theme choice is kept per-profile (`tui-{profile}.toml`), so these + // read/write against the running profile rather than the shared default. + SettingsAction::ThemeOpen => { + handle_theme_open(state, &Roots::from_env_for_profile(profile)) + } SettingsAction::ThemeSelect(index) => { - handle_theme_select(state, &Roots::from_env(), index); + handle_theme_select(state, &Roots::from_env_for_profile(profile), index); + } + SettingsAction::ThemeApply => { + handle_theme_apply(state, &Roots::from_env_for_profile(profile)) + } + SettingsAction::ThemeCancel => { + handle_theme_cancel(state, &Roots::from_env_for_profile(profile)) + } + SettingsAction::ThemeCopy => { + handle_theme_copy(state, &Roots::from_env_for_profile(profile)) + } + SettingsAction::ThemeReload => { + handle_theme_reload(state, &Roots::from_env_for_profile(profile)) } - SettingsAction::ThemeApply => handle_theme_apply(state, &Roots::from_env()), - SettingsAction::ThemeCancel => handle_theme_cancel(state, &Roots::from_env()), - SettingsAction::ThemeCopy => handle_theme_copy(state, &Roots::from_env()), - SettingsAction::ThemeReload => handle_theme_reload(state, &Roots::from_env()), } SettingsOutcome::Continue } @@ -974,6 +986,7 @@ mod tests { config: Some(base.join("config")), home: Some(base.join("home")), system_omarchy: None, + profile: "default".to_string(), }; theme::set_active(&Theme::default_theme()); let mut state = State::default(); diff --git a/openvtc/src/theme/catalog.rs b/openvtc/src/theme/catalog.rs index 45d87b2..06e0c98 100644 --- a/openvtc/src/theme/catalog.rs +++ b/openvtc/src/theme/catalog.rs @@ -9,9 +9,11 @@ //! | `omarchy/` | an Omarchy theme directory, read in place | //! //! `` is `OPENVTC_CONFIG_PATH`, else `~/.config/openvtc` (the -//! platform's config directory on Windows). The choice is kept in -//! `/tui.toml`, shared by every profile: how the TUI looks is the -//! person's, not the account's. +//! platform's config directory on Windows). The choice is kept per profile — +//! `/tui.toml` for `default`, `/tui-{profile}.toml` otherwise — +//! so running two instances under different profiles no longer makes a theme +//! change in one repaint the others. The theme *library* (`/themes/`) +//! stays shared: it is content the person authored, not a per-profile setting. //! //! ```toml //! theme = "auto" @@ -121,12 +123,28 @@ pub struct Roots { pub home: Option, /// Omarchy's system-wide themes. pub system_omarchy: Option, + /// The active config profile. It scopes the remembered theme *choice* + /// (`tui.toml` for `default`, `tui-{profile}.toml` otherwise), so two + /// instances running under different profiles no longer share a look — the + /// theme leaked across profiles because the choice file was fixed-named. The + /// theme *library* (`themes/`) stays shared: it is content the person + /// authored, not a per-profile setting. Empty or `"default"` ⇒ `tui.toml`. + pub profile: String, } impl Roots { - /// The real locations for this user. + /// The real locations for this user, scoped to the `default` profile. Use + /// [`from_env_for_profile`](Self::from_env_for_profile) once the running + /// profile is known so the theme choice is per-profile. #[must_use] pub fn from_env() -> Self { + Self::from_env_for_profile("default") + } + + /// [`from_env`](Self::from_env), scoped to `profile` so the remembered theme + /// choice is kept per-profile rather than shared across all of them. + #[must_use] + pub fn from_env_for_profile(profile: &str) -> Self { let home = dirs::home_dir(); let config = std::env::var_os("OPENVTC_CONFIG_PATH") .map(PathBuf::from) @@ -144,19 +162,29 @@ impl Roots { config, home, system_omarchy: Some(PathBuf::from("/usr/share/omarchy/themes")), + profile: profile.to_string(), } } - /// Where the person's own theme files live. + /// Where the person's own theme files live. Shared across profiles — the + /// theme library is content, not a per-profile choice. #[must_use] pub fn themes_dir(&self) -> Option { self.config.as_ref().map(|c| c.join("themes")) } - /// `tui.toml`, where the choice is kept. + /// The file the theme choice is kept in: `tui.toml` for the `default` + /// profile, `tui-{profile}.toml` for any other — mirroring the + /// profile-suffixed naming the main config already uses, so a choice made + /// under one profile does not reach another. #[must_use] pub fn settings_file(&self) -> Option { - self.config.as_ref().map(|c| c.join("tui.toml")) + let name = if self.profile.is_empty() || self.profile == "default" { + "tui.toml".to_string() + } else { + format!("tui-{}.toml", self.profile) + }; + self.config.as_ref().map(|c| c.join(name)) } /// Omarchy's theme directories, the person's own first. @@ -505,10 +533,43 @@ mod tests { config: Some(base.join("config")), home: Some(base.join("home")), system_omarchy: Some(base.join("system")), + profile: "default".to_string(), }; (base, roots) } + /// The theme choice file is per-profile: the `default` profile keeps the + /// unsuffixed `tui.toml`, and any other profile gets its own + /// `tui-{profile}.toml`, so a choice made under one profile is never read or + /// overwritten by another. + #[test] + fn settings_file_is_scoped_per_profile() { + let (_base, mut roots) = roots("settings-file-profile"); + + roots.profile = "default".to_string(); + assert!( + roots.settings_file().unwrap().ends_with("tui.toml"), + "the default profile keeps the unsuffixed name" + ); + + roots.profile = String::new(); + assert!( + roots.settings_file().unwrap().ends_with("tui.toml"), + "an empty profile is treated as default" + ); + + roots.profile = "work".to_string(); + assert!( + roots.settings_file().unwrap().ends_with("tui-work.toml"), + "a named profile gets its own choice file" + ); + assert_ne!( + Roots::from_env_for_profile("work").settings_file(), + Roots::from_env_for_profile("home").settings_file(), + "two profiles resolve to different choice files" + ); + } + fn omarchy_theme(dir: &Path, name: &str, background: &str) { let theme = dir.join(name); fs::create_dir_all(&theme).unwrap(); diff --git a/openvtc/src/theme/live.rs b/openvtc/src/theme/live.rs index f85ad9c..47e8101 100644 --- a/openvtc/src/theme/live.rs +++ b/openvtc/src/theme/live.rs @@ -156,6 +156,7 @@ mod tests { config: Some(base.join("config")), home: Some(base.join("home")), system_omarchy: None, + profile: "default".to_string(), }; (base, roots) } diff --git a/openvtc/src/theme_cmd.rs b/openvtc/src/theme_cmd.rs index 5250a81..d7d48ac 100644 --- a/openvtc/src/theme_cmd.rs +++ b/openvtc/src/theme_cmd.rs @@ -138,8 +138,11 @@ pub fn command() -> Command { /// # Errors /// /// A theme that cannot be found, read, imported or written. -pub fn run(matches: &ArgMatches) -> Result<()> { - let roots = Roots::from_env(); +pub fn run(matches: &ArgMatches, profile: &str) -> Result<()> { + // The remembered theme choice is per-profile (`tui-{profile}.toml`), so + // `openvtc theme set/auto/import` writes the profile the command was run + // under rather than a shared file every profile would then read. + let roots = Roots::from_env_for_profile(profile); match matches.subcommand() { Some(("list", _)) => { list(&roots);