Merge branch 'feat/drag-out' into integration/round-1

This commit is contained in:
2026-08-23 09:51:44 -07:00
19 changed files with 1205 additions and 26 deletions
+190 -20
View File
@@ -630,6 +630,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.25.0"
@@ -1016,6 +1029,28 @@ dependencies = [
"serde",
]
[[package]]
name = "drag"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c"
dependencies = [
"core-graphics 0.24.0",
"dunce",
"gdk",
"gdkx11",
"gtk",
"log",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"raw-window-handle",
"serde",
"thiserror 2.0.18",
"windows 0.52.0",
"windows-core 0.58.0",
]
[[package]]
name = "dtoa"
version = "1.0.11"
@@ -1135,7 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2647,9 +2682,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -2669,6 +2712,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-foundation",
]
@@ -2729,6 +2773,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3394,7 +3451,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3743,7 +3800,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4400,7 +4457,7 @@ dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation 0.10.1",
"core-graphics",
"core-graphics 0.25.0",
"crossbeam-channel",
"dbus",
"dispatch2",
@@ -4425,7 +4482,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -4508,7 +4565,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4608,6 +4665,21 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-drag"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "729ca0ce4b1169869d3405216d3c09a524f41ea5e2eec89f917cd6623f8a70ca"
dependencies = [
"base64 0.22.1",
"drag",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
@@ -4650,7 +4722,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows",
"windows 0.61.3",
"zbus",
]
@@ -4692,7 +4764,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4717,7 +4789,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4782,7 +4854,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5186,6 +5258,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-drag",
"tauri-plugin-opener",
"tauri-plugin-store",
"tokio",
@@ -5639,10 +5712,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
]
[[package]]
@@ -5663,7 +5736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -5689,7 +5762,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5713,6 +5786,18 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core 0.52.0",
"windows-implement 0.52.0",
"windows-interface 0.52.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -5735,14 +5820,36 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -5754,8 +5861,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -5772,6 +5879,28 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -5783,6 +5912,28 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -5816,6 +5967,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5834,6 +5994,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -6261,7 +6431,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
+1
View File
@@ -37,6 +37,7 @@ tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
local-ip-address = "0.6"
tauri-plugin-drag = "2.1"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
+3 -1
View File
@@ -28,6 +28,8 @@
"store:allow-save",
"store:allow-clear",
"opener:default",
"opener:allow-open-url"
"opener:allow-open-url",
"drag:default",
"drag:allow-start-drag"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url"]}}
{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url","drag:default","drag:allow-start-drag"]}}
@@ -2426,6 +2426,24 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
"type": "string",
"const": "drag:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
},
{
"description": "Enables the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:allow-start-drag",
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
},
{
"description": "Denies the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:deny-start-drag",
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
@@ -2426,6 +2426,24 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
"type": "string",
"const": "drag:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
},
{
"description": "Enables the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:allow-start-drag",
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
},
{
"description": "Denies the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:deny-start-drag",
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
+348 -1
View File
@@ -1,10 +1,14 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::State;
use tauri::{AppHandle, Manager, State};
use crate::docker::client::get_docker;
use crate::docker::exec::{
@@ -334,6 +338,239 @@ pub async fn read_container_file(
})
}
// ─────────────────────────────────────────────────────────────────────────────
// Drag-out staging
// ─────────────────────────────────────────────────────────────────────────────
//
// Dragging a file onto the host desktop hands the OS a *host* path, and the
// files in this panel live inside a container, where nothing on the desktop can
// reach them. So a drag-out is really a copy-then-drag: materialise the file
// into a host temp directory first, then start the native drag on that copy.
//
// The copy is the reason this section carries a lifecycle. A staging directory
// nobody empties is a disk leak with a gesture attached to it, so there are two
// halves and both matter: `clear_drag_staging` on exit, and
// `reap_drag_staging` at startup for whatever a crash left behind.
/// Ceiling on one staged copy. Deliberately the same 256 MiB as
/// [`MAX_UPLOAD_BYTES`] — it is the same whole-file-through-host-RAM round trip,
/// only in the other direction.
const MAX_DRAG_STAGE_BYTES: u64 = 256 * 1024 * 1024;
/// Name of the app-owned directory inside the OS temp dir. Everything staged by
/// any Triple-C process lives under it, so housekeeping has exactly one place to
/// look and never walks the rest of the user's temp dir.
const DRAG_STAGE_DIR_NAME: &str = "triple-c-drag-out";
/// How long *another* process's leftover staging directory may sit before
/// startup housekeeping deletes it.
///
/// Only ever applied to directories this process does not own (see
/// [`drag_stage_session_dir`]), so it is not a limit on how long a staged file
/// survives in a live session — it is the crash-recovery threshold, and it is
/// generous because a second Triple-C running right now would also look like a
/// leftover.
const DRAG_STAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
/// This process's own sub-directory name, stable for the life of the process.
///
/// Per-process rather than shared so exit cleanup can delete *ours* outright
/// without reaching into a directory another instance may be dragging out of.
fn drag_stage_session() -> &'static str {
static SESSION: OnceLock<String> = OnceLock::new();
SESSION.get_or_init(|| uuid::Uuid::new_v4().to_string())
}
/// The app-owned staging root inside `temp_dir`.
///
/// Takes the temp dir rather than reading it, because on Windows it is neither
/// `/tmp` nor a constant — Tauri's path API is the only thing that knows it —
/// and because a pure function is what the tests can drive.
pub fn drag_stage_root(temp_dir: &Path) -> PathBuf {
temp_dir.join(DRAG_STAGE_DIR_NAME)
}
/// This process's staging directory: `<temp>/triple-c-drag-out/<session>`.
pub fn drag_stage_session_dir(temp_dir: &Path) -> PathBuf {
drag_stage_root(temp_dir).join(drag_stage_session())
}
/// The per-file sub-directory a staged copy lives in, derived from the
/// container path.
///
/// Filenames are only unique within a directory, so `a/notes.txt` and
/// `b/notes.txt` would otherwise be the same host path — and the second drag
/// would silently rewrite the first one's contents under the first one's cached
/// path. A digest of the full container path separates them while staying
/// *deterministic*, so re-staging the same file reuses its slot instead of
/// growing a new one every drag.
fn drag_stage_slot(container_path: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(container_path.as_bytes());
digest[..8].iter().map(|b| format!("{:02x}", b)).collect()
}
/// The name the staged copy is given on the host.
///
/// The whole point is that what lands on the desktop is called `notes.txt` and
/// not `tmp1234`, so the container's basename is kept verbatim wherever it can
/// be. Only the characters Windows refuses outright are substituted — a Linux
/// file really can be called `a:b`, and the staged copy has to exist on NTFS.
/// A name that is not a filename at all (empty, `.`, `..`) is rejected rather
/// than invented: that means the caller passed something that never named a
/// file, and quietly inventing a name would stage the wrong thing.
fn stage_file_name(container_path: &str) -> Result<String, String> {
let base = container_path
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or("");
let cleaned: String = base
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
// Windows also silently drops a trailing dot or space, which would make the
// path we hand back not the path that exists.
let cleaned = cleaned.trim_end_matches([' ', '.']);
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return Err(format!("{} does not name a file", container_path));
}
Ok(cleaned.to_string())
}
/// Reject an oversize file *by its real size*, before anything is written.
///
/// Split out so the ceiling and its wording are testable without a container.
/// The message names the fallback, because "too large" with no way forward is
/// the one thing a size cap must not be.
fn check_stage_size(size: u64) -> Result<(), String> {
if size > MAX_DRAG_STAGE_BYTES {
return Err(format!(
"{:.0} MB is too large to drag out (limit {} MB) — use \"Save to host…\" instead.",
size as f64 / (1024.0 * 1024.0),
MAX_DRAG_STAGE_BYTES / (1024 * 1024)
));
}
Ok(())
}
/// Whether a leftover staging directory is old enough to delete.
///
/// A modification time in the *future* (a clock step, a copied temp dir) makes
/// `duration_since` fail, and that answers "not stale" — housekeeping deleting
/// something it cannot date is worse than leaving it for the next startup.
fn drag_stage_is_stale(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool {
now.duration_since(modified)
.map(|age| age >= max_age)
.unwrap_or(false)
}
/// Delete every staging directory except this process's own, once it is older
/// than [`DRAG_STAGE_MAX_AGE`]. Called from startup housekeeping.
pub async fn reap_drag_staging(temp_dir: PathBuf) {
let root = drag_stage_root(&temp_dir);
let keep = drag_stage_session_dir(&temp_dir);
let now = SystemTime::now();
let mut dir = match tokio::fs::read_dir(&root).await {
Ok(dir) => dir,
// Nothing staged yet is the normal case, not a problem.
Err(_) => return,
};
let mut reaped = 0usize;
while let Ok(Some(entry)) = dir.next_entry().await {
let path = entry.path();
if path == keep {
continue;
}
let stale = match entry.metadata().await.and_then(|m| m.modified()) {
Ok(modified) => drag_stage_is_stale(modified, now, DRAG_STAGE_MAX_AGE),
Err(_) => false,
};
if !stale {
continue;
}
if tokio::fs::remove_dir_all(&path).await.is_ok() {
reaped += 1;
}
}
if reaped > 0 {
log::info!("Startup housekeeping removed {} stale drag-out staging directory(ies)", reaped);
}
}
/// Delete this process's staging directory. Called from the shutdown teardown.
pub async fn clear_drag_staging(temp_dir: PathBuf) {
let dir = drag_stage_session_dir(&temp_dir);
if let Err(e) = tokio::fs::remove_dir_all(&dir).await {
if e.kind() != std::io::ErrorKind::NotFound {
log::warn!("Failed to clear drag-out staging at {}: {}", dir.display(), e);
}
}
// Best effort: leave no empty root behind either. Fails harmlessly while
// another instance still has a directory in there.
let _ = tokio::fs::remove_dir(drag_stage_root(&temp_dir)).await;
}
/// Copy a container file onto the host so it can be dragged to the desktop, and
/// return the absolute host path.
///
/// Reuses [`fetch_container_file`] rather than extracting a second way, so a
/// dragged file, a downloaded file and a previewed file are byte-identical and
/// refuse folders and links with the same words. The fetch is capped at
/// [`MAX_DRAG_STAGE_BYTES`], so an oversize file is recognised from the tar
/// header without being pulled across the socket in full.
#[tauri::command]
pub async fn stage_container_file_for_drag(
app: AppHandle,
project_id: String,
path: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
// Before the transfer: a path that cannot become a host filename is not
// worth a round trip.
let file_name = stage_file_name(&path)?;
let fetched = fetch_container_file(container_id, &path, Some(MAX_DRAG_STAGE_BYTES)).await?;
// `size` is the tar header's, i.e. the file's real size, which is exactly
// what a truncated fetch does not tell you from `bytes.len()`.
check_stage_size(fetched.size)?;
let temp_dir = app
.path()
.temp_dir()
.map_err(|e| format!("No host temporary directory available: {}", e))?;
let dir = drag_stage_session_dir(&temp_dir).join(drag_stage_slot(&path));
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("Failed to create the drag staging directory: {}", e))?;
let dest = dir.join(&file_name);
tokio::fs::write(&dest, &fetched.bytes)
.await
.map_err(|e| format!("Failed to stage {} on the host: {}", file_name, e))?;
Ok(dest.to_string_lossy().to_string())
}
/// Rename an entry in place. `to_path` is the **new name**, not a destination
/// path — moving between directories is deliberately not offered here, so the
/// name is validated to carry no `/`.
@@ -849,4 +1086,114 @@ mod tests {
assert_eq!(Some(u64::MAX).unwrap().min(MAX_READ_BYTES), MAX_READ_BYTES);
assert!(MAX_READ_BYTES < MAX_UPLOAD_BYTES);
}
// ── Drag-out staging ────────────────────────────────────────────────────
#[test]
fn the_staging_path_is_built_under_the_supplied_temp_dir() {
// Never `/tmp`: on Windows the temp dir is per-user and nowhere near it,
// so the whole path has to be derived from what Tauri hands us.
let temp = Path::new("/somewhere/else");
let root = drag_stage_root(temp);
assert_eq!(root, Path::new("/somewhere/else/triple-c-drag-out"));
let session = drag_stage_session_dir(temp);
assert_eq!(session.parent(), Some(root.as_path()));
assert!(session.starts_with(root));
}
#[test]
fn every_call_in_a_process_stages_into_the_same_session_directory() {
// Exit cleanup deletes this directory by name rather than tracking what
// it wrote, which only works if the name does not move.
let temp = Path::new("/tmp-ish");
assert_eq!(drag_stage_session_dir(temp), drag_stage_session_dir(temp));
assert_ne!(drag_stage_session_dir(temp), drag_stage_root(temp));
}
#[test]
fn the_staged_copy_keeps_the_original_file_name() {
// The reason the feature stages into a per-session directory at all: a
// plain temp file would be dropped onto the desktop called `tmp1234`.
assert_eq!(stage_file_name("/workspace/notes.txt").unwrap(), "notes.txt");
assert_eq!(stage_file_name("/workspace/a b/.env").unwrap(), ".env");
assert_eq!(stage_file_name("report.pdf").unwrap(), "report.pdf");
assert_eq!(stage_file_name("/workspace/über.md").unwrap(), "über.md");
}
#[test]
fn a_name_windows_cannot_hold_is_substituted_rather_than_dropped() {
// These are all legal on Linux and all refused by NTFS, and the staged
// copy has to exist on the host we are dragging onto.
assert_eq!(stage_file_name("/workspace/a:b.txt").unwrap(), "a_b.txt");
assert_eq!(stage_file_name("/workspace/q?.log").unwrap(), "q_.log");
assert_eq!(stage_file_name("/workspace/a\\b").unwrap(), "a_b");
// A trailing dot or space is not refused, it is silently dropped — so
// the path we return would not be the path that exists.
assert_eq!(stage_file_name("/workspace/trailing. ").unwrap(), "trailing");
}
#[test]
fn a_path_that_does_not_name_a_file_is_refused_not_invented() {
assert!(stage_file_name("/").is_err());
assert!(stage_file_name("").is_err());
assert!(stage_file_name("/workspace/..").is_err());
assert!(stage_file_name("/workspace/.").is_err());
// Trims down to nothing, which is the same problem one step later.
assert!(stage_file_name("/workspace/...").is_err());
}
#[test]
fn two_files_with_the_same_name_stage_to_different_places() {
// Names are unique per directory, not per container — and the second
// drag would otherwise rewrite the first one's bytes under the path the
// first one is still cached at.
assert_ne!(
drag_stage_slot("/workspace/a/notes.txt"),
drag_stage_slot("/workspace/b/notes.txt")
);
}
#[test]
fn re_staging_the_same_file_reuses_its_slot() {
// Deterministic, so a file dragged repeatedly does not grow a new
// directory in the host temp dir every time.
assert_eq!(
drag_stage_slot("/workspace/notes.txt"),
drag_stage_slot("/workspace/notes.txt")
);
// Short enough to keep the path sane, long enough not to collide.
assert_eq!(drag_stage_slot("/workspace/notes.txt").len(), 16);
}
#[test]
fn the_drag_size_cap_matches_the_established_ceiling_and_names_the_fallback() {
assert_eq!(MAX_DRAG_STAGE_BYTES, MAX_UPLOAD_BYTES);
assert!(check_stage_size(MAX_DRAG_STAGE_BYTES).is_ok());
let err = check_stage_size(MAX_DRAG_STAGE_BYTES + 1).unwrap_err();
assert!(err.contains("256 MB"), "{}", err);
// A size cap with no way forward is the one thing this must not be.
assert!(err.contains("Save to host"), "{}", err);
}
#[test]
fn the_reaper_only_takes_entries_past_the_age_threshold() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let age = Duration::from_secs(3_600);
assert!(drag_stage_is_stale(now - Duration::from_secs(3_601), now, age));
assert!(drag_stage_is_stale(now - age, now, age));
assert!(!drag_stage_is_stale(now - Duration::from_secs(3_599), now, age));
assert!(!drag_stage_is_stale(now, now, age));
}
#[test]
fn a_future_timestamp_is_left_alone_rather_than_reaped() {
// A clock step must not turn housekeeping into deletion of something it
// cannot date.
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let age = Duration::from_secs(3_600);
assert!(!drag_stage_is_stale(now + Duration::from_secs(60), now, age));
}
}
+30 -1
View File
@@ -215,6 +215,11 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
// Drag a file from the Files tab onto the host desktop. The gesture is
// pointer-driven for the same reason the tab drag is (see MainTabs):
// `dragDropEnabled` is on for the terminal's sake and blocks HTML5 drag
// inside the webview, so this plugin's native drag is the only route out.
.plugin(tauri_plugin_drag::init())
.manage(AppState {
projects_store,
settings_store,
@@ -250,13 +255,22 @@ pub fn run() {
// an image open and the sweep will not force; pins are untagged
// second so the images they were holding are dangling by the time
// the sweep lists them; the sweep runs last and collects both.
tauri::async_runtime::spawn(async {
//
// Drag-out staging is swept here too, and it is the *other* half of
// a lifecycle whose first half is the exit cleanup below: a run that
// crashed never got to clear its staged copies, and those are whole
// files, not metadata.
let drag_temp_dir = app.path().temp_dir().ok();
tauri::async_runtime::spawn(async move {
crate::docker::reap_probe_containers().await;
let reaped = crate::docker::reap_stale_migration_pins().await;
if reaped > 0 {
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
}
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
if let Some(temp_dir) = drag_temp_dir {
commands::file_commands::reap_drag_staging(temp_dir).await;
}
});
// Auto-start web terminal server if enabled in settings
@@ -383,6 +397,10 @@ pub fn run() {
let _ = window.emit("app-shutting-down", ());
let app_handle = window.app_handle().clone();
// Resolved here rather than inside the teardown, which is
// already under a wall-clock budget and should not spend any of
// it asking where the temp dir is.
let drag_temp_dir = app_handle.path().temp_dir().ok();
tauri::async_runtime::spawn(async move {
let teardown = async {
// First: let the auto-starts unwind. Anything they are
@@ -409,10 +427,20 @@ pub fn run() {
log::warn!("Failed to stop the model gateway on exit: {}", e);
}
};
// Whole files copied out of containers for drag-out.
// Left behind they are a disk leak with a gesture
// attached; startup housekeeping is the backstop for a
// run that never reaches this point.
let clear_drag_staging = async {
if let Some(temp_dir) = drag_temp_dir {
commands::file_commands::clear_drag_staging(temp_dir).await;
}
};
tokio::join!(
web_terminal,
stop_stt,
stop_gateway,
clear_drag_staging,
exec_manager.close_all_sessions(),
auth_bridge.stop_all(),
browser_view::manager().stop_all(),
@@ -502,6 +530,7 @@ pub fn run() {
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,
commands::file_commands::stage_container_file_for_drag,
// AWS
commands::aws_commands::aws_sso_refresh,
// Updates