agent: wayland — evdev hotkey backend, download resume, SHA256 integrity
Add kon-hotkey crate with evdev-based global hotkey capture that works on Wayland (and X11). Patterns from whisper-overlay: per-device async listeners, inotify hotplug with udev permission retry, watch channel for live config updates. Frontend detects Wayland at startup and selects evdev or tauri-plugin-global-shortcut automatically. Model downloads now support HTTP Range resume for interrupted downloads and optional SHA256 integrity verification (incremental, no second pass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,3 +16,6 @@ tokio = { version = "1", features = ["rt", "sync"] }
|
||||
# Model downloads
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Download integrity verification
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -76,6 +76,11 @@ pub async fn download(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a single file with HTTP Range resume and optional SHA256 verification.
|
||||
///
|
||||
/// Resume pattern from Buzz (chidiwilliams/buzz): if a .part file exists,
|
||||
/// send a Range header to resume from where we left off. SHA256 is checked
|
||||
/// incrementally during download — no second pass over the file.
|
||||
async fn download_file(
|
||||
file: &ModelFile,
|
||||
dest: &Path,
|
||||
@@ -83,6 +88,7 @@ async fn download_file(
|
||||
progress: &(impl Fn(DownloadProgress) + Send),
|
||||
) -> Result<()> {
|
||||
use futures_util::StreamExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let part_path = dest.with_extension(
|
||||
dest.extension()
|
||||
@@ -95,23 +101,72 @@ async fn download_file(
|
||||
.build()
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
|
||||
let response = client
|
||||
.get(file.url)
|
||||
// Check for existing partial download (resume support)
|
||||
let existing_bytes = if part_path.exists() {
|
||||
std::fs::metadata(&part_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut request = client.get(file.url);
|
||||
|
||||
// If we have a partial file and no SHA256 to verify (can't verify partial),
|
||||
// request a range resume. If SHA256 is set, we restart to ensure integrity.
|
||||
let resuming = existing_bytes > 0 && file.sha256.is_none();
|
||||
if resuming {
|
||||
request = request.header("Range", format!("bytes={existing_bytes}-"));
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
|
||||
let total_bytes = response.content_length().unwrap_or(0);
|
||||
// Check if server supports range (206 Partial Content) or gave full file (200)
|
||||
let actually_resuming = resuming && response.status().as_u16() == 206;
|
||||
|
||||
let total_bytes = if actually_resuming {
|
||||
// Content-Range: bytes START-END/TOTAL — extract TOTAL
|
||||
response
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.rsplit('/').next())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
response.content_length().unwrap_or(0)
|
||||
};
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut downloaded: u64 = if actually_resuming { existing_bytes } else { 0 };
|
||||
let mut last_percent: u8 = 0;
|
||||
|
||||
let mut out = std::fs::File::create(&part_path)?;
|
||||
// Open file for append (resume) or create (fresh start)
|
||||
let mut out = if actually_resuming {
|
||||
std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&part_path)?
|
||||
} else {
|
||||
std::fs::File::create(&part_path)?
|
||||
};
|
||||
|
||||
// Incremental SHA256 — only when a checksum is provided
|
||||
let mut hasher = file.sha256.map(|_| Sha256::new());
|
||||
|
||||
// If resuming without SHA256, we can't hash the already-downloaded portion,
|
||||
// but we also don't need to — we only hash when sha256 is set, and we
|
||||
// restart from scratch in that case.
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk
|
||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut out, &chunk)?;
|
||||
if let Some(ref mut h) = hasher {
|
||||
h.update(&chunk);
|
||||
}
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let percent = if total_bytes > 0 {
|
||||
@@ -133,6 +188,21 @@ async fn download_file(
|
||||
}
|
||||
|
||||
drop(out);
|
||||
|
||||
// Verify SHA256 if provided
|
||||
if let (Some(expected), Some(hasher)) = (file.sha256, hasher) {
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != expected {
|
||||
// Delete corrupt file so next attempt starts fresh
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
file.filename, expected, actual
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic rename — file is complete and verified
|
||||
std::fs::rename(&part_path, dest)?;
|
||||
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user