diff --git a/AGENTS.md b/AGENTS.md index 2ccbc0f..073a3f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,4 +90,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-15: the Linux tarball builds on Debian 12, in `rust:1.95.0-bookworm`: a binary asks for the glibc symbol versions it was linked against, so it is linked against an old glibc, not the builder's. `scripts/linux-tar.sh` prints the glibc its build asks for, and the README names it: 2.35 for v0.1.0, which runs on Ubuntu 22.04. - 2026-10-15: on Windows gpui answers the system's hit test from the last frame: under the pointer, a `WindowControlArea::Drag` hitbox that nothing occludes makes it the caption, and Windows keeps the press, so no click reaches the app. In v0.1.0 every title bar control was dead so. Ely now occludes the drag region under its window buttons, which then answer as close, maximize and minimize, and under each column header's item row, whose controls get plain presses; the row stays the scroll container, and its title is a drag region of its own. - 2026-10-15: a release's macOS disk image comes from the Release workflow, dispatched once the draft holds the Windows and Linux builds. It runs on `macos-signing`, the Mac mini's runner in the owner's login session, whose keychain holds the Developer ID identity; at the owner's choice that runner moved from AgentCompany alone to the owner's level, so every repository reaches it. The job reads the App Store Connect key from the owner's user-level secrets `APPLE_API_ISSUER`, `APPLE_API_KEY_ID` and `APPLE_API_KEY_P8`, writes the .p8 into the runner's temp folder for notarytool alone, and puts the stapled image on the draft through `scripts/release-asset.sh` with the job's own token. One notarization covers the image and the app inside it; the image carries the ticket, and the app copied out finds its own online on its first open. -- 2026-10-15: on Omarchy 4 (Linux) ELYTerminal follows the system's theme. Omarchy renders the current theme's `colors.toml` through its own resolver into `~/.local/state/omarchy/current/theme/ghostty.conf`, under `$HOME` whatever `XDG_STATE_HOME` says, so ELYTerminal reads that file and wears what Ghostty wears there: its color lines go through the strict parser the bundled themes use, and the rest, such as the glass block, pass by. Omarchy writes no `cursor-text`, so the background stands in, as in Ghostty and Omarchy's kitty template. A switch replaces the whole folder, so the file's device, inode, time and size change; ELYTerminal checks them twice a second, since Omarchy signals only Alacritty, kitty and Ghostty, by name. The Omarchy theme is the default wherever that file exists, and another pick stops following; settings that wear it with no readable file stop the start, and a failed read while running is logged and keeps the last theme. Omarchy 3's `~/.config/omarchy/current` gets no shim. +- 2026-10-15: on Omarchy 4 (Linux) ELYTerminal follows the system's theme. Omarchy renders the current theme's `colors.toml` through its own resolver into `~/.local/state/omarchy/current/theme/ghostty.conf`, under `$HOME` whatever `XDG_STATE_HOME` says, so ELYTerminal reads that file and wears what Ghostty wears there: its color lines go through the strict parser the bundled themes use, and the rest, such as the glass block, pass by. Omarchy writes no `cursor-text`, so the background stands in, as in Ghostty and Omarchy's kitty template. ELYTerminal reads the file twice a second, since Omarchy signals only Alacritty, kitty and Ghostty, by name, and wears it whenever its text changes and parses; a check that fails is logged once while it lasts and tried again, so a file made readable again is worn though nothing else about it changed. The Omarchy theme is the default unless that file is surely absent, so a folder that cannot be read makes a fresh install wear it and the start name the error; another pick stops following. At start a missing file means no Omarchy unless the settings wear it, a file that cannot be read stops the start, and one that does not parse stops it only when worn, else it is logged and no Omarchy card shows. While running, a missing file is a switch in progress, and any other failure keeps the last theme. Omarchy 3's `~/.config/omarchy/current` gets no shim. diff --git a/src/themes/mod.rs b/src/themes/mod.rs index a6026e4..ae324fe 100644 --- a/src/themes/mod.rs +++ b/src/themes/mod.rs @@ -127,10 +127,10 @@ pub fn known(id: &str) -> bool { find(id).is_some() || (cfg!(target_os = "linux") && id == OMARCHY) } -/// A fresh install wears Omarchy's theme where Omarchy runs. +/// A fresh install wears Omarchy's theme unless its file is surely absent. pub fn default_id() -> &'static str { #[cfg(target_os = "linux")] - if omarchy::path().is_some_and(|path| path.is_file()) { + if omarchy::path().is_some_and(|path| !matches!(path.try_exists(), Ok(false))) { return OMARCHY; } "catppuccin-mocha" diff --git a/src/themes/omarchy.rs b/src/themes/omarchy.rs index 3c096fd..3666245 100644 --- a/src/themes/omarchy.rs +++ b/src/themes/omarchy.rs @@ -1,5 +1,6 @@ use std::{ - os::unix::fs::MetadataExt, + fs, + io::{self, ErrorKind}, path::{Path, PathBuf}, time::Duration, }; @@ -10,7 +11,7 @@ use gpui::App; use super::{OMARCHY, Omarchy, TerminalTheme, apply, parse}; use crate::settings::SettingsStore; -/// How often the theme folder is checked for a switch. +/// How often Omarchy's theme file is read for a switch. const CHECK: Duration = Duration::from_millis(500); /// A Ghostty theme file's keys; Omarchy's other lines pass by. @@ -29,71 +30,82 @@ pub fn path() -> Option { dirs::home_dir().map(|home| home.join(".local/state/omarchy/current/theme/ghostty.conf")) } -/// Follows Omarchy's theme; a failed read stops the start only if worn. +/// Follows Omarchy's theme; an unreadable file, or a bad one worn, stops the start. pub fn start(cx: &mut App) -> anyhow::Result<()> { let settings = SettingsStore::get(cx); let worn = settings.theme == OMARCHY || settings.dark_theme.as_deref() == Some(OMARCHY); let path = path().context("omarchy: no home folder")?; match read(&path) { - Ok(theme) => { + Ok((text, theme)) => { log::info!("omarchy: the theme follows {}", path.display()); cx.set_global(Omarchy(theme)); - watch(path, cx); + watch(path, text, cx); Ok(()) } Err(error) if worn => Err(error.context("settings wear the Omarchy theme")), - Err(error) if path.exists() => { - log::error!("{error:#}; no Omarchy theme to follow"); + Err(error) if missing(&error) => { + log::info!("omarchy: none at {}", path.display()); Ok(()) } - Err(_) => { - log::info!("omarchy: none at {}", path.display()); + Err(error) if error.is::() => Err(error), + Err(error) => { + log::error!("{error:#}; no Omarchy theme to follow"); Ok(()) } } } -/// A switch replaces the folder, so the file's identity changes. -fn stamp(path: &Path) -> Option<(u64, u64, i64, i64, u64)> { - let meta = std::fs::metadata(path).ok()?; - Some(( - meta.dev(), - meta.ino(), - meta.mtime(), - meta.mtime_nsec(), - meta.len(), - )) -} - -/// Omarchy signals only Alacritty, kitty and Ghostty, so this checks. -fn watch(path: PathBuf, cx: &mut App) { - let mut seen = stamp(&path); +/// Omarchy signals only Alacritty, kitty and Ghostty, so this reads the file. +fn watch(path: PathBuf, mut shown: String, cx: &mut App) { cx.spawn(async move |cx| { + let mut failed = String::new(); loop { cx.background_executor().timer(CHECK).await; - let now = stamp(&path); - // Gone mid-switch: the next check finds the new folder. - if now.is_none() || now == seen { - continue; - } - seen = now; - match read(&path) { - Ok(theme) => cx.update(|cx| { - log::info!("omarchy: the theme changed"); - cx.set_global(Omarchy(theme)); - apply(cx); - }), - Err(error) => log::error!("{error:#}; the last Omarchy theme stays"), + match check(&path, &shown) { + Ok(None) => failed.clear(), + Ok(Some((text, theme))) => { + shown = text; + failed.clear(); + cx.update(|cx| { + log::info!("omarchy: the theme changed"); + cx.set_global(Omarchy(theme)); + apply(cx); + }); + } + Err(error) => { + let error = format!("{error:#}"); + if error != failed { + log::error!("{error}; the last Omarchy theme stays"); + failed = error; + } + } } } }) .detach(); } -fn read(path: &Path) -> anyhow::Result { - let text = std::fs::read_to_string(path) - .with_context(|| format!("omarchy: reading {}", path.display()))?; - colors(&text).with_context(|| format!("omarchy: {}", path.display())) +/// The file's theme when its text is not `shown`'s; none while unchanged. +pub(super) fn check(path: &Path, shown: &str) -> anyhow::Result> { + match read(path) { + Ok((text, _)) if text == shown => Ok(None), + // Gone mid-switch: the next check finds the new folder. + Err(error) if missing(&error) => Ok(None), + next => next.map(Some), + } +} + +fn read(path: &Path) -> anyhow::Result<(String, TerminalTheme)> { + let text = + fs::read_to_string(path).with_context(|| format!("omarchy: reading {}", path.display()))?; + let theme = colors(&text).with_context(|| format!("omarchy: {}", path.display()))?; + Ok((text, theme)) +} + +fn missing(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::NotFound) } /// Omarchy writes no cursor-text; the background stands in, as Ghostty's default. diff --git a/src/themes/tests.rs b/src/themes/tests.rs index f8ceea9..603c05f 100644 --- a/src/themes/tests.rs +++ b/src/themes/tests.rs @@ -86,11 +86,10 @@ fn a_missing_key_or_bad_color_names_it() { assert!(parse("x", "X", "palette = 16=#000000").is_err()); } +/// Omarchy 4 renders Tokyo Night so; glass mode appends the last four lines. #[cfg(target_os = "linux")] -#[test] -fn omarchys_ghostty_conf_reads_without_its_glass() { - // Omarchy 4 renders Tokyo Night so; glassy windows append the last four lines. - let rendered = [ +fn tokyo_night() -> String { + [ "background = #1a1b26", "foreground = #a9b1d6", "cursor-color = #c0caf5", @@ -118,7 +117,13 @@ fn omarchys_ghostty_conf_reads_without_its_glass() { "gtk-custom-css = /usr/share/omarchy/default/gtk/glass.css", "# omarchy-glass end", ] - .join("\n"); + .join("\n") +} + +#[cfg(target_os = "linux")] +#[test] +fn omarchys_ghostty_conf_reads_without_its_glass() { + let rendered = tokyo_night(); let theme = omarchy::colors(&rendered).expect("Omarchy's file reads"); assert_eq!(theme.id, OMARCHY); assert_eq!(theme.background, rgb(0x1a1b26)); @@ -132,6 +137,39 @@ fn omarchys_ghostty_conf_reads_without_its_glass() { ); } +#[cfg(target_os = "linux")] +#[test] +fn omarchys_file_is_worn_again_once_it_reads() { + use std::{fs, os::unix::fs::PermissionsExt}; + let folder = std::env::temp_dir().join(format!("elyterminal-omarchy-{}", std::process::id())); + if folder.exists() { + fs::remove_dir_all(&folder).expect("a stale test folder goes"); + } + fs::create_dir_all(&folder).expect("a test folder"); + let path = folder.join("ghostty.conf"); + let text = tokyo_night(); + fs::write(&path, &text).expect("a theme"); + assert!(omarchy::check(&path, &text).expect("reads").is_none()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).expect("000"); + // Root reads any mode. + if unsafe { libc::geteuid() } != 0 { + let error = omarchy::check(&path, "").expect_err("unreadable"); + assert!( + format!("{error:#}").contains("Permission denied"), + "{error:#}" + ); + } + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("644"); + let (shown, theme) = omarchy::check(&path, "").expect("reads").expect("a theme"); + assert_eq!((shown, theme.background), (text, rgb(0x1a1b26))); + fs::remove_dir_all(&folder).expect("the test folder goes"); + assert!( + omarchy::check(&path, "") + .expect("gone mid-switch") + .is_none() + ); +} + #[test] fn the_dark_theme_wins_only_in_dark_mode() { let settings = Settings {