Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions apps/staged/docs/inline-note-media.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,24 @@ the current webview. Older QuickTime files without an `ftyp` header are rejected
Chat attachments (file picker, paste, drop) share the validation code but are
stored under the format their bytes actually are: browsers label `file.type`
from the filename, so a WebP saved as `shot.png` is stored as `shot.webp` with
an `image/webp` MIME rather than rejected. Only unrecognized signatures, videos,
and oversized files are refused, and the composer shows the reason for pasted,
picked, and natively dropped files alike, clearing it when the message is sent
or the attachment removed.
an `image/webp` MIME rather than rejected. A dropped, pasted, or picked PDF (up
to 50 MiB) is stored byte-for-byte as `application/pdf` so the viewer can open
it in the webview's native PDF viewer (vector zoom, every page) from its served
media URL (`staged-media://<uuid>.pdf`, `/api/media/<uuid>.pdf` in web mode,
where the response omits the `sandbox` CSP directive that would blank WebKit's
viewer); thumbnails show its first page, rendered once with CoreGraphics on
macOS and written atomically as `<uuid>.thumb.png` beside the file, and the
agent prompt gets its pages as captioned image blocks, since the ACP agents
accept only image blocks: up to 20 pages per PDF, within a 20 MB budget of
encoded images that the whole message's attachments share (an image or page
that would cross it is named in the prompt text), each page at most 5 MB (PNG,
re-rendered at half scale and then re-encoded as JPEG if it has to be), with
the pages left out named on the last one sent. A `.pdf`
reference in a note is still refused. Only
unrecognized signatures, videos, oversized files, and PDFs CoreGraphics cannot
open are refused, and the composer shows the reason for pasted, picked, and
natively dropped files alike, clearing it when the message is sent or the
attachment removed.

On session completion, files are copied into the existing project `images/`
store and destinations become `staged-media://<uuid>.<ext>`. Captions and titles
Expand Down
5 changes: 5 additions & 0 deletions apps/staged/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions apps/staged/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,29 @@ tiny-skia = "0.12"
tar = "0.4"
flate2 = "1"

# PDF attachments: rasterize pages with CoreGraphics (note_media::pdf). All
# three crates are already in the lock via arboard/tray-icon (and `image`'s
# jpeg feature only adds zune-jpeg, which resvg pulls in); the feature lists
# are the minimum for CGPDFDocument + a bitmap CGContext + CFData input, and
# a JPEG encoder for prompt pages whose PNG is over the per-image cap.
[target.'cfg(target_os = "macos")'.dependencies]
image = { version = "0.25", default-features = false, features = ["jpeg"] }
objc2-core-graphics = { version = "0.3.2", default-features = false, features = [
"CGAffineTransform",
"CGBitmapContext",
"CGColorSpace",
"CGContext",
"CGDataProvider",
"CGGeometry",
"CGImage",
"CGPDFDocument",
"CGPDFPage",
] }
objc2-core-foundation = { version = "0.3.2", default-features = false, features = [
"CFCGTypes",
"CFData",
] }

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
# test-util: `#[tokio::test(start_paused = true)]` for the store-events
Expand Down
15 changes: 15 additions & 0 deletions apps/staged/src-tauri/src/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2679,6 +2679,21 @@ fn move_branch_image_files(
let (Ok(from), Ok(to)) = (from, to) else {
continue;
};
// A PDF's cached thumbnail travels with it; losing one only costs a
// re-render, so its failure is not worth more than a log line.
if let (Ok(thumb_from), Ok(thumb_to)) = (
store::images::thumbnail_file_path(source_project_id, &image.id),
store::images::thumbnail_file_path(target_project_id, &image.id),
) {
if thumb_from.exists() {
if let Some(parent) = thumb_to.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::rename(&thumb_from, &thumb_to) {
log::warn!("Failed to move thumbnail {}: {e}", thumb_from.display());
}
}
}
if !from.exists() {
continue;
}
Expand Down
198 changes: 180 additions & 18 deletions apps/staged/src-tauri/src/image_commands.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Image commands — image upload, retrieval, and management.

mod thumbnail;
mod tool_results;
pub use thumbnail::*;
pub use tool_results::*;

use crate::store::Store;
Expand All @@ -14,22 +16,35 @@ use std::sync::{Arc, Mutex};
/// real session ID). Pass `false` for images that should appear in the
/// timeline immediately (e.g. direct branch-card drops).
#[tauri::command(rename_all = "camelCase")]
pub fn create_image(
pub async fn create_image(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
branch_id: Option<String>,
project_id: String,
file_path: String,
pending: Option<bool>,
) -> Result<crate::store::Image, String> {
let store = crate::get_store(&store)?;
// Reading and validating a 50 MB PDF is not main-thread work.
tauri::async_runtime::spawn_blocking(move || {
create_image_impl(store, branch_id, project_id, file_path, pending)
})
.await
.map_err(|e| format!("Failed to store image: {e}"))?
}

fn create_image_impl(
store: Arc<Store>,
branch_id: Option<String>,
project_id: String,
file_path: String,
pending: Option<bool>,
) -> Result<crate::store::Image, String> {
let src = Path::new(&file_path);
let filename = src
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
let bytes =
crate::note_media::files::read_local(src, crate::note_media::files::MAX_IMAGE_SIZE)?;
let bytes = crate::note_media::files::read_local_attachment(src)?;
crate::note_media::files::store_sniffed_image(
&store,
branch_id.as_deref(),
Expand Down Expand Up @@ -73,18 +88,25 @@ pub fn delete_image(
// Delete the DB record first (triggers session cleanup).
store.delete_image(&image_id).map_err(|e| e.to_string())?;

// Best-effort file removal.
if let Ok(path) =
crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename)
{
if let Err(e) = std::fs::remove_file(&path) {
log::warn!("Failed to remove image file {}: {e}", path.display());
}
}
// Best-effort removal of the file and any cached PDF thumbnail.
crate::store::images::remove_image_files(&image.project_id, &image.id, &image.filename);

Ok(())
}

/// A small preview of an attachment: the image itself, or for a PDF a PNG of
/// its first page, rendered on first use and cached beside the file.
#[tauri::command(rename_all = "camelCase")]
pub async fn get_attachment_thumbnail(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
image_id: String,
) -> Result<AttachmentThumbnail, String> {
let store = crate::get_store(&store)?;
tauri::async_runtime::spawn_blocking(move || attachment_thumbnail_impl(&store, &image_id))
.await
.map_err(|e| format!("Failed to load thumbnail: {e}"))?
}

/// List all images for a branch.
#[tauri::command(rename_all = "camelCase")]
pub fn list_branch_images(
Expand Down Expand Up @@ -121,7 +143,7 @@ pub fn get_image_data(
/// the stored format follows the file signature. See [`create_image`] for the
/// meaning of the `pending` flag.
#[tauri::command(rename_all = "camelCase")]
pub fn create_image_from_data(
pub async fn create_image_from_data(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
branch_id: Option<String>,
project_id: String,
Expand All @@ -131,9 +153,14 @@ pub fn create_image_from_data(
pending: Option<bool>,
) -> Result<crate::store::Image, String> {
let store = crate::get_store(&store)?;
create_image_from_data_impl(
store, branch_id, project_id, filename, mime_type, data, pending,
)
// See `create_image`: decoding a large payload must not block the main thread.
tauri::async_runtime::spawn_blocking(move || {
create_image_from_data_impl(
store, branch_id, project_id, filename, mime_type, data, pending,
)
})
.await
.map_err(|e| format!("Failed to store image: {e}"))?
}

pub(crate) fn create_image_from_data_impl(
Expand All @@ -146,10 +173,19 @@ pub(crate) fn create_image_from_data_impl(
pending: Option<bool>,
) -> Result<crate::store::Image, String> {
use base64::Engine;
if data.len() as u64 > crate::note_media::files::MAX_IMAGE_SIZE.div_ceil(3) * 4 {
return Err("Image too large (max 10 MB)".into());
let engine = base64::engine::general_purpose::STANDARD;
// The cap depends on the payload (a PDF may be larger than an image), so
// peek at the first six bytes — two base64 quanta — before decoding a
// payload that may turn out to be oversized.
let header = data
.get(..8)
.and_then(|quanta| engine.decode(quanta).ok())
.unwrap_or_default();
let (cap, what) = crate::note_media::files::attachment_limit(&header);
if data.len() as u64 > cap.div_ceil(3) * 4 {
return Err(crate::note_media::files::size_error(what, cap));
}
let bytes = base64::engine::general_purpose::STANDARD
let bytes = engine
.decode(&data)
.map_err(|e| format!("Invalid base64 data: {e}"))?;

Expand Down Expand Up @@ -219,4 +255,130 @@ mod tests {
let err = attach(&f, "clip.png", "", b"\0\0\0\x18ftypisom\0\0\0\0").unwrap_err();
assert!(err.contains("only images"), "{err}");
}

#[test]
fn pdf_attachments_are_stored_as_pdfs() {
use crate::note_media::pdf::tests::{tiny_pdf, PAGE_ONE};
let f = Fixture::new();
let pdf = tiny_pdf(&[PAGE_ONE]);
let image = attach(&f, "doc.pdf", "application/pdf", &pdf).unwrap();
assert_eq!(image.mime_type, "application/pdf");
assert_eq!(image.filename, "doc.pdf");
assert_eq!(image.size_bytes, pdf.len() as i64);
let path =
crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename)
.unwrap();
assert_eq!(path.extension().unwrap(), "pdf");
assert_eq!(std::fs::read(path).unwrap(), pdf);

// Pasted PDFs arrive without a usable name, like pasted images, and a
// mislabelled one takes the extension its bytes call for.
assert_eq!(
attach(&f, "pasted", "application/pdf", &pdf)
.unwrap()
.filename,
"pasted.pdf"
);
assert_eq!(
attach(&f, "scan.png", "image/png", &pdf).unwrap().filename,
"scan.pdf"
);
}

/// PDFs get the larger input cap on both routes; images keep theirs. The
/// padded PDFs here are well-formed, so acceptance means the PDF passed
/// the open check, not that the bytes merely got past the size check.
#[test]
fn pdf_input_cap_is_separate_from_the_image_cap() {
use crate::note_media::files::{MAX_IMAGE_SIZE, MAX_PDF_INPUT_SIZE};
use crate::note_media::pdf::tests::{padded_pdf, PAGE_ONE};
use crate::note_media::tests::PNG;
let f = Fixture::new();
let large_pdf = padded_pdf(&[PAGE_ONE], MAX_IMAGE_SIZE as usize + 1);

let image = attach(&f, "scan.pdf", "application/pdf", &large_pdf).unwrap();
assert_eq!(image.mime_type, "application/pdf");
assert_eq!(image.size_bytes, large_pdf.len() as i64);

let path = f.dir.path().join("dropped.pdf");
std::fs::write(&path, &large_pdf).unwrap();
let image = create_image_impl(
f.store.clone(),
Some(f.branch.id.clone()),
f.project.id.clone(),
path.to_string_lossy().into_owned(),
None,
)
.unwrap();
assert_eq!(
(image.mime_type.as_str(), image.filename.as_str()),
("application/pdf", "dropped.pdf")
);

// Over the PDF cap: refused before decoding (base64) or reading (path).
let mut oversized = "JVBERi0x".to_owned(); // "%PDF-1" — a PDF signature
oversized.push_str(&"A".repeat((MAX_PDF_INPUT_SIZE.div_ceil(3) * 4) as usize));
let err = create_image_from_data_impl(
f.store.clone(),
None,
f.project.id.clone(),
"huge.pdf".into(),
"application/pdf".into(),
oversized,
None,
)
.unwrap_err();
assert_eq!(err, "PDF exceeds 50 MB limit");
let sparse = f.dir.path().join("huge.pdf");
let mut file = std::fs::File::create(&sparse).unwrap();
std::io::Write::write_all(&mut file, b"%PDF-1.4\n").unwrap();
file.set_len(MAX_PDF_INPUT_SIZE + 1).unwrap();
let err = create_image_impl(
f.store.clone(),
None,
f.project.id.clone(),
sparse.to_string_lossy().into_owned(),
None,
)
.unwrap_err();
assert_eq!(err, "PDF exceeds 50 MB limit");

// An image over its own cap is still refused with the image message,
// on both routes, and from metadata alone on the path route.
let mut png = PNG.to_vec();
png.resize(MAX_IMAGE_SIZE as usize + 1, 0);
let err = attach(&f, "big.png", "image/png", &png).unwrap_err();
assert_eq!(err, "media exceeds 10 MB limit");
let sparse = f.dir.path().join("big.png");
let mut file = std::fs::File::create(&sparse).unwrap();
std::io::Write::write_all(&mut file, PNG).unwrap();
file.set_len(MAX_IMAGE_SIZE + 1).unwrap();
let err = create_image_impl(
f.store.clone(),
None,
f.project.id.clone(),
sparse.to_string_lossy().into_owned(),
None,
)
.unwrap_err();
assert_eq!(err, "media exceeds 10 MB limit");
}

#[cfg(target_os = "macos")]
#[test]
fn malformed_pdf_is_rejected_without_hanging() {
let f = Fixture::new();
let err = attach(&f, "doc.pdf", "application/pdf", b"%PDF-1.4\ngarbage").unwrap_err();
assert!(err.contains("PDF"), "{err}");
}

/// Without CoreGraphics only the signature is checked, so the file is
/// still stored and viewable as a PDF.
#[cfg(not(target_os = "macos"))]
#[test]
fn pdf_attachments_are_stored_without_a_renderer() {
let f = Fixture::new();
let image = attach(&f, "doc.pdf", "application/pdf", b"%PDF-1.4\ngarbage").unwrap();
assert_eq!(image.mime_type, "application/pdf");
}
}
Loading
Loading