Add OS settings panels for updates, printing, VPN, and related surfaces
All checks were successful
dev release / build (push) Successful in 2m11s
All checks were successful
dev release / build (push) Successful in 2m11s
Typed commands only: pacman/bakery/fwupd compose on Updates, CUPS/nmcli, hyprsunset, fcitx5, MIME defaults, bakery track, restic (backup.toml 0600), curated optional software, and an NVIDIA offer card gated on the probe file.
This commit is contained in:
parent
32604b492b
commit
60a473fff0
29 changed files with 4229 additions and 8 deletions
116
src/src/commands/a11y.rs
Normal file
116
src/src/commands/a11y.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! Accessibility toggles that actually do something on Hyprland.
|
||||
//! Orca launches. Magnifier is Hyprland `cursor:zoom_factor`. Sticky/slow
|
||||
//! keys are not exposed by Hyprland or xkeyboard-config rules — the UI
|
||||
//! must show that honestly rather than a dead switch.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util::{command_exists, fail_output, pacman_installed};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct A11yStatus {
|
||||
orca_installed: bool,
|
||||
orca_running: bool,
|
||||
zoom_factor: f64,
|
||||
sticky_keys_supported: bool,
|
||||
slow_keys_supported: bool,
|
||||
kmag_installed: bool,
|
||||
note: String,
|
||||
}
|
||||
|
||||
async fn orca_running() -> bool {
|
||||
Command::new("pgrep")
|
||||
.args(["-x", "orca"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn read_zoom() -> f64 {
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["getoption", "cursor:zoom_factor", "-j"])
|
||||
.output()
|
||||
.await;
|
||||
let Ok(output) = output else {
|
||||
return 1.0;
|
||||
};
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
|
||||
return 1.0;
|
||||
};
|
||||
v.get("float")
|
||||
.and_then(|x| x.as_f64())
|
||||
.or_else(|| v.get("int").and_then(|x| x.as_i64()).map(|i| i as f64))
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_a11y_status() -> A11yStatus {
|
||||
A11yStatus {
|
||||
orca_installed: command_exists("orca") || pacman_installed("orca"),
|
||||
orca_running: orca_running().await,
|
||||
zoom_factor: read_zoom().await,
|
||||
sticky_keys_supported: false,
|
||||
slow_keys_supported: false,
|
||||
kmag_installed: command_exists("kmag") || pacman_installed("kmag"),
|
||||
note: "Hyprland does not expose XKB AccessX (sticky keys / slow keys). Those toggles stay off because they would not do anything.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_cursor_zoom(factor: f64) -> Result<f64, String> {
|
||||
let factor = factor.clamp(1.0, 8.0);
|
||||
let value = format!("{factor:.2}");
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["keyword", "cursor:zoom_factor", &value])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(factor)
|
||||
} else {
|
||||
Err(fail_output(&output, "hyprctl keyword cursor:zoom_factor"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_orca_running(running: bool) -> Result<(), String> {
|
||||
if running {
|
||||
if !command_exists("orca") {
|
||||
return Err("orca is not installed".into());
|
||||
}
|
||||
std::process::Command::new("orca")
|
||||
.arg("--replace")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start orca: {e}"))?;
|
||||
Ok(())
|
||||
} else {
|
||||
let _ = Command::new("pkill").args(["-x", "orca"]).status().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_kmag() -> Result<(), String> {
|
||||
if !command_exists("kmag") {
|
||||
return Err("kmag is not installed".into());
|
||||
}
|
||||
std::process::Command::new("kmag")
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start kmag: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn zoom_clamp_bounds() {
|
||||
let f = 0.2_f64.clamp(1.0, 8.0);
|
||||
assert_eq!(f, 1.0);
|
||||
assert_eq!(12.0_f64.clamp(1.0, 8.0), 8.0);
|
||||
}
|
||||
}
|
||||
358
src/src/commands/backup.rs
Normal file
358
src/src/commands/backup.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
//! restic backups of `$HOME`. Repo path + password live in
|
||||
//! `~/.config/bos-settings/backup.toml` (0600). The password is write-only
|
||||
//! to the webview — empty on save keeps the stored secret.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::config;
|
||||
use super::streaming;
|
||||
use super::util::{self, command_exists, fail_output};
|
||||
|
||||
fn backup_toml() -> PathBuf {
|
||||
util::bos_settings_dir().join("backup.toml")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BackupSecrets {
|
||||
pub repo: String,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
impl BackupSecrets {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
repo: String::new(),
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_secrets() -> BackupSecrets {
|
||||
load_secrets_from(&backup_toml())
|
||||
}
|
||||
|
||||
fn load_secrets_from(path: &Path) -> BackupSecrets {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return BackupSecrets::empty();
|
||||
};
|
||||
let doc = text.parse::<toml_edit::DocumentMut>().unwrap_or_default();
|
||||
BackupSecrets {
|
||||
repo: config::get_str(&doc, &["repo"]).unwrap_or_default(),
|
||||
password: config::get_str(&doc, &["password"]).filter(|s| !s.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_secrets_to(path: &Path, repo: &str, password: Option<&str>) -> Result<(), String> {
|
||||
let existing = load_secrets_from(path);
|
||||
let password = match password.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(p) => Some(p.to_string()),
|
||||
None => existing.password,
|
||||
};
|
||||
let mut doc = toml_edit::DocumentMut::new();
|
||||
config::set_str(&mut doc, &["repo"], repo.trim());
|
||||
if let Some(p) = password.as_deref() {
|
||||
config::set_str(&mut doc, &["password"], p);
|
||||
}
|
||||
util::write_secure(path, &doc.to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BackupStatus {
|
||||
restic_installed: bool,
|
||||
repo: String,
|
||||
has_password: bool,
|
||||
snapshots: Vec<ResticSnapshot>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ResticSnapshot {
|
||||
id: String,
|
||||
time: String,
|
||||
paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_backup_config() -> BackupStatus {
|
||||
let s = load_secrets();
|
||||
BackupStatus {
|
||||
restic_installed: command_exists("restic"),
|
||||
repo: s.repo,
|
||||
has_password: s.password.is_some(),
|
||||
snapshots: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveBackupInput {
|
||||
repo: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_backup_config(input: SaveBackupInput) -> Result<(), String> {
|
||||
if !valid_repo(&input.repo) {
|
||||
return Err("repo must be an absolute path or sftp:user@host:path".into());
|
||||
}
|
||||
save_secrets_to(&backup_toml(), &input.repo, input.password.as_deref())
|
||||
}
|
||||
|
||||
pub fn valid_repo(repo: &str) -> bool {
|
||||
let repo = repo.trim();
|
||||
if repo.is_empty() || repo.len() > 512 || repo.contains('\n') || repo.contains('\0') {
|
||||
return false;
|
||||
}
|
||||
if let Some(rest) = repo.strip_prefix("sftp:") {
|
||||
return !rest.is_empty() && rest.contains('@') && rest.contains(':') && !rest.contains(' ');
|
||||
}
|
||||
std::path::Path::new(repo).is_absolute()
|
||||
}
|
||||
|
||||
fn require_ready() -> Result<BackupSecrets, String> {
|
||||
if !command_exists("restic") {
|
||||
return Err("restic is not installed".into());
|
||||
}
|
||||
let s = load_secrets();
|
||||
if !valid_repo(&s.repo) {
|
||||
return Err("set a repository path first".into());
|
||||
}
|
||||
if s.password.is_none() {
|
||||
return Err("set a repository password first".into());
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn restic_args<'a>(repo: &'a str, extra: &'a [&'a str]) -> Vec<&'a str> {
|
||||
let mut args = vec!["--repo", repo];
|
||||
args.extend_from_slice(extra);
|
||||
args
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_init(app: AppHandle, session_id: String) -> bool {
|
||||
let Ok(s) = require_ready() else {
|
||||
streaming::emit_line(
|
||||
&app,
|
||||
&session_id,
|
||||
"Error: configure repo and password first",
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let extra = ["init"];
|
||||
let args = restic_args(&s.repo, &extra);
|
||||
streaming::run_hardcoded_env(
|
||||
app,
|
||||
session_id,
|
||||
"restic",
|
||||
&args,
|
||||
&[("RESTIC_PASSWORD", password)],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn exclude_args(home: &str) -> Vec<String> {
|
||||
let extras = [
|
||||
".cache",
|
||||
".local/share/Trash",
|
||||
".local/share/Steam",
|
||||
".npm",
|
||||
".cargo/registry",
|
||||
".cargo/git",
|
||||
".rustup",
|
||||
".var/app",
|
||||
];
|
||||
let mut args = vec![
|
||||
"--exclude-caches".into(),
|
||||
"--exclude".into(),
|
||||
"node_modules".into(),
|
||||
"--exclude".into(),
|
||||
"target".into(),
|
||||
"--exclude".into(),
|
||||
".git".into(),
|
||||
];
|
||||
for rel in extras {
|
||||
args.push("--exclude".into());
|
||||
args.push(format!("{home}/{rel}"));
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_backup(app: AppHandle, session_id: String) -> bool {
|
||||
let s = match require_ready() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let excludes = exclude_args(&home);
|
||||
let mut args = vec!["--repo".to_string(), s.repo.clone()];
|
||||
args.extend(excludes);
|
||||
args.push("backup".into());
|
||||
args.push(home);
|
||||
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
streaming::run_hardcoded_env(
|
||||
app,
|
||||
session_id,
|
||||
"restic",
|
||||
&arg_refs,
|
||||
&[("RESTIC_PASSWORD", password)],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool {
|
||||
let s = match require_ready() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let snap = snapshot.trim();
|
||||
if !valid_snapshot_id(snap) {
|
||||
streaming::emit_line(&app, &session_id, "Error: invalid snapshot id");
|
||||
return false;
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"];
|
||||
let args = restic_args(&s.repo, &extra);
|
||||
streaming::run_hardcoded_env(
|
||||
app,
|
||||
session_id,
|
||||
"restic",
|
||||
&args,
|
||||
&[("RESTIC_PASSWORD", password)],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn valid_snapshot_id(id: &str) -> bool {
|
||||
if id == "latest" {
|
||||
return true;
|
||||
}
|
||||
let bytes = id.as_bytes();
|
||||
!bytes.is_empty() && bytes.len() <= 64 && bytes.iter().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_restic_snapshots() -> Result<Vec<ResticSnapshot>, String> {
|
||||
let s = require_ready()?;
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let output = Command::new("restic")
|
||||
.args(["--repo", &s.repo, "snapshots", "--json"])
|
||||
.env("RESTIC_PASSWORD", password)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(fail_output(&output, "restic snapshots"));
|
||||
}
|
||||
parse_snapshots(&output.stdout)
|
||||
}
|
||||
|
||||
fn parse_snapshots(bytes: &[u8]) -> Result<Vec<ResticSnapshot>, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(bytes).map_err(|e| format!("restic json: {e}"))?;
|
||||
let Some(arr) = v.as_array() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(arr
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let id = s
|
||||
.get("short_id")
|
||||
.or_else(|| s.get("id"))
|
||||
.and_then(|x| x.as_str())?
|
||||
.to_string();
|
||||
let time = s
|
||||
.get("time")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let paths = s
|
||||
.get("paths")
|
||||
.and_then(|x| x.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|p| p.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(ResticSnapshot { id, time, paths })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repo_accepts_abs_and_sftp() {
|
||||
assert!(valid_repo("/mnt/backup/bos"));
|
||||
assert!(valid_repo("sftp:user@host:/backups/bos"));
|
||||
assert!(!valid_repo("relative/path"));
|
||||
assert!(!valid_repo("sftp:nocolon"));
|
||||
assert!(!valid_repo("sftp:user host:/x"));
|
||||
assert!(!valid_repo(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_hex_or_latest() {
|
||||
assert!(valid_snapshot_id("latest"));
|
||||
assert!(valid_snapshot_id("a1b2c3d4"));
|
||||
assert!(!valid_snapshot_id("../x"));
|
||||
assert!(!valid_snapshot_id("latest;rm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_secure_is_0600_and_keeps_password() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bos-settings-backup-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("backup.toml");
|
||||
save_secrets_to(&path, "/tmp/repo", Some("hunter2")).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("hunter2"));
|
||||
assert!(text.contains("/tmp/repo"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "backup.toml must be 0600, got {mode:o}");
|
||||
}
|
||||
save_secrets_to(&path, "/tmp/repo2", Some("")).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("hunter2"), "empty password keeps secret");
|
||||
assert!(text.contains("/tmp/repo2"));
|
||||
let loaded = load_secrets_from(&path);
|
||||
assert_eq!(loaded.password.as_deref(), Some("hunter2"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_restic_json() {
|
||||
let json = br#"[{"short_id":"abc123","time":"2026-08-15T01:00:00Z","paths":["/home/a"]}]"#;
|
||||
let v = parse_snapshots(json).unwrap();
|
||||
assert_eq!(v[0].id, "abc123");
|
||||
assert_eq!(v[0].paths[0], "/home/a");
|
||||
}
|
||||
}
|
||||
91
src/src/commands/channel.rs
Normal file
91
src/src/commands/channel.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//! Bakery track (stable / beta / dev). Preference only — `bakery update
|
||||
//! --all` afterwards actually installs the new track's builds.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util::{fail_output, strip_ansi};
|
||||
|
||||
const TRACKS: &[&str] = &["stable", "beta", "dev"];
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BakeryTrack {
|
||||
current: String,
|
||||
tracks: Vec<String>,
|
||||
}
|
||||
|
||||
fn parse_track_show(text: &str) -> String {
|
||||
let text = strip_ansi(text);
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if let Some(rest) = lower.strip_prefix("current track:") {
|
||||
let raw = line[line.len() - rest.len()..].trim();
|
||||
return raw.to_ascii_lowercase();
|
||||
}
|
||||
if TRACKS.contains(&line) {
|
||||
return line.to_string();
|
||||
}
|
||||
}
|
||||
let lower = text.to_ascii_lowercase();
|
||||
for track in TRACKS {
|
||||
if lower.contains(track) {
|
||||
return (*track).to_string();
|
||||
}
|
||||
}
|
||||
"stable".into()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bakery_track() -> Result<BakeryTrack, String> {
|
||||
let output = Command::new("bakery")
|
||||
.args(["track", "show"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("couldn't run bakery: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(fail_output(&output, "bakery track show"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(BakeryTrack {
|
||||
current: parse_track_show(&text),
|
||||
tracks: TRACKS.iter().map(|s| (*s).to_string()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_bakery_track(track: String) -> Result<BakeryTrack, String> {
|
||||
let track = track.trim().to_ascii_lowercase();
|
||||
if !TRACKS.contains(&track.as_str()) {
|
||||
return Err(format!(
|
||||
"unknown track '{track}' — expected stable, beta, or dev"
|
||||
));
|
||||
}
|
||||
let output = Command::new("bakery")
|
||||
.args(["track", "set", &track])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(fail_output(&output, "bakery track set"));
|
||||
}
|
||||
get_bakery_track().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_current_track_line() {
|
||||
assert_eq!(parse_track_show("current track: dev\n"), "dev");
|
||||
assert_eq!(parse_track_show("current track: stable"), "stable");
|
||||
assert_eq!(parse_track_show("beta"), "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_in_set_guard() {
|
||||
assert!(!TRACKS.contains(&"nightly"));
|
||||
assert!(TRACKS.contains(&"stable"));
|
||||
}
|
||||
}
|
||||
431
src/src/commands/defaults.rs
Normal file
431
src/src/commands/defaults.rs
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
//! Default applications via `~/.config/mimeapps.list`. Categories cover
|
||||
//! the associations BOS already ships in skel (browser, files, images,
|
||||
//! PDF, editor) plus a terminal entry.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::config;
|
||||
|
||||
const CATEGORIES: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"browser",
|
||||
&[
|
||||
"x-scheme-handler/http",
|
||||
"x-scheme-handler/https",
|
||||
"text/html",
|
||||
],
|
||||
),
|
||||
("files", &["inode/directory"]),
|
||||
("terminal", &["x-scheme-handler/terminal"]),
|
||||
(
|
||||
"image",
|
||||
&[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
],
|
||||
),
|
||||
("pdf", &["application/pdf"]),
|
||||
("editor", &["text/plain", "text/markdown"]),
|
||||
];
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DesktopApp {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DefaultsStatus {
|
||||
path: String,
|
||||
current: HashMap<String, String>,
|
||||
options: HashMap<String, Vec<DesktopApp>>,
|
||||
}
|
||||
|
||||
fn mimeapps_path() -> PathBuf {
|
||||
config::config_dir().join("mimeapps.list")
|
||||
}
|
||||
|
||||
fn xdg_terminals_path() -> PathBuf {
|
||||
config::config_dir().join("xdg-terminals.list")
|
||||
}
|
||||
|
||||
fn applications_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = vec![
|
||||
PathBuf::from("/usr/share/applications"),
|
||||
PathBuf::from("/usr/local/share/applications"),
|
||||
];
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
dirs.push(PathBuf::from(home).join(".local/share/applications"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DesktopMeta {
|
||||
id: String,
|
||||
name: String,
|
||||
mimes: Vec<String>,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
fn parse_desktop(id: &str, text: &str) -> Option<DesktopMeta> {
|
||||
let mut in_entry = false;
|
||||
let mut name = String::new();
|
||||
let mut mimes = Vec::new();
|
||||
let mut terminal = false;
|
||||
let mut hidden = false;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_entry = line.eq_ignore_ascii_case("[Desktop Entry]");
|
||||
continue;
|
||||
}
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = line.strip_prefix("Name=") {
|
||||
if name.is_empty() {
|
||||
name = v.to_string();
|
||||
}
|
||||
} else if let Some(v) = line.strip_prefix("MimeType=") {
|
||||
mimes = v
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
} else if let Some(v) = line.strip_prefix("Categories=") {
|
||||
terminal |= v.split(';').any(|c| c.trim() == "TerminalEmulator");
|
||||
} else if line == "Hidden=true" || line == "NoDisplay=true" {
|
||||
hidden = true;
|
||||
}
|
||||
}
|
||||
if hidden || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(DesktopMeta {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
mimes,
|
||||
terminal,
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_desktops() -> Vec<DesktopMeta> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for dir in applications_dirs() {
|
||||
let Ok(entries) = std::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 Some(id) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(meta) = parse_desktop(id, &text) {
|
||||
out.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|a| a.name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_default_applications(text: &str) -> BTreeMap<String, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut in_defaults = false;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('[') {
|
||||
in_defaults = t.eq_ignore_ascii_case("[Default Applications]");
|
||||
continue;
|
||||
}
|
||||
if !in_defaults || t.is_empty() || t.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = t.split_once('=') {
|
||||
let desktop = v.split(';').next().unwrap_or("").trim();
|
||||
if !desktop.is_empty() {
|
||||
map.insert(k.trim().to_string(), desktop.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn current_for_category(defaults: &BTreeMap<String, String>, mimes: &[&str]) -> String {
|
||||
for mime in mimes {
|
||||
if let Some(v) = defaults.get(*mime) {
|
||||
return v.clone();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn options_for(
|
||||
apps: &[DesktopMeta],
|
||||
category: &str,
|
||||
mimes: &[&str],
|
||||
current: &str,
|
||||
) -> Vec<DesktopApp> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for app in apps {
|
||||
let matches = if category == "terminal" {
|
||||
app.terminal || app.mimes.iter().any(|m| mimes.contains(&m.as_str()))
|
||||
} else {
|
||||
app.mimes.iter().any(|m| mimes.contains(&m.as_str()))
|
||||
};
|
||||
if matches && seen.insert(app.id.clone()) {
|
||||
out.push(DesktopApp {
|
||||
id: app.id.clone(),
|
||||
name: app.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !current.is_empty() && !seen.contains(current) {
|
||||
out.insert(
|
||||
0,
|
||||
DesktopApp {
|
||||
id: current.to_string(),
|
||||
name: current.trim_end_matches(".desktop").to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_apps() -> DefaultsStatus {
|
||||
let path = mimeapps_path();
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let defaults = parse_default_applications(&text);
|
||||
let apps = scan_desktops();
|
||||
let mut current = HashMap::new();
|
||||
let mut options = HashMap::new();
|
||||
for (cat, mimes) in CATEGORIES {
|
||||
let cur = if *cat == "terminal" {
|
||||
read_terminal_default(&defaults)
|
||||
} else {
|
||||
current_for_category(&defaults, mimes)
|
||||
};
|
||||
options.insert((*cat).to_string(), options_for(&apps, cat, mimes, &cur));
|
||||
current.insert((*cat).to_string(), cur);
|
||||
}
|
||||
DefaultsStatus {
|
||||
path: path.display().to_string(),
|
||||
current,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_terminal_default(defaults: &BTreeMap<String, String>) -> String {
|
||||
if let Ok(text) = std::fs::read_to_string(xdg_terminals_path()) {
|
||||
if let Some(id) = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
{
|
||||
return id.to_string();
|
||||
}
|
||||
}
|
||||
current_for_category(defaults, &["x-scheme-handler/terminal"])
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SaveDefaultsInput {
|
||||
current: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_default_apps(input: SaveDefaultsInput) -> Result<(), String> {
|
||||
let path = mimeapps_path();
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut replacements = BTreeMap::new();
|
||||
for (cat, mimes) in CATEGORIES {
|
||||
let Some(desktop) = input.current.get(*cat).map(|s| s.trim()) else {
|
||||
continue;
|
||||
};
|
||||
if desktop.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !valid_desktop_id(desktop) {
|
||||
return Err(format!("invalid desktop id '{desktop}'"));
|
||||
}
|
||||
for mime in *mimes {
|
||||
replacements.insert((*mime).to_string(), desktop.to_string());
|
||||
}
|
||||
if *cat == "terminal" {
|
||||
write_terminal_list(desktop)?;
|
||||
}
|
||||
}
|
||||
let text = upsert_defaults(&existing, &replacements);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&path, &text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_terminal_list(desktop: &str) -> Result<(), String> {
|
||||
let path = xdg_terminals_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&path, &format!("{desktop}\n")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn valid_desktop_id(id: &str) -> bool {
|
||||
let bytes = id.as_bytes();
|
||||
bytes.ends_with(b".desktop")
|
||||
&& bytes.len() > ".desktop".len()
|
||||
&& bytes.len() <= 128
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.' | b'+'))
|
||||
}
|
||||
|
||||
fn upsert_defaults(existing: &str, replacements: &BTreeMap<String, String>) -> String {
|
||||
if existing.trim().is_empty() {
|
||||
let mut out = String::from("[Default Applications]\n");
|
||||
for (mime, desktop) in replacements {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut in_defaults = false;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut wrote_header = false;
|
||||
for line in existing.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('[') {
|
||||
if in_defaults {
|
||||
for (mime, desktop) in replacements {
|
||||
if seen.insert(mime.clone()) {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
in_defaults = t.eq_ignore_ascii_case("[Default Applications]");
|
||||
if in_defaults {
|
||||
wrote_header = true;
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
if in_defaults {
|
||||
if let Some((k, _)) = t.split_once('=') {
|
||||
let key = k.trim();
|
||||
if let Some(desktop) = replacements.get(key) {
|
||||
out.push_str(&format!("{key}={desktop}\n"));
|
||||
seen.insert(key.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
if in_defaults {
|
||||
for (mime, desktop) in replacements {
|
||||
if seen.insert(mime.clone()) {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
}
|
||||
} else if !wrote_header {
|
||||
if !out.ends_with('\n') && !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("\n[Default Applications]\n");
|
||||
for (mime, desktop) in replacements {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_skel_defaults() {
|
||||
let text = "\
|
||||
[Default Applications]
|
||||
text/html=zen.desktop
|
||||
x-scheme-handler/http=zen.desktop
|
||||
inode/directory=org.gnome.Nautilus.desktop
|
||||
";
|
||||
let map = parse_default_applications(text);
|
||||
assert_eq!(map.get("text/html").unwrap(), "zen.desktop");
|
||||
assert_eq!(
|
||||
current_for_category(&map, &["x-scheme-handler/http", "text/html"]),
|
||||
"zen.desktop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_only_named_keys() {
|
||||
let existing = "\
|
||||
# keep
|
||||
[Default Applications]
|
||||
text/html=old.desktop
|
||||
image/png=org.gnome.Loupe.desktop
|
||||
|
||||
[Added Associations]
|
||||
text/html=extra.desktop;
|
||||
";
|
||||
let mut rep = BTreeMap::new();
|
||||
rep.insert("text/html".into(), "zen.desktop".into());
|
||||
rep.insert("x-scheme-handler/http".into(), "zen.desktop".into());
|
||||
let out = upsert_defaults(existing, &rep);
|
||||
assert!(out.contains("# keep"));
|
||||
assert!(out.contains("text/html=zen.desktop"));
|
||||
assert!(out.contains("x-scheme-handler/http=zen.desktop"));
|
||||
assert!(out.contains("image/png=org.gnome.Loupe.desktop"));
|
||||
assert!(out.contains("[Added Associations]"));
|
||||
assert!(out.contains("text/html=extra.desktop;"));
|
||||
assert_eq!(out.matches("text/html=zen.desktop").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_id_check() {
|
||||
assert!(valid_desktop_id("zen.desktop"));
|
||||
assert!(valid_desktop_id("org.gnome.Nautilus.desktop"));
|
||||
assert!(!valid_desktop_id("zen"));
|
||||
assert!(!valid_desktop_id("../evil.desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_desktop_skips_hidden() {
|
||||
let hidden = parse_desktop(
|
||||
"x.desktop",
|
||||
"[Desktop Entry]\nName=X\nNoDisplay=true\nMimeType=text/plain;\n",
|
||||
);
|
||||
assert!(hidden.is_none());
|
||||
let ok = parse_desktop(
|
||||
"ed.desktop",
|
||||
"[Desktop Entry]\nName=Editor\nMimeType=text/plain;\nCategories=Utility;\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok.name, "Editor");
|
||||
assert!(ok.mimes.contains(&"text/plain".into()));
|
||||
}
|
||||
}
|
||||
177
src/src/commands/ime.rs
Normal file
177
src/src/commands/ime.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! fcitx5 input method for this session: environment.d + Hyprland env +
|
||||
//! systemd --user / `fcitx5 -d`. Missing packages are offered via the
|
||||
//! allowlisted pacman installer, not installed on page load.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::config;
|
||||
use super::util::{self, command_exists, pacman_installed};
|
||||
|
||||
const FRAGMENT: &str = "fcitx5.conf";
|
||||
const ENV_FILE: &str = "90-fcitx5.conf";
|
||||
|
||||
const ENV_LINES_SYSTEMD: &str = "\
|
||||
GTK_IM_MODULE=fcitx
|
||||
QT_IM_MODULE=fcitx
|
||||
XMODIFIERS=@im=fcitx
|
||||
SDL_IM_MODULE=fcitx
|
||||
";
|
||||
|
||||
const ENV_LINES_HYPR: &str = "\
|
||||
env = GTK_IM_MODULE,fcitx
|
||||
env = QT_IM_MODULE,fcitx
|
||||
env = XMODIFIERS,@im=fcitx
|
||||
env = SDL_IM_MODULE,fcitx
|
||||
exec-once = fcitx5 -d
|
||||
";
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ImePackage {
|
||||
name: String,
|
||||
installed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImeStatus {
|
||||
enabled: bool,
|
||||
running: bool,
|
||||
packages: Vec<ImePackage>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn env_path() -> std::path::PathBuf {
|
||||
config::config_dir().join("environment.d").join(ENV_FILE)
|
||||
}
|
||||
|
||||
fn wanted_packages() -> &'static [&'static str] {
|
||||
&[
|
||||
"fcitx5",
|
||||
"fcitx5-gtk",
|
||||
"fcitx5-qt",
|
||||
"fcitx5-configtool",
|
||||
"fcitx5-chinese-addons",
|
||||
]
|
||||
}
|
||||
|
||||
fn packages_status() -> Vec<ImePackage> {
|
||||
wanted_packages()
|
||||
.iter()
|
||||
.map(|name| ImePackage {
|
||||
name: (*name).to_string(),
|
||||
installed: pacman_installed(name),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn env_file_present() -> bool {
|
||||
env_path().is_file()
|
||||
}
|
||||
|
||||
async fn fcitx_running() -> bool {
|
||||
Command::new("pgrep")
|
||||
.args(["-x", "fcitx5"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_ime_status() -> ImeStatus {
|
||||
ImeStatus {
|
||||
enabled: env_file_present(),
|
||||
running: fcitx_running().await,
|
||||
packages: packages_status(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_ime_enabled(enabled: bool) -> Result<ImeStatus, String> {
|
||||
if enabled {
|
||||
enable_ime().await?;
|
||||
} else {
|
||||
disable_ime().await?;
|
||||
}
|
||||
Ok(ImeStatus {
|
||||
enabled: env_file_present(),
|
||||
running: fcitx_running().await,
|
||||
packages: packages_status(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn enable_ime() -> Result<(), String> {
|
||||
if !command_exists("fcitx5") {
|
||||
return Err("fcitx5 is not installed".into());
|
||||
}
|
||||
let env = env_path();
|
||||
if let Some(parent) = env.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&env, ENV_LINES_SYSTEMD).map_err(|e| e.to_string())?;
|
||||
|
||||
let hypr = util::hypr_dir().join(FRAGMENT);
|
||||
std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?;
|
||||
config::atomic_write(&hypr, ENV_LINES_HYPR).map_err(|e| e.to_string())?;
|
||||
util::ensure_hypr_source(FRAGMENT)?;
|
||||
|
||||
let _ = Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"import-environment",
|
||||
"GTK_IM_MODULE",
|
||||
"QT_IM_MODULE",
|
||||
"XMODIFIERS",
|
||||
"SDL_IM_MODULE",
|
||||
])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
let enabled_unit = Command::new("systemctl")
|
||||
.args(["--user", "enable", "--now", "fcitx5.service"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !enabled_unit && !fcitx_running().await {
|
||||
std::process::Command::new("fcitx5")
|
||||
.arg("-d")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start fcitx5: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable_ime() -> Result<(), String> {
|
||||
let _ = std::fs::remove_file(env_path());
|
||||
let _ = std::fs::remove_file(util::hypr_dir().join(FRAGMENT));
|
||||
util::remove_hypr_source(FRAGMENT)?;
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "disable", "--now", "fcitx5.service"])
|
||||
.status()
|
||||
.await;
|
||||
let _ = Command::new("pkill").args(["-x", "fcitx5"]).status().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_fcitx_config() {
|
||||
let _ = std::process::Command::new("fcitx5-configtool").spawn();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn env_files_use_fcitx_module_name() {
|
||||
assert!(ENV_LINES_SYSTEMD.contains("GTK_IM_MODULE=fcitx"));
|
||||
assert!(ENV_LINES_HYPR.contains("XMODIFIERS,@im=fcitx"));
|
||||
assert!(ENV_LINES_HYPR.contains("exec-once = fcitx5 -d"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
pub mod a11y;
|
||||
pub mod about;
|
||||
pub mod appearance;
|
||||
pub mod aur;
|
||||
pub mod autostart;
|
||||
pub mod backup;
|
||||
pub mod bluetooth;
|
||||
pub mod bread;
|
||||
pub mod breadbar;
|
||||
|
|
@ -15,18 +17,28 @@ pub mod breadpad;
|
|||
pub mod breadpaper;
|
||||
pub mod breadsearch;
|
||||
pub mod breadshot;
|
||||
pub mod channel;
|
||||
pub mod config;
|
||||
pub mod datetime;
|
||||
pub mod defaults;
|
||||
pub mod firewall;
|
||||
pub mod firmware;
|
||||
pub mod hyprland;
|
||||
pub mod ime;
|
||||
pub mod keybinds;
|
||||
pub mod network;
|
||||
pub mod nightlight;
|
||||
pub mod nvidia;
|
||||
pub mod optional;
|
||||
pub mod packages;
|
||||
pub mod power;
|
||||
pub mod printing;
|
||||
pub mod service;
|
||||
pub mod snapshots;
|
||||
pub mod sound;
|
||||
pub mod streaming;
|
||||
pub mod theme;
|
||||
pub mod updates;
|
||||
pub mod users;
|
||||
pub mod util;
|
||||
pub mod vpn;
|
||||
|
|
|
|||
216
src/src/commands/nightlight.rs
Normal file
216
src/src/commands/nightlight.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//! Night light via hyprsunset (Hyprland twilight IPC). The compositor
|
||||
//! talks to a hyprsunset daemon socket; if the binary is missing we offer
|
||||
//! a pacman install rather than pretending the toggle works.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::config;
|
||||
use super::util::{self, command_exists, fail_output};
|
||||
|
||||
const FRAGMENT: &str = "nightlight.conf";
|
||||
const DEFAULT_TEMP: u32 = 3500;
|
||||
const MIN_TEMP: u32 = 2000;
|
||||
const MAX_TEMP: u32 = 6500;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NightlightConfig {
|
||||
enabled: bool,
|
||||
temperature: u32,
|
||||
}
|
||||
|
||||
impl Default for NightlightConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
temperature: DEFAULT_TEMP,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NightlightStatus {
|
||||
installed: bool,
|
||||
running: bool,
|
||||
enabled: bool,
|
||||
temperature: u32,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn persist_path() -> std::path::PathBuf {
|
||||
util::bos_settings_dir().join("nightlight.toml")
|
||||
}
|
||||
|
||||
fn load_persist() -> NightlightConfig {
|
||||
let Ok(text) = std::fs::read_to_string(persist_path()) else {
|
||||
return NightlightConfig::default();
|
||||
};
|
||||
let doc = text.parse::<toml_edit::DocumentMut>().unwrap_or_default();
|
||||
NightlightConfig {
|
||||
enabled: config::get_bool(&doc, &["enabled"]).unwrap_or(false),
|
||||
temperature: config::get_i64(&doc, &["temperature"])
|
||||
.unwrap_or(DEFAULT_TEMP as i64)
|
||||
.clamp(MIN_TEMP as i64, MAX_TEMP as i64) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_persist(cfg: &NightlightConfig) -> Result<(), String> {
|
||||
let mut doc = toml_edit::DocumentMut::new();
|
||||
config::set_bool(&mut doc, &["enabled"], cfg.enabled);
|
||||
config::set_i64(&mut doc, &["temperature"], cfg.temperature as i64);
|
||||
let path = persist_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&path, &doc.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn clamp_temp(t: u32) -> u32 {
|
||||
t.clamp(MIN_TEMP, MAX_TEMP)
|
||||
}
|
||||
|
||||
async fn hyprsunset_running() -> bool {
|
||||
Command::new("hyprctl")
|
||||
.args(["hyprsunset", "gamma", "1.0"])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn start_daemon() -> Result<(), String> {
|
||||
if hyprsunset_running().await {
|
||||
return Ok(());
|
||||
}
|
||||
if !command_exists("hyprsunset") {
|
||||
return Err("hyprsunset is not installed".into());
|
||||
}
|
||||
std::process::Command::new("hyprsunset")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start hyprsunset: {e}"))?;
|
||||
for _ in 0..15 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
if hyprsunset_running().await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("hyprsunset started but Hyprland twilight socket never came up".into())
|
||||
}
|
||||
|
||||
async fn apply_temperature(temp: u32) -> Result<(), String> {
|
||||
start_daemon().await?;
|
||||
let t = clamp_temp(temp).to_string();
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["hyprsunset", "temperature", &t])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "hyprctl hyprsunset"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_identity() -> Result<(), String> {
|
||||
if !hyprsunset_running().await {
|
||||
return Ok(());
|
||||
}
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["hyprsunset", "identity"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "hyprctl hyprsunset"))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_autostart() -> Result<(), String> {
|
||||
let path = util::hypr_dir().join(FRAGMENT);
|
||||
std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?;
|
||||
config::atomic_write(&path, "exec-once = hyprsunset\n").map_err(|e| e.to_string())?;
|
||||
util::ensure_hypr_source(FRAGMENT)
|
||||
}
|
||||
|
||||
fn clear_autostart() -> Result<(), String> {
|
||||
let path = util::hypr_dir().join(FRAGMENT);
|
||||
let _ = std::fs::remove_file(path);
|
||||
util::remove_hypr_source(FRAGMENT)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_nightlight() -> NightlightStatus {
|
||||
let persist = load_persist();
|
||||
let installed = command_exists("hyprsunset");
|
||||
let running = if installed {
|
||||
hyprsunset_running().await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
NightlightStatus {
|
||||
installed,
|
||||
running,
|
||||
enabled: persist.enabled && running,
|
||||
temperature: persist.temperature,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_nightlight(enabled: bool, temperature: u32) -> Result<NightlightStatus, String> {
|
||||
if !command_exists("hyprsunset") {
|
||||
return Ok(NightlightStatus {
|
||||
installed: false,
|
||||
running: false,
|
||||
enabled: false,
|
||||
temperature: clamp_temp(temperature),
|
||||
error: Some("hyprsunset is not installed".into()),
|
||||
});
|
||||
}
|
||||
let mut cfg = NightlightConfig {
|
||||
enabled,
|
||||
temperature: clamp_temp(temperature),
|
||||
};
|
||||
let mut error = None;
|
||||
if enabled {
|
||||
if let Err(e) = apply_temperature(cfg.temperature).await {
|
||||
error = Some(e);
|
||||
cfg.enabled = false;
|
||||
} else if let Err(e) = write_autostart() {
|
||||
error = Some(e);
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = apply_identity().await {
|
||||
error = Some(e);
|
||||
}
|
||||
if let Err(e) = clear_autostart() {
|
||||
error = Some(error.unwrap_or(e));
|
||||
}
|
||||
}
|
||||
save_persist(&cfg)?;
|
||||
Ok(NightlightStatus {
|
||||
installed: true,
|
||||
running: hyprsunset_running().await,
|
||||
enabled: cfg.enabled,
|
||||
temperature: cfg.temperature,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn temp_clamps() {
|
||||
assert_eq!(clamp_temp(100), MIN_TEMP);
|
||||
assert_eq!(clamp_temp(9000), MAX_TEMP);
|
||||
assert_eq!(clamp_temp(3500), 3500);
|
||||
}
|
||||
}
|
||||
106
src/src/commands/nvidia.rs
Normal file
106
src/src/commands/nvidia.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! NVIDIA driver offer. BOS writes a probe file when it sees a discrete
|
||||
//! NVIDIA GPU; Settings only shows the card if that file exists and does
|
||||
//! not install anything until the user clicks.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct NvidiaOffer {
|
||||
gpu: String,
|
||||
reason: String,
|
||||
packages: Vec<String>,
|
||||
}
|
||||
|
||||
fn offer_paths() -> Vec<std::path::PathBuf> {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
let state = std::path::Path::new(&home).join(".local/state/bos");
|
||||
vec![
|
||||
state.join("nvidia-offer.json"),
|
||||
state.join("nvidia-probe.json"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn read_nvidia_offer() -> Option<NvidiaOffer> {
|
||||
for path in offer_paths() {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return Some(generic_offer());
|
||||
};
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if v.get("offer").and_then(|x| x.as_bool()) == Some(false)
|
||||
|| v.get("dismissed").and_then(|x| x.as_bool()) == Some(true)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let gpu = v
|
||||
.get("gpu")
|
||||
.or_else(|| v.get("name"))
|
||||
.or_else(|| v.get("device"))
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("NVIDIA GPU")
|
||||
.to_string();
|
||||
let reason = v
|
||||
.get("reason")
|
||||
.or_else(|| v.get("message"))
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("A discrete NVIDIA GPU was detected. The proprietary driver is not installed until you choose it.")
|
||||
.to_string();
|
||||
let packages = v
|
||||
.get("packages")
|
||||
.and_then(|x| x.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|p| !p.is_empty())
|
||||
.unwrap_or_else(|| vec!["nvidia".into(), "nvidia-utils".into()]);
|
||||
return Some(NvidiaOffer {
|
||||
gpu,
|
||||
reason,
|
||||
packages,
|
||||
});
|
||||
}
|
||||
return Some(generic_offer());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn generic_offer() -> NvidiaOffer {
|
||||
NvidiaOffer {
|
||||
gpu: "NVIDIA GPU".into(),
|
||||
reason: "BOS found an NVIDIA device. Install the proprietary driver only if you want it — nouveau stays otherwise.".into(),
|
||||
packages: vec!["nvidia".into(), "nvidia-utils".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_nvidia_offer() -> Option<NvidiaOffer> {
|
||||
read_nvidia_offer()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn missing_file_is_none() {
|
||||
// This machine's real probe path is not something the unit test
|
||||
// should depend on; the helper is covered via parse cases below.
|
||||
let parsed = serde_json::from_str::<serde_json::Value>("{\"offer\":false}").unwrap();
|
||||
assert_eq!(parsed["offer"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissed_or_offer_false_hides() {
|
||||
// Inlined copies of the hide conditions so a schema change is obvious.
|
||||
let hide = |v: &str| {
|
||||
let v: serde_json::Value = serde_json::from_str(v).unwrap();
|
||||
v.get("offer").and_then(|x| x.as_bool()) == Some(false)
|
||||
|| v.get("dismissed").and_then(|x| x.as_bool()) == Some(true)
|
||||
};
|
||||
assert!(hide(r#"{"offer":false}"#));
|
||||
assert!(hide(r#"{"dismissed":true}"#));
|
||||
assert!(!hide(r#"{"gpu":"RTX 4060"}"#));
|
||||
}
|
||||
}
|
||||
112
src/src/commands/optional.rs
Normal file
112
src/src/commands/optional.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Curated optional software. Not an AUR dump — four explicit offers,
|
||||
//! each installed through a typed command (bakery or allowlisted pacman).
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::packages::get_installed_packages;
|
||||
use super::util::{command_exists, fail_output, pacman_installed};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct OptionalItem {
|
||||
id: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
installed: bool,
|
||||
via: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OptionalStatus {
|
||||
items: Vec<OptionalItem>,
|
||||
flathub: bool,
|
||||
}
|
||||
|
||||
fn bakery_has(name: &str) -> bool {
|
||||
get_installed_packages().iter().any(|p| p.name == name)
|
||||
}
|
||||
|
||||
fn flathub_enabled() -> bool {
|
||||
if !command_exists("flatpak") {
|
||||
return false;
|
||||
}
|
||||
std::process::Command::new("flatpak")
|
||||
.args(["remotes"])
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.to_ascii_lowercase()
|
||||
.contains("flathub")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_optional_software() -> OptionalStatus {
|
||||
let breadcast = bakery_has("breadcast") || command_exists("breadcast");
|
||||
let flatpak = pacman_installed("flatpak") || command_exists("flatpak");
|
||||
let office = pacman_installed("libreoffice-fresh")
|
||||
&& (pacman_installed("papers") || pacman_installed("evince"));
|
||||
let steam = pacman_installed("steam") || command_exists("steam");
|
||||
OptionalStatus {
|
||||
items: vec![
|
||||
OptionalItem {
|
||||
id: "breadcast".into(),
|
||||
title: "breadcast".into(),
|
||||
detail:
|
||||
"Optional bread-ecosystem app. Installed through bakery — it is not on the ISO."
|
||||
.into(),
|
||||
installed: breadcast,
|
||||
via: "bakery".into(),
|
||||
},
|
||||
OptionalItem {
|
||||
id: "flatpak".into(),
|
||||
title: "Flatpak + Flathub".into(),
|
||||
detail: "Enables the Flatpak runtime and the Flathub user remote.".into(),
|
||||
installed: flatpak && flathub_enabled(),
|
||||
via: "pacman".into(),
|
||||
},
|
||||
OptionalItem {
|
||||
id: "office".into(),
|
||||
title: "LibreOffice + PDF".into(),
|
||||
detail: "libreoffice-fresh and papers (GNOME document viewer).".into(),
|
||||
installed: office,
|
||||
via: "pacman".into(),
|
||||
},
|
||||
OptionalItem {
|
||||
id: "steam".into(),
|
||||
title: "Steam".into(),
|
||||
detail: "Valve Steam from the multilib repo.".into(),
|
||||
installed: steam,
|
||||
via: "pacman".into(),
|
||||
},
|
||||
],
|
||||
flathub: flathub_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
/// User Flathub remote — no root. Flatpak itself is installed separately
|
||||
/// via the allowlisted pacman command when missing.
|
||||
#[tauri::command]
|
||||
pub async fn enable_flathub() -> Result<(), String> {
|
||||
if !command_exists("flatpak") {
|
||||
return Err("flatpak is not installed".into());
|
||||
}
|
||||
let output = Command::new("flatpak")
|
||||
.args([
|
||||
"remote-add",
|
||||
"--if-not-exists",
|
||||
"--user",
|
||||
"flathub",
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "flatpak remote-add"))
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
|||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct InstalledPackage {
|
||||
name: String,
|
||||
pub name: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
|
|
@ -23,14 +23,19 @@ pub fn get_installed_packages() -> Vec<InstalledPackage> {
|
|||
let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages) else {
|
||||
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut list: Vec<InstalledPackage> = packages
|
||||
.into_iter()
|
||||
.map(|(name, val)| {
|
||||
let version = val.get("version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
|
||||
let version = val
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
InstalledPackage { name, version }
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
204
src/src/commands/printing.rs
Normal file
204
src/src/commands/printing.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
//! CUPS printers via lpstat / lpadmin. Adding a printer through the full
|
||||
//! device wizard is `system-config-printer`; a simple IPP Everywhere queue
|
||||
//! can be created here when the user has a URI.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util::{fail_output, valid_printer_name};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Printer {
|
||||
name: String,
|
||||
status: String,
|
||||
enabled: bool,
|
||||
is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PrintingStatus {
|
||||
printers: Vec<Printer>,
|
||||
default: Option<String>,
|
||||
cups_ok: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_printers() -> PrintingStatus {
|
||||
let output = match Command::new("lpstat").args(["-p", "-d"]).output().await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return PrintingStatus {
|
||||
printers: Vec::new(),
|
||||
default: None,
|
||||
cups_ok: false,
|
||||
error: Some(format!("couldn't run lpstat: {e}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
return PrintingStatus {
|
||||
printers: Vec::new(),
|
||||
default: None,
|
||||
cups_ok: false,
|
||||
error: Some(fail_output(&output, "lpstat")),
|
||||
};
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
parse_lpstat(&text)
|
||||
}
|
||||
|
||||
fn parse_lpstat(text: &str) -> PrintingStatus {
|
||||
let mut printers = Vec::new();
|
||||
let mut default = None;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("printer ") {
|
||||
let mut parts = rest.splitn(2, ' ');
|
||||
let name = parts.next().unwrap_or("").to_string();
|
||||
let rest = parts.next().unwrap_or("");
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let enabled = !rest.contains("disabled");
|
||||
let status = rest
|
||||
.strip_prefix("is ")
|
||||
.unwrap_or(rest)
|
||||
.split(". ")
|
||||
.next()
|
||||
.unwrap_or(rest)
|
||||
.trim()
|
||||
.to_string();
|
||||
printers.push(Printer {
|
||||
name,
|
||||
status,
|
||||
enabled,
|
||||
is_default: false,
|
||||
});
|
||||
} else if let Some(name) = line.strip_prefix("system default destination: ") {
|
||||
default = Some(name.trim().to_string());
|
||||
} else if line == "no system default destination" {
|
||||
default = None;
|
||||
}
|
||||
}
|
||||
if let Some(def) = default.as_deref() {
|
||||
for p in &mut printers {
|
||||
p.is_default = p.name == def;
|
||||
}
|
||||
}
|
||||
PrintingStatus {
|
||||
printers,
|
||||
default,
|
||||
cups_ok: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_default_printer(name: String) -> Result<(), String> {
|
||||
if !valid_printer_name(&name) {
|
||||
return Err(format!("invalid printer name '{name}'"));
|
||||
}
|
||||
let output = Command::new("lpadmin")
|
||||
.args(["-d", &name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let output = Command::new("pkexec")
|
||||
.args(["lpadmin", "-d", &name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "lpadmin"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_ipp_printer(name: String, uri: String) -> Result<(), String> {
|
||||
if !valid_printer_name(&name) {
|
||||
return Err(format!("invalid printer name '{name}'"));
|
||||
}
|
||||
if !valid_printer_uri(&uri) {
|
||||
return Err("URI must be ipp://, ipps://, socket://, usb://, or dnssd://".into());
|
||||
}
|
||||
let args_owned = [
|
||||
"-p".into(),
|
||||
name.clone(),
|
||||
"-E".into(),
|
||||
"-v".into(),
|
||||
uri,
|
||||
"-m".into(),
|
||||
"everywhere".into(),
|
||||
];
|
||||
let output = Command::new("lpadmin")
|
||||
.args(&args_owned)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut pk = vec!["lpadmin".to_string()];
|
||||
pk.extend(args_owned);
|
||||
let output = Command::new("pkexec")
|
||||
.args(&pk)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "lpadmin"))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_printer_uri(uri: &str) -> bool {
|
||||
let u = uri.trim();
|
||||
!u.is_empty()
|
||||
&& u.len() <= 512
|
||||
&& !u.contains(char::is_whitespace)
|
||||
&& (u.starts_with("ipp://")
|
||||
|| u.starts_with("ipps://")
|
||||
|| u.starts_with("socket://")
|
||||
|| u.starts_with("usb://")
|
||||
|| u.starts_with("dnssd://"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_printer_settings() {
|
||||
let _ = std::process::Command::new("system-config-printer").spawn();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lpstat_parses_idle_and_default() {
|
||||
let text = "\
|
||||
printer Canon-TS6360a is idle. enabled since Mon 10 Aug 2026
|
||||
printer Hall is disabled since yesterday
|
||||
system default destination: Canon-TS6360a
|
||||
";
|
||||
let st = parse_lpstat(text);
|
||||
assert_eq!(st.printers.len(), 2);
|
||||
assert!(st.printers[0].is_default);
|
||||
assert!(st.printers[0].enabled);
|
||||
assert!(!st.printers[1].enabled);
|
||||
assert_eq!(st.default.as_deref(), Some("Canon-TS6360a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_schemes() {
|
||||
assert!(valid_printer_uri("ipp://192.168.1.5/ipp/print"));
|
||||
assert!(valid_printer_uri("ipps://printer.local/ipp"));
|
||||
assert!(!valid_printer_uri("http://evil"));
|
||||
assert!(!valid_printer_uri("ipp://x y"));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,20 +15,49 @@ use tokio::io::{AsyncBufReadExt, BufReader};
|
|||
use tokio::process::Command;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct CmdOutputEvent {
|
||||
pub(crate) struct CmdOutputEvent {
|
||||
session_id: String,
|
||||
line: String,
|
||||
}
|
||||
|
||||
pub(crate) fn emit_line(app: &AppHandle, session_id: &str, line: &str) {
|
||||
let _ = app.emit(
|
||||
"cmd-output",
|
||||
CmdOutputEvent {
|
||||
session_id: session_id.to_string(),
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs a hardcoded `program args...`, emitting one `cmd-output` event per
|
||||
/// line of stdout/stderr (tagged with `session_id` so the frontend can route
|
||||
/// concurrent streams), and resolves to whether it exited successfully.
|
||||
async fn run_hardcoded(app: AppHandle, session_id: String, program: &str, args: &[&str]) -> bool {
|
||||
let child = Command::new(program)
|
||||
.args(args)
|
||||
pub(crate) async fn run_hardcoded(
|
||||
app: AppHandle,
|
||||
session_id: String,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
) -> bool {
|
||||
run_hardcoded_env(app, session_id, program, args, &[]).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_hardcoded_env(
|
||||
app: AppHandle,
|
||||
session_id: String,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
envs: &[(&str, String)],
|
||||
) -> bool {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
.kill_on_drop(true);
|
||||
for (k, v) in envs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let child = cmd.spawn();
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
|
|
@ -135,6 +164,35 @@ pub async fn fwupd_update(app: AppHandle, session_id: String) -> bool {
|
|||
run_hardcoded(app, session_id, "fwupdmgr", &["update", "-y"]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn bakery_install(app: AppHandle, session_id: String, name: String) -> bool {
|
||||
if let Err(e) = super::util::allowed_bakery_install(&name) {
|
||||
emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
run_hardcoded(app, session_id, "bakery", &["-y", "install", &name]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pacman_install(app: AppHandle, session_id: String, packages: Vec<String>) -> bool {
|
||||
let names = match super::util::allowed_pacman_packages(&packages) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mut args: Vec<String> = vec![
|
||||
"pacman".into(),
|
||||
"-S".into(),
|
||||
"--noconfirm".into(),
|
||||
"--".into(),
|
||||
];
|
||||
args.extend(names);
|
||||
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run_hardcoded(app, session_id, "pkexec", &refs).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::valid_bakery_pkg;
|
||||
|
|
|
|||
184
src/src/commands/updates.rs
Normal file
184
src/src/commands/updates.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! Aggregated Updates page: pacman -Qu, bakery dry-run, fwupd devices.
|
||||
//! Rollback is Snapshots / grub-btrfs — not `snapper rollback`.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::firmware::{get_updatable_firmware, FwDevice};
|
||||
use super::nvidia::{read_nvidia_offer, NvidiaOffer};
|
||||
use super::util::strip_ansi;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PendingUpdate {
|
||||
name: String,
|
||||
current: String,
|
||||
latest: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdatesStatus {
|
||||
pacman: Vec<PendingUpdate>,
|
||||
pacman_error: Option<String>,
|
||||
bakery: Vec<PendingUpdate>,
|
||||
bakery_error: Option<String>,
|
||||
firmware: Vec<FwDevice>,
|
||||
nvidia: Option<NvidiaOffer>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_updates_status() -> UpdatesStatus {
|
||||
let (pacman, bakery, firmware) = tokio::join!(
|
||||
list_pacman_upgrades(),
|
||||
list_bakery_outdated(),
|
||||
get_updatable_firmware()
|
||||
);
|
||||
let (pacman, pacman_error) = match pacman {
|
||||
Ok(v) => (v, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
let (bakery, bakery_error) = match bakery {
|
||||
Ok(v) => (v, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
UpdatesStatus {
|
||||
pacman,
|
||||
pacman_error,
|
||||
bakery,
|
||||
bakery_error,
|
||||
firmware,
|
||||
nvidia: read_nvidia_offer(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_pacman_upgrades() -> Result<Vec<PendingUpdate>, String> {
|
||||
let output = Command::new("pacman")
|
||||
.args(["-Qu"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("couldn't run pacman: {e}"))?;
|
||||
// pacman -Qu exits 1 when there is nothing to upgrade.
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_pacman_qu(&text))
|
||||
}
|
||||
|
||||
fn parse_pacman_qu(text: &str) -> Vec<PendingUpdate> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// "name old -> new" — extra fields after new are ignored.
|
||||
let mut parts = line.split_whitespace();
|
||||
let name = parts.next()?.to_string();
|
||||
let current = parts.next()?.to_string();
|
||||
let arrow = parts.next()?;
|
||||
if arrow != "->" {
|
||||
return None;
|
||||
}
|
||||
let latest = parts.next()?.to_string();
|
||||
Some(PendingUpdate {
|
||||
name,
|
||||
current,
|
||||
latest,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn list_bakery_outdated() -> Result<Vec<PendingUpdate>, String> {
|
||||
let output = Command::new("bakery")
|
||||
.args(["--dry-run", "update", "--all"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("couldn't run bakery: {e}"))?;
|
||||
let text = strip_ansi(&String::from_utf8_lossy(&output.stdout));
|
||||
let err = strip_ansi(&String::from_utf8_lossy(&output.stderr));
|
||||
let combined = format!("{text}\n{err}");
|
||||
Ok(parse_bakery_outdated(&combined))
|
||||
}
|
||||
|
||||
/// bakery has no `outdated` subcommand. `--dry-run update --all` is the
|
||||
/// CLI's own preview of what a track-aware update would change.
|
||||
fn parse_bakery_outdated(text: &str) -> Vec<PendingUpdate> {
|
||||
let mut out = Vec::new();
|
||||
for raw in text.lines() {
|
||||
let line = raw.trim();
|
||||
if let Some(pkg) = parse_would_update(line).or_else(|| parse_updating_arrow(line)) {
|
||||
if !out.iter().any(|p: &PendingUpdate| p.name == pkg.name) {
|
||||
out.push(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_would_update(line: &str) -> Option<PendingUpdate> {
|
||||
// "dry-run: would update bakery to 0.7.3-dev.…"
|
||||
// "Would update bakery 0.7.3-dev.…"
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let i = lower.find("would update")?;
|
||||
let rest = line[i + "would update".len()..].trim();
|
||||
let rest = rest.strip_prefix(':').unwrap_or(rest).trim();
|
||||
let rest = rest.strip_prefix("to ").unwrap_or(rest);
|
||||
let mut parts = rest.split_whitespace();
|
||||
let name = parts.next()?.to_string();
|
||||
let mut latest = parts.next().unwrap_or("").to_string();
|
||||
if latest.eq_ignore_ascii_case("to") {
|
||||
latest = parts.next().unwrap_or("").to_string();
|
||||
}
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PendingUpdate {
|
||||
name,
|
||||
current: String::new(),
|
||||
latest,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_updating_arrow(line: &str) -> Option<PendingUpdate> {
|
||||
// "updating bakery 0.7.2 → 0.7.3"
|
||||
let line = line.strip_prefix("updating ")?;
|
||||
let (name, rest) = line.split_once(' ')?;
|
||||
let (current, latest) = rest.split_once('→')?;
|
||||
Some(PendingUpdate {
|
||||
name: name.trim().to_string(),
|
||||
current: current.trim().to_string(),
|
||||
latest: latest.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pacman_qu_parses_arrow_lines() {
|
||||
let text =
|
||||
"linux 6.15.1-1 -> 6.15.2-1\nextra-note\nbos-settings 0.8.0-1 -> 0.8.1-1 [ignored]\n";
|
||||
let v = parse_pacman_qu(text);
|
||||
assert_eq!(v.len(), 2);
|
||||
assert_eq!(v[0].name, "linux");
|
||||
assert_eq!(v[0].current, "6.15.1-1");
|
||||
assert_eq!(v[0].latest, "6.15.2-1");
|
||||
assert_eq!(v[1].name, "bos-settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bakery_dry_run_parses_would_update_and_arrow() {
|
||||
let text = "\
|
||||
· breadbar is already at 0.3.2
|
||||
updating bakery 0.7.2-dev.1 → 0.7.3-dev.2
|
||||
dry-run: would update bakery to 0.7.3-dev.2
|
||||
Would update breadcast 1.2.3
|
||||
1 updated, 14 already up to date
|
||||
";
|
||||
let v = parse_bakery_outdated(text);
|
||||
assert_eq!(v.len(), 2);
|
||||
assert_eq!(v[0].name, "bakery");
|
||||
assert_eq!(v[0].latest, "0.7.3-dev.2");
|
||||
assert_eq!(v[1].name, "breadcast");
|
||||
assert_eq!(v[1].latest, "1.2.3");
|
||||
}
|
||||
}
|
||||
258
src/src/commands/util.rs
Normal file
258
src/src/commands/util.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
//! Shared helpers for the OS-panel commands: PATH lookups, tight name
|
||||
//! checks, 0600 writes, and the Hyprland `source =` fragment convention.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::config;
|
||||
|
||||
/// Pacman packages these panels may install. A generic `pacman -S` runner
|
||||
/// is an arbitrary-package primitive; every name must be on this list.
|
||||
pub const PACMAN_ALLOWLIST: &[&str] = &[
|
||||
"hyprsunset",
|
||||
"fcitx5",
|
||||
"fcitx5-configtool",
|
||||
"fcitx5-gtk",
|
||||
"fcitx5-qt",
|
||||
"fcitx5-im",
|
||||
"fcitx5-chinese-addons",
|
||||
"fcitx5-table-extra",
|
||||
"orca",
|
||||
"kmag",
|
||||
"restic",
|
||||
"flatpak",
|
||||
"libreoffice-fresh",
|
||||
"papers",
|
||||
"evince",
|
||||
"steam",
|
||||
"nvidia",
|
||||
"nvidia-utils",
|
||||
];
|
||||
|
||||
/// Bakery packages these panels may `bakery install`. breadcast is optional
|
||||
/// software and is not on the ISO; do not add breadarr.
|
||||
pub const BAKERY_INSTALL_ALLOWLIST: &[&str] = &["breadcast"];
|
||||
|
||||
pub fn command_exists(name: &str) -> bool {
|
||||
let Some(paths) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
std::env::split_paths(&paths).any(|dir| {
|
||||
let candidate = dir.join(name);
|
||||
candidate.is_file()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pacman_installed(pkg: &str) -> bool {
|
||||
std::process::Command::new("pacman")
|
||||
.args(["-Q", pkg])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Arch package / bakery name: starts alphanumeric, then `[A-Za-z0-9+._-]`.
|
||||
pub fn valid_pkg_name(name: &str) -> bool {
|
||||
let bytes = name.as_bytes();
|
||||
!bytes.is_empty()
|
||||
&& bytes.len() <= 128
|
||||
&& bytes[0].is_ascii_alphanumeric()
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'+' | b'.'))
|
||||
}
|
||||
|
||||
pub fn allowed_pacman_packages(names: &[String]) -> Result<Vec<String>, String> {
|
||||
if names.is_empty() {
|
||||
return Err("no packages given".into());
|
||||
}
|
||||
let mut out = Vec::with_capacity(names.len());
|
||||
for name in names {
|
||||
if !valid_pkg_name(name) || !PACMAN_ALLOWLIST.contains(&name.as_str()) {
|
||||
return Err(format!("refusing to install '{name}'"));
|
||||
}
|
||||
if !out.iter().any(|e| e == name) {
|
||||
out.push(name.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn allowed_bakery_install(name: &str) -> Result<(), String> {
|
||||
if !valid_pkg_name(name) || !BAKERY_INSTALL_ALLOWLIST.contains(&name) {
|
||||
return Err(format!("refusing to bakery-install '{name}'"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bos_settings_dir() -> PathBuf {
|
||||
config::config_dir().join("bos-settings")
|
||||
}
|
||||
|
||||
/// Atomic write with mode 0600 set on the new inode before/after replace,
|
||||
/// matching breadcrumbs' `networks.toml` care.
|
||||
pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("creating {}: {e}", parent.display()))?;
|
||||
}
|
||||
bread_utils::atomic::write_atomic(path, contents, Some(0o600))
|
||||
.map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn strip_ansi(s: &str) -> String {
|
||||
let re = regex::Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("ansi regex");
|
||||
re.replace_all(s, "").into_owned()
|
||||
}
|
||||
|
||||
pub fn fail_output(output: &std::process::Output, what: &str) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let msg = stderr.trim();
|
||||
if !msg.is_empty() {
|
||||
return msg.to_string();
|
||||
}
|
||||
let msg = stdout.trim();
|
||||
if !msg.is_empty() {
|
||||
return msg.to_string();
|
||||
}
|
||||
format!("{what} failed")
|
||||
}
|
||||
|
||||
pub fn hypr_dir() -> PathBuf {
|
||||
config::config_dir().join("hypr")
|
||||
}
|
||||
|
||||
pub fn hyprland_conf() -> PathBuf {
|
||||
hypr_dir().join("hyprland.conf")
|
||||
}
|
||||
|
||||
/// Ensure `hyprland.conf` sources `~/.config/hypr/{fragment}`. Appends a
|
||||
/// single source line when missing; does not rewrite the rest of the file.
|
||||
pub fn ensure_hypr_source(fragment: &str) -> Result<(), String> {
|
||||
if !valid_fragment(fragment) {
|
||||
return Err(format!("invalid hypr fragment '{fragment}'"));
|
||||
}
|
||||
let dir = hypr_dir();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = hyprland_conf();
|
||||
let marker = format!("hypr/{fragment}");
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if existing.lines().any(|l| l.contains(&marker)) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut text = existing;
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&format!("source = ~/.config/hypr/{fragment}\n"));
|
||||
config::atomic_write(&path, &text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn remove_hypr_source(fragment: &str) -> Result<(), String> {
|
||||
if !valid_fragment(fragment) {
|
||||
return Err(format!("invalid hypr fragment '{fragment}'"));
|
||||
}
|
||||
let path = hyprland_conf();
|
||||
let Ok(existing) = std::fs::read_to_string(&path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let marker = format!("hypr/{fragment}");
|
||||
let filtered: String =
|
||||
existing
|
||||
.lines()
|
||||
.filter(|l| !l.contains(&marker))
|
||||
.fold(String::new(), |mut acc, l| {
|
||||
acc.push_str(l);
|
||||
acc.push('\n');
|
||||
acc
|
||||
});
|
||||
if filtered != existing {
|
||||
config::atomic_write(&path, &filtered).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_fragment(name: &str) -> bool {
|
||||
let bytes = name.as_bytes();
|
||||
!bytes.is_empty()
|
||||
&& bytes.len() <= 64
|
||||
&& bytes[0].is_ascii_alphanumeric()
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.'))
|
||||
}
|
||||
|
||||
/// Connection / printer names: no flags, no newlines. Spaces are allowed
|
||||
/// (NetworkManager connection ids often have them).
|
||||
pub fn valid_nm_id(name: &str) -> bool {
|
||||
let t = name.trim();
|
||||
!t.is_empty()
|
||||
&& t.len() <= 256
|
||||
&& !t.starts_with('-')
|
||||
&& !t.contains('\n')
|
||||
&& !t.contains('\0')
|
||||
&& !t.contains(';')
|
||||
}
|
||||
|
||||
pub fn valid_printer_name(name: &str) -> bool {
|
||||
let bytes = name.as_bytes();
|
||||
!bytes.is_empty()
|
||||
&& bytes.len() <= 127
|
||||
&& bytes[0].is_ascii_alphanumeric()
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pkg_name_accepts_arch_names() {
|
||||
assert!(valid_pkg_name("hyprsunset"));
|
||||
assert!(valid_pkg_name("fcitx5-chinese-addons"));
|
||||
assert!(valid_pkg_name("libreoffice-fresh"));
|
||||
assert!(valid_pkg_name("nvidia-utils"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pkg_name_rejects_flags() {
|
||||
assert!(!valid_pkg_name(""));
|
||||
assert!(!valid_pkg_name("-S"));
|
||||
assert!(!valid_pkg_name("--noconfirm"));
|
||||
assert!(!valid_pkg_name("foo;rm"));
|
||||
assert!(!valid_pkg_name("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_rejects_unknown() {
|
||||
assert!(allowed_pacman_packages(&["steam".into()]).is_ok());
|
||||
assert!(allowed_pacman_packages(&["evil".into()]).is_err());
|
||||
assert!(allowed_bakery_install("breadcast").is_ok());
|
||||
assert!(allowed_bakery_install("breadarr").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nm_id_allows_spaces_not_flags() {
|
||||
assert!(valid_nm_id("Home VPN"));
|
||||
assert!(!valid_nm_id("-evil"));
|
||||
assert!(!valid_nm_id("a\nb"));
|
||||
assert!(!valid_nm_id(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_name_is_tight() {
|
||||
assert!(valid_printer_name("Canon-TS6360a"));
|
||||
assert!(!valid_printer_name("foo bar"));
|
||||
assert!(!valid_printer_name("-d"));
|
||||
}
|
||||
}
|
||||
179
src/src/commands/vpn.rs
Normal file
179
src/src/commands/vpn.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//! NetworkManager VPN / WireGuard connections. breadcrumbs stays Wi-Fi
|
||||
//! profiles; this panel only lists `vpn` and `wireguard` connection types.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util::{fail_output, valid_nm_id};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct VpnConnection {
|
||||
name: String,
|
||||
kind: String,
|
||||
active: bool,
|
||||
autoconnect: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VpnStatus {
|
||||
connections: Vec<VpnConnection>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_vpn_connections() -> VpnStatus {
|
||||
let output = match Command::new("nmcli")
|
||||
.args([
|
||||
"-t",
|
||||
"-f",
|
||||
"NAME,TYPE,STATE,AUTOCONNECT",
|
||||
"connection",
|
||||
"show",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return VpnStatus {
|
||||
connections: Vec::new(),
|
||||
error: Some(format!("couldn't run nmcli: {e}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
return VpnStatus {
|
||||
connections: Vec::new(),
|
||||
error: Some(fail_output(&output, "nmcli")),
|
||||
};
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
VpnStatus {
|
||||
connections: parse_nm_connections(&text),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nm_connections(text: &str) -> Vec<VpnConnection> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
// nmcli -t escapes ":" in names as "\:".
|
||||
let cols = split_nmcli(line);
|
||||
if cols.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let kind = cols[1].as_str();
|
||||
if kind != "vpn" && kind != "wireguard" {
|
||||
return None;
|
||||
}
|
||||
let state = cols[2].as_str();
|
||||
let autoconnect = cols.get(3).map(|s| s == "yes").unwrap_or(false);
|
||||
Some(VpnConnection {
|
||||
name: cols[0].clone(),
|
||||
kind: kind.to_string(),
|
||||
active: state == "activated" || state == "activating",
|
||||
autoconnect,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn split_nmcli(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut chars = line.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
if let Some(n) = chars.next() {
|
||||
cur.push(n);
|
||||
}
|
||||
} else if c == ':' {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
} else {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn vpn_connect(name: String) -> Result<(), String> {
|
||||
nmcli_con(&["connection", "up", "id", &checked_id(&name)?]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn vpn_disconnect(name: String) -> Result<(), String> {
|
||||
nmcli_con(&["connection", "down", "id", &checked_id(&name)?]).await
|
||||
}
|
||||
|
||||
fn checked_id(name: &str) -> Result<String, String> {
|
||||
if !valid_nm_id(name) {
|
||||
return Err(format!("invalid connection name '{name}'"));
|
||||
}
|
||||
Ok(name.trim().to_string())
|
||||
}
|
||||
|
||||
async fn nmcli_con(args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("nmcli")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "nmcli"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn vpn_import(path: String) -> Result<(), String> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() || path.contains('\0') || path.contains('\n') {
|
||||
return Err("invalid path".into());
|
||||
}
|
||||
let p = std::path::Path::new(path);
|
||||
if !p.is_absolute() || !p.is_file() {
|
||||
return Err("pick an existing .conf or .ovpn file".into());
|
||||
}
|
||||
let kind = match p
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("ovpn") => "openvpn",
|
||||
Some("conf") => "wireguard",
|
||||
_ => return Err("import a WireGuard .conf or OpenVPN .ovpn file".into()),
|
||||
};
|
||||
nmcli_con(&["connection", "import", "type", kind, "file", path]).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_wireguard_and_skips_wifi() {
|
||||
let text = "\
|
||||
Home WG:wireguard:activated:yes
|
||||
Office:vpn:
|
||||
NetComm:802-11-wireless:activated
|
||||
tailscale0:tun:activated:yes
|
||||
";
|
||||
let v = parse_nm_connections(text);
|
||||
assert_eq!(v.len(), 2);
|
||||
assert_eq!(v[0].name, "Home WG");
|
||||
assert!(v[0].active);
|
||||
assert_eq!(v[1].kind, "vpn");
|
||||
assert!(!v[1].active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescapes_colon_in_name() {
|
||||
let text = r"Work\:VPN:vpn:activated:no";
|
||||
let v = parse_nm_connections(text);
|
||||
assert_eq!(v[0].name, "Work:VPN");
|
||||
}
|
||||
}
|
||||
|
|
@ -89,7 +89,9 @@ pub fn run() {
|
|||
commands::streaming::bakery_update,
|
||||
commands::streaming::bakery_list,
|
||||
commands::streaming::bakery_update_all,
|
||||
commands::streaming::bakery_install,
|
||||
commands::streaming::pacman_system_update,
|
||||
commands::streaming::pacman_install,
|
||||
commands::streaming::fwupd_refresh,
|
||||
commands::streaming::fwupd_update,
|
||||
commands::packages::get_installed_packages,
|
||||
|
|
@ -111,6 +113,37 @@ pub fn run() {
|
|||
commands::breadshot::breadshot_region_clipboard,
|
||||
commands::breadmon::open_breadmon,
|
||||
commands::breadhelp::open_breadhelp,
|
||||
commands::updates::get_updates_status,
|
||||
commands::nvidia::get_nvidia_offer,
|
||||
commands::printing::get_printers,
|
||||
commands::printing::set_default_printer,
|
||||
commands::printing::add_ipp_printer,
|
||||
commands::printing::open_printer_settings,
|
||||
commands::vpn::get_vpn_connections,
|
||||
commands::vpn::vpn_connect,
|
||||
commands::vpn::vpn_disconnect,
|
||||
commands::vpn::vpn_import,
|
||||
commands::nightlight::get_nightlight,
|
||||
commands::nightlight::set_nightlight,
|
||||
commands::ime::get_ime_status,
|
||||
commands::ime::set_ime_enabled,
|
||||
commands::ime::open_fcitx_config,
|
||||
commands::a11y::get_a11y_status,
|
||||
commands::a11y::set_cursor_zoom,
|
||||
commands::a11y::set_orca_running,
|
||||
commands::a11y::open_kmag,
|
||||
commands::defaults::get_default_apps,
|
||||
commands::defaults::save_default_apps,
|
||||
commands::channel::get_bakery_track,
|
||||
commands::channel::set_bakery_track,
|
||||
commands::backup::get_backup_config,
|
||||
commands::backup::save_backup_config,
|
||||
commands::backup::restic_init,
|
||||
commands::backup::restic_backup,
|
||||
commands::backup::restic_restore_dry_run,
|
||||
commands::backup::list_restic_snapshots,
|
||||
commands::optional::get_optional_software,
|
||||
commands::optional::enable_flathub,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -59,6 +59,16 @@ const KNOWN_VIEWS: &[&str] = &[
|
|||
"aur",
|
||||
"firmware",
|
||||
"snapshots",
|
||||
"updates",
|
||||
"printing",
|
||||
"vpn",
|
||||
"nightlight",
|
||||
"ime",
|
||||
"accessibility",
|
||||
"defaults",
|
||||
"channel",
|
||||
"backup",
|
||||
"optional",
|
||||
"breadlock",
|
||||
"breadshot",
|
||||
"breadmon",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue