A fork of pulp-os for the xteink4 adding custom apps
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: drag and drop and delete upload manager

hans 47f20dfb 20038aab

+553 -29
+329
assets/upload.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width,initial-scale=1" /> 6 + <title>pulp manager</title> 7 + <link rel="icon" href="data:," /> 8 + <style> 9 + * { 10 + box-sizing: border-box; 11 + margin: 0; 12 + padding: 0; 13 + } 14 + body { 15 + font-family: system-ui, sans-serif; 16 + background: #f5f5f0; 17 + color: #222; 18 + max-width: 540px; 19 + margin: 0 auto; 20 + padding: 16px; 21 + } 22 + h1 { 23 + font-size: 1.4em; 24 + margin-bottom: 12px; 25 + } 26 + #drop { 27 + border: 2px dashed #bbb; 28 + border-radius: 8px; 29 + padding: 32px 16px; 30 + text-align: center; 31 + cursor: pointer; 32 + background: #fff; 33 + transition: 34 + border-color 0.2s, 35 + background 0.2s; 36 + margin-bottom: 12px; 37 + } 38 + #drop.over { 39 + border-color: #333; 40 + background: #eef6ee; 41 + } 42 + #drop p { 43 + pointer-events: none; 44 + } 45 + #drop .hint { 46 + color: #888; 47 + font-size: 0.85em; 48 + margin-top: 6px; 49 + } 50 + #fin { 51 + display: none; 52 + } 53 + #bar { 54 + height: 6px; 55 + border-radius: 3px; 56 + background: #ddd; 57 + margin-bottom: 4px; 58 + overflow: hidden; 59 + } 60 + #bar > div { 61 + height: 100%; 62 + width: 0; 63 + background: #3a3; 64 + border-radius: 3px; 65 + transition: width 0.15s; 66 + } 67 + #status { 68 + font-size: 0.85em; 69 + color: #666; 70 + min-height: 1.2em; 71 + margin-bottom: 12px; 72 + word-break: break-all; 73 + } 74 + #status.err { 75 + color: #c33; 76 + } 77 + #status.ok { 78 + color: #2a2; 79 + } 80 + table { 81 + width: 100%; 82 + border-collapse: collapse; 83 + font-size: 0.9em; 84 + } 85 + th { 86 + text-align: left; 87 + border-bottom: 1px solid #ccc; 88 + padding: 6px 4px; 89 + color: #777; 90 + font-weight: 500; 91 + } 92 + td { 93 + padding: 6px 4px; 94 + border-bottom: 1px solid #e5e5e5; 95 + } 96 + .sz { 97 + text-align: right; 98 + white-space: nowrap; 99 + color: #888; 100 + } 101 + .del { 102 + background: none; 103 + border: 1px solid #ccc; 104 + color: #c33; 105 + border-radius: 4px; 106 + padding: 2px 8px; 107 + cursor: pointer; 108 + font-size: 0.85em; 109 + } 110 + .del:hover { 111 + background: #c33; 112 + color: #fff; 113 + } 114 + #empty { 115 + color: #999; 116 + padding: 24px 0; 117 + text-align: center; 118 + } 119 + </style> 120 + </head> 121 + <body> 122 + <h1>Pulp</h1> 123 + 124 + <div id="drop" onclick="fin.click()"> 125 + <p>Drop files here or tap to browse</p> 126 + <p class="hint">.epub &middot; .txt &middot; any file</p> 127 + </div> 128 + <input type="file" id="fin" multiple onchange="upload(this.files)" /> 129 + <div id="bar"><div id="fill"></div></div> 130 + <div id="status"></div> 131 + 132 + <table> 133 + <thead> 134 + <tr> 135 + <th>Name</th> 136 + <th class="sz">Size</th> 137 + <th></th> 138 + </tr> 139 + </thead> 140 + <tbody id="list"></tbody> 141 + </table> 142 + <div id="empty"></div> 143 + 144 + <script> 145 + var D = document, 146 + drop = D.getElementById("drop"), 147 + list = D.getElementById("list"), 148 + fill = D.getElementById("fill"), 149 + st = D.getElementById("status"), 150 + empty = D.getElementById("empty"), 151 + queue = [], 152 + busy = 0; 153 + 154 + function s(msg, cls) { 155 + st.textContent = msg; 156 + st.className = cls || ""; 157 + } 158 + 159 + function fmt(b) { 160 + if (b < 1024) return b + " B"; 161 + if (b < 1048576) return (b / 1024).toFixed(1) + " KB"; 162 + return (b / 1048576).toFixed(1) + " MB"; 163 + } 164 + 165 + function load(retries, delay) { 166 + if (retries === undefined) retries = 3; 167 + if (delay === undefined) delay = 400; 168 + var x = new XMLHttpRequest(); 169 + x.timeout = 5000; 170 + x.open("GET", "/files"); 171 + x.onload = function () { 172 + if (x.status !== 200) { 173 + list.innerHTML = ""; 174 + empty.textContent = "Could not list files"; 175 + return; 176 + } 177 + var f; 178 + try { 179 + f = JSON.parse(x.responseText); 180 + } catch (e) { 181 + empty.textContent = "Bad response"; 182 + return; 183 + } 184 + list.innerHTML = ""; 185 + if (!f.length) { 186 + empty.textContent = "No files on device"; 187 + return; 188 + } 189 + empty.textContent = ""; 190 + for (var i = 0; i < f.length; i++) 191 + (function (n) { 192 + var tr = D.createElement("tr"), 193 + td1 = D.createElement("td"), 194 + td2 = D.createElement("td"), 195 + td3 = D.createElement("td"), 196 + btn = D.createElement("button"); 197 + td1.textContent = n.name; 198 + td2.textContent = fmt(n.size); 199 + td2.className = "sz"; 200 + btn.textContent = "\u00d7"; 201 + btn.className = "del"; 202 + btn.title = "Delete " + n.name; 203 + btn.onclick = function () { 204 + del(n.name, tr); 205 + }; 206 + td3.appendChild(btn); 207 + tr.appendChild(td1); 208 + tr.appendChild(td2); 209 + tr.appendChild(td3); 210 + list.appendChild(tr); 211 + })(f[i]); 212 + }; 213 + x.onerror = x.ontimeout = function () { 214 + if (retries > 0) { 215 + setTimeout(function () { 216 + load(retries - 1, Math.min(delay * 2, 3000)); 217 + }, delay); 218 + } else { 219 + empty.textContent = "Connection error"; 220 + } 221 + }; 222 + x.send(); 223 + } 224 + 225 + function del(name, tr) { 226 + if (!confirm("Delete " + name + "?")) return; 227 + var x = new XMLHttpRequest(); 228 + x.open("POST", "/delete"); 229 + x.setRequestHeader("Content-Type", "text/plain"); 230 + x.onload = function () { 231 + if (x.status === 200) { 232 + tr.remove(); 233 + if (!list.children.length) 234 + empty.textContent = "No files on device"; 235 + s(name + " deleted", "ok"); 236 + } else s("Delete failed: " + x.responseText, "err"); 237 + }; 238 + x.onerror = function () { 239 + s("Delete failed: network error", "err"); 240 + }; 241 + x.send(name); 242 + } 243 + 244 + function upload(files) { 245 + for (var i = 0; i < files.length; i++) queue.push(files[i]); 246 + fin.value = ""; 247 + pump(); 248 + } 249 + 250 + function pump() { 251 + if (busy || !queue.length) return; 252 + busy = 1; 253 + var f = queue.shift(), 254 + rest = queue.length, 255 + fd = new FormData(); 256 + fd.append("file", f); 257 + var x = new XMLHttpRequest(); 258 + x.open("POST", "/upload"); 259 + x.upload.onprogress = function (e) { 260 + if (e.lengthComputable) { 261 + var pct = Math.round((100 * e.loaded) / e.total); 262 + fill.style.width = pct + "%"; 263 + s( 264 + "Uploading " + 265 + f.name + 266 + "... " + 267 + pct + 268 + "%" + 269 + (rest ? " (" + rest + " queued)" : ""), 270 + ); 271 + } 272 + }; 273 + x.onload = function () { 274 + busy = 0; 275 + if (x.status === 200) { 276 + fill.style.width = "100%"; 277 + s( 278 + f.name + 279 + " uploaded" + 280 + (rest ? " \u2014 " + rest + " remaining" : ""), 281 + "ok", 282 + ); 283 + setTimeout(function () { 284 + load(); 285 + }, 300); 286 + } else { 287 + s( 288 + "Upload failed: " + 289 + (x.responseText || "server error"), 290 + "err", 291 + ); 292 + } 293 + setTimeout(function () { 294 + fill.style.width = "0"; 295 + }, 800); 296 + pump(); 297 + }; 298 + x.onerror = function () { 299 + busy = 0; 300 + fill.style.width = "0"; 301 + s("Upload failed: network error", "err"); 302 + pump(); 303 + }; 304 + x.send(fd); 305 + } 306 + 307 + // drag and drop 308 + drop.ondragover = drop.ondragenter = function (e) { 309 + e.preventDefault(); 310 + drop.classList.add("over"); 311 + }; 312 + drop.ondragleave = function (e) { 313 + e.preventDefault(); 314 + drop.classList.remove("over"); 315 + }; 316 + drop.ondrop = function (e) { 317 + e.preventDefault(); 318 + drop.classList.remove("over"); 319 + if (e.dataTransfer.files.length) upload(e.dataTransfer.files); 320 + }; 321 + 322 + // wait for page to fully load so the GET / TCP connection is 323 + // closed and the single-connection server can accept /files 324 + window.onload = function () { 325 + load(); 326 + }; 327 + </script> 328 + </body> 329 + </html>
+182 -29
src/apps/upload.rs
··· 42 42 43 43 // HTTP response fragments 44 44 45 - const HTTP_200: &[u8] = b"HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n"; 46 - const HTTP_500: &[u8] = 47 - b"HTTP/1.0 500 Internal Server Error\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n"; 45 + const HTTP_200_HTML: &[u8] = 46 + b"HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n"; 47 + const HTTP_200_JSON: &[u8] = 48 + b"HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n"; 49 + const HTTP_200_TEXT: &[u8] = 50 + b"HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n"; 51 + const HTTP_500_TEXT: &[u8] = 52 + b"HTTP/1.0 500 Internal Server Error\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n"; 48 53 const HTTP_404: &[u8] = b"HTTP/1.0 404 Not Found\r\nConnection: close\r\n\r\nNot Found"; 49 54 50 - const UPLOAD_FORM: &[u8] = b"<title>Pulp</title>\ 51 - <h1>Pulp</h1>\ 52 - <form method=POST action=/upload enctype=multipart/form-data>\ 53 - <input type=file name=file required><br><br>\ 54 - <input type=submit value=Upload>\ 55 - </form>"; 56 - 57 - const UPLOAD_OK: &[u8] = b"<title>Pulp</title>\ 58 - <p>Upload complete.</p>\ 59 - <a href=/>Upload another</a>"; 60 - 61 - const UPLOAD_ERR_PREFIX: &[u8] = b"<title>Pulp</title><p>Upload failed: "; 62 - const UPLOAD_ERR_SUFFIX: &[u8] = b"</p><a href=/>Try again</a>"; 55 + // Embedded HTML page with drag-and-drop, file listing, and delete support. 56 + // JS is minimal: XHR upload with progress, fetch /files for listing, POST /delete. 57 + const UPLOAD_PAGE: &[u8] = include_bytes!("../../assets/upload.html"); 63 58 64 59 // mDNS constants 65 60 ··· 84 79 Nothing, 85 80 Uploaded { name: [u8; 13], name_len: u8 }, 86 81 UploadFailed, 82 + Deleted { name: [u8; 13], name_len: u8 }, 83 + DeleteFailed, 87 84 } 88 85 89 86 pub async fn run_upload_mode<SPI>( ··· 335 332 ServerEvent::UploadFailed => { 336 333 info!("upload: file upload failed"); 337 334 } 335 + ServerEvent::Deleted { name, name_len } => { 336 + let fname = core::str::from_utf8(&name[..name_len as usize]).unwrap_or("???"); 337 + info!("upload: deleted '{}'", fname); 338 + } 339 + ServerEvent::DeleteFailed => { 340 + info!("upload: file delete failed"); 341 + } 338 342 ServerEvent::Nothing => {} 339 343 } 340 344 } ··· 423 427 424 428 let path = extract_path(request_line); 425 429 430 + // ── GET / ── serve the upload page HTML ────────────────────────── 426 431 if is_get && path == b"/" { 427 - let _ = socket.write_all(HTTP_200).await; 428 - let _ = socket.write_all(UPLOAD_FORM).await; 432 + let _ = socket.write_all(HTTP_200_HTML).await; 433 + let _ = socket.write_all(UPLOAD_PAGE).await; 434 + let _ = socket.flush().await; 435 + close_socket(&mut socket).await; 436 + return ServerEvent::Nothing; 437 + } 438 + 439 + // ── GET /files ── JSON array of {name, size} ──────────────────── 440 + if is_get && path == b"/files" { 441 + let _ = socket.write_all(HTTP_200_JSON).await; 442 + 443 + let mut entries = [storage::DirEntry::EMPTY; 64]; 444 + let count = match storage::list_root_files(sd, &mut entries) { 445 + Ok(n) => n, 446 + Err(_) => { 447 + let _ = socket.write_all(b"[]").await; 448 + let _ = socket.flush().await; 449 + close_socket(&mut socket).await; 450 + return ServerEvent::Nothing; 451 + } 452 + }; 453 + 454 + let _ = socket.write_all(b"[").await; 455 + let mut json_buf = [0u8; 80]; // per-entry scratch: {"name":"XXXXXXXX.XXX","size":4294967295} 456 + for i in 0..count { 457 + let e = &entries[i]; 458 + let name = e.name_str(); 459 + let mut pos = 0usize; 460 + let prefix = b"{\"name\":\""; 461 + json_buf[..prefix.len()].copy_from_slice(prefix); 462 + pos += prefix.len(); 463 + let nb = name.as_bytes(); 464 + json_buf[pos..pos + nb.len()].copy_from_slice(nb); 465 + pos += nb.len(); 466 + let mid = b"\",\"size\":"; 467 + json_buf[pos..pos + mid.len()].copy_from_slice(mid); 468 + pos += mid.len(); 469 + // format u32 without alloc 470 + pos += fmt_u32(e.size, &mut json_buf[pos..]); 471 + json_buf[pos] = b'}'; 472 + pos += 1; 473 + if i + 1 < count { 474 + json_buf[pos] = b','; 475 + pos += 1; 476 + } 477 + let _ = socket.write_all(&json_buf[..pos]).await; 478 + } 479 + let _ = socket.write_all(b"]").await; 429 480 let _ = socket.flush().await; 430 481 close_socket(&mut socket).await; 431 482 return ServerEvent::Nothing; 432 483 } 433 484 485 + // ── POST /upload ── multipart file upload ─────────────────────── 434 486 if is_post && path == b"/upload" { 435 - // extract boundary from Content-Type header 436 487 let boundary = match find_boundary(headers) { 437 488 Some(b) => b, 438 489 None => { 439 - send_error_page(&mut socket, "Missing multipart boundary").await; 490 + send_error_response(&mut socket, "Missing multipart boundary").await; 440 491 close_socket(&mut socket).await; 441 492 return ServerEvent::UploadFailed; 442 493 } 443 494 }; 444 495 445 - // handle the file upload 446 496 match handle_upload(&mut socket, sd, boundary, initial_body).await { 447 497 Ok((name_buf, name_len)) => { 448 - let _ = socket.write_all(HTTP_200).await; 449 - let _ = socket.write_all(UPLOAD_OK).await; 498 + let _ = socket.write_all(HTTP_200_TEXT).await; 499 + let _ = socket.write_all(b"OK").await; 450 500 let _ = socket.flush().await; 451 501 close_socket(&mut socket).await; 452 502 return ServerEvent::Uploaded { ··· 456 506 } 457 507 Err(e) => { 458 508 info!("upload: handle_upload error: {}", e); 459 - send_error_page(&mut socket, e).await; 509 + send_error_response(&mut socket, e).await; 460 510 close_socket(&mut socket).await; 461 511 return ServerEvent::UploadFailed; 462 512 } 463 513 } 464 514 } 465 515 516 + // ── POST /delete ── body = filename to delete ─────────────────── 517 + if is_post && path == b"/delete" { 518 + // read body (filename); may need to read more beyond initial_body 519 + let content_len = extract_content_length(headers).unwrap_or(0); 520 + let max_body = content_len.min(13); // 8.3 filename max 521 + let mut body = [0u8; 16]; 522 + let have = initial_body.len().min(body.len()); 523 + body[..have].copy_from_slice(&initial_body[..have]); 524 + let mut body_len = have; 525 + 526 + while body_len < max_body && body_len < body.len() { 527 + match socket.read(&mut body[body_len..]).await { 528 + Ok(0) => break, 529 + Ok(n) => body_len += n, 530 + Err(_) => break, 531 + } 532 + } 533 + 534 + let name = match core::str::from_utf8(&body[..body_len]) { 535 + Ok(s) => s.trim(), 536 + Err(_) => { 537 + send_error_response(&mut socket, "Invalid filename").await; 538 + close_socket(&mut socket).await; 539 + return ServerEvent::DeleteFailed; 540 + } 541 + }; 542 + 543 + if name.is_empty() || name.len() > 12 { 544 + send_error_response(&mut socket, "Invalid filename").await; 545 + close_socket(&mut socket).await; 546 + return ServerEvent::DeleteFailed; 547 + } 548 + 549 + // copy name into fixed buffer before passing to storage 550 + let mut name_buf = [0u8; 13]; 551 + let name_bytes = name.as_bytes(); 552 + name_buf[..name_bytes.len()].copy_from_slice(name_bytes); 553 + let name_len = name_bytes.len() as u8; 554 + 555 + match storage::delete_file(sd, name) { 556 + Ok(()) => { 557 + let _ = socket.write_all(HTTP_200_TEXT).await; 558 + let _ = socket.write_all(b"OK").await; 559 + let _ = socket.flush().await; 560 + close_socket(&mut socket).await; 561 + return ServerEvent::Deleted { 562 + name: name_buf, 563 + name_len, 564 + }; 565 + } 566 + Err(e) => { 567 + info!("upload: delete failed for '{}': {}", name, e); 568 + send_error_response(&mut socket, e).await; 569 + close_socket(&mut socket).await; 570 + return ServerEvent::DeleteFailed; 571 + } 572 + } 573 + } 574 + 466 575 // fallback: 404 467 576 let _ = socket.write_all(HTTP_404).await; 468 577 let _ = socket.flush().await; ··· 719 828 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'~' | b'!' | b'#' | b'$' | b'&') 720 829 } 721 830 722 - // send an HTML error page 723 - async fn send_error_page(socket: &mut TcpSocket<'_>, msg: &str) { 724 - let _ = socket.write_all(HTTP_500).await; 725 - let _ = socket.write_all(UPLOAD_ERR_PREFIX).await; 831 + // send a plain-text error response 832 + async fn send_error_response(socket: &mut TcpSocket<'_>, msg: &str) { 833 + let _ = socket.write_all(HTTP_500_TEXT).await; 726 834 let _ = socket.write_all(msg.as_bytes()).await; 727 - let _ = socket.write_all(UPLOAD_ERR_SUFFIX).await; 728 835 let _ = socket.flush().await; 836 + } 837 + 838 + // format a u32 into decimal ASCII; returns number of bytes written 839 + fn fmt_u32(mut n: u32, buf: &mut [u8]) -> usize { 840 + if n == 0 { 841 + buf[0] = b'0'; 842 + return 1; 843 + } 844 + let mut tmp = [0u8; 10]; 845 + let mut pos = 0; 846 + while n > 0 { 847 + tmp[pos] = b'0' + (n % 10) as u8; 848 + n /= 10; 849 + pos += 1; 850 + } 851 + for i in 0..pos { 852 + buf[i] = tmp[pos - 1 - i]; 853 + } 854 + pos 855 + } 856 + 857 + // extract Content-Length value from headers 858 + fn extract_content_length(headers: &[u8]) -> Option<usize> { 859 + let marker = b"content-length:"; 860 + let pos = headers 861 + .windows(marker.len()) 862 + .position(|w| w.eq_ignore_ascii_case(marker))?; 863 + let start = pos + marker.len(); 864 + let rest = &headers[start..]; 865 + // skip whitespace 866 + let trimmed = rest.iter().position(|&b| b != b' ' && b != b'\t')?; 867 + let rest = &rest[trimmed..]; 868 + let end = rest 869 + .iter() 870 + .position(|&b| b == b'\r' || b == b'\n') 871 + .unwrap_or(rest.len()); 872 + let digits = &rest[..end]; 873 + let mut val: usize = 0; 874 + for &b in digits { 875 + if b.is_ascii_digit() { 876 + val = val.saturating_mul(10).saturating_add((b - b'0') as usize); 877 + } else { 878 + break; 879 + } 880 + } 881 + Some(val) 729 882 } 730 883 731 884 // gracefully close a TCP socket
+42
src/drivers/storage.rs
··· 455 455 with_dir!(sd, |root| do_append!(root, name, data)) 456 456 } 457 457 458 + pub fn delete_file<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<(), &'static str> 459 + where 460 + SPI: embedded_hal::spi::SpiDevice, 461 + { 462 + with_dir!(sd, |root| do_delete!(root, name)) 463 + } 464 + 465 + pub fn list_root_files<SPI>( 466 + sd: &SdStorage<SPI>, 467 + buf: &mut [DirEntry], 468 + ) -> Result<usize, &'static str> 469 + where 470 + SPI: embedded_hal::spi::SpiDevice, 471 + { 472 + with_dir!(sd, |root| { 473 + let mut count = 0usize; 474 + root.iterate_dir(|entry| { 475 + if matches!(entry.name.base_name()[0], b'.' | b'_') { 476 + return; 477 + } 478 + if entry.attributes.is_directory() { 479 + return; 480 + } 481 + if count < buf.len() { 482 + let mut name_buf = [0u8; 13]; 483 + let name_len = format_83_name(&entry.name, &mut name_buf); 484 + buf[count] = DirEntry { 485 + name: name_buf, 486 + name_len: name_len as u8, 487 + is_dir: false, 488 + size: entry.size, 489 + title: [0u8; TITLE_CAP], 490 + title_len: 0, 491 + }; 492 + count += 1; 493 + } 494 + }) 495 + .map_err(|_| "iterate dir failed")?; 496 + Ok(count) 497 + }) 498 + } 499 + 458 500 // subdirectory operations 459 501 460 502 pub fn ensure_dir<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<(), &'static str>