Skip to main content

onenote_cli/
desktop.rs

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        // A valid error envelope is authoritative about whether mutation began.
140        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}