1use crate::error::Error;
2use base64::{Engine, engine::general_purpose::STANDARD};
3use serde_json::Value;
4use std::{path::PathBuf, process::Stdio, time::Duration};
5use tokio::{
6 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
7 process::Command,
8};
9
10pub const WRITE_SCRIPT: &str = include_str!("desktop_write.ps1");
11pub const SCRIPT: &str = include_str!("desktop.ps1");
12const MAX_OUTPUT: u64 = 32 * 1024 * 1024;
13
14pub fn powershell() -> Result<PathBuf, Error> {
15 let supported = cfg!(windows)
16 || (cfg!(target_os = "linux")
17 && std::fs::read_to_string("/proc/sys/kernel/osrelease")
18 .unwrap_or_default()
19 .to_ascii_lowercase()
20 .contains("microsoft"));
21 if !supported {
22 return Err(Error::new(
23 "unsupported",
24 "Desktop access requires Windows or WSL with Windows interop, and the Windows OneNote desktop application. macOS and web OneNote do not expose this COM interface.",
25 ));
26 }
27 std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
28 .map(|p| p.join("powershell.exe")).find(|p| p.is_file())
29 .ok_or_else(|| Error::desktop("powershell.exe is not on PATH; enable Windows interop in WSL or restore Windows PowerShell to PATH"))
30}
31
32pub async fn run(request: Value) -> Result<String, Error> {
33 let value = run_bridge(
34 Command::new(powershell()?),
35 request,
36 SCRIPT,
37 Duration::from_secs(45),
38 )
39 .await?;
40 value["xml"]
41 .as_str()
42 .map(str::to_owned)
43 .ok_or_else(|| Error::desktop("OneNote bridge returned no XML"))
44}
45
46pub async fn scan(request: Value) -> Result<Value, Error> {
47 run_bridge(
48 Command::new(powershell()?),
49 request,
50 SCRIPT,
51 Duration::from_secs(45),
52 )
53 .await
54}
55
56pub async fn write(request: Value) -> Result<Value, Error> {
57 let command = Command::new(powershell()?);
58 run_bridge(command, request, WRITE_SCRIPT, Duration::from_secs(45)).await
59}
60
61async fn bounded(reader: impl AsyncRead + Unpin, max: u64) -> Result<Vec<u8>, Error> {
62 let mut bytes = Vec::new();
63 reader.take(max + 1).read_to_end(&mut bytes).await?;
64 if bytes.len() as u64 > max {
65 return Err(Error::desktop(
66 "OneNote response exceeded the size limit; narrow the notebook or search scope",
67 ));
68 }
69 Ok(bytes)
70}
71
72async fn run_bridge(
73 mut command: Command,
74 request: Value,
75 script: &str,
76 timeout: Duration,
77) -> Result<Value, Error> {
78 let encoded = STANDARD.encode(
79 script
80 .encode_utf16()
81 .flat_map(u16::to_le_bytes)
82 .collect::<Vec<_>>(),
83 );
84 let mut child = command
85 .args([
86 "-NoLogo",
87 "-NoProfile",
88 "-NonInteractive",
89 "-STA",
90 "-EncodedCommand",
91 &encoded,
92 ])
93 .stdin(Stdio::piped())
94 .stdout(Stdio::piped())
95 .stderr(Stdio::piped())
96 .kill_on_drop(true)
97 .spawn()
98 .map_err(|e| Error::desktop(format!("Cannot start Windows PowerShell: {e}")))?;
99 let mut stdin = child
100 .stdin
101 .take()
102 .ok_or_else(|| Error::desktop("Missing bridge stdin"))?;
103 let stdout = child
104 .stdout
105 .take()
106 .ok_or_else(|| Error::desktop("Missing bridge stdout"))?;
107 let stderr = child
108 .stderr
109 .take()
110 .ok_or_else(|| Error::desktop("Missing bridge stderr"))?;
111 let bytes = serde_json::to_vec(&request).map_err(|e| Error::invalid(e.to_string()))?;
112 let uncertain = |e: Error| {
113 if script == WRITE_SCRIPT {
114 Error::new(
115 "write_uncertain",
116 format!(
117 "{}. The {} on target {} may have taken effect; inspect OneNote before retrying.",
118 e.message,
119 request["operation"].as_str().unwrap_or("write"),
120 request["target"].as_str().unwrap_or("unknown")
121 ),
122 )
123 } else {
124 e
125 }
126 };
127 let operation = async {
128 let (_, stdout, stderr, status) = tokio::try_join!(
129 async {
130 stdin.write_all(&bytes).await?;
131 drop(stdin);
132 Ok::<_, Error>(())
133 },
134 bounded(stdout, MAX_OUTPUT),
135 bounded(stderr, 64 * 1024),
136 async { Ok::<_, Error>(child.wait().await?) },
137 )
138 .map_err(&uncertain)?;
139 if script == WRITE_SCRIPT {
141 let decoded: Option<Value> =
142 serde_json::from_slice(stdout.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&stdout))
143 .ok();
144 if decoded.as_ref().is_some_and(|v| v.get("error").is_some()) {
145 return parse_output(status.success(), &stdout, &stderr);
146 }
147 parse_output(status.success(), &stdout, &stderr).map_err(&uncertain)
148 } else {
149 parse_output(status.success(), &stdout, &stderr)
150 }
151 };
152 let result = match tokio::time::timeout(timeout, operation).await {
153 Ok(result) => result,
154 Err(_) => Err(uncertain(Error::desktop(
155 "OneNote did not respond within 45 seconds; check its Windows desktop for setup, password, or sync prompts",
156 ))),
157 };
158 if result.is_err() {
159 let _ = child.kill().await;
160 }
161 result
162}
163
164fn parse_output(success: bool, stdout: &[u8], stderr: &[u8]) -> Result<Value, Error> {
165 let value: Value =
166 serde_json::from_slice(stdout.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(stdout))
167 .map_err(|_| {
168 Error::desktop(format!(
169 "Invalid PowerShell response: {}",
170 String::from_utf8_lossy(stderr).trim()
171 ))
172 })?;
173 if let Some(error) = value.get("error") {
174 let kind = match error["kind"].as_str() {
175 Some("invalid_input") => "invalid_input",
176 Some("not_found") => "not_found",
177 Some("conflict") => "conflict",
178 Some("write_uncertain") => "write_uncertain",
179 _ => "desktop_error",
180 };
181 let mut failure = Error::new(
182 kind,
183 error["message"]
184 .as_str()
185 .unwrap_or("OneNote COM operation failed"),
186 );
187 failure.page_id = error["page_id"]
188 .as_str()
189 .filter(|id| !id.is_empty())
190 .map(str::to_owned);
191 return Err(failure);
192 }
193 if !success {
194 return Err(Error::desktop(format!(
195 "PowerShell failed: {}",
196 String::from_utf8_lossy(stderr).trim()
197 )));
198 }
199 Ok(value)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 #[test]
206 fn script_fits_windows_command_line_and_protocol_errors_are_preserved() {
207 for script in [SCRIPT, WRITE_SCRIPT] {
208 assert!(script.encode_utf16().count() * 2 * 4 / 3 < 32000);
209 }
210 assert!(parse_output(true, b"noise", b"").is_err());
211 assert_eq!(
212 parse_output(
213 false,
214 br#"{"error":{"kind":"not_found","message":"Gone"}}"#,
215 b""
216 )
217 .unwrap_err()
218 .kind,
219 "not_found"
220 );
221 assert_eq!(
222 parse_output(true, b"\xef\xbb\xbf{\"xml\":\"hello\"}\r\n", b"").unwrap()["xml"],
223 "hello"
224 );
225 }
226 #[tokio::test]
227 async fn output_size_is_bounded() {
228 assert!(bounded(&b"12345"[..], 4).await.is_err());
229 assert_eq!(bounded(&b"1234"[..], 4).await.unwrap(), b"1234");
230 }
231 #[cfg(unix)]
232 #[tokio::test]
233 async fn hung_bridge_times_out() {
234 let mut command = Command::new("sh");
235 command.args(["-c", "exec sleep 10"]);
236 let error = run_bridge(
237 command,
238 serde_json::json!({}),
239 SCRIPT,
240 Duration::from_millis(50),
241 )
242 .await
243 .unwrap_err();
244 assert_eq!(error.kind, "desktop_error");
245 assert!(error.message.contains("did not respond"));
246 }
247 #[cfg(unix)]
248 #[tokio::test]
249 async fn write_transport_failures_are_uncertain_but_com_errors_are_preserved() {
250 for (shell, expected) in [
251 ("exec sleep 10", "write_uncertain"),
252 ("cat >/dev/null; printf noise", "write_uncertain"),
253 (
254 "cat >/dev/null; printf '%s' '{\"error\":{\"kind\":\"desktop_error\",\"message\":\"Locked\",\"page_id\":\"page\"}}'; exit 1",
255 "desktop_error",
256 ),
257 ] {
258 let mut command = Command::new("sh");
259 command.args(["-c", shell]);
260 let error = run_bridge(
261 command,
262 serde_json::json!({"operation":"append","target":"page"}),
263 WRITE_SCRIPT,
264 Duration::from_millis(100),
265 )
266 .await
267 .unwrap_err();
268 assert_eq!(error.kind, expected);
269 if expected == "desktop_error" {
270 assert_eq!(error.page_id.as_deref(), Some("page"));
271 } else {
272 assert!(error.message.contains("inspect OneNote before retrying"));
273 }
274 }
275 }
276}