Browse / github.com/storytold/vectorcraft / patch
Fix Linux file-manager document handoff and bounded regular-file reads
Fix Linux file-manager document handoff and bounded regular-file reads ## What and why On Linux, opening another document from the file manager starts a separate workspace. Send ordinary subsequent launches to the running VectorCraft window over session D-Bus and open the documents as tabs, preserving unsaved work. Requests wait for native and in-app dialogs; activation obeys compositor focus policy. Automation stays independent, and sessions without a usable bus retain normal startup. Forwarding is document-only: plug-ins, scripts, presets, libraries and shortcut imports are rejected through the canonical `io::opens_as` classification before reading. Linux
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- mockydevs (imported from pull request #3423, open)
- Base
443b31b13df3a7ab611c2fe1b95e73898ed74fdd- Size
- +757 −6 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a1f034c09b3657b04ce5e9d7
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock apps/vectorcraft/Cargo.toml apps/vectorcraft/src/app.rs apps/vectorcraft/src/host_services.rs apps/vectorcraft/src/linux_file_read.rs apps/vectorcraft/src/main.rs apps/vectorcraft/src/single_instance.rs docs/development.md docs/gaps.md docs/roadmap.md docs/target-app-parity.md docs/ui-parity.md
@@ -5900,6 +5900,7 @@ dependencies = [ "pollster 1.0.1", "raw-window-handle", "rfd",+ "rustix 1.1.5", "serde_json", "socket2", "vectorcraft-engine",@@ -81,6 +81,8 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std" pollster = "1" [target.'cfg(target_os = "linux")'.dependencies]+# Nonblocking file opens and descriptor checks (already used by the format crate).+rustix = { workspace = true, features = ["std", "fs"] } # GNOME/Wayland needs client-side window controls without a bundled title font. winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } # A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a@@ -104,6 +104,10 @@ impl App { } open_files(&mut self.app, files); }+ #[cfg(target_os = "linux")]+ if let Some(instance) = &self.instance {+ instance.dispatch(&mut self.app, ctx);+ } self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window {@@ -268,7 +268,16 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Windows reads it from the pen's pointer messages (`windows_stylus`). #[cfg(all(windows, not(target_vendor = "win7")))] stylus: Some(windows_stylus::start()),- read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),+ read: Some(Box::new(|p: &str| {+ #[cfg(target_os = "linux")]+ {+ linux_file_read::read(p)+ }+ #[cfg(not(target_os = "linux"))]+ {+ std::fs::read(p).map_err(|e| e.to_string())+ }+ })), write: Some(Box::new(write_file)), exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread.new file mode 100644@@ -0,0 +1,111 @@+//! Linux desktop reads must not consume pipes/devices or allocate without a byte limit.++use rustix::fs::{Mode, OFlags};+use std::fs::File;+use std::io::Read;+use std::path::Path;++/// Match the native format's 512 MiB unpacked limit for desktop input bytes.+const MAX_BYTES: u64 = 512 << 20;++/// Read a bounded regular file, following ordinary document symlinks.+pub fn read(path: &str) -> Result<Vec<u8>, String> {+ read_capped(Path::new(path), MAX_BYTES)+}++/// Check before opening to avoid device opens; check the descriptor again to close path races.+fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {+ if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {+ return Err("Only regular files can be opened; pipes, devices and directories are not supported".into());+ }+ // NONBLOCK prevents a regular-file-to-FIFO replacement from blocking open; NOCTTY avoids+ // acquiring a controlling terminal if a path is replaced with a terminal device.+ let fd =+ rustix::fs::open(path, OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOCTTY | OFlags::CLOEXEC, Mode::empty()).map_err(|e| e.to_string())?;+ read_open_file(File::from(fd), max)+}++/// Validate the actual opened object and cap reads even when it grows or reports length zero.+fn read_open_file(file: File, max: u64) -> Result<Vec<u8>, String> {+ let meta = file.metadata().map_err(|e| e.to_string())?;+ if !meta.is_file() {+ return Err("Only regular files can be opened; the path changed to a special file".into());+ }+ let too_large = || format!("File exceeds the desktop input limit of {max} bytes");+ if meta.len() > max {+ return Err(too_large());+ }+ let mut bytes = Vec::new();+ file.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| e.to_string())?;+ if bytes.len() as u64 > max {+ return Err(too_large());+ }+ Ok(bytes)+}++#[cfg(test)]+mod tests {+ use super::*;+ use std::os::unix::fs::symlink;++ /// A private fixture directory, removed after each test without touching user files.+ struct Fixture(std::path::PathBuf);+ impl Fixture {+ /// Allocate a test-specific directory under the OS temporary folder.+ fn new(name: &str) -> Self {+ let path = std::env::temp_dir().join(format!("vectorcraft-read-{}-{name}", std::process::id()));+ std::fs::create_dir(&path).unwrap();+ Self(path)+ }+ }+ impl Drop for Fixture {+ /// Remove only the files created inside this fixture directory.+ fn drop(&mut self) {+ for entry in std::fs::read_dir(&self.0).unwrap().flatten() {+ let _ = std::fs::remove_file(entry.path());+ }+ let _ = std::fs::remove_dir(&self.0);+ }+ }++ /// Exercise the actual desktop service, including symlinks to devices.+ #[test]+ fn desktop_service_rejects_devices_and_directories() {+ let dir = Fixture::new("devices");+ let link = dir.0.join("device.svg");+ symlink("/dev/zero", &link).unwrap();+ let services = crate::services(None, egui::Context::default());+ let read = services.read.unwrap();+ for path in [Path::new("/dev/null"), Path::new("/dev/zero"), &dir.0, &link] {+ assert!(read(path.to_str().unwrap()).unwrap_err().contains("regular files"));+ }+ }++ /// FIFOs and a descriptor swapped after the path check cannot enter a blocking read.+ #[test]+ fn fifo_and_opened_special_descriptor_are_rejected() {+ let dir = Fixture::new("fifo");+ let path = dir.0.join("pipe.svg");+ rustix::fs::mkfifoat(rustix::fs::CWD, &path, Mode::RUSR | Mode::WUSR).unwrap();+ assert!(read(path.to_str().unwrap()).unwrap_err().contains("regular files"));+ let fd = rustix::fs::open(&path, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty()).unwrap();+ assert!(read_open_file(File::from(fd), 16).unwrap_err().contains("regular files"));+ }++ /// Normal symlinks still work; sparse oversized files are refused before allocation.+ #[test]+ fn regular_files_symlinks_and_byte_limits() {+ let dir = Fixture::new("regular");+ let path = dir.0.join("日本語.svg");+ std::fs::write(&path, b"test").unwrap();+ let link = dir.0.join("linked.svg");+ symlink(&path, &link).unwrap();+ assert_eq!(read(link.to_str().unwrap()).unwrap(), b"test");+ assert_eq!(read_capped(&path, 4).unwrap(), b"test");+ assert!(read_capped(&path, 3).unwrap_err().contains("input limit"));+ File::options().write(true).open(&path).unwrap().set_len(MAX_BYTES + 1).unwrap();+ assert!(read(path.to_str().unwrap()).unwrap_err().contains("input limit"));+ // procfs reports zero length: the streaming cap must still apply.+ assert!(read_capped(Path::new("/proc/self/status"), 16).unwrap_err().contains("input limit"));+ }+}@@ -31,6 +31,8 @@ mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; #[cfg(target_os = "linux")]+mod linux_file_read;+#[cfg(target_os = "linux")] mod linux_stylus; mod logging; #[cfg(target_os = "macos")]@@ -50,6 +52,8 @@ mod open_documents; mod portal_save; mod prefs_dir; mod printing;+#[cfg(target_os = "linux")]+mod single_instance; #[cfg(all(windows, not(target_vendor = "win7")))] mod system_fonts; mod tablet;@@ -96,6 +100,8 @@ struct App { graphics_loss: GraphicsLoss, /// The graphics device was lost and the unsaved changes are kept for Data Recovery. graphics_lost: bool,+ #[cfg(target_os = "linux")]+ instance: Option<single_instance::Instance>, /// Frames the UI has run (see [`end_before_teardown`]). frames: u64, /// Keeps the custom title bar's window borderless (Windows, #1758).@@ -186,6 +192,25 @@ fn main() -> std::process::ExitCode { } }, };+ // Automation runs stay independent. Ordinary desktop launches share one window on Linux.+ #[cfg(target_os = "linux")]+ let instance = if control_port.is_some() || !prefs_enabled() {+ None+ } else {+ match single_instance::start(&files) {+ Ok(single_instance::Launch::Forwarded) => return std::process::ExitCode::SUCCESS,+ Ok(single_instance::Launch::Primary(instance)) => Some(instance),+ Err(single_instance::StartError::BusUnavailable(e)) => {+ // Minimal desktop sessions may have no bus. Keep file opening available there.+ eprintln!("vectorcraft: could not reuse the existing window: {e}");+ None+ }+ Err(single_instance::StartError::ForwardingFailed(e)) => {+ eprintln!("vectorcraft: could not forward to the existing window: {e}");+ return std::process::ExitCode::FAILURE;+ }+ }+ }; // Before desktop services, so every start-up warning is recorded (`logging`). let logger = logging::install(); vectorcraft_ui_egui::i18n::detect_system_lang_in_background();@@ -440,6 +465,10 @@ fn main() -> std::process::ExitCode { { app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into()); }+ #[cfg(target_os = "linux")]+ if let Some(instance) = &instance {+ instance.set_ui(&cc.egui_ctx);+ } app.custom_titlebar = custom_titlebar; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone());@@ -474,6 +503,8 @@ fn main() -> std::process::ExitCode { graphics_lost: false, frames: 0, window_frame,+ #[cfg(target_os = "linux")]+ instance, winit_window, pending_maximize, maximize_tries: 8,new file mode 100644@@ -0,0 +1,416 @@+//! Linux file-manager launches hand their paths to the running desktop app over the session bus.+//! The bus owns the name atomically and releases it when the process exits; no stale lock files.++use std::sync::{Arc, Mutex, PoisonError};+use std::time::Duration;++const NAME: &str = "ai.storyteller.vectorcraft.OpenDocuments";+const PATH: &str = "/ai/storyteller/vectorcraft/OpenDocuments";+// A shared handoff budget, retained through decoding and the lifetime of open tabs.+const MAX_FILES: usize = 32;+const CAPACITY_MESSAGE: &str = "Too many open or queued documents. Close some tabs and try opening the file again.";++#[derive(Default)]+struct Pending {+ files: Vec<String>,+ /// UI-owned documents, decoders and modal work; a dispatched path reserves its slot.+ occupied: usize,+ activate: bool,+ /// Coalesce capacity refusals into one owner-side status update.+ rejected: bool,+ ctx: Option<egui::Context>,+}++#[derive(Clone, Default)]+struct OpenDocuments(Arc<Mutex<Pending>>);++#[zbus::interface(name = "ai.storyteller.vectorcraft.OpenDocuments")]+impl OpenDocuments {+ /// Validate and queue absolute file paths, then wake the UI to dispatch them.+ fn open(&self, files: Vec<String>) -> zbus::fdo::Result<()> {+ let mut pending = self.0.lock().unwrap_or_else(PoisonError::into_inner);+ if files.iter().any(|p| p.len() > 32768 || !std::path::Path::new(p).is_absolute()) {+ return Err(zbus::fdo::Error::InvalidArgs("invalid absolute paths".into()));+ }+ // Draining the queue must not free admission capacity while the document is decoding+ // or remains open. An empty request may still focus a workspace that is at capacity.+ if !files.is_empty() && files.len().saturating_add(pending.files.len()).saturating_add(pending.occupied) > MAX_FILES {+ pending.rejected = true;+ if let Some(ctx) = &pending.ctx {+ ctx.request_repaint();+ }+ return Err(zbus::fdo::Error::LimitsExceeded(CAPACITY_MESSAGE.into()));+ }+ pending.files.extend(files);+ pending.activate = true;+ if let Some(ctx) = &pending.ctx {+ ctx.request_repaint();+ }+ Ok(())+ }+}++/// Keep the connection alive for the lifetime of the window.+pub struct Instance {+ _connection: zbus::blocking::Connection,+ documents: OpenDocuments,+}++/// Whether this process owns the desktop window or handed its request to the owner.+pub enum Launch {+ Primary(Instance),+ Forwarded,+}++/// Only an unavailable session bus permits a separate desktop window.+#[derive(Debug)]+pub enum StartError {+ BusUnavailable(String),+ ForwardingFailed(String),+}++/// Claim the session-bus name or forward this launch to its existing owner.+pub fn start(files: &[String]) -> Result<Launch, StartError> {+ let builder = zbus::blocking::connection::Builder::session().map_err(|e| StartError::BusUnavailable(e.to_string()))?;+ start_on(builder, files, OpenDocuments::default())+}++/// Start on a supplied connection builder so transport tests can isolate their bus.+fn start_on(builder: zbus::blocking::connection::Builder<'_>, files: &[String], documents: OpenDocuments) -> Result<Launch, StartError> {+ // Reserve primary startup work before the service becomes reachable by other launches.+ documents.0.lock().unwrap_or_else(PoisonError::into_inner).occupied = files.len();+ let connection = builder+ .method_timeout(Duration::from_secs(5))+ .serve_at(PATH, documents.clone())+ .map_err(|e| StartError::BusUnavailable(e.to_string()))?+ .build()+ .map_err(|e| StartError::BusUnavailable(e.to_string()))?;+ match connection.request_name_with_flags(NAME, zbus::fdo::RequestNameFlags::DoNotQueue.into()) {+ Ok(_) => Ok(Launch::Primary(Instance { _connection: connection, documents })),+ Err(zbus::Error::NameTaken) => {+ // Once an owner exists, failure (including rejection/timeout) must not bypass it.+ let cwd = std::env::current_dir().map_err(|e| StartError::ForwardingFailed(e.to_string()))?;+ let paths: Vec<String> = files.iter().map(|p| cwd.join(p).to_string_lossy().into_owned()).collect();+ let proxy = zbus::blocking::Proxy::new(&connection, NAME, PATH, NAME).map_err(|e| StartError::ForwardingFailed(e.to_string()))?;+ let _: () = proxy.call("Open", &(paths,)).map_err(|e| StartError::ForwardingFailed(e.to_string()))?;+ Ok(Launch::Forwarded)+ }+ // A connected bus that refuses ownership must not enable another workspace.+ Err(e) => Err(StartError::ForwardingFailed(e.to_string())),+ }+}++impl Instance {+ /// Register the repaint context after the primary window has been created.+ pub fn set_ui(&self, ctx: &egui::Context) {+ self.documents.0.lock().unwrap_or_else(PoisonError::into_inner).ctx = Some(ctx.clone());+ }++ /// File-open requests must not change the document a dialog is acting on.+ pub fn dispatch(&self, app: &mut vectorcraft_ui_egui::VectorcraftApp, ctx: &egui::Context) {+ self.update_occupancy(app);+ let rejected = {+ let mut pending = self.documents.0.lock().unwrap_or_else(PoisonError::into_inner);+ std::mem::take(&mut pending.rejected)+ };+ if rejected {+ app.status(CAPACITY_MESSAGE);+ }+ if !app.file_dialog_open()+ && app.ui.dialog.is_none()+ && app.opening.pending.is_empty()+ && let Some(files) = self.take()+ {+ for file in files {+ // IPC grants document opening only. File Open also installs plug-ins and+ // imports settings/libraries; those actions require an explicit local choice.+ if vectorcraft_ui_egui::io::opens_as(&file).is_some() {+ vectorcraft_ui_egui::io::report_open_error(+ app,+ &file,+ "Forwarded requests can only open documents. Open this file from within VectorCraft instead.",+ );+ } else {+ super::open_files(app, vec![file]);+ }+ }+ self.update_occupancy(app);+ // At most one path was dispatched. Let its dialog/decoder finish before the next.+ if app.ui.dialog.is_none() && app.opening.pending.is_empty() {+ ctx.request_repaint();+ }+ ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));+ ctx.send_viewport_cmd(egui::ViewportCommand::Focus);+ }+ }++ /// Synchronize admission with live work; closed tabs and cancelled dialogs release slots.+ fn update_occupancy(&self, app: &vectorcraft_ui_egui::VectorcraftApp) {+ self.documents.0.lock().unwrap_or_else(PoisonError::into_inner).occupied = app+ .session+ .documents()+ .len()+ .saturating_add(app.opening.pending.len())+ .saturating_add(usize::from(app.file_dialog_open() || app.ui.dialog.is_some()));+ }++ /// Reserve one path without draining later requests; an empty request still focuses.+ fn take(&self) -> Option<Vec<String>> {+ let mut pending = self.documents.0.lock().unwrap_or_else(PoisonError::into_inner);+ if !pending.activate || (!pending.files.is_empty() && pending.occupied >= MAX_FILES) {+ return None;+ }+ let n = pending.files.len().min(1);+ let files = pending.files.drain(..n).collect();+ pending.occupied = pending.occupied.saturating_add(n);+ pending.activate = !pending.files.is_empty();+ Some(files)+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ /// Invalid requests leave previously queued paths intact.+ #[test]+ fn queues_files_until_taken_and_rejects_invalid_requests() {+ let documents = OpenDocuments::default();+ documents.open(vec!["/tmp/a b.svg".into(), "/tmp/日本語.svg".into()]).unwrap();+ assert!(documents.open(vec!["relative.svg".into()]).is_err());+ assert!(documents.open(vec!["/tmp/a.svg".into(); 256]).is_err());+ let pending = documents.0.lock().unwrap();+ assert_eq!(pending.files, ["/tmp/a b.svg", "/tmp/日本語.svg"]);+ assert!(pending.activate);+ }++ /// An unavailable private socket permits the documented no-session-bus fallback.+ #[test]+ fn unavailable_bus_is_distinct_from_a_rejected_handoff() {+ let dir = std::env::temp_dir().join(format!("vectorcraft-absent-bus-{}", std::process::id()));+ let address = format!("unix:path={}/bus", dir.display());+ let builder = zbus::blocking::connection::Builder::address(address.as_str()).unwrap();+ assert!(matches!(start_on(builder, &[], OpenDocuments::default()), Err(StartError::BusUnavailable(_))));+ }++ /// A live bus may deny name ownership; only connection failure permits fallback.+ #[test]+ fn denied_name_ownership_never_permits_standalone_startup() {+ use std::io::BufRead as _;+ let dir = std::env::temp_dir().join(format!("vectorcraft-denied-bus-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let config = dir.join("bus.conf");+ std::fs::write(+ &config,+ format!(+ r#"<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen>+<auth>EXTERNAL</auth><policy context="default"><allow user="*"/>+<allow send_destination="*"/><allow receive_sender="*"/><deny own="{NAME}"/>+</policy></busconfig>"#+ ),+ )+ .unwrap();+ let Ok(mut daemon) = std::process::Command::new("dbus-daemon")+ .arg(format!("--config-file={}", config.display()))+ .args(["--nofork", "--print-address=1"])+ .stdout(std::process::Stdio::piped())+ .spawn()+ else {+ std::fs::remove_file(config).unwrap();+ std::fs::remove_dir(dir).unwrap();+ eprintln!("dbus-daemon unavailable; skipping ownership policy regression");+ return;+ };+ struct Stop(std::process::Child, std::path::PathBuf);+ impl Drop for Stop {+ /// Stop the bus and remove its synthetic policy even if assertions fail.+ fn drop(&mut self) {+ let _ = self.0.kill();+ let _ = self.0.wait();+ let _ = std::fs::remove_file(self.1.join("bus.conf"));+ let _ = std::fs::remove_dir(&self.1);+ }+ }+ let mut address = String::new();+ std::io::BufReader::new(daemon.stdout.take().unwrap()).read_line(&mut address).unwrap();+ let _stop = Stop(daemon, dir);+ let builder = zbus::blocking::connection::Builder::address(address.trim()).unwrap();+ let error = start_on(builder, &[], OpenDocuments::default()).err().unwrap();+ assert!(matches!(&error, StartError::ForwardingFailed(message) if message.contains("AccessDenied")), "{error:?}");+ }++ /// Exercise real forwarding, dialog deferral, tab opening and ownership cleanup.+ #[test]+ fn second_launch_forwards_and_name_is_released_on_exit() {+ use std::io::BufRead as _;+ let Ok(mut daemon) = std::process::Command::new("dbus-daemon")+ .args(["--session", "--nofork", "--print-address=1"])+ .stdout(std::process::Stdio::piped())+ .spawn()+ else {+ eprintln!("dbus-daemon unavailable; skipping the private-bus transport regression");+ return;+ };+ struct Stop(std::process::Child);+ impl Drop for Stop {+ /// Stop the private test bus even when an assertion unwinds.+ fn drop(&mut self) {+ let _ = self.0.kill();+ let _ = self.0.wait();+ }+ }+ let mut address = String::new();+ std::io::BufReader::new(daemon.stdout.take().unwrap()).read_line(&mut address).unwrap();+ let _stop = Stop(daemon);+ let builder = || zbus::blocking::connection::Builder::address(address.trim()).unwrap();+ // The owner is reachable before any UI frame: startup work must already occupy slots.+ for startup_count in [MAX_FILES - 1, MAX_FILES, MAX_FILES + 1] {+ let startup = vec!["/tmp/startup.svg".into(); startup_count];+ let Launch::Primary(owner) = start_on(builder(), &startup, OpenDocuments::default()).unwrap() else { panic!("startup owner") };+ let remaining = MAX_FILES.saturating_sub(startup_count);+ assert!(matches!(start_on(builder(), &vec!["/tmp/early.svg".into(); remaining], OpenDocuments::default()).unwrap(), Launch::Forwarded));+ assert!(matches!(start_on(builder(), &["/tmp/overflow.svg".into()], OpenDocuments::default()), Err(StartError::ForwardingFailed(_))));+ assert_eq!(owner.documents.0.lock().unwrap().files.len(), remaining, "startup paths are reserved, not enqueued twice");+ assert!(matches!(start_on(builder(), &[], OpenDocuments::default()).unwrap(), Launch::Forwarded), "activation works at capacity");+ // The first UI synchronization replaces startup reservations with realized work.+ let mut app = vectorcraft_ui_egui::VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());+ app.run("file.new", serde_json::Value::Null).unwrap();+ app.run("file.newDialog", serde_json::Value::Null).unwrap();+ owner.dispatch(&mut app, &egui::Context::default());+ assert!(app.ui.dialog.is_some(), "startup reconciliation preserves modal ownership");+ assert_eq!(owner.documents.0.lock().unwrap().occupied, 2, "one open tab and its dialog replace startup reservations");+ let freed = MAX_FILES - remaining - 2;+ assert!(matches!(+ start_on(builder(), &vec!["/tmp/after-startup.svg".into(); freed], OpenDocuments::default()).unwrap(),+ Launch::Forwarded+ ));+ assert!(matches!(start_on(builder(), &["/tmp/still-full.svg".into()], OpenDocuments::default()), Err(StartError::ForwardingFailed(_))));+ drop(owner);+ }+ let Launch::Primary(first) = start_on(builder(), &[], OpenDocuments::default()).unwrap() else { panic!("first launch") };+ first.set_ui(&egui::Context::default());+ let failure = start_on(builder(), &vec!["/tmp/rejected.svg".into(); MAX_FILES + 1], OpenDocuments::default()).err().unwrap();+ assert!(matches!(failure, StartError::ForwardingFailed(_)), "an owner rejection must never enable standalone startup");+ assert!(first.documents.0.lock().unwrap().files.is_empty());+ assert!(matches!(+ start_on(builder(), &["a b.svg".into(), "/tmp/日本語.svg".into(), "/tmp/external.WASM".into()], OpenDocuments::default()).unwrap(),+ Launch::Forwarded+ ));+ let files = first.documents.0.lock().unwrap().files.clone();+ assert_eq!(+ files,+ [std::env::current_dir().unwrap().join("a b.svg").to_string_lossy(), "/tmp/日本語.svg".into(), "/tmp/external.WASM".into()]+ );+ // Exercise the actual desktop file-opening path with an existing unsaved document.+ let special_reads = Arc::new(std::sync::atomic::AtomicUsize::new(0));+ let reads = special_reads.clone();+ let total_reads = Arc::new(std::sync::atomic::AtomicUsize::new(0));+ let total = total_reads.clone();+ let services = vectorcraft_ui_egui::Services {+ read: Some(Box::new(move |path| {+ total.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+ if vectorcraft_ui_egui::io::opens_as(path).is_some() {+ reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+ }+ if vectorcraft_ui_egui::io::opens_as(path) == Some(vectorcraft_ui_egui::io::OpensAs::Plugin) {+ return Ok(include_bytes!("../../../crates/plugins/tests/fixtures/desaturate.wasm").to_vec());+ }+ Ok(br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="10" height="10"/></svg>"#.to_vec())+ })),+ ..Default::default()+ };+ let mut app = vectorcraft_ui_egui::VectorcraftApp::new(vectorcraft_engine::Session::new(), services);+ let plugin = serde_json::json!({"id": "org.vectorcraft.example.desaturate"});+ assert!(app.run("plugin.info", plugin.clone()).is_err());+ app.run("file.new", serde_json::json!({"width": 100, "height": 100})).unwrap();+ app.run("shape.rectangle", serde_json::json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap();+ let original = app.session.documents()[0].doc.clone();+ let ctx = egui::Context::default();+ app.run("file.newDialog", serde_json::Value::Null).unwrap();+ assert!(app.ui.dialog.is_some());+ first.dispatch(&mut app, &ctx);+ assert_eq!(app.session.documents().len(), 1, "dialog keeps its document active");+ assert_eq!(first.documents.0.lock().unwrap().files, files, "queued files wait for the dialog");+ assert_eq!(app.ui.status, CAPACITY_MESSAGE, "owner displays the remote capacity rejection even during a dialog");+ app.status("sentinel");+ first.dispatch(&mut app, &ctx);+ assert_eq!(app.ui.status, "sentinel", "a rejection is consumed exactly once");+ assert!(matches!(+ start_on(builder(), &vec!["/tmp/overflow.svg".into(); MAX_FILES + 1], OpenDocuments::default()),+ Err(StartError::ForwardingFailed(_))+ ));+ assert!(matches!(+ start_on(builder(), &vec!["/tmp/overflow.svg".into(); MAX_FILES + 1], OpenDocuments::default()),+ Err(StartError::ForwardingFailed(_))+ ));+ first.dispatch(&mut app, &ctx);+ assert_eq!(app.ui.status, CAPACITY_MESSAGE, "repeated refusals coalesce and later rejections still notify");+ assert_eq!(first.documents.0.lock().unwrap().files, files);+ assert_eq!(app.session.documents()[0].doc, original);+ app.ui.dialog = None;+ first.dispatch(&mut app, &ctx);+ assert_eq!(app.opening.pending.len(), 1, "one forwarded decoder at a time");+ let read_count = total_reads.load(std::sync::atomic::Ordering::Relaxed);+ first.dispatch(&mut app, &ctx);+ assert_eq!(total_reads.load(std::sync::atomic::Ordering::Relaxed), read_count, "no read starts while earlier work is pending");+ let deadline = std::time::Instant::now() + Duration::from_secs(10);+ while app.ui.dialog.is_none() && std::time::Instant::now() < deadline {+ vectorcraft_ui_egui::opening::poll(&mut app);+ first.dispatch(&mut app, &ctx);+ std::thread::sleep(Duration::from_millis(10));+ }+ assert!(app.run("plugin.info", plugin.clone()).is_err(), "forwarded files cannot install plug-ins in the running editor");+ assert_eq!(special_reads.load(std::sync::atomic::Ordering::Relaxed), 0, "reject before reading the module");+ assert!(app.ui.dialog.as_ref().unwrap().str("detail").contains("only open documents"));+ app.ui.dialog = None; // The user closes the refusal after the earlier decoders finish.+ assert!(app.opening.pending.is_empty(), "forwarded files finished opening");+ assert_eq!(app.session.documents().len(), 3, "both files become tabs in the same app");+ assert_eq!(app.session.documents()[0].doc, original, "unsaved artwork is preserved");+ assert!(app.session.documents()[0].is_dirty());+ assert_ne!(app.session.active_index(), Some(0));+ assert!(first.take().is_none());+ // Deliberate local installation still works, and IPC cannot replace that plug-in.+ vectorcraft_ui_egui::io::open_path(&mut app, "/tmp/manual.wasm").unwrap();+ let installed = app.run("plugin.info", plugin.clone()).unwrap();+ assert!(matches!(start_on(builder(), &["/tmp/external.WASM".into()], OpenDocuments::default()).unwrap(), Launch::Forwarded));+ first.dispatch(&mut app, &ctx);+ assert_eq!(app.run("plugin.info", plugin).unwrap(), installed, "forwarding must not replace an installed plug-in");+ assert_eq!(special_reads.load(std::sync::atomic::Ordering::Relaxed), 1, "only the explicit local install reads the module");+ app.ui.dialog = None;+ assert!(matches!(start_on(builder(), &[], OpenDocuments::default()).unwrap(), Launch::Forwarded));+ assert_eq!(first.take(), Some(vec![]));+ // Each refusal owns its dialog until the user closes it; later files must wait.+ first.documents.open(vec!["/tmp/refusal-a.wasm".into(), "/tmp/refusal-b.wasm".into()]).unwrap();+ first.dispatch(&mut app, &ctx);+ assert!(app.ui.dialog.as_ref().unwrap().str("message").contains("refusal-a.wasm"));+ assert_eq!(first.documents.0.lock().unwrap().files, ["/tmp/refusal-b.wasm"]);+ first.dispatch(&mut app, &ctx);+ assert!(app.ui.dialog.as_ref().unwrap().str("message").contains("refusal-a.wasm"));+ app.ui.dialog = None;+ first.dispatch(&mut app, &ctx);+ assert!(app.ui.dialog.as_ref().unwrap().str("message").contains("refusal-b.wasm"));+ app.ui.dialog = None;+ // Admission remains occupied after dispatch and after decoding, until a tab closes.+ first.dispatch(&mut app, &ctx); // Refresh the slot held by the just-closed dialog.+ let count = app.session.documents().len();+ first.documents.open(vec!["/tmp/budget.svg".into(); MAX_FILES - count]).unwrap();+ assert!(first.documents.open(vec!["/tmp/overflow.svg".into()]).is_err());+ first.dispatch(&mut app, &ctx);+ assert_eq!(app.opening.pending.len(), 1);+ assert!(first.documents.open(vec!["/tmp/overflow.svg".into()]).is_err(), "dequeueing keeps its reservation");+ let deadline = std::time::Instant::now() + Duration::from_secs(10);+ while !app.opening.pending.is_empty() && std::time::Instant::now() < deadline {+ vectorcraft_ui_egui::opening::poll(&mut app);+ std::thread::sleep(Duration::from_millis(10));+ }+ assert!(app.opening.pending.is_empty());+ first.update_occupancy(&app);+ assert!(first.documents.open(vec!["/tmp/overflow.svg".into()]).is_err(), "the resulting tab keeps its slot");+ assert!(app.session.close_document(app.session.documents().len() - 1));+ first.update_occupancy(&app);+ first.documents.open(vec!["/tmp/after-close.svg".into()]).unwrap();+ assert!(first.documents.open(vec!["/tmp/overflow.svg".into()]).is_err(), "closing a tab releases exactly one slot");+ drop(first);+ assert!(matches!(start_on(builder(), &[], OpenDocuments::default()).unwrap(), Launch::Primary(_)));+ }+}@@ -500,6 +500,10 @@ Scores and hour estimates are unchanged. | 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). | | 2026-10-14 | minor | Document registration of every explicitly supplied craft-fonts face after the existing script-specific prefix; empty input and web selection remain unchanged. | | 2026-10-13 | minor | Document pinned PngSuite files, integrity checks and download budgets (#1729). |+| 2026-10-21 | minor | M4: Linux document-only window reuse; regular-file/512 MiB read safeguards; isolated transport and reader regressions; scores and estimates unchanged. |+| 2026-10-21 | minor | M4 review: rejected forwarding cannot bypass the owner; per-file dialog/decoder deferral and shared 32-slot admission preserve request ownership and bound repeated handoff work; real-bus regressions; scores unchanged. |+| 2026-10-22 | minor | M4 review: owner-side capacity refusals show a coalesced status message without disturbing dialogs or queued documents; connected-bus ownership refusals fail closed. Private D-Bus regressions; scores and estimates unchanged. |+| 2026-10-22 | minor | M4 startup review: reserve primary file slots before publishing the D-Bus service; real-bus boundary regressions cover startup admission, activation and first-frame capacity reconciliation. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. @@ -509,3 +513,50 @@ Scores and hour estimates are unchanged. | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged |++## Linux file-manager handoff (2026-10-21)++Ordinary Linux launches share the session D-Bus name `ai.storyteller.vectorcraft.OpenDocuments`.+Later launches queue absolute document paths and exit; the running app opens document tabs+while preserving existing unsaved work. Activation remains subject to compositor focus policy.+Automation (`--control`, `VECTORCRAFT_CONTROL_PORT`, `VECTORCRAFT_NO_PREFS`) stays independent.+Only unavailable-bus startup errors permit standalone fallback. An existing owner's rejection,+timeout or other forwarding failure exits unsuccessfully instead of starting a second window.++Handoff shares a 32-slot admission budget with queued paths, decoding, modal work and open tabs+(including tabs opened locally). Dispatch reserves a slot and starts at most one file; it waits+for the decoder and any native or in-app dialog to finish before taking the next queued path.+A completed tab retains its slot until closed. Capacity is refreshed by the UI; closing a tab+or cancelling a dialog releases its slot. Overload is rejected atomically; close tabs and retry.+An activation-only request still works at capacity. Local File Open is not capped by this IPC+policy, but its tabs count against new forwarded work. Startup requests wait for the first UI+frame, which checks actual occupancy before dispatching.++Forwarded paths use `io::opens_as` to refuse plug-ins, presets, libraries, shortcut sets and+scripts before reading; deliberate local File Open and plug-in installation stay available.+Linux desktop reads accept regular files and ordinary symlinks, reject pipes/devices/directories,+recheck the opened descriptor, and cap input at 512 MiB even for growing or zero-reported-length+files. Nonblocking opens prevent FIFO replacement from hanging. Regular-file I/O remains+synchronous and network filesystem latency is not bounded. The admission budget bounds work+count, not decoded bytes; existing importer limits still apply.++Tests: `single_instance::tests` covers isolated real D-Bus, rejected handoff classification,+no-bus fallback, serialized decoding, retained dialog requests, slot retention and close/retry,+Unicode/relative paths, ownership release, unsaved documents and document-only handling.+`linux_file_read::tests` covers special descriptors, symlinks and byte limits. Native/Flatpak+tab opening was manually verified before this review follow-up. Scores and estimates unchanged.++Linux handoff review follow-up (2026-10-22): capacity refusals wake the owner and+show a one-shot status message, including while a dialog defers opening. Repeated+refusals coalesce without adding queued work. Once the session bus is connected,+name-ownership errors fail closed rather than starting a second workspace. Private+D-Bus tests cover denied ownership and visible, one-shot capacity feedback while+preserving queued files, dialogs and unsaved artwork. Scores and estimates unchanged.++Startup admission follow-up (2026-10-22): the primary reserves its startup file+count before publishing the D-Bus service. Concurrent launches therefore count+startup work toward the shared limit. The first UI dispatch replaces this temporary+reservation with realized tabs, decoders and dialogs. Explicit primary startup files+retain their existing behavior, including counts above the forwarded-work limit;+additional forwarded files are refused until capacity becomes available. Real-bus+regressions exercise the boundary before any UI frame and subsequent reconciliation.@@ -707,6 +707,10 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top. No entry closed, estimates unchanged. | | 2026-10-20 | minor | G1: for the selection tools, a press within the selection tolerance of a clipping path's outline (a clip group's, or a layer's clipping mask) hits the clipping path before the art it clips, so Direct Selection takes its segments and the Selection tool the clip group even where the art lies over the mask's edge; Select Behind lists the same; the eyedropper and the tools that act on what is drawn are unchanged. Doc and tool regression tests. No entry closed, estimates unchanged. |+| 2026-10-21 | minor | M4: Linux document-only window reuse; regular-file/512 MiB read safeguards; isolated transport and reader regressions; scores and estimates unchanged. |+| 2026-10-21 | minor | M4 review: rejected forwarding cannot bypass the owner; per-file dialog/decoder deferral and shared 32-slot admission preserve request ownership and bound repeated handoff work; real-bus regressions; scores unchanged. |+| 2026-10-22 | minor | M4 review: owner-side capacity refusals show a coalesced status message without disturbing dialogs or queued documents; connected-bus ownership refusals fail closed. Private D-Bus regressions; scores and estimates unchanged. |+| 2026-10-22 | minor | M4 startup review: reserve primary file slots before publishing the D-Bus service; real-bus boundary regressions cover startup admission, activation and first-frame capacity reconciliation. Scores and estimates unchanged. | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | | 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. | | 2026-10-19 | minor | G17: heavy filter plug-ins (`"budget": "heavy"`), installed once the user agrees and run in the background with Cancel; ABI, engine and headless UI regressions. No entry closed, estimates unchanged. |@@ -826,3 +830,29 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged |++## Linux file-manager handoff (2026-10-21)++Document-only session D-Bus requests open tabs in the existing window. Forwarding failures+exit unsuccessfully; only an unavailable bus permits standalone fallback. Dispatch processes+one file at a time, retaining later requests across dialogs and decoding. A shared 32-slot+budget covers queued work, decoders, dialogs and open tabs; closing tabs releases capacity.+Linux reads reject pipes/devices and cap regular-file input at 512 MiB. Automation remains+independent; compositor focus policy applies. Real-bus and reader regressions cover these+safeguards and unsaved work. Work count is bounded, not total decoded bytes or network latency.+Scores and estimates unchanged. See [development.md](development.md#linux-file-manager-handoff-2026-10-21).++Linux handoff review follow-up (2026-10-22): capacity refusals wake the owner and+show a one-shot status message, including while a dialog defers opening. Repeated+refusals coalesce without adding queued work. Once the session bus is connected,+name-ownership errors fail closed rather than starting a second workspace. Private+D-Bus tests cover denied ownership and visible, one-shot capacity feedback while+preserving queued files, dialogs and unsaved artwork. Scores and estimates unchanged.++Startup admission follow-up (2026-10-22): the primary reserves its startup file+count before publishing the D-Bus service. Concurrent launches therefore count+startup work toward the shared limit. The first UI dispatch replaces this temporary+reservation with realized tabs, decoders and dialogs. Explicit primary startup files+retain their existing behavior, including counts above the forwarded-work limit;+additional forwarded files are refused until capacity becomes available. Real-bus+regressions exercise the boundary before any UI frame and subsequent reconciliation.@@ -85,7 +85,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 |-| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |+| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Linux document-only window handoff and bounded regular-file reads regression-tested (serialized dispatch, 32 shared slots, rejected handoff never starts a second window); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 |@@ -169,6 +169,10 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-21 | minor | Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. |+| 2026-10-21 | minor | M4: Linux document-only window reuse; regular-file/512 MiB read safeguards; isolated transport and reader regressions; scores and estimates unchanged. |+| 2026-10-21 | minor | M4 review: rejected forwarding cannot bypass the owner; per-file dialog/decoder deferral and shared 32-slot admission preserve request ownership and bound repeated handoff work; real-bus regressions; scores unchanged. |+| 2026-10-22 | minor | M4 review: owner-side capacity refusals show a coalesced status message without disturbing dialogs or queued documents; connected-bus ownership refusals fail closed. Private D-Bus regressions; scores and estimates unchanged. |+| 2026-10-22 | minor | M4 startup review: reserve primary file slots before publishing the D-Bus service; real-bus boundary regressions cover startup admission, activation and first-frame capacity reconciliation. Scores and estimates unchanged. | | 2026-10-20 | minor | M12: consolidated three duplicate milestone rows into one, retaining distinct Image Trace and Graphs details and the quick-preset wording correction; status, scores and 6–10 h estimate unchanged. | | 2026-10-20 | minor | Move the live-trace bounds/highlights note into the Image Trace milestone description and restore the Graphs label-formatting code span; implementation, scores and hours unchanged. | | 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. |@@ -286,3 +290,29 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged |++## Linux file-manager handoff (2026-10-21)++Document-only session D-Bus requests open tabs in the existing window. Forwarding failures+exit unsuccessfully; only an unavailable bus permits standalone fallback. Dispatch processes+one file at a time, retaining later requests across dialogs and decoding. A shared 32-slot+budget covers queued work, decoders, dialogs and open tabs; closing tabs releases capacity.+Linux reads reject pipes/devices and cap regular-file input at 512 MiB. Automation remains+independent; compositor focus policy applies. Real-bus and reader regressions cover these+safeguards and unsaved work. Work count is bounded, not total decoded bytes or network latency.+Scores and estimates unchanged. See [development.md](development.md#linux-file-manager-handoff-2026-10-21).++Linux handoff review follow-up (2026-10-22): capacity refusals wake the owner and+show a one-shot status message, including while a dialog defers opening. Repeated+refusals coalesce without adding queued work. Once the session bus is connected,+name-ownership errors fail closed rather than starting a second workspace. Private+D-Bus tests cover denied ownership and visible, one-shot capacity feedback while+preserving queued files, dialogs and unsaved artwork. Scores and estimates unchanged.++Startup admission follow-up (2026-10-22): the primary reserves its startup file+count before publishing the D-Bus service. Concurrent launches therefore count+startup work toward the shared limit. The first UI dispatch replaces this temporary+reservation with realized tabs, decoders and dialogs. Explicit primary startup files+retain their existing behavior, including counts above the forwarded-work limit;+additional forwarded files are refused until capacity becomes available. Real-bus+regressions exercise the boundary before any UI frame and subsequent reconciliation.@@ -136,7 +136,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated; document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) |-| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions) |+| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); Linux document-only window reuse and regular-file input cap (512 MiB) regression-tested; compositor focus policy applies; scores and hours unchanged; review follow-up: one forwarded decode at a time, 32 shared slots, failed handoff never opens a second window | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated; document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; other tool and drag cases remain), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged |@@ -186,7 +186,7 @@ Values are from [By dimension](#by-dimension). |---|---:|---:|---:| | Features (depth) | 35 | 75% | 26.3 | | UI/UX fidelity | 15 | 55% | 8.3 |-| File formats | 12 | 75% | 9.0 |+| File formats | 12 | 75% | 9.0; Linux document-only window reuse and regular-file input cap (512 MiB) regression-tested; compositor focus policy applies; scores and hours unchanged; review follow-up: one forwarded decode at a time, 32 shared slots, failed handoff never opens a second window | | Stability | 10 | 60% | 6.0 | | Performance | 8 | 60% | 4.8 | | Platforms | 5 | 80% | 4.0 |@@ -297,7 +297,7 @@ what keeps it from beta, not from alpha. |---|---:|---:|---|---| | Features (depth, weighted) | 75% | 220–360 | estimated | this file | | UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md) |-| File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) |+| File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md); Linux document-only window reuse and regular-file input cap (512 MiB) regression-tested; compositor focus policy applies; scores and hours unchanged; review follow-up: one forwarded decode at a time, 32 shared slots, failed handoff never opens a second window | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | | Performance | ~60% | 15–25 | estimated (unverified budgets) | [gaps.md](gaps.md) |@@ -401,7 +401,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4; status-bar X/Y uses the active ruler origin and General units |-| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |+| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated); Linux document-only window reuse and regular-file input cap (512 MiB) regression-tested; compositor focus policy applies; scores and hours unchanged; review follow-up: one forwarded decode at a time, 32 shared slots, failed handoff never opens a second window | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |@@ -1208,6 +1208,10 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-21 | minor | Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding | | 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged |+| 2026-10-21 | minor | M4: Linux document-only window reuse; regular-file/512 MiB read safeguards; isolated transport and reader regressions; scores and estimates unchanged. |+| 2026-10-21 | minor | M4 review: rejected forwarding cannot bypass the owner; per-file dialog/decoder deferral and shared 32-slot admission preserve request ownership and bound repeated handoff work; real-bus regressions; scores unchanged. |+| 2026-10-22 | minor | M4 review: owner-side capacity refusals show a coalesced status message without disturbing dialogs or queued documents; connected-bus ownership refusals fail closed. Private D-Bus regressions; scores and estimates unchanged. |+| 2026-10-22 | minor | M4 startup review: reserve primary file slots before publishing the D-Bus service; real-bus boundary regressions cover startup admission, activation and first-frame capacity reconciliation. Scores and estimates unchanged. | | 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | Properties: one selected clip group has Edit Clipping Path and Edit Contents at the top of the panel (public doc cited) (G1). Scores and hours unchanged. |@@ -1394,3 +1398,29 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged |++## Linux file-manager handoff (2026-10-21)++Document-only session D-Bus requests open tabs in the existing window. Forwarding failures+exit unsuccessfully; only an unavailable bus permits standalone fallback. Dispatch processes+one file at a time, retaining later requests across dialogs and decoding. A shared 32-slot+budget covers queued work, decoders, dialogs and open tabs; closing tabs releases capacity.+Linux reads reject pipes/devices and cap regular-file input at 512 MiB. Automation remains+independent; compositor focus policy applies. Real-bus and reader regressions cover these+safeguards and unsaved work. Work count is bounded, not total decoded bytes or network latency.+Scores and estimates unchanged. See [development.md](development.md#linux-file-manager-handoff-2026-10-21).++Linux handoff review follow-up (2026-10-22): capacity refusals wake the owner and+show a one-shot status message, including while a dialog defers opening. Repeated+refusals coalesce without adding queued work. Once the session bus is connected,+name-ownership errors fail closed rather than starting a second workspace. Private+D-Bus tests cover denied ownership and visible, one-shot capacity feedback while+preserving queued files, dialogs and unsaved artwork. Scores and estimates unchanged.++Startup admission follow-up (2026-10-22): the primary reserves its startup file+count before publishing the D-Bus service. Concurrent launches therefore count+startup work toward the shared limit. The first UI dispatch replaces this temporary+reservation with realized tabs, decoders and dialogs. Explicit primary startup files+retain their existing behavior, including counts above the forwarded-work limit;+additional forwarded files are refused until capacity becomes available. Real-bus+regressions exercise the boundary before any UI frame and subsequent reconciliation.@@ -237,6 +237,12 @@ tools, never checked side by side) isn't captured row by row. | Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display and Caps Lock unverified; a Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | +## Linux file-opening workflow++| Behaviour | Status | Notes |+|---|---|---|+| File-manager opens become tabs in the existing window | done | Document-only session D-Bus; modal deferral and unsaved work preserved; special files refused and regular-file input capped at 512 MiB; automation independent; no-bus fallback; compositor focus policy applies. Transport and reader tests; native/Flatpak verification; review follow-up: one forwarded decode at a time, 32 shared slots, failed handoff never opens a second window |+ ## Keyboard shortcuts Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the@@ -400,6 +406,10 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top (public doc cited in target-app-parity.md). Scores unchanged. | | 2026-10-19 | minor | Distribute Objects and Distribute Spacing space the selected ruler guides across the axis with the objects, as items with no size, and two or more guides selected alone (#1597 in part); with a key guide and a spacing, the guide stays put and the other items are spaced from it (key guide from #3165); the Distribute Spacing value is enabled for a key guide; scores unchanged |+| 2026-10-21 | minor | M4: Linux document-only window reuse; regular-file/512 MiB read safeguards; isolated transport and reader regressions; scores and estimates unchanged. |+| 2026-10-21 | minor | M4 review: rejected forwarding cannot bypass the owner; per-file dialog/decoder deferral and shared 32-slot admission preserve request ownership and bound repeated handoff work; real-bus regressions; scores unchanged. |+| 2026-10-22 | minor | M4 review: owner-side capacity refusals show a coalesced status message without disturbing dialogs or queued documents; connected-bus ownership refusals fail closed. Private D-Bus regressions; scores and estimates unchanged. |+| 2026-10-22 | minor | M4 startup review: reserve primary file slots before publishing the D-Bus service; real-bus boundary regressions cover startup admission, activation and first-frame capacity reconciliation. Scores and estimates unchanged. | | 2026-10-21 | minor | Snap to Glyph marked done (#3124); scores unchanged | | 2026-10-20 | minor | The selection tools (Selection, Direct Selection, Group Selection) take a clipping path on its outline, within the selection tolerance, before the art it clips, so its segments can be dragged where the art covers the mask's edge; Select Behind lists the same, and the eyedropper and the tools that act on what is drawn are unchanged. Doc and tool regression tests. Scores unchanged. | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. |@@ -489,3 +499,29 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged |++## Linux file-manager handoff (2026-10-21)++Document-only session D-Bus requests open tabs in the existing window. Forwarding failures+exit unsuccessfully; only an unavailable bus permits standalone fallback. Dispatch processes+one file at a time, retaining later requests across dialogs and decoding. A shared 32-slot+budget covers queued work, decoders, dialogs and open tabs; closing tabs releases capacity.+Linux reads reject pipes/devices and cap regular-file input at 512 MiB. Automation remains+independent; compositor focus policy applies. Real-bus and reader regressions cover these+safeguards and unsaved work. Work count is bounded, not total decoded bytes or network latency.+Scores and estimates unchanged. See [development.md](development.md#linux-file-manager-handoff-2026-10-21).++Linux handoff review follow-up (2026-10-22): capacity refusals wake the owner and+show a one-shot status message, including while a dialog defers opening. Repeated+refusals coalesce without adding queued work. Once the session bus is connected,+name-ownership errors fail closed rather than starting a second workspace. Private+D-Bus tests cover denied ownership and visible, one-shot capacity feedback while+preserving queued files, dialogs and unsaved artwork. Scores and estimates unchanged.++Startup admission follow-up (2026-10-22): the primary reserves its startup file+count before publishing the D-Bus service. Concurrent launches therefore count+startup work toward the shared limit. The first UI dispatch replaces this temporary+reservation with realized tabs, decoders and dialogs. Explicit primary startup files+retain their existing behavior, including counts above the forwarded-work limit;+additional forwarded files are refused until capacity becomes available. Real-bus+regressions exercise the boundary before any UI frame and subsequent reconciliation.