breadbox: fast Rust application launcher for wofi
Zero-dependency Rust rewrite of the breadboard launcher. Parses .desktop files in-process (no awk subprocesses), builds a tab-separated cache for wofi dmenu, and supports stale-while-revalidate background rebuilds. Apps-only for now; icon support to be added later. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
d94a00d982
5 changed files with 326 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/target/
|
||||
|
||||
# Editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS artifacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Claude Code session data
|
||||
.claude/
|
||||
7
Cargo.lock
generated
Normal file
7
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "breadbox"
|
||||
version = "0.1.0"
|
||||
4
Cargo.toml
Normal file
4
Cargo.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[package]
|
||||
name = "breadbox"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Breadway
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
273
src/main.rs
Normal file
273
src/main.rs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
fs::{self, File},
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const CACHE_TIMEOUT_SECS: u64 = 86400;
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
|
||||
}
|
||||
|
||||
fn cache_path() -> PathBuf {
|
||||
let dir = env::var("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home_dir().join(".cache"));
|
||||
dir.join("breadbox.cache")
|
||||
}
|
||||
|
||||
fn app_dirs() -> [PathBuf; 2] {
|
||||
[
|
||||
PathBuf::from("/usr/share/applications"),
|
||||
home_dir().join(".local/share/applications"),
|
||||
]
|
||||
}
|
||||
|
||||
fn mtime(path: &Path) -> u64 {
|
||||
fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn cache_valid(cache: &Path) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let cm = mtime(cache);
|
||||
if now.saturating_sub(cm) >= CACHE_TIMEOUT_SECS {
|
||||
return false;
|
||||
}
|
||||
app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm)
|
||||
}
|
||||
|
||||
fn strip_exec_codes(exec: &str) -> String {
|
||||
let mut out = String::with_capacity(exec.len());
|
||||
let mut chars = exec.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match chars.peek().copied() {
|
||||
Some('%') => {
|
||||
chars.next();
|
||||
out.push('%');
|
||||
}
|
||||
Some(n) if n.is_ascii_alphabetic() => {
|
||||
chars.next();
|
||||
}
|
||||
_ => out.push(c),
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct App {
|
||||
name: String,
|
||||
exec: String,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
fn parse_desktop(path: &Path) -> Option<App> {
|
||||
let file = File::open(path).ok()?;
|
||||
let mut in_entry = false;
|
||||
let (mut name, mut exec, mut app_type) = (None::<String>, None::<String>, None::<String>);
|
||||
let (mut no_display, mut hidden, mut terminal) = (false, false, false);
|
||||
|
||||
for line in BufReader::new(file).lines() {
|
||||
let Ok(raw) = line else { continue };
|
||||
let s = raw.trim();
|
||||
if s.starts_with('#') || s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if s.starts_with('[') {
|
||||
in_entry = s == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(v) = s.strip_prefix("Name=") {
|
||||
name.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Exec=") {
|
||||
exec.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Type=") {
|
||||
app_type.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
|
||||
no_display = v == "true";
|
||||
} else if let Some(v) = s.strip_prefix("Hidden=") {
|
||||
hidden = v == "true";
|
||||
} else if let Some(v) = s.strip_prefix("Terminal=") {
|
||||
terminal = v == "true" || v == "1";
|
||||
}
|
||||
}
|
||||
|
||||
if no_display || hidden {
|
||||
return None;
|
||||
}
|
||||
if app_type.as_deref().is_some_and(|t| t != "Application") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = name?.trim().to_string();
|
||||
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
|
||||
if name.is_empty() || exec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(App { name, exec, terminal })
|
||||
}
|
||||
|
||||
fn build_cache(cache: &Path) {
|
||||
let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp")));
|
||||
let mut apps: HashMap<String, App> = HashMap::new();
|
||||
|
||||
for dir in &app_dirs() {
|
||||
let Ok(entries) = fs::read_dir(dir) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
let id = entry.file_name().to_string_lossy().into_owned();
|
||||
if let Some(app) = parse_desktop(&path) {
|
||||
apps.insert(id, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = apps
|
||||
.into_values()
|
||||
.map(|a| {
|
||||
let prefix = if a.terminal { "term" } else { "app" };
|
||||
format!("{}\t{}::{}", a.name, prefix, a.exec)
|
||||
})
|
||||
.collect();
|
||||
lines.sort_unstable();
|
||||
|
||||
let tmp = cache.with_extension("tmp");
|
||||
if let Ok(mut f) = File::create(&tmp) {
|
||||
for line in &lines {
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
let _ = fs::rename(&tmp, cache);
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_terminal() -> String {
|
||||
if let Ok(t) = env::var("TERMINAL") {
|
||||
if !t.is_empty() {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
let path_var = env::var("PATH").unwrap_or_default();
|
||||
for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] {
|
||||
if path_var.split(':').any(|d| Path::new(d).join(t).exists()) {
|
||||
return t.to_string();
|
||||
}
|
||||
}
|
||||
"xterm".to_string()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cache = cache_path();
|
||||
|
||||
if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") {
|
||||
build_cache(&cache);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle: second press closes an open wofi instance
|
||||
if Command::new("pgrep")
|
||||
.args(["-f", "wofi.*breadbox"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-f", "wofi.*breadbox"])
|
||||
.status();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate: never block on a rebuild if cache exists
|
||||
if !cache.exists() {
|
||||
build_cache(&cache);
|
||||
} else if !cache_valid(&cache) {
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
let _ = Command::new(exe)
|
||||
.env("BREADBOX_REBUILD_ONLY", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&cache).unwrap_or_default();
|
||||
|
||||
let mut child = match Command::new("wofi")
|
||||
.args([
|
||||
"--dmenu",
|
||||
"--parse-search",
|
||||
"--matching",
|
||||
"fuzzy",
|
||||
"--insensitive",
|
||||
"--prompt",
|
||||
"breadbox",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = write!(stdin, "{}", content);
|
||||
}
|
||||
|
||||
let out = match child.wait_with_output() {
|
||||
Ok(o) => o,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let choice = std::str::from_utf8(&out.stdout)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if choice.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = choice.split('\t').nth(1).unwrap_or("");
|
||||
|
||||
if let Some(cmd) = action.strip_prefix("app::") {
|
||||
let _ = Command::new("bash")
|
||||
.args(["-c", cmd])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
} else if let Some(cmd) = action.strip_prefix("term::") {
|
||||
let term = pick_terminal();
|
||||
let _ = Command::new(&term)
|
||||
.args(["-e", "bash", "-c", cmd])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue