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