280 lines
9.2 KiB
Rust
280 lines
9.2 KiB
Rust
//! Best-effort Linux project suggestions and optional desktop dialogs.
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use std::{
|
|
collections::BTreeSet,
|
|
fs,
|
|
io::{Read, Write},
|
|
path::{Path, PathBuf},
|
|
process::{Command, Stdio},
|
|
};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct DestinationChoice {
|
|
#[serde(default)]
|
|
pub skip: bool,
|
|
pub project: Option<PathBuf>,
|
|
pub remember: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct Candidate {
|
|
pub label: String,
|
|
pub path: PathBuf,
|
|
pub raw: String,
|
|
pub differences: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum FootprintChoice {
|
|
Reuse(usize),
|
|
Separate,
|
|
Skip,
|
|
}
|
|
|
|
fn project_path(path: &Path) -> Option<PathBuf> {
|
|
let candidate = match path.extension().and_then(|s| s.to_str()) {
|
|
Some("kicad_pro") => path.to_path_buf(),
|
|
Some("kicad_pcb" | "kicad_sch" | "pro" | "sch") => path.with_extension("kicad_pro"),
|
|
_ => return None,
|
|
};
|
|
candidate
|
|
.is_file()
|
|
.then(|| fs::canonicalize(&candidate).unwrap_or(candidate))
|
|
}
|
|
|
|
/// Process arguments/cwd are suggestions, not an authoritative active-window API.
|
|
pub fn projects() -> Vec<PathBuf> {
|
|
let mut found = BTreeSet::new();
|
|
let Ok(processes) = fs::read_dir("/proc") else {
|
|
return vec![];
|
|
};
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::MetadataExt;
|
|
#[cfg(unix)]
|
|
let owner = fs::metadata("/proc/self").ok().map(|m| m.uid());
|
|
for process in processes.flatten() {
|
|
#[cfg(unix)]
|
|
if owner.is_some() && process.metadata().ok().map(|m| m.uid()) != owner {
|
|
continue;
|
|
}
|
|
if !process
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.chars()
|
|
.all(|c| c.is_ascii_digit())
|
|
{
|
|
continue;
|
|
}
|
|
let proc_path = process.path();
|
|
let Ok(cmdline) = fs::read(proc_path.join("cmdline")) else {
|
|
continue;
|
|
};
|
|
let args: Vec<_> = cmdline
|
|
.split(|b| *b == 0)
|
|
.filter(|b| !b.is_empty())
|
|
.map(|b| String::from_utf8_lossy(b).into_owned())
|
|
.collect();
|
|
let Some(executable) = args.first() else {
|
|
continue;
|
|
};
|
|
let name = Path::new(executable)
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy();
|
|
if !matches!(name.as_ref(), "kicad" | "pcbnew" | "eeschema") {
|
|
continue;
|
|
}
|
|
let cwd = fs::read_link(proc_path.join("cwd")).ok();
|
|
for arg in args.iter().skip(1).filter(|a| !a.starts_with('-')) {
|
|
let path = Path::new(arg);
|
|
let full = if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else if let Some(cwd) = &cwd {
|
|
cwd.join(path)
|
|
} else {
|
|
continue;
|
|
};
|
|
if let Some(project) = project_path(&full) {
|
|
found.insert(project);
|
|
}
|
|
}
|
|
// The editor may omit the document argument. A project in its cwd is a
|
|
// useful fallback, but it remains explicitly labelled a suggestion.
|
|
if let Some(cwd) = &cwd
|
|
&& let Ok(entries) = fs::read_dir(cwd)
|
|
{
|
|
for entry in entries.flatten() {
|
|
if entry.path().extension().and_then(|s| s.to_str()) == Some("kicad_pro")
|
|
&& let Some(project) = project_path(&entry.path())
|
|
{
|
|
found.insert(project);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
found.into_iter().collect()
|
|
}
|
|
|
|
fn dialog(request: Value) -> Result<Option<Value>> {
|
|
if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
|
|
eprintln!("UniLoader: no graphical session; choice remains pending");
|
|
return Ok(None);
|
|
}
|
|
let spawn = Command::new(std::env::current_exe().context("locating UniLoader executable")?)
|
|
.arg("__dialog")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit())
|
|
.spawn();
|
|
let mut child = match spawn {
|
|
Ok(child) => child,
|
|
Err(error) => {
|
|
eprintln!("UniLoader: could not start native dialog ({error}); choice remains pending");
|
|
return Ok(None);
|
|
}
|
|
};
|
|
let payload = serde_json::to_vec(&request)?;
|
|
let write_result = child
|
|
.stdin
|
|
.take()
|
|
.context("dialog stdin unavailable")
|
|
.and_then(|mut input| {
|
|
input
|
|
.write_all(&payload)
|
|
.context("writing native dialog request")
|
|
});
|
|
if let Err(error) = write_result {
|
|
eprintln!("UniLoader: native dialog unavailable ({error:#}); choice remains pending");
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Ok(None);
|
|
}
|
|
let output = match child.wait_with_output() {
|
|
Ok(output) => output,
|
|
Err(error) => {
|
|
eprintln!("UniLoader: native dialog unavailable ({error}); choice remains pending");
|
|
return Ok(None);
|
|
}
|
|
};
|
|
if !output.status.success() {
|
|
eprintln!("UniLoader: native dialog failed; choice remains pending");
|
|
return Ok(None);
|
|
}
|
|
let value: Value = match serde_json::from_slice(&output.stdout) {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
eprintln!(
|
|
"UniLoader: invalid native dialog response ({error}); choice remains pending"
|
|
);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
Ok((!value.is_null()).then_some(value))
|
|
}
|
|
|
|
/// Internal entry point: run the native window on the subprocess main thread.
|
|
/// It deliberately avoids loading configuration or acquiring importer locks.
|
|
pub fn run_internal_dialog() -> Result<()> {
|
|
const LIMIT: u64 = 32 * 1024 * 1024;
|
|
let mut bytes = Vec::new();
|
|
std::io::stdin().take(LIMIT + 1).read_to_end(&mut bytes)?;
|
|
anyhow::ensure!(
|
|
bytes.len() as u64 <= LIMIT,
|
|
"native dialog request exceeds 32 MiB"
|
|
);
|
|
let request: Value = serde_json::from_slice(&bytes).context("invalid native dialog request")?;
|
|
let response =
|
|
if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
|
|
eprintln!("UniLoader: no graphical session; choice remains pending");
|
|
None
|
|
} else {
|
|
match crate::gui::run(request) {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
eprintln!(
|
|
"UniLoader: native dialog unavailable ({error:#}); choice remains pending"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
};
|
|
let stdout = std::io::stdout();
|
|
let mut output = stdout.lock();
|
|
serde_json::to_writer(&mut output, &response)?;
|
|
writeln!(output)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn destination(projects: &[PathBuf], component: &str) -> Result<Option<DestinationChoice>> {
|
|
dialog(json!({"mode": "destination", "projects": projects, "component": component}))?
|
|
.map(serde_json::from_value)
|
|
.transpose()
|
|
.context("invalid destination choice")
|
|
}
|
|
|
|
pub fn compare(incoming: &str, candidates: &[Candidate]) -> Result<Option<FootprintChoice>> {
|
|
if candidates.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let Some(value) =
|
|
dialog(json!({"mode": "compare", "incoming": incoming, "candidates": candidates}))?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
match value["action"].as_str() {
|
|
Some("reuse") => {
|
|
let index = value["index"]
|
|
.as_u64()
|
|
.context("missing footprint candidate index")? as usize;
|
|
anyhow::ensure!(
|
|
index < candidates.len(),
|
|
"footprint candidate index out of range"
|
|
);
|
|
Ok(Some(FootprintChoice::Reuse(index)))
|
|
}
|
|
Some("separate") => Ok(Some(FootprintChoice::Separate)),
|
|
Some("skip") => Ok(Some(FootprintChoice::Skip)),
|
|
_ => anyhow::bail!("invalid footprint choice"),
|
|
}
|
|
}
|
|
|
|
pub fn choose_file(title: &str, paths: &[PathBuf]) -> Result<Option<PathBuf>> {
|
|
let Some(value) = dialog(json!({"mode": "file", "title": title, "paths": paths}))? else {
|
|
return Ok(None);
|
|
};
|
|
let path: PathBuf =
|
|
serde_json::from_value(value["path"].clone()).context("invalid file choice")?;
|
|
anyhow::ensure!(paths.contains(&path), "selected file was not a candidate");
|
|
Ok(Some(path))
|
|
}
|
|
|
|
/// Optional desktop notification; logs remain the authoritative import report.
|
|
pub fn notify(message: &str) {
|
|
let _ = Command::new("notify-send")
|
|
.args(["--app-name=UniLoader", "UniLoader", message])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn discovers_existing_project_from_editor_document() {
|
|
let dir = std::env::temp_dir().join(format!("uniloader-desktop-{}", std::process::id()));
|
|
fs::create_dir_all(&dir).unwrap();
|
|
let project = dir.join("board.kicad_pro");
|
|
fs::write(&project, "{}").unwrap();
|
|
assert_eq!(
|
|
project_path(&dir.join("board.kicad_pcb")),
|
|
Some(fs::canonicalize(&project).unwrap())
|
|
);
|
|
assert_eq!(project_path(&dir.join("missing.kicad_sch")), None);
|
|
fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
}
|