Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
600 changes: 236 additions & 364 deletions app/bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ tauri-build = { version = "2", features = [] }
pkg-config = "0.3"

[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri = { version = "2", features = ["protocol-asset", "devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
Expand Down
5 changes: 5 additions & 0 deletions app/src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub struct FaceMethodConfig {
pub enable: bool,
pub retries: u32,
pub retry_delay: u32,
pub camera: Option<String>,
pub detection: DetectionConfig,
pub recognition: RecognitionConfig,
pub anti_spoofing: AntiSpoofingConfig,
Expand Down Expand Up @@ -69,6 +70,8 @@ struct FaceMethodConfigRaw {
#[serde(default = "default_face_delay")]
pub retry_delay: u32,
#[serde(default)]
pub camera: Option<String>,
#[serde(default)]
pub detection: DetectionConfig,
#[serde(default)]
pub recognition: RecognitionConfig,
Expand Down Expand Up @@ -99,6 +102,7 @@ impl<'de> Deserialize<'de> for FaceMethodConfig {
enable: raw.enable,
retries: raw.retries,
retry_delay: raw.retry_delay,
camera: raw.camera,
detection: raw.detection,
recognition: raw.recognition,
anti_spoofing,
Expand Down Expand Up @@ -262,6 +266,7 @@ fn get_default_config(app: &AppHandle) -> BiopassConfig {
enable: true,
retries: 5,
retry_delay: 200,
camera: None,
detection: DetectionConfig {
model: model_path("yolov8n-face.onnx"),
threshold: 0.8,
Expand Down
57 changes: 21 additions & 36 deletions app/src-tauri/src/face.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,28 @@
use base64::{engine::general_purpose, Engine as _};
use std::fs;
use tauri::AppHandle;

use crate::config::{load_config, BiopassConfig};
use crate::paths::get_faces_dir;

#[tauri::command]
pub fn capture_face(app: AppHandle, data: String) -> Result<String, String> {
pub fn capture_face(app: AppHandle, camera: Option<String>) -> Result<String, String> {
let faces_dir = get_faces_dir(&app)?;
let app_config: BiopassConfig = load_config(app.clone())?;

// Decode base64 image data
let image_bytes = general_purpose::STANDARD
.decode(&data)
.map_err(|e| format!("Failed to decode image: {}", e))?;

// Generate filename with timestamp
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("Failed to get timestamp: {}", e))?
.as_millis();
let filename = format!("face_{}.jpg", timestamp);
let temp_filename = format!("temp_face_{}.jpg", timestamp);

let file_path = faces_dir.join(&filename);
let temp_file_path = faces_dir.join(&temp_filename);

// Create directory if needed
if !faces_dir.exists() {
fs::create_dir_all(&faces_dir)
.map_err(|e| format!("Failed to create faces directory: {}", e))?;
}

// Write temp file
fs::write(&temp_file_path, &image_bytes)
.map_err(|e| format!("Failed to write image: {}", e))?;
// DEBUG: Save a permanent copy to /tmp to see if the frontend is generating a valid jpeg
let _ = fs::write("/tmp/debug_capture.jpg", &image_bytes);

// Run cropper
let detect_model = app_config.methods.face.detection.model;

// Resolve the helper from installed, development, then PATH locations.
let helper_bin = if std::path::Path::new("/usr/bin/biopass-helper").exists() {
"/usr/bin/biopass-helper".to_string()
} else if std::path::Path::new("../../auth/build/pam/biopass-helper").exists() {
Expand All @@ -50,29 +31,33 @@ pub fn capture_face(app: AppHandle, data: String) -> Result<String, String> {
"biopass-helper".to_string()
};

let status = std::process::Command::new(&helper_bin)
.arg("crop-face")
.arg("--input")
.arg(&temp_file_path)
let mut cmd_builder = std::process::Command::new(&helper_bin);
cmd_builder
.arg("capture-face")
.arg("--output")
.arg(&file_path)
.arg("--model")
.arg(&detect_model)
.status()
.map_err(|e| format!("Failed to execute face cropper: {}", e))?;
.arg(&detect_model);

if let Some(cam) = camera.filter(|s| !s.is_empty()) {
cmd_builder.arg("--camera").arg(cam);
}

// Delete temp file
let _ = fs::remove_file(&temp_file_path);
let output = cmd_builder
.output()
.map_err(|e| format!("Failed to execute helper: {}", e))?;

if status.success() {
if output.status.success() {
Ok(file_path.to_string_lossy().to_string())
} else if status.code() == Some(2) {
Err(
"No face detected in the main image. Please make sure your face is visible."
.to_string(),
)
} else if output.status.code() == Some(2) {
Err("No face detected. Please position your face in front of the camera.".to_string())
} else {
Err(format!("Cropper failed with exit status: {}", status))
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"Capture failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr
))
}
}

Expand Down
212 changes: 212 additions & 0 deletions app/src-tauri/src/face_session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;

use base64::{engine::general_purpose, Engine as _};
use tauri::{AppHandle, Emitter};

use crate::config::{load_config, BiopassConfig};
use crate::paths::get_faces_dir;

const PREVIEW_EVENT: &str = "face-preview-frame";
const FRAME_INTERVAL_MS: u64 = 33; // ~30fps ceiling

struct ChildIO {
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}

struct PreviewSession {
child: Child,
io: Arc<Mutex<ChildIO>>,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}

static SESSION: Mutex<Option<PreviewSession>> = Mutex::new(None);

fn helper_path() -> String {
if std::path::Path::new("/usr/bin/biopass-helper").exists() {
"/usr/bin/biopass-helper".into()
} else if std::path::Path::new("../../auth/build/pam/biopass-helper").exists() {
"../../auth/build/pam/biopass-helper".into()
} else {
"biopass-helper".into()
}
}

fn read_line_trim(reader: &mut BufReader<ChildStdout>) -> std::io::Result<String> {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"child closed stdout",
));
}
if line.ends_with('\n') {
line.pop();
}
if line.ends_with('\r') {
line.pop();
}
Ok(line)
}

#[tauri::command]
pub fn start_face_preview(app: AppHandle, camera: Option<String>) -> Result<(), String> {
let mut guard = SESSION.lock().map_err(|e| e.to_string())?;
if guard.is_some() {
return Ok(());
}

let config: BiopassConfig = load_config(app.clone())?;
let detect_model = config.methods.face.detection.model;

let mut cmd = Command::new(helper_path());
cmd.arg("preview-session")
.arg("--model")
.arg(&detect_model)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
if let Some(cam) = camera.filter(|c| !c.is_empty()) {
cmd.arg("--camera").arg(cam);
}

let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn helper: {e}"))?;
let stdin = child.stdin.take().ok_or("missing stdin")?;
let stdout = child.stdout.take().ok_or("missing stdout")?;
let mut reader = BufReader::new(stdout);

let ready = read_line_trim(&mut reader).map_err(|e| format!("Helper did not respond: {e}"))?;
if ready != "READY" {
let _ = child.kill();
return Err(format!("Helper failed to initialize: {ready}"));
}

let io = Arc::new(Mutex::new(ChildIO {
stdin,
stdout: reader,
}));
let stop = Arc::new(AtomicBool::new(false));
let thread = {
let io = Arc::clone(&io);
let stop = Arc::clone(&stop);
let app = app.clone();
thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let frame_result: Result<Vec<u8>, ()> = {
let mut io_guard = match io.lock() {
Ok(g) => g,
Err(_) => break,
};
let io_ref: &mut ChildIO = &mut *io_guard;
let send_err = io_ref.stdin.write_all(b"FRAME\n").is_err()
|| io_ref.stdin.flush().is_err();
if send_err {
break;
}
let mut header = String::new();
if io_ref.stdout.read_line(&mut header).is_err() {
break;
}
let header = header.trim();
if let Some(rest) = header.strip_prefix("OK ") {
if let Ok(len) = rest.parse::<usize>() {
let mut buf = vec![0u8; len];
if io_ref.stdout.read_exact(&mut buf).is_err() {
break;
}
Ok(buf)
} else {
Err(())
}
} else {
// ERR or unexpected, skip this frame.
Err(())
}
};

if let Ok(frame) = frame_result {
let b64 = general_purpose::STANDARD.encode(&frame);
let _ = app.emit(PREVIEW_EVENT, b64);
}

thread::sleep(Duration::from_millis(FRAME_INTERVAL_MS));
}
})
};

*guard = Some(PreviewSession {
child,
io,
stop,
thread: Some(thread),
});
Ok(())
}

#[tauri::command]
pub fn stop_face_preview() -> Result<(), String> {
let mut guard = SESSION.lock().map_err(|e| e.to_string())?;
if let Some(mut sess) = guard.take() {
sess.stop.store(true, Ordering::Relaxed);
// Best-effort QUIT; helper exits on EOF anyway.
if let Ok(mut io) = sess.io.lock() {
let _ = io.stdin.write_all(b"QUIT\n");
let _ = io.stdin.flush();
}
if let Some(t) = sess.thread.take() {
let _ = t.join();
}
let _ = sess.child.kill();
let _ = sess.child.wait();
}
Ok(())
}

#[tauri::command]
pub fn capture_face_in_session(app: AppHandle) -> Result<String, String> {
let guard = SESSION.lock().map_err(|e| e.to_string())?;
let sess = guard.as_ref().ok_or("No active preview session")?;

let faces_dir = get_faces_dir(&app)?;
if !faces_dir.exists() {
std::fs::create_dir_all(&faces_dir)
.map_err(|e| format!("Failed to create faces directory: {e}"))?;
}

let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("Failed to get timestamp: {e}"))?
.as_millis();
let file_path = faces_dir.join(format!("face_{}.jpg", ts));

let mut io = sess.io.lock().map_err(|e| e.to_string())?;
let cmd = format!("CAPTURE {}\n", file_path.display());
io.stdin
.write_all(cmd.as_bytes())
.map_err(|e| format!("write CAPTURE: {e}"))?;
io.stdin.flush().map_err(|e| format!("flush: {e}"))?;

let mut response = String::new();
io.stdout
.read_line(&mut response)
.map_err(|e| format!("read response: {e}"))?;
let response = response.trim();

match response {
"OK" => Ok(file_path.to_string_lossy().to_string()),
"NO_FACE" => {
Err("No face detected. Please position your face in front of the camera.".into())
}
s if s.starts_with("ERR") => Err(s.to_string()),
other => Err(format!("Unexpected response: {other}")),
}
}
5 changes: 5 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
pub mod config;
pub mod face;
pub mod face_session;
pub mod fingerprint;
pub mod fingerprint_ffi;
pub mod paths;
pub mod system;

use config::{load_config, save_config};
use face::{capture_face, delete_face, list_faces};
use face_session::{capture_face_in_session, start_face_preview, stop_face_preview};
use fingerprint::{
add_fingerprint, delete_fingerprint, enroll_fingerprint, fingerprint_is_available,
list_enrolled_fingerprints, list_fingerprint_devices, remove_fingerprint,
Expand Down Expand Up @@ -44,6 +46,9 @@ pub fn run() {
save_config,
get_current_username,
capture_face,
start_face_preview,
stop_face_preview,
capture_face_in_session,
list_faces,
list_video_devices,
delete_face,
Expand Down
Loading