Skip to content

Commit 0a0e1e8

Browse files
committed
Use native macOS auth dialog for Touch ID support
Replace the custom AppleScript askpass + sudo -A fallback with osascript 'do shell script ... with administrator privileges', which shows the native macOS authorization dialog supporting Touch ID, Face ID, and Apple Watch authentication. This also removes the tempfile dependency since we no longer write askpass scripts to disk. Closes #43
1 parent fccb8fa commit 0a0e1e8

3 files changed

Lines changed: 50 additions & 73 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ portable-pty = "0.9"
2626
tauri-plugin-process = "2.3.1"
2727
tauri-plugin-notification = "2.3.3"
2828
tauri-plugin-dialog = "2"
29-
tempfile = "3.25.0"
3029

3130
[target.'cfg(target_os = "macos")'.dependencies]
3231
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSRunningApplication"] }

src-tauri/src/cli.rs

Lines changed: 50 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::process::{Command, Stdio};
22
use std::io::Read;
3-
use std::fs;
43
use std::path::{Path, PathBuf};
54
use std::time::{Duration, Instant};
65
use std::sync::OnceLock;
@@ -263,47 +262,62 @@ fn execute_with_sudo(args: &[&str], passphrase: Option<&str>, silent: bool) -> R
263262
}
264263
}
265264

266-
// Fall back to askpass dialog
267-
let askpass_script = create_askpass_script()?;
265+
// Fall back to native macOS authorization dialog (supports Touch ID)
266+
execute_with_osascript_admin(cli_path, args, passphrase)
267+
}
268+
269+
/// Escape a string for use inside a single-quoted shell argument.
270+
/// Replaces `'` with `'\''` (end quote, escaped quote, start quote).
271+
fn shell_escape(s: &str) -> String {
272+
s.replace('\'', "'\\''")
273+
}
274+
275+
/// Escape a string for use inside an AppleScript double-quoted string.
276+
/// Escapes backslashes and double quotes.
277+
fn applescript_escape(s: &str) -> String {
278+
s.replace('\\', "\\\\").replace('"', "\\\"")
279+
}
268280

269-
// Preserve ALFS_PASSPHRASE through sudo — env_reset strips it otherwise
281+
/// Execute a command with administrator privileges via the native macOS
282+
/// authorization dialog (`do shell script ... with administrator privileges`).
283+
/// This shows the standard macOS auth prompt which supports Touch ID/Face ID.
284+
fn execute_with_osascript_admin(cli_path: &Path, args: &[&str], passphrase: Option<&str>) -> Result<String, String> {
270285
let cli_path_str = cli_path.to_string_lossy();
271-
let mut sudo_args: Vec<&str> = if passphrase.is_some() {
272-
vec!["-A", "--preserve-env=ALFS_PASSPHRASE", "--", &cli_path_str]
273-
} else {
274-
vec!["-A", "--", &cli_path_str]
275-
};
276-
sudo_args.extend(args.iter().copied());
277286

278-
let mut cmd = Command::new("sudo");
279-
cmd.args(&sudo_args);
280-
cmd.env("SUDO_ASKPASS", &askpass_script);
281-
// Use piped stdin instead of null - libkrun's epoll fails with /dev/null
287+
// Build the inner shell command: ALFS_PASSPHRASE='...' /path/to/anylinuxfs arg1 arg2
288+
let mut shell_cmd = String::new();
289+
if let Some(pass) = passphrase {
290+
shell_cmd.push_str(&format!("ALFS_PASSPHRASE='{}' ", shell_escape(pass)));
291+
}
292+
shell_cmd.push_str(&format!("'{}'", shell_escape(&cli_path_str)));
293+
for arg in args {
294+
shell_cmd.push_str(&format!(" '{}'", shell_escape(arg)));
295+
}
296+
297+
// Wrap in AppleScript: do shell script "<cmd>" with administrator privileges
298+
let applescript = format!(
299+
"do shell script \"{}\" with administrator privileges",
300+
applescript_escape(&shell_cmd)
301+
);
302+
303+
let mut cmd = Command::new("osascript");
304+
cmd.arg("-e").arg(&applescript);
282305
cmd.stdin(Stdio::piped());
283306
cmd.stdout(Stdio::piped());
284307
cmd.stderr(Stdio::piped());
285308

286-
if let Some(pass) = passphrase {
287-
cmd.env("ALFS_PASSPHRASE", pass);
288-
}
289-
290-
// Spawn the process so we can handle it with timeout
291309
let mut child = cmd.spawn()
292-
.map_err(|e| format!("Failed to execute sudo: {}", e))?;
310+
.map_err(|e| format!("Failed to execute osascript: {}", e))?;
293311

294-
// Wait for process with timeout (30 seconds for mount operations)
295-
let timeout = Duration::from_secs(30);
312+
// 60-second timeout — user needs time to authenticate via Touch ID / password
313+
let timeout = Duration::from_secs(60);
296314
let start = Instant::now();
297315

298316
loop {
299317
match child.try_wait() {
300318
Ok(Some(status)) => {
301-
// Process finished
302-
let _ = fs::remove_file(&askpass_script);
303-
304319
let mut stdout = String::new();
305320
let mut stderr = String::new();
306-
307321
if let Some(ref mut out) = child.stdout {
308322
let _ = out.read_to_string(&mut stdout);
309323
}
@@ -313,64 +327,29 @@ fn execute_with_sudo(args: &[&str], passphrase: Option<&str>, silent: bool) -> R
313327

314328
if status.success() {
315329
return Ok(stdout);
316-
} else {
317-
// Check for wrong password or cancelled
318-
if stderr.contains("Sorry, try again") || stderr.contains("incorrect password") {
319-
return Err("Incorrect password".to_string());
320-
} else if stderr.contains("no askpass program") || stderr.contains("no password was provided") {
321-
return Err("Authentication cancelled".to_string());
322-
} else {
323-
return Err(sanitize_error(&stdout, &stderr));
324-
}
325330
}
331+
332+
// User pressed Cancel in the auth dialog
333+
if stderr.contains("User canceled")
334+
|| stderr.contains("user canceled")
335+
|| stderr.contains("-128")
336+
{
337+
return Err("Authentication cancelled".to_string());
338+
}
339+
340+
return Err(sanitize_error(&stdout, &stderr));
326341
}
327342
Ok(None) => {
328-
// Process still running
329343
if start.elapsed() > timeout {
330-
// Timeout - kill the process
331344
let _ = child.kill();
332345
let _ = child.wait();
333-
let _ = fs::remove_file(&askpass_script);
334346
return Err("Command timed out".to_string());
335347
}
336348
std::thread::sleep(Duration::from_millis(100));
337349
}
338350
Err(e) => {
339-
let _ = fs::remove_file(&askpass_script);
340351
return Err(format!("Error waiting for process: {}", e));
341352
}
342353
}
343354
}
344355
}
345-
346-
fn create_askpass_script() -> Result<String, String> {
347-
use std::io::Write;
348-
use std::os::unix::fs::PermissionsExt;
349-
350-
// Create askpass script that uses osascript to prompt for password
351-
// The password goes directly from osascript to sudo, never through our app
352-
let script_content = r#"#!/bin/bash
353-
osascript -e 'Tell application "System Events" to display dialog "anylinuxfs requires administrator privileges." & return & return & "Enter your password:" with hidden answer default answer "" buttons {"Cancel", "OK"} default button "OK" with title "Authentication Required" with icon caution' -e 'text returned of result' 2>/dev/null
354-
"#;
355-
356-
// Use tempfile for a cryptographically random filename, preventing symlink attacks
357-
let mut file = tempfile::Builder::new()
358-
.prefix("anylinuxfs-askpass-")
359-
.suffix(".sh")
360-
.tempfile()
361-
.map_err(|e| format!("Failed to create askpass script: {}", e))?;
362-
363-
// Set restrictive permissions (owner-only executable)
364-
file.as_file().set_permissions(fs::Permissions::from_mode(0o700))
365-
.map_err(|e| format!("Failed to set askpass script permissions: {}", e))?;
366-
367-
file.write_all(script_content.as_bytes())
368-
.map_err(|e| format!("Failed to write askpass script: {}", e))?;
369-
370-
// Persist the file (disables auto-delete) so sudo can read it;
371-
// the caller is responsible for removing it after use
372-
let (_, path) = file.keep()
373-
.map_err(|e| format!("Failed to persist askpass script: {}", e))?;
374-
375-
Ok(path.to_string_lossy().to_string())
376-
}

0 commit comments

Comments
 (0)