1#![allow(clippy::doc_markdown)]
10
11use crate::locked;
12use crate::prelude::Error;
13
14#[derive(Copy, Clone, Debug)]
16pub enum InputKind {
17 Secret,
18 Visible,
19}
20
21pub fn prompt(
25 title: &str,
26 message: &str,
27 button: &str,
28 kind: InputKind,
29) -> Result<locked::Password, Error> {
30 #[cfg(target_os = "macos")]
31 {
32 imp::prompt(title, message, button, kind)
33 }
34 #[cfg(not(target_os = "macos"))]
35 {
36 let _ = (title, message, button, kind);
37 Err(Error::NativePromptUnsupported)
38 }
39}
40
41pub fn prompt_master_password(
42 title: &str,
43 message: &str,
44) -> Result<locked::Password, Error> {
45 prompt(title, message, "Unlock", InputKind::Secret)
46}
47
48#[cfg(target_os = "macos")]
49mod imp {
50 use std::process::Command;
51
52 use zeroize::Zeroize as _;
53
54 use super::{locked, Error, InputKind};
55
56 fn escape(s: &str) -> String {
61 let mut out = String::with_capacity(s.len() + 2);
62 out.push('"');
63 for ch in s.chars() {
64 match ch {
65 '\\' | '"' => {
66 out.push('\\');
67 out.push(ch);
68 }
69 _ => out.push(ch),
70 }
71 }
72 out.push('"');
73 out
74 }
75
76 const MARKER: &str = ", text returned:";
77
78 pub fn prompt(
79 title: &str,
80 message: &str,
81 button: &str,
82 kind: InputKind,
83 ) -> Result<locked::Password, Error> {
84 let hidden = match kind {
85 InputKind::Secret => "with hidden answer",
86 InputKind::Visible => "",
87 };
88 let script = format!(
89 "display dialog {msg} with title {title} \
90 default answer \"\" {hidden} \
91 buttons {{\"Cancel\", {btn}}} default button {btn} \
92 with icon caution",
93 msg = escape(message),
94 title = escape(title),
95 btn = escape(button),
96 );
97
98 let mut output = Command::new("/usr/bin/osascript")
99 .arg("-e")
100 .arg(&script)
101 .output()
102 .map_err(|e| Error::NativePromptFailed {
103 code: e.raw_os_error().unwrap_or(-1),
104 stage: "osascript spawn",
105 })?;
106
107 let result = extract_password(&output);
111 output.stdout.zeroize();
112 output.stderr.zeroize();
113 result
114 }
115
116 fn extract_password(
117 output: &std::process::Output,
118 ) -> Result<locked::Password, Error> {
119 if !output.status.success() {
120 let stderr = String::from_utf8_lossy(&output.stderr);
121 if stderr.contains("User canceled") || stderr.contains("-128") {
122 return Err(Error::PinentryCancelled);
123 }
124 return Err(Error::NativePromptFailed {
125 code: output.status.code().unwrap_or(-1),
126 stage: "osascript exit",
127 });
128 }
129
130 let Ok(stdout) = std::str::from_utf8(&output.stdout) else {
134 return Err(Error::NativePromptFailed {
135 code: 0,
136 stage: "osascript stdout utf8",
137 });
138 };
139 let value_str = stdout
140 .find(MARKER)
141 .map(|idx| stdout[idx + MARKER.len()..].trim_end_matches('\n'))
142 .ok_or(Error::NativePromptFailed {
143 code: 0,
144 stage: "osascript stdout parse",
145 })?;
146
147 let mut buf = locked::Vec::new();
148 buf.extend(value_str.as_bytes().iter().copied());
149 Ok(locked::Password::new(buf))
150 }
151}