709 lines
23 KiB
Rust
709 lines
23 KiB
Rust
//! Linux Hello CLI
|
|
//!
|
|
//! Command-line interface for enrollment, testing, and diagnostics.
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use linux_hello_common::{Config, Result};
|
|
use tracing::{info, warn, Level};
|
|
use tracing_subscriber::FmtSubscriber;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "linux-hello")]
|
|
#[command(author, version, about = "Linux Hello facial authentication", long_about = None)]
|
|
struct Cli {
|
|
/// Enable verbose output
|
|
#[arg(short, long)]
|
|
verbose: bool,
|
|
|
|
#[command(subcommand)]
|
|
command: Commands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Capture frames from IR camera (for testing)
|
|
Capture {
|
|
/// Output directory for captured frames
|
|
#[arg(short, long, default_value = ".")]
|
|
output: String,
|
|
|
|
/// Number of frames to capture
|
|
#[arg(short, long, default_value = "5")]
|
|
count: u32,
|
|
|
|
/// Camera device path (auto-detect if not specified)
|
|
#[arg(short, long)]
|
|
device: Option<String>,
|
|
},
|
|
|
|
/// Test face detection on camera or image
|
|
Detect {
|
|
/// Image file to process (use camera if not specified)
|
|
#[arg(short, long)]
|
|
image: Option<String>,
|
|
|
|
/// Show detection confidence scores
|
|
#[arg(long)]
|
|
scores: bool,
|
|
|
|
/// Save annotated output image
|
|
#[arg(short, long)]
|
|
output: Option<String>,
|
|
},
|
|
|
|
/// Show system status
|
|
Status {
|
|
/// Include camera diagnostics
|
|
#[arg(long)]
|
|
camera: bool,
|
|
|
|
/// Include daemon status
|
|
#[arg(long)]
|
|
daemon: bool,
|
|
},
|
|
|
|
/// Enroll a face model
|
|
Enroll {
|
|
/// Label for this enrollment
|
|
#[arg(short, long, default_value = "default")]
|
|
label: String,
|
|
},
|
|
|
|
/// List enrolled face models
|
|
List,
|
|
|
|
/// Remove enrolled face models
|
|
Remove {
|
|
/// Label to remove (or --all for all)
|
|
label: Option<String>,
|
|
|
|
/// Remove all enrolled models
|
|
#[arg(long)]
|
|
all: bool,
|
|
},
|
|
|
|
/// Test authentication
|
|
Test {
|
|
/// Show detailed confidence scores
|
|
#[arg(long)]
|
|
verbose: bool,
|
|
|
|
/// Save debug information
|
|
#[arg(long)]
|
|
debug: bool,
|
|
},
|
|
|
|
/// Show or modify configuration
|
|
Config {
|
|
/// Show config as JSON
|
|
#[arg(long)]
|
|
json: bool,
|
|
|
|
/// Set a configuration value (key=value)
|
|
#[arg(long)]
|
|
set: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Get the real username (handles sudo case)
|
|
fn get_real_username() -> String {
|
|
// Try SUDO_USER first (set when running with sudo)
|
|
// This is the most reliable way to get the real user when using sudo
|
|
std::env::var("SUDO_USER")
|
|
.or_else(|_| std::env::var("USER"))
|
|
.or_else(|_| std::env::var("USERNAME"))
|
|
.unwrap_or_else(|_| {
|
|
// Final fallback: try to get from whoami command
|
|
std::process::Command::new("whoami")
|
|
.output()
|
|
.ok()
|
|
.and_then(|output| String::from_utf8(output.stdout).ok())
|
|
.map(|s| s.trim().to_string())
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
})
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
|
|
// Initialize logging
|
|
let log_level = if cli.verbose {
|
|
Level::DEBUG
|
|
} else {
|
|
Level::INFO
|
|
};
|
|
FmtSubscriber::builder()
|
|
.with_max_level(log_level)
|
|
.with_target(false)
|
|
.init();
|
|
|
|
// Load configuration
|
|
let config = Config::load_or_default();
|
|
|
|
match cli.command {
|
|
Commands::Capture {
|
|
output,
|
|
count,
|
|
device,
|
|
} => cmd_capture(&config, &output, count, device.as_deref()).await,
|
|
Commands::Detect {
|
|
image,
|
|
scores,
|
|
output,
|
|
} => cmd_detect(&config, image.as_deref(), scores, output.as_deref()).await,
|
|
Commands::Status { camera, daemon } => cmd_status(&config, camera, daemon).await,
|
|
Commands::Enroll { label } => cmd_enroll(&config, &label).await,
|
|
Commands::List => cmd_list(&config).await,
|
|
Commands::Remove { label, all } => cmd_remove(&config, label.as_deref(), all).await,
|
|
Commands::Test { verbose, debug } => cmd_test(&config, verbose, debug).await,
|
|
Commands::Config { json, set } => cmd_config(&config, json, set.as_deref()).await,
|
|
}
|
|
}
|
|
|
|
async fn cmd_capture(
|
|
_config: &Config,
|
|
output: &str,
|
|
count: u32,
|
|
device: Option<&str>,
|
|
) -> Result<()> {
|
|
info!("Capturing {} frames to {}", count, output);
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use linux_hello_daemon::camera::{enumerate_cameras, Camera};
|
|
#[cfg(not(target_os = "linux"))]
|
|
use linux_hello_daemon::camera::{enumerate_cameras, Camera};
|
|
|
|
use linux_hello_daemon::PixelFormat;
|
|
|
|
// Find camera
|
|
let device_path = match device {
|
|
Some(d) => d.to_string(),
|
|
None => {
|
|
let cameras = enumerate_cameras()?;
|
|
let ir_cam = cameras.iter().find(|c| c.is_ir);
|
|
let cam = ir_cam.or(cameras.first());
|
|
|
|
match cam {
|
|
Some(c) => {
|
|
info!("Using camera: {}", c);
|
|
c.device_path.clone()
|
|
}
|
|
None => {
|
|
return Err(linux_hello_common::Error::NoCameraFound);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Open camera and capture
|
|
let mut camera = Camera::open(&device_path)?;
|
|
info!("Camera opened, resolution: {:?}", camera.resolution());
|
|
|
|
// Create output directory if it doesn't exist
|
|
std::fs::create_dir_all(output)?;
|
|
|
|
for i in 0..count {
|
|
let frame = camera.capture_frame()?;
|
|
|
|
match frame.format {
|
|
PixelFormat::Grey => {
|
|
let filename = format!("{}/frame_{:03}.png", output, i);
|
|
if let Some(img) = image::GrayImage::from_raw(frame.width, frame.height, frame.data)
|
|
{
|
|
img.save(&filename).map_err(|e| {
|
|
linux_hello_common::Error::Io(std::io::Error::new(
|
|
std::io::ErrorKind::Other,
|
|
e,
|
|
))
|
|
})?;
|
|
info!("Saved frame {} (Permissions: Grey)", filename);
|
|
}
|
|
}
|
|
PixelFormat::Yuyv => {
|
|
// Convert YUYV to grayscale (extract Y channel)
|
|
let filename = format!("{}/frame_{:03}.png", output, i);
|
|
let mut gray_data = Vec::with_capacity((frame.width * frame.height) as usize);
|
|
for chunk in frame.data.chunks_exact(2) {
|
|
gray_data.push(chunk[0]); // Y component
|
|
}
|
|
if let Some(img) = image::GrayImage::from_raw(frame.width, frame.height, gray_data)
|
|
{
|
|
img.save(&filename).map_err(|e| {
|
|
linux_hello_common::Error::Io(std::io::Error::new(
|
|
std::io::ErrorKind::Other,
|
|
e,
|
|
))
|
|
})?;
|
|
info!(
|
|
"Saved frame {} (converted from YUYV to grayscale)",
|
|
filename
|
|
);
|
|
}
|
|
}
|
|
PixelFormat::Mjpeg => {
|
|
// Decode MJPEG and save as PNG
|
|
let filename = format!("{}/frame_{:03}.png", output, i);
|
|
match image::load_from_memory(&frame.data) {
|
|
Ok(img) => {
|
|
let gray = img.to_luma8();
|
|
gray.save(&filename).map_err(|e| {
|
|
linux_hello_common::Error::Io(std::io::Error::new(
|
|
std::io::ErrorKind::Other,
|
|
e,
|
|
))
|
|
})?;
|
|
info!("Saved frame {} (decoded from MJPEG)", filename);
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to decode MJPEG frame {}: {}", i, e);
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
let filename = format!("{}/frame_{:03}.raw", output, i);
|
|
std::fs::write(&filename, &frame.data)?;
|
|
info!(
|
|
"Saved frame {} ({} bytes - Unknown format)",
|
|
filename,
|
|
frame.data.len()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
info!("Capture complete");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn cmd_detect(
|
|
_config: &Config,
|
|
image: Option<&str>,
|
|
show_scores: bool,
|
|
output: Option<&str>,
|
|
) -> Result<()> {
|
|
match image {
|
|
Some(path) => {
|
|
info!("Detecting faces in: {}", path);
|
|
|
|
// Load image
|
|
let img = image::open(path)
|
|
.map_err(|e| linux_hello_common::Error::Detection(e.to_string()))?;
|
|
|
|
let gray = img.to_luma8();
|
|
let (width, height) = gray.dimensions();
|
|
|
|
// Run simple detection (placeholder)
|
|
use linux_hello_daemon::detection::detect_face_simple;
|
|
let detection = detect_face_simple(gray.as_raw(), width, height);
|
|
|
|
match detection {
|
|
Some(det) => {
|
|
let (x, y, w, h) = det.to_pixels(width, height);
|
|
println!("Face detected:");
|
|
println!(" Position: ({}, {})", x, y);
|
|
println!(" Size: {}x{}", w, h);
|
|
if show_scores {
|
|
println!(" Confidence: {:.2}%", det.confidence * 100.0);
|
|
}
|
|
|
|
if let Some(out_path) = output {
|
|
info!("Saving annotated image to: {}", out_path);
|
|
// Convert to RGB for drawing
|
|
let mut rgb_img = img.to_rgb8();
|
|
|
|
// Draw bounding box (red color)
|
|
draw_bounding_box(&mut rgb_img, x, y, w, h, [255, 0, 0]);
|
|
|
|
rgb_img.save(out_path).map_err(|e| {
|
|
linux_hello_common::Error::Io(std::io::Error::new(
|
|
std::io::ErrorKind::Other,
|
|
e,
|
|
))
|
|
})?;
|
|
println!("Annotated image saved to: {}", out_path);
|
|
}
|
|
}
|
|
None => {
|
|
println!("No face detected");
|
|
|
|
if let Some(out_path) = output {
|
|
// Save original image without annotations
|
|
img.save(out_path).map_err(|e| {
|
|
linux_hello_common::Error::Io(std::io::Error::new(
|
|
std::io::ErrorKind::Other,
|
|
e,
|
|
))
|
|
})?;
|
|
println!("Image saved to: {} (no face detected)", out_path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
println!("Live camera detection not yet implemented");
|
|
println!("Use --image to detect in an image file");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Draw a bounding box on an RGB image
|
|
fn draw_bounding_box(img: &mut image::RgbImage, x: u32, y: u32, w: u32, h: u32, color: [u8; 3]) {
|
|
let (img_width, img_height) = img.dimensions();
|
|
let thickness = 2u32;
|
|
|
|
// Clamp coordinates to image bounds
|
|
let x1 = x.min(img_width.saturating_sub(1));
|
|
let y1 = y.min(img_height.saturating_sub(1));
|
|
let x2 = (x + w).min(img_width.saturating_sub(1));
|
|
let y2 = (y + h).min(img_height.saturating_sub(1));
|
|
|
|
// Draw horizontal lines (top and bottom)
|
|
for t in 0..thickness {
|
|
// Top line
|
|
let top_y = y1.saturating_add(t).min(img_height.saturating_sub(1));
|
|
for px in x1..=x2 {
|
|
img.put_pixel(px, top_y, image::Rgb(color));
|
|
}
|
|
|
|
// Bottom line
|
|
let bottom_y = y2.saturating_sub(t);
|
|
if bottom_y >= y1 {
|
|
for px in x1..=x2 {
|
|
img.put_pixel(px, bottom_y, image::Rgb(color));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw vertical lines (left and right)
|
|
for t in 0..thickness {
|
|
// Left line
|
|
let left_x = x1.saturating_add(t).min(img_width.saturating_sub(1));
|
|
for py in y1..=y2 {
|
|
img.put_pixel(left_x, py, image::Rgb(color));
|
|
}
|
|
|
|
// Right line
|
|
let right_x = x2.saturating_sub(t);
|
|
if right_x >= x1 {
|
|
for py in y1..=y2 {
|
|
img.put_pixel(right_x, py, image::Rgb(color));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn cmd_status(config: &Config, show_camera: bool, show_daemon: bool) -> Result<()> {
|
|
println!("Linux Hello Status");
|
|
println!("==================");
|
|
|
|
if show_camera || (!show_camera && !show_daemon) {
|
|
println!("\nCamera:");
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
use linux_hello_daemon::camera::enumerate_cameras;
|
|
match enumerate_cameras() {
|
|
Ok(cameras) => {
|
|
if cameras.is_empty() {
|
|
println!(" No cameras found");
|
|
} else {
|
|
for cam in cameras {
|
|
println!(" - {}", cam);
|
|
if !cam.resolutions.is_empty() {
|
|
println!(
|
|
" Resolutions: {:?}",
|
|
cam.resolutions.iter().take(3).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!(" Error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
println!(" Camera detection requires Linux");
|
|
}
|
|
|
|
if show_daemon || (!show_camera && !show_daemon) {
|
|
println!("\nDaemon:");
|
|
// TODO: Check daemon via D-Bus
|
|
println!(" Status: Unknown (D-Bus not implemented)");
|
|
}
|
|
|
|
println!("\nConfiguration:");
|
|
println!(" Detection model: {}", config.detection.model);
|
|
println!(
|
|
" Anti-spoofing: {}",
|
|
if config.anti_spoofing.enabled {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
}
|
|
);
|
|
println!(
|
|
" TPM: {}",
|
|
if config.tpm.enabled {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
}
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn cmd_enroll(config: &Config, label: &str) -> Result<()> {
|
|
use linux_hello_daemon::auth::AuthService;
|
|
|
|
info!("Starting enrollment with label: {}", label);
|
|
|
|
// Get real user (handles sudo)
|
|
let user = get_real_username();
|
|
|
|
println!("Enrolling user: {}", user);
|
|
println!("Label: {}", label);
|
|
println!("Please look at the camera...");
|
|
|
|
// Create auth service
|
|
let auth_service = AuthService::new(config.clone());
|
|
auth_service.initialize()?;
|
|
|
|
// Enroll with 5 frames
|
|
match auth_service.enroll(&user, label, 5).await {
|
|
Ok(()) => {
|
|
println!("✓ Enrollment successful!");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
eprintln!("✗ Enrollment failed: {}", e);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn cmd_list(_config: &Config) -> Result<()> {
|
|
use linux_hello_common::TemplateStore;
|
|
|
|
let store = TemplateStore::new(TemplateStore::default_path());
|
|
|
|
let users = store.list_users()?;
|
|
|
|
if users.is_empty() {
|
|
println!("No enrolled users");
|
|
return Ok(());
|
|
}
|
|
|
|
println!("Enrolled users:");
|
|
for user in users {
|
|
let templates = store.list_templates(&user)?;
|
|
if templates.is_empty() {
|
|
println!(" {}: (no templates)", user);
|
|
} else {
|
|
println!(" {}:", user);
|
|
for label in templates {
|
|
if let Ok(template) = store.load(&user, &label) {
|
|
println!(
|
|
" - {} (enrolled: {}, frames: {})",
|
|
label, template.enrolled_at, template.frame_count
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn cmd_remove(_config: &Config, label: Option<&str>, all: bool) -> Result<()> {
|
|
use linux_hello_common::TemplateStore;
|
|
|
|
let user = std::env::var("USER")
|
|
.or_else(|_| std::env::var("USERNAME"))
|
|
.unwrap_or_else(|_| "unknown".to_string());
|
|
|
|
let store = TemplateStore::new(TemplateStore::default_path());
|
|
|
|
if all {
|
|
store.remove_all(&user)?;
|
|
println!("Removed all templates for user: {}", user);
|
|
} else if let Some(l) = label {
|
|
store.remove(&user, l)?;
|
|
println!("Removed template '{}' for user: {}", l, user);
|
|
} else {
|
|
println!("Specify a label or --all");
|
|
return Err(linux_hello_common::Error::Config(
|
|
"No label specified".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn cmd_test(config: &Config, verbose: bool, _debug: bool) -> Result<()> {
|
|
use linux_hello_daemon::auth::AuthService;
|
|
|
|
// Get real user (handles sudo)
|
|
let user = get_real_username();
|
|
|
|
println!("Testing authentication for user: {}", user);
|
|
println!("Please look at the camera...");
|
|
|
|
// Create auth service
|
|
let auth_service = AuthService::new(config.clone());
|
|
auth_service.initialize()?;
|
|
|
|
match auth_service.authenticate(&user).await {
|
|
Ok(true) => {
|
|
println!("✓ Authentication successful!");
|
|
if verbose {
|
|
println!(" User: {}", user);
|
|
}
|
|
Ok(())
|
|
}
|
|
Ok(false) => {
|
|
println!("✗ Authentication failed");
|
|
Err(linux_hello_common::Error::AuthenticationFailed)
|
|
}
|
|
Err(e) => {
|
|
eprintln!("✗ Authentication error: {}", e);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn cmd_config(config: &Config, json: bool, set: Option<&str>) -> Result<()> {
|
|
if let Some(kv) = set {
|
|
// Parse key=value
|
|
let parts: Vec<&str> = kv.splitn(2, '=').collect();
|
|
if parts.len() != 2 {
|
|
return Err(linux_hello_common::Error::Config(
|
|
"Invalid format. Use key=value".to_string(),
|
|
));
|
|
}
|
|
|
|
let key = parts[0].trim();
|
|
let value = parts[1].trim();
|
|
|
|
let mut new_config = config.clone();
|
|
|
|
// Update configuration based on key
|
|
match key {
|
|
"general.log_level" => new_config.general.log_level = value.to_string(),
|
|
"general.timeout_seconds" => {
|
|
new_config.general.timeout_seconds = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid timeout value".to_string())
|
|
})?;
|
|
}
|
|
"camera.device" => new_config.camera.device = value.to_string(),
|
|
"camera.ir_emitter" => new_config.camera.ir_emitter = value.to_string(),
|
|
"camera.fps" => {
|
|
new_config.camera.fps = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid fps value".to_string())
|
|
})?;
|
|
}
|
|
"detection.model" => new_config.detection.model = value.to_string(),
|
|
"detection.min_face_size" => {
|
|
new_config.detection.min_face_size = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid min_face_size value".to_string())
|
|
})?;
|
|
}
|
|
"detection.confidence_threshold" => {
|
|
new_config.detection.confidence_threshold = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config(
|
|
"Invalid confidence_threshold value".to_string(),
|
|
)
|
|
})?;
|
|
}
|
|
"embedding.model" => new_config.embedding.model = value.to_string(),
|
|
"embedding.distance_threshold" => {
|
|
new_config.embedding.distance_threshold = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config(
|
|
"Invalid distance_threshold value".to_string(),
|
|
)
|
|
})?;
|
|
}
|
|
"anti_spoofing.enabled" => {
|
|
new_config.anti_spoofing.enabled = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid boolean value".to_string())
|
|
})?;
|
|
}
|
|
"anti_spoofing.depth_check" => {
|
|
new_config.anti_spoofing.depth_check = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid boolean value".to_string())
|
|
})?;
|
|
}
|
|
"anti_spoofing.liveness_model" => {
|
|
new_config.anti_spoofing.liveness_model = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid boolean value".to_string())
|
|
})?;
|
|
}
|
|
"anti_spoofing.min_score" => {
|
|
new_config.anti_spoofing.min_score = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid min_score value".to_string())
|
|
})?;
|
|
}
|
|
"tpm.enabled" => {
|
|
new_config.tpm.enabled = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid boolean value".to_string())
|
|
})?;
|
|
}
|
|
"tpm.pcr_binding" => {
|
|
new_config.tpm.pcr_binding = value.parse().map_err(|_| {
|
|
linux_hello_common::Error::Config("Invalid boolean value".to_string())
|
|
})?;
|
|
}
|
|
_ => {
|
|
return Err(linux_hello_common::Error::Config(format!(
|
|
"Unknown config key: {}. Valid keys: general.log_level, general.timeout_seconds, camera.device, camera.ir_emitter, camera.fps, detection.model, detection.min_face_size, detection.confidence_threshold, embedding.model, embedding.distance_threshold, anti_spoofing.enabled, anti_spoofing.depth_check, anti_spoofing.liveness_model, anti_spoofing.min_score, tpm.enabled, tpm.pcr_binding",
|
|
key
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Save configuration
|
|
let config_path = "/etc/linux-hello/config.toml";
|
|
match new_config.save(config_path) {
|
|
Ok(()) => println!("Configuration saved to {}", config_path),
|
|
Err(_) => {
|
|
// Try user config as fallback
|
|
let user_config_path = dirs_config_path();
|
|
std::fs::create_dir_all(user_config_path.parent().unwrap())?;
|
|
new_config.save(&user_config_path)?;
|
|
println!(
|
|
"Configuration saved to {} (no write access to {})",
|
|
user_config_path.display(),
|
|
config_path
|
|
);
|
|
}
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
if json {
|
|
let json = serde_json::to_string_pretty(config)
|
|
.map_err(|e| linux_hello_common::Error::Serialization(e.to_string()))?;
|
|
println!("{}", json);
|
|
} else {
|
|
let toml = toml::to_string_pretty(config)
|
|
.map_err(|e| linux_hello_common::Error::Serialization(e.to_string()))?;
|
|
println!("{}", toml);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get user-specific config path
|
|
fn dirs_config_path() -> std::path::PathBuf {
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
|
std::path::PathBuf::from(home).join(".config/linux-hello/config.toml")
|
|
}
|