Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions openvtc-core/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf> {
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)
}
Expand Down
121 changes: 67 additions & 54 deletions openvtc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,62 @@ fn open_debug_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
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<String> {
let cli_profile = matches
.get_one::<String>("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
// ****************************************************************************
Expand Down Expand Up @@ -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::<String>("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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions openvtc/src/state_handler/settings_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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();
Expand Down
75 changes: 68 additions & 7 deletions openvtc/src/theme/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
//! | `omarchy/<name>` | an Omarchy theme directory, read in place |
//!
//! `<config>` is `OPENVTC_CONFIG_PATH`, else `~/.config/openvtc` (the
//! platform's config directory on Windows). The choice is kept in
//! `<config>/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 —
//! `<config>/tui.toml` for `default`, `<config>/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* (`<config>/themes/`)
//! stays shared: it is content the person authored, not a per-profile setting.
//!
//! ```toml
//! theme = "auto"
Expand Down Expand Up @@ -121,12 +123,28 @@ pub struct Roots {
pub home: Option<PathBuf>,
/// Omarchy's system-wide themes.
pub system_omarchy: Option<PathBuf>,
/// 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)
Expand All @@ -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<PathBuf> {
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<PathBuf> {
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.
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions openvtc/src/theme/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions openvtc/src/theme_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading