Skip to main content

max / supernote-push

confirm wire format against Supernote Nomad (firmware ~2026-07) Reconnaissance against a real device (Nomad on port 8089) revealed the actual Browse & Access shape, which differs from the initial guess: - Upload is POST multipart/form-data to the folder URL, field name "file", plus a custom "file-size" header. Not PUT with a raw body. - There is no JSON listing endpoint. The folder URL returns a full HTML page with the directory contents embedded as a `const json = '...'` literal in the page's script. We extract the blob and parse it. - Real listing shape: {"deviceName","fileList":[{"name","uri", "isDirectory","size","date","extension"}]}. `isDirectory` (camelCase), size in bytes. browse.rs is now the sole home for these facts; client.rs is unchanged externally (still calls `push_pdf` / `list`). Multipart is hand-rolled (~30 lines) rather than pulling a crate. Examples added: `discover` (mDNS scan) and `push` (POST to a host). Verified: pushed a 14817-byte PDF to /Document; listing confirms exact byte size and filename.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 14:02 UTC
Commit: 88e8de483a93668452ad8d6072b840ebada57970
Parent: b0c65c2
4 files changed, +212 insertions, -54 deletions
@@ -0,0 +1,22 @@
1 + //! mDNS scan for Supernote devices on the local network.
2 + //!
3 + //! cargo run --example discover
4 + //! cargo run --example discover -- 10 # wait up to 10 seconds
5 +
6 + use std::time::Duration;
7 +
8 + fn main() -> Result<(), Box<dyn std::error::Error>> {
9 + let secs: u64 = std::env::args().nth(1).as_deref().unwrap_or("5").parse()?;
10 + println!("scanning for {secs}s...");
11 +
12 + let devices = supernote_push::discover(Duration::from_secs(secs))?;
13 + if devices.is_empty() {
14 + println!("no devices found.");
15 + println!("make sure Wi-Fi Transfer is on: Files app -> menu -> Browse & Access.");
16 + return Ok(());
17 + }
18 + for d in devices {
19 + println!(" {}", d.host());
20 + }
21 + Ok(())
22 + }
@@ -0,0 +1,54 @@
1 + //! Push a local PDF to a Supernote via Browse & Access.
2 + //!
3 + //! cargo run --example push -- <host> <local.pdf> [remote_dir] [remote_name]
4 + //!
5 + //! Examples:
6 + //!
7 + //! cargo run --example push -- 192.168.1.42 ~/Downloads/workout.pdf
8 + //! cargo run --example push -- 192.168.1.42 ./test.pdf Document/ripgrow today.pdf
9 + //!
10 + //! Passcode: set `SUPERNOTE_PASSCODE` in the environment if Browse & Access
11 + //! is protected.
12 +
13 + use std::path::PathBuf;
14 +
15 + use supernote_push::{Reachability, Supernote};
16 +
17 + fn main() -> Result<(), Box<dyn std::error::Error>> {
18 + let mut args = std::env::args().skip(1);
19 + let host = args.next().ok_or("usage: push <host> <local.pdf> [remote_dir] [remote_name]")?;
20 + let local: PathBuf = args.next().ok_or("missing <local.pdf>")?.into();
21 + let remote_dir = args.next().unwrap_or_else(|| "Document/ripgrow".to_string());
22 + let remote_name = args.next().unwrap_or_else(|| {
23 + local
24 + .file_name()
25 + .and_then(|s| s.to_str())
26 + .unwrap_or("push.pdf")
27 + .to_string()
28 + });
29 +
30 + let bytes = std::fs::read(&local)?;
31 + println!("read {} bytes from {}", bytes.len(), local.display());
32 +
33 + let mut sn = Supernote::at(&host);
34 + if let Ok(pc) = std::env::var("SUPERNOTE_PASSCODE") {
35 + sn = sn.with_passcode(pc);
36 + }
37 +
38 + print!("probing {host}... ");
39 + match sn.reachable() {
40 + Reachability::Up => println!("up"),
41 + Reachability::WifiTransferOff => {
42 + println!("responded but not as expected — check Wi-Fi Transfer");
43 + }
44 + Reachability::Unreachable => {
45 + println!("unreachable");
46 + return Err("device did not respond".into());
47 + }
48 + }
49 +
50 + println!("POST {host}/{remote_dir} (filename={remote_name}, {} bytes)", bytes.len());
51 + sn.push_pdf(&remote_dir, &remote_name, &bytes)?;
52 + println!("done.");
53 + Ok(())
54 + }
M src/browse.rs +135 -53
@@ -2,6 +2,14 @@
2 2 //!
3 3 //! Every assumption about the device's HTTP surface lives in this file.
4 4 //! When firmware drifts, only this module needs to change.
5 + //!
6 + //! Confirmed against a Supernote Nomad on 2026-07-19:
7 + //! - Upload is POST multipart/form-data to the folder URL (not PUT).
8 + //! - Field name is `file`, plus a custom `file-size` header with the byte
9 + //! count of the file.
10 + //! - Listing is not a JSON endpoint — the folder URL returns an HTML page
11 + //! with a `const json = '...'` blob embedded in the page's script. We
12 + //! extract and parse that.
5 13
6 14 use std::time::Duration;
7 15
@@ -17,14 +25,15 @@ const PATH: &AsciiSet = &CONTROLS.add(b' ').add(b'#').add(b'?').add(b'"').add(b'
17 25 pub(crate) fn probe(host: &str, port: u16, timeout: Duration) -> Reachability {
18 26 let url = format!("http://{host}:{port}/");
19 27 match agent(timeout).get(&url).call() {
20 - Ok(_) => Reachability::Up,
28 + Ok(resp) if resp.status() >= 200 && resp.status() < 300 => Reachability::Up,
29 + Ok(_) => Reachability::WifiTransferOff,
21 30 Err(ureq::Error::Status(401 | 403, _)) => Reachability::Up,
22 31 Err(ureq::Error::Status(_, _)) => Reachability::WifiTransferOff,
23 32 Err(ureq::Error::Transport(_)) => Reachability::Unreachable,
24 33 }
25 34 }
26 35
27 - pub(crate) fn put(
36 + pub(crate) fn upload(
28 37 host: &str,
29 38 port: u16,
30 39 passcode: Option<&str>,
@@ -33,14 +42,17 @@ pub(crate) fn put(
33 42 bytes: &[u8],
34 43 timeout: Duration,
35 44 ) -> Result<()> {
36 - let url = build_url(host, port, dir, Some(filename));
45 + let url = build_url(host, port, dir, None);
46 + let (body, content_type) = encode_multipart(filename, "application/pdf", bytes);
47 +
37 48 let mut req = agent(timeout)
38 - .put(&url)
39 - .set("Content-Type", "application/pdf");
49 + .post(&url)
50 + .set("Content-Type", &content_type)
51 + .set("file-size", &bytes.len().to_string());
40 52 if let Some(pc) = passcode {
41 53 req = req.set("Authorization", &basic(pc));
42 54 }
43 - req.send_bytes(bytes).map_err(Error::from_http)?;
55 + req.send_bytes(&body).map_err(Error::from_http)?;
44 56 Ok(())
45 57 }
46 58
@@ -52,13 +64,17 @@ pub(crate) fn list(
52 64 timeout: Duration,
53 65 ) -> Result<Vec<Entry>> {
54 66 let url = build_url(host, port, dir, None);
55 - let mut req = agent(timeout).get(&url).set("Accept", "application/json");
67 + let mut req = agent(timeout).get(&url);
56 68 if let Some(pc) = passcode {
57 69 req = req.set("Authorization", &basic(pc));
58 70 }
59 - let resp = req.call().map_err(Error::from_http)?;
60 - let raw: Listing = resp.into_json().map_err(Error::from)?;
61 - Ok(raw.into_entries())
71 + let html = req.call().map_err(Error::from_http)?.into_string()?;
72 + let json = extract_embedded_json(&html).ok_or_else(|| {
73 + Error::Http("could not find embedded webInfo JSON in listing page".to_string())
74 + })?;
75 + let page: PageInfo = serde_json::from_str(&json)
76 + .map_err(|e| Error::Http(format!("failed to parse listing JSON: {e}")))?;
77 + Ok(page.file_list.into_iter().map(RawEntry::into_entry).collect())
62 78 }
63 79
64 80 fn agent(timeout: Duration) -> ureq::Agent {
@@ -69,8 +85,10 @@ fn build_url(host: &str, port: u16, dir: &str, filename: Option<&str>) -> String
69 85 let dir = dir.trim_matches('/');
70 86 let mut url = format!("http://{host}:{port}");
71 87 if !dir.is_empty() {
72 - url.push('/');
73 - url.push_str(&utf8_percent_encode(dir, PATH).to_string());
88 + for segment in dir.split('/') {
89 + url.push('/');
90 + url.push_str(&utf8_percent_encode(segment, PATH).to_string());
91 + }
74 92 }
75 93 if let Some(name) = filename {
76 94 url.push('/');
@@ -79,25 +97,78 @@ fn build_url(host: &str, port: u16, dir: &str, filename: Option<&str>) -> String
79 97 url
80 98 }
81 99
100 + fn encode_multipart(filename: &str, content_type: &str, bytes: &[u8]) -> (Vec<u8>, String) {
101 + let boundary = format!(
102 + "----supernote-push-{}",
103 + std::time::SystemTime::now()
104 + .duration_since(std::time::UNIX_EPOCH)
105 + .map(|d| d.as_nanos())
106 + .unwrap_or(0)
107 + );
108 +
109 + let disposition = format!(
110 + "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
111 + escape_quoted(filename)
112 + );
113 +
114 + let mut body = Vec::with_capacity(bytes.len() + 256);
115 + body.extend_from_slice(b"--");
116 + body.extend_from_slice(boundary.as_bytes());
117 + body.extend_from_slice(b"\r\n");
118 + body.extend_from_slice(disposition.as_bytes());
119 + body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes());
120 + body.extend_from_slice(bytes);
121 + body.extend_from_slice(b"\r\n--");
122 + body.extend_from_slice(boundary.as_bytes());
123 + body.extend_from_slice(b"--\r\n");
124 +
125 + (body, format!("multipart/form-data; boundary={boundary}"))
126 + }
127 +
128 + fn escape_quoted(s: &str) -> String {
129 + s.replace('\\', "\\\\").replace('"', "\\\"")
130 + }
131 +
132 + fn extract_embedded_json(html: &str) -> Option<String> {
133 + let marker = "const json = '";
134 + let start = html.find(marker)? + marker.len();
135 + let tail = &html[start..];
136 + // The JS embeds the JSON as a single-quoted string literal terminated by
137 + // a newline. JSON itself uses double quotes, and any embedded apostrophe
138 + // in a filename would be `\'`-escaped by the device before it reached
139 + // the client, so `'\n` is the reliable terminator.
140 + let end = tail.find("'\n").or_else(|| tail.find("'\r\n"))?;
141 + Some(tail[..end].replace("\\'", "'"))
142 + }
143 +
82 144 fn basic(passcode: &str) -> String {
83 - use base64_stub::encode;
84 - format!("Basic {}", encode(format!(":{passcode}")))
145 + format!("Basic {}", base64_stub::encode(format!(":{passcode}")))
85 146 }
86 147
87 - // Base64 without pulling in the `base64` crate — small enough to inline.
88 148 mod base64_stub {
89 - const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
149 + const TABLE: &[u8; 64] =
150 + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
90 151
91 152 pub fn encode(input: impl AsRef<[u8]>) -> String {
92 153 let input = input.as_ref();
93 154 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
94 155 for chunk in input.chunks(3) {
95 - let (b0, b1, b2) = (chunk[0], chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0));
156 + let b0 = chunk[0];
157 + let b1 = chunk.get(1).copied().unwrap_or(0);
158 + let b2 = chunk.get(2).copied().unwrap_or(0);
96 159 let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
97 160 out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
98 161 out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
99 - out.push(if chunk.len() > 1 { TABLE[((n >> 6) & 0x3f) as usize] as char } else { '=' });
100 - out.push(if chunk.len() > 2 { TABLE[(n & 0x3f) as usize] as char } else { '=' });
162 + out.push(if chunk.len() > 1 {
163 + TABLE[((n >> 6) & 0x3f) as usize] as char
164 + } else {
165 + '='
166 + });
167 + out.push(if chunk.len() > 2 {
168 + TABLE[(n & 0x3f) as usize] as char
169 + } else {
170 + '='
171 + });
101 172 }
102 173 out
103 174 }
@@ -116,32 +187,16 @@ mod base64_stub {
116 187 }
117 188 }
118 189
119 - /// Deserialization shape for a directory listing.
120 - ///
121 - /// The exact field names are unconfirmed against real firmware; the enum
122 - /// tolerates both a bare array and an object with a `files` key so a single
123 - /// `curl` session can pin the actual shape without breaking callers.
124 190 #[derive(serde::Deserialize)]
125 - #[serde(untagged)]
126 - enum Listing {
127 - Bare(Vec<RawEntry>),
128 - Wrapped { files: Vec<RawEntry> },
129 - }
130 -
131 - impl Listing {
132 - fn into_entries(self) -> Vec<Entry> {
133 - let raws = match self {
134 - Self::Bare(v) => v,
135 - Self::Wrapped { files } => files,
136 - };
137 - raws.into_iter().map(RawEntry::into_entry).collect()
138 - }
191 + struct PageInfo {
192 + #[serde(rename = "fileList", default)]
193 + file_list: Vec<RawEntry>,
139 194 }
140 195
141 196 #[derive(serde::Deserialize)]
142 197 struct RawEntry {
143 198 name: String,
144 - #[serde(default, alias = "isDirectory", alias = "directory")]
199 + #[serde(default, rename = "isDirectory")]
145 200 is_dir: bool,
146 201 #[serde(default)]
147 202 size: u64,
@@ -168,7 +223,7 @@ mod tests {
168 223 }
169 224
170 225 #[test]
171 - fn url_without_filename() {
226 + fn url_folder_only() {
172 227 assert_eq!(
173 228 build_url("10.0.0.5", 8089, "Document/ripgrow", None),
174 229 "http://10.0.0.5:8089/Document/ripgrow"
@@ -176,31 +231,58 @@ mod tests {
176 231 }
177 232
178 233 #[test]
179 - fn url_root_dir() {
234 + fn url_root() {
180 235 assert_eq!(build_url("10.0.0.5", 8089, "/", None), "http://10.0.0.5:8089");
181 236 }
182 237
183 238 #[test]
239 + fn multipart_body_has_expected_shape() {
240 + let (body, ctype) = encode_multipart("hi.pdf", "application/pdf", b"ABC");
241 + assert!(ctype.starts_with("multipart/form-data; boundary=----supernote-push-"));
242 + let s = String::from_utf8_lossy(&body);
243 + assert!(s.contains(r#"Content-Disposition: form-data; name="file"; filename="hi.pdf""#));
244 + assert!(s.contains("Content-Type: application/pdf"));
245 + assert!(s.contains("ABC"));
246 + assert!(s.trim_end().ends_with("--"));
247 + }
248 +
249 + #[test]
250 + fn escapes_quotes_in_filename() {
251 + let (body, _) = encode_multipart(r#"a"b.pdf"#, "application/pdf", b"");
252 + let s = String::from_utf8_lossy(&body);
253 + assert!(s.contains(r#"filename="a\"b.pdf""#));
254 + }
255 +
256 + #[test]
184 257 fn basic_header_is_colon_prefixed() {
185 258 assert_eq!(basic("secret"), "Basic OnNlY3JldA==");
186 259 }
187 260
188 261 #[test]
189 - fn listing_parses_bare_array() {
190 - let json = r#"[{"name":"a.pdf","size":123}]"#;
191 - let l: Listing = serde_json::from_str(json).unwrap();
192 - let entries = l.into_entries();
193 - assert_eq!(entries.len(), 1);
194 - assert_eq!(entries[0].name, "a.pdf");
195 - assert_eq!(entries[0].size, 123);
196 - assert!(!entries[0].is_dir);
262 + fn extracts_embedded_json_blob() {
263 + let html = "<html>...\n const json = '{\"fileList\":[]}'\n const webInfo...</html>";
264 + assert_eq!(extract_embedded_json(html).as_deref(), Some(r#"{"fileList":[]}"#));
265 + }
266 +
267 + #[test]
268 + fn unescapes_apostrophes_in_extracted_json() {
269 + let html = " const json = '{\"name\":\"Mike\\'s\"}'\n";
270 + assert_eq!(
271 + extract_embedded_json(html).as_deref(),
272 + Some(r#"{"name":"Mike's"}"#)
273 + );
197 274 }
198 275
199 276 #[test]
200 - fn listing_parses_wrapped_object() {
201 - let json = r#"{"files":[{"name":"Docs","isDirectory":true}]}"#;
202 - let l: Listing = serde_json::from_str(json).unwrap();
203 - let entries = l.into_entries();
277 + fn parses_real_nomad_listing_shape() {
278 + let json = r#"{"deviceName":"Supernote Nomad","fileList":[{"date":"2026-07-19 07:39","extension":"","isDirectory":true,"name":"Document","size":0,"uri":"/Document"},{"date":"2026-07-19 07:58","extension":".pdf","isDirectory":false,"name":"guide.pdf","size":14817,"uri":"/Document/guide.pdf"}]}"#;
279 + let page: PageInfo = serde_json::from_str(json).unwrap();
280 + let entries: Vec<Entry> = page.file_list.into_iter().map(RawEntry::into_entry).collect();
281 + assert_eq!(entries.len(), 2);
282 + assert_eq!(entries[0].name, "Document");
204 283 assert!(entries[0].is_dir);
284 + assert_eq!(entries[1].name, "guide.pdf");
285 + assert!(!entries[1].is_dir);
286 + assert_eq!(entries[1].size, 14817);
205 287 }
206 288 }
M src/client.rs +1 -1
@@ -58,7 +58,7 @@ impl Supernote {
58 58 }
59 59
60 60 pub fn push_pdf(&self, remote_dir: &str, filename: &str, bytes: &[u8]) -> Result<()> {
61 - browse::put(
61 + browse::upload(
62 62 &self.host,
63 63 self.port,
64 64 self.passcode.as_deref(),