diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d94..e84df5a38b 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -28,7 +28,6 @@ windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", - "Win32_System_Ole", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", diff --git a/windows/src-tauri/src/files.rs b/windows/src-tauri/src/files.rs index 1449071525..9e11c89236 100644 --- a/windows/src-tauri/src/files.rs +++ b/windows/src-tauri/src/files.rs @@ -30,27 +30,13 @@ pub fn ingest(source: &str) -> Result { return Err("Folders can't be dropped yet.".into()); } - let dir = inbox_dir(); - crate::platform::ensure_private_dir(&settings::local_dir()).map_err(|e| e.to_string())?; - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let dir = prepare_inbox()?; let name = src .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "file".into()); - - let mut dest = dir.join(&name); - if dest.exists() { - let stem = src.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); - let ext = src.extension().map(|s| format!(".{}", s.to_string_lossy())).unwrap_or_default(); - for i in 2..1000 { - let candidate = dir.join(format!("{stem} ({i}){ext}")); - if !candidate.exists() { - dest = candidate; - break; - } - } - } + let dest = unique_dest(&dir, &name); std::fs::copy(src, &dest).map_err(|e| format!("cannot copy: {e}"))?; // CopyFileEx carries the source's timestamps across, so a file last edited @@ -68,6 +54,68 @@ pub fn ingest(source: &str) -> Result { }) } +/// A file handed over by its contents rather than its path — what WebView2 gives +/// the page for a browser drag and drop. Lands in the inbox like any other drop. +pub fn ingest_bytes(name: &str, bytes: &[u8]) -> Result { + // Only the last component: a name must never steer the write elsewhere. + let name = Path::new(name) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "file".into()); + let dir = prepare_inbox()?; + let dest = unique_dest(&dir, &name); + std::fs::write(&dest, bytes).map_err(|e| format!("cannot save: {e}"))?; + sweep(&dir); + + Ok(DroppedFile { + name, + path: dest.to_string_lossy().to_string(), + size: bytes.len() as u64, + }) +} + +fn prepare_inbox() -> Result { + let dir = inbox_dir(); + crate::platform::ensure_private_dir(&settings::local_dir()).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +/// `name`, or `name (2)`, `name (3)`… — the inbox never overwrites. +fn unique_dest(dir: &Path, name: &str) -> PathBuf { + let dest = dir.join(name); + if !dest.exists() { + return dest; + } + let as_path = Path::new(name); + let stem = as_path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let ext = as_path.extension().map(|s| format!(".{}", s.to_string_lossy())).unwrap_or_default(); + (2..1000) + .map(|i| dir.join(format!("{stem} ({i}){ext}"))) + .find(|candidate| !candidate.exists()) + .unwrap_or(dest) +} + +/// Decodes the `encodeURIComponent` file name the page sends in a header. +pub fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + /// Drops anything copied here more than a week ago. `ingest` stamps every copy /// with the time it landed, so this really is the age of the copy and not the /// age of whatever the user happened to drag in. diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cbf..258e440464 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -20,6 +20,9 @@ pub const PANEL_H: f64 = 320.0; /// Logical size of the invisible strip that wakes the island when it is hidden. pub const STRIP_W: f64 = 240.0; pub const STRIP_H: f64 = 6.0; +/// Logical height the wake strip grows to while something is being dragged, so +/// a file only has to reach the top-centre of the screen, not a 6 px line. +const DROP_ZONE_H: f64 = 150.0; pub const WINDOW_LABEL: &str = "island"; @@ -150,6 +153,12 @@ pub fn screen_info(app: &AppHandle, pref: &str) -> ScreenInfo { /// Places and sizes the window. `collapsed` picks the wake strip instead of the panel. pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { + let (lw, lh) = if collapsed { (STRIP_W, STRIP_H) } else { (PANEL_W, PANEL_H) }; + place(app, pref, lw, lh); +} + +/// Centres a `lw` × `lh` logical window on the top edge of the island's monitor. +fn place(app: &AppHandle, pref: &str, lw: f64, lh: f64) { let Some(win) = window(app) else { return }; let Some(m) = target_monitor(app, pref) else { return }; @@ -157,7 +166,6 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let mp = *m.position(); let ms = *m.size(); - let (lw, lh) = if collapsed { (STRIP_W, STRIP_H) } else { (PANEL_W, PANEL_H) }; let pw = (lw * scale).round().max(1.0) as u32; let ph = (lh * scale).round().max(1.0) as u32; let x = mp.x + (ms.width as i32 - pw as i32) / 2; @@ -175,6 +183,83 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { // Moving across displays can rescale the window: re-assert the physical size. let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_always_on_top(true); + platform::raise_topmost(&win); +} + +/// Grows the hidden island's wake strip into a drop zone while the left button is +/// held, and shrinks it back on release. +/// +/// OLE only delivers a dragged file to the window under the cursor, and the +/// strip is a 6 px line at the very top of the screen: nobody drops a file +/// there. While the island is hidden and a drag may be in flight, the strip +/// becomes an invisible zone as wide as the panel; the file entering it wakes +/// the island exactly as the strip would have. One GetAsyncKeyState every 50 ms +/// while hidden; window moves and resizes are left alone. +pub fn spawn_drop_zone_watch(app: AppHandle, gate: Arc) { + if !platform::CURSOR_POLL { + return; + } + std::thread::spawn(move || { + let mut zone_up = false; + let mut was_down = false; + // Set when the press began outside the zone: only such a press can be a + // drag *into* it. A click inside the zone must never have the zone pop + // up under it, or the button release would land on us and be lost. + let mut armed_at: Option<(f64, f64)> = None; + loop { + std::thread::sleep(Duration::from_millis(50)); + if !gate.collapsed.load(Ordering::Relaxed) { + zone_up = false; + was_down = false; + armed_at = None; + continue; + } + let down = left_button_down(); + if !down && !was_down { + continue; + } + let pref = app + .try_state::() + .map(|s| s.settings.lock().unwrap().screen.clone()) + .unwrap_or_else(|| "primary".into()); + + if down && !was_down { + armed_at = cursor_physical().filter(|&(x, y)| { + !drop_zone_rect(&app, &pref).is_some_and(|(l, t, r, b)| { + x >= l && x < r && y >= t && y < b + }) + }); + } + was_down = down; + + if down { + let moved = match (armed_at, cursor_physical()) { + (Some((ax, ay)), Some((cx, cy))) => (cx - ax).hypot(cy - ay) >= 8.0, + _ => false, + }; + if !zone_up && moved && !platform::moving_window() { + place(&app, &pref, PANEL_W, DROP_ZONE_H); + zone_up = true; + } + } else { + if zone_up && gate.collapsed.load(Ordering::Relaxed) { + apply_geometry(&app, &pref, true); + } + zone_up = false; + armed_at = None; + } + } + }); +} + +/// The drop zone in physical screen pixels: (left, top, right, bottom). +fn drop_zone_rect(app: &AppHandle, pref: &str) -> Option<(f64, f64, f64, f64)> { + let m = target_monitor(app, pref)?; + let scale = m.scale_factor(); + let (mp, ms) = (*m.position(), *m.size()); + let w = PANEL_W * scale; + let left = mp.x as f64 + (ms.width as f64 - w) / 2.0; + Some((left, mp.y as f64, left + w, mp.y as f64 + DROP_ZONE_H * scale)) } /// Position, size and scale of the monitor the island lives on. Any change here @@ -194,7 +279,6 @@ fn current_screen_key(app: &AppHandle) -> Option<(i32, i32, u32, u32, u64)> { /// visible. Parked on a condvar the rest of the time. pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { std::thread::spawn(move || { - let mut was_down = false; // Remembered across wakes so a display change while hidden is noticed the // moment the island comes back. let mut last_screen: Option<(i32, i32, u32, u32, u64)> = None; @@ -205,6 +289,7 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { loop { gate.wait_until_active(); let mut last = (f64::MIN, f64::MIN); + let mut was_down = false; let mut ticks: u32 = 0; while gate.is_active() { std::thread::sleep(Duration::from_millis(period)); @@ -236,6 +321,16 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { Ok(s) => (s.width as f64 / scale, s.height as f64 / scale), Err(_) => (PANEL_W, PANEL_H), }; + // A file drag the page saw leave can end anywhere, and HTML5 has + // no event for "dropped somewhere else". Tell the page when the + // button goes up — before the "cursor didn't move" shortcut, since + // a release needs no movement. + let down = left_button_down(); + if was_down && !down { + let _ = app.emit_to(WINDOW_LABEL, "pointer-released", ()); + } + was_down = down; + if (x - last.0).abs() < 1.0 && (y - last.1).abs() < 1.0 { continue; } @@ -258,14 +353,6 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { // registered destinations whatever ignoresMouseEvents says. So while // a button is held anywhere over the panel, the whole panel takes // the mouse, which also makes the drop zone as forgiving as the Mac's. - // A press may be the start of a drag: make sure the drop target is - // ours before the file arrives. - let down = left_button_down(); - if down && !was_down { - let handle = app.clone(); - let _ = app.run_on_main_thread(move || platform::unblock_webview_drops(&handle)); - } - was_down = down; let dragging = down && x >= 0.0 @@ -273,7 +360,13 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { && y >= 0.0 && y <= size.1; - let accept = on_island || dragging; + // The wake strip always takes the mouse (hover to wake, drop a + // file). Checked here and not only in set_collapsed: a tick that + // was asleep while the island collapsed would otherwise compute + // "not on the island" for the new strip and make it click-through + // again — invisible to hovers and to dragged files alike. + let collapsed = gate.collapsed.load(Ordering::Relaxed); + let accept = on_island || dragging || collapsed; if gate.ignoring.load(Ordering::Relaxed) == accept { gate.ignoring.store(!accept, Ordering::Relaxed); let _ = win.set_ignore_cursor_events(!accept); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59e..b567ec914c 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -93,9 +93,10 @@ fn set_collapsed(app: AppHandle, shared: State, collapsed: bool) { let pref = shared.settings.lock().unwrap().screen.clone(); shared.gate.collapsed.store(collapsed, Ordering::Relaxed); island::apply_geometry(&app, &pref, collapsed); + // Stop the cursor poll first so no in-flight tick can undo what follows. + shared.gate.set_active(!collapsed); // The wake strip must always take the mouse, and a resize invalidates the flag. island::refresh_click_through(&app, &shared.gate); - shared.gate.set_active(!collapsed); } /// The front end pushes the island shape; Rust decides click-through from it. @@ -256,6 +257,23 @@ fn ingest_file(path: String) -> Result { files::ingest(&path) } +/// A file dropped on the island page. WebView2 hands the page the file's +/// contents, not its path, so the bytes come over as the raw request body and +/// the name (URI-encoded) in a header. +#[tauri::command] +fn ingest_bytes(request: tauri::ipc::Request<'_>) -> Result { + let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { + return Err("Expected the file's contents.".into()); + }; + let name = request + .headers() + .get("x-file-name") + .and_then(|v| v.to_str().ok()) + .map(files::percent_decode) + .unwrap_or_else(|| "file".into()); + files::ingest_bytes(&name, bytes) +} + /// The island may only ask whether a key exists — never read it. #[tauri::command] fn secret_present(key: String) -> bool { @@ -394,6 +412,7 @@ pub fn run() { chat_send, chat_reset, ingest_file, + ingest_bytes, secret_present, secret_set, secret_clear, @@ -405,6 +424,7 @@ pub fn run() { .setup(move |app| { let handle = app.handle().clone(); tray::build(&handle)?; + platform::keep_topmost(&handle); // Before the island: see create_settings_window. create_settings_window(&handle); @@ -421,6 +441,7 @@ pub fn run() { } gate.set_active(true); island::spawn_cursor_poll(handle.clone(), gate.clone()); + island::spawn_drop_zone_watch(handle.clone(), gate.clone()); log::line(format!("--- Coucou {} started ---", env!("CARGO_PKG_VERSION"))); hooks::ensure_hook_exe(&handle); diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a4..c711266f09 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -198,9 +198,6 @@ fn gtk_window_ptr(win: >k::ApplicationWindow) -> *mut gtk::ffi::GtkWindow { w.to_glib_none().0 } -/// WebKitGTK has no competing drop target to remove. -pub fn unblock_webview_drops(_app: &AppHandle) {} - /// Turns the island into an overlay surface on the top edge that never takes /// the keyboard. Must run before the window is first shown: a layer surface /// cannot be made out of a window the compositor already knows. @@ -345,3 +342,12 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } } + +/// Layer-shell keeps the island above the other surfaces already. +pub fn raise_topmost(_win: &WebviewWindow) {} + +pub fn keep_topmost(_app: &AppHandle) {} + +pub fn moving_window() -> bool { + false +} diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8aa..611fa94c41 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -6,17 +6,16 @@ use std::process::Command; use tauri::{AppHandle, Manager, WebviewWindow}; -use ::windows::core::{BOOL, PWSTR}; -use ::windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, HWND, LPARAM, LocalFree, POINT}; +use ::windows::core::PWSTR; +use ::windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, HWND, LocalFree, POINT}; use ::windows::Win32::Security::Authorization::ConvertSidToStringSidW; use ::windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER}; -use ::windows::Win32::System::Ole::RevokeDragDrop; use ::windows::Win32::System::SystemInformation::GetLocalTime; use ::windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use ::windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; use ::windows::Win32::UI::WindowsAndMessaging::{ - EnumChildWindows, GetClassNameW, GetCursorPos, GetWindowLongPtrW, SetWindowLongPtrW, - GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + GetCursorPos, GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, }; use super::LocalTime; @@ -173,37 +172,76 @@ fn hwnd_of(win: &WebviewWindow) -> Option { Some(HWND(raw as *mut _)) } -/// Lets dropped files reach the app again. -/// -/// wry installs its drop target by walking the webview's child windows **once**, -/// when the webview is created. WebView2 creates `Chrome_RenderWidgetHostHWND` -/// later and registers its own target on it; being the innermost window, that one -/// wins, and since the page has no HTML5 drop handler it refuses everything — the -/// "no drop" cursor, with nothing reaching Tauri. Revoking it makes OLE fall -/// through to the target wry registered on the parent widget, which is the one -/// that feeds Tauri's drag events. +/// True while the foreground window is being moved or resized by its title bar +/// or border — a held button that is not a drag the island should answer. +pub fn moving_window() -> bool { + use ::windows::Win32::UI::WindowsAndMessaging::{GetGUIThreadInfo, GUITHREADINFO, GUI_INMOVESIZE}; + let mut info = GUITHREADINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { GetGUIThreadInfo(0, &mut info).is_ok() && (info.flags.0 & GUI_INMOVESIZE.0) != 0 } +} + +/// Puts the island back on top of the other always-on-top windows. /// -/// Cheap and idempotent, so it is simply re-run whenever a drag might be starting. -pub fn unblock_webview_drops(app: &AppHandle) { - for label in [WINDOW_LABEL, "settings"] { - let Some(win) = app.get_webview_window(label) else { continue }; - let Some(hwnd) = hwnd_of(&win) else { continue }; - unsafe { - let _ = EnumChildWindows(Some(hwnd), Some(revoke_render_widget), LPARAM(0)); +/// Every topmost window shares one band, and whichever was raised last wins. +/// A full-width status bar along the top of the screen is often topmost too, and +/// then covers the strip that wakes the island and takes dropped files. Raising +/// the island again hands that edge back to it. Never activates, never moves or +/// resizes. If Explorer is showing its drag picture, that picture is put back +/// above the island so the file does not vanish behind the bar. +pub fn raise_topmost(win: &WebviewWindow) { + let Some(hwnd) = hwnd_of(win) else { return }; + raise_hwnd(hwnd); +} + +fn raise_hwnd(hwnd: HWND) { + set_topmost(hwnd); + if let Some(drag) = shell_drag_image() { + if drag != hwnd { + set_topmost(drag); } } } -unsafe extern "system" fn revoke_render_widget(hwnd: HWND, _: LPARAM) -> BOOL { - let mut name = [0u16; 64]; - let len = unsafe { GetClassNameW(hwnd, &mut name) }; - if len > 0 { - let class = String::from_utf16_lossy(&name[..len as usize]); - if class == "Chrome_RenderWidgetHostHWND" { - let _ = unsafe { RevokeDragDrop(hwnd) }; - } +fn set_topmost(hwnd: HWND) { + use ::windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, HWND_TOPMOST, SWP_ASYNCWINDOWPOS, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + }; + unsafe { + let _ = SetWindowPos( + hwnd, + Some(HWND_TOPMOST), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS, + ); } - true.into() +} + +/// Explorer's drag picture. Present while a file is being dragged, absent for a +/// text selection or a slider. +fn shell_drag_image() -> Option { + use ::windows::core::w; + use ::windows::Win32::UI::WindowsAndMessaging::{FindWindowExW, IsWindowVisible}; + let hwnd = unsafe { FindWindowExW(None, None, w!("SysDragImage"), None).ok()? }; + unsafe { IsWindowVisible(hwnd).as_bool() }.then_some(hwnd) +} + +/// Such bars can raise themselves again later (on a redraw, or coming back from +/// a full-screen app), so the island checks back every couple of seconds. One +/// cheap call, no wake-up of the page. +pub fn keep_topmost(app: &AppHandle) { + let Some(win) = app.get_webview_window(WINDOW_LABEL) else { return }; + let Some(hwnd) = hwnd_of(&win) else { return }; + let raw = hwnd.0 as isize; + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + raise_hwnd(HWND(raw as *mut _)); + }); } /// WS_EX_NOACTIVATE keeps clicks from stealing focus; WS_EX_TOOLWINDOW keeps the diff --git a/windows/src-tauri/tauri.conf.json b/windows/src-tauri/tauri.conf.json index 2196b8159a..5d2f0282c3 100644 --- a/windows/src-tauri/tauri.conf.json +++ b/windows/src-tauri/tauri.conf.json @@ -34,7 +34,7 @@ "maximizable": false, "minimizable": false, "closable": false, - "dragDropEnabled": true, + "dragDropEnabled": false, "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required" } ] diff --git a/windows/src-tauri/tauri.linux.conf.json b/windows/src-tauri/tauri.linux.conf.json index 2e64242006..47721d6ca5 100644 --- a/windows/src-tauri/tauri.linux.conf.json +++ b/windows/src-tauri/tauri.linux.conf.json @@ -48,7 +48,7 @@ "maximizable": false, "minimizable": false, "closable": false, - "dragDropEnabled": true, + "dragDropEnabled": false, "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required" } ] diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff9..52750b352d 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { getCurrentWebview } from "@tauri-apps/api/webview"; import type { Settings } from "./state"; export const IS_TAURI = @@ -147,14 +146,78 @@ export type BridgeEvent = export interface DragDropPayload { type: "enter" | "over" | "drop" | "leave"; - paths?: string[]; + /** On drop: the first file, already on its way into the inbox. */ + file?: { name: string; ingest: Promise }; } -/** Files dragged onto the island. Only reaches us when the window takes the mouse. */ +/** Bigger than this and the bytes are not worth pushing through the IPC. */ +const MAX_DROP_BYTES = 200 * 1024 * 1024; + +/** + * Files dragged onto the island, as a plain HTML5 drag and drop. + * + * Tauri's native drop hook never fires on Windows here: its target sits on a + * window above the ones WebView2's own process owns, and OLE stops at those + * first. WebView2 itself delivers the drop to the page, but hands over the + * file's contents, not its path — so the contents go to Rust, which writes + * them into the inbox like any other drop. + */ export async function onDragDrop(handler: (e: DragDropPayload) => void) { if (!IS_TAURI) return () => {}; - return getCurrentWebview().onDragDropEvent((event) => { - handler(event.payload as DragDropPayload); + const hasFiles = (e: DragEvent) => !!e.dataTransfer?.types.includes("Files"); + // dragenter/dragleave fire for every element crossed; count to know when + // the drag really enters and leaves the page. + let depth = 0; + + const enter = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + if (depth++ === 0) handler({ type: "enter" }); + }; + const over = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = "copy"; + }; + const leave = (e: DragEvent) => { + if (!hasFiles(e) || depth === 0) return; + if (--depth === 0) handler({ type: "leave" }); + }; + const drop = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + depth = 0; + const file = e.dataTransfer!.files[0]; + handler( + file + ? { type: "drop", file: { name: file.name, ingest: ingestDropped(file) } } + : { type: "drop" }, + ); + }; + + window.addEventListener("dragenter", enter); + window.addEventListener("dragover", over); + window.addEventListener("dragleave", leave); + window.addEventListener("drop", drop); + return () => { + window.removeEventListener("dragenter", enter); + window.removeEventListener("dragover", over); + window.removeEventListener("dragleave", leave); + window.removeEventListener("drop", drop); + }; +} + +async function ingestDropped(file: File): Promise { + if (file.size > MAX_DROP_BYTES) throw new Error("That file is too big to drop (200 MB max)."); + let bytes: ArrayBuffer; + try { + bytes = await file.arrayBuffer(); + } catch { + // A folder arrives as a File that cannot be read. + throw new Error("Folders can't be dropped yet."); + } + return invoke("ingest_bytes", bytes, { + headers: { "x-file-name": encodeURIComponent(file.name) }, }); } diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb618027..2283e55f26 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -2,7 +2,7 @@ // Mirrors IslandRootView.swift + IslandWindowController.swift. import { Tracked, Spring, clamp } from "../core/anim"; -import { Bridge, IS_TAURI, onDragDrop } from "../core/bridge"; +import { Bridge, IS_TAURI, onDragDrop, onEvent, type DragDropPayload, type DroppedFile } from "../core/bridge"; import { EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, @@ -73,6 +73,8 @@ export class Island { /** Last shape handed to Rust for the click-through test. */ private pushedRect = { x: -1, y: -1, w: -1, h: -1 }; private homeCollapseAt: number | null = null; + /** A file drag entered the island since the mouse button last went up. */ + private fileDragSeen = false; // Bot hover → love (IslandWindowController.botHoverIn) private botHovering = false; @@ -341,8 +343,8 @@ export class Island { // ── File drop ─────────────────────────────────────────────────────────────── - private onDragDrop(e: { type: string; paths?: string[] }) { - if (e.type !== "over") void Bridge.log(`drag ${e.type} ${e.paths?.length ?? 0} file(s)`); + private onDragDrop(e: DragDropPayload) { + if (e.type !== "over") void Bridge.log(`drag ${e.type}${e.file ? ` ${e.file.name}` : ""}`); if (State.paused) return; switch (e.type) { case "enter": @@ -353,7 +355,11 @@ export class Island { // enterZone must run before the island expands, so the sequence is // already active by the time the view becomes `upload`. UploadSeq.enterZone(State.mouseInIsland.x, State.mouseInIsland.y); + this.fileDragSeen = true; this.alert("upload"); + // Waking a hidden island passes through the home view, and leaving the + // drop views on the way switches the sequence off: switch it back on. + if (!UploadSeq.isActive) UploadSeq.enterZone(State.mouseInIsland.x, State.mouseInIsland.y); break; } case "leave": { @@ -367,27 +373,42 @@ export class Island { } case "drop": { State.fileDragOver = false; - const path = e.paths?.[0]; - if (!path) { + if (!e.file) { this.engine.animateMorph(0); this.setView(State.defaultView()); return; } - this.swallow(path); + this.swallow(e.file.name, e.file.ingest); break; } } } + /** + * The mouse button went up. If a file drag had already left the island + * without being dropped, it ended somewhere else: close the drop view rather + * than wait for a file that is never coming. + */ + private onPointerReleased() { + // Only a release that ends a file drag counts: the "+" tab opens the same + // view with a plain click, and that release must leave it open. + const wasFileDrag = this.fileDragSeen; + this.fileDragSeen = false; + if (!wasFileDrag || State.fileDragOver || State.view !== "upload" || UploadSeq.dropped) return; + void Bridge.log("drag ended outside the island"); + this.engine.animateMorph(0); + this.setView(State.defaultView()); + } + /** * Mochi eats the file. Nothing here waits on the file system: the copy into * the inbox runs in the background and swaps the path in when it lands, so a * slow disk can never stall the animation — same as FileDropHandler on macOS. */ - private swallow(path: string) { - const name = path.split(/[\\/]/).pop() || "file"; - State.droppedFile = { name, path }; - State.promptContext = { kind: "file", name, path }; + private swallow(name: string, ingest: Promise) { + // The path fills in when the copy lands, a moment later. + State.droppedFile = { name, path: "" }; + State.promptContext = { kind: "file", name, path: "" }; State.chatHistory = []; void Bridge.chatReset(); @@ -404,7 +425,7 @@ export class Island { this.setView("uploading"); this.ensureRunning(); - void Bridge.ingestFile(path) + void ingest .then((file) => { State.droppedFile = { name: file.name, path: file.path }; State.promptContext = { kind: "file", name: file.name, path: file.path }; @@ -550,6 +571,7 @@ export class Island { }); void onDragDrop((e) => this.onDragDrop(e)); + void onEvent("pointer-released", () => this.onPointerReleased()); // Outside Tauri (plain browser) drive the cursor from DOM events so the // island can be inspected with `npm run dev`. @@ -715,6 +737,8 @@ export class Island { if (uploadActive) this.uploadCanvas.draw(UploadSeq.frame(), nowMs / 1000); this.uploadCanvas.el.classList.toggle("on", uploadActive); this.viewsEl.classList.toggle("hidden-by-upload", uploadActive); + // Lets the header stay clickable while #content passes clicks through. + this.contentEl.classList.toggle("upload-on", uploadActive); tickMiniBots(dt); this.views.get(State.view)?.tick?.(nowMs); @@ -837,7 +861,9 @@ export class Island { const greetingActive = expanded && State.view === "greeting"; this.contentEl.style.opacity = expanded && !greetingActive ? "1" : "0"; - this.contentEl.style.pointerEvents = expanded && !greetingActive ? "auto" : "none"; + // While the drop sequence owns the body, the content layer lets clicks through + // to the invisible hit areas under it (the header opts back in, see style.css). + this.contentEl.style.pointerEvents = expanded && !greetingActive && !this.uploadActive ? "auto" : "none"; this.greetingCanvas.style.display = greetingActive ? "block" : "none"; this.header.sync(); diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c8..4ff1ad8f1d 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -133,6 +133,17 @@ body { transition: opacity 0.22s ease-in-out; } +/* The invisible views must not swallow the clicks meant for the buttons the + canvas paints (.upload-hit sits below #content). */ +#views.hidden-by-upload * { + pointer-events: none; +} + +/* #content itself is switched to pointer-events: none from island.ts (inline). */ +#content.upload-on #header { + pointer-events: auto; +} + #bot-glow { position: absolute; pointer-events: none; diff --git a/windows/src/upload/sequence.ts b/windows/src/upload/sequence.ts index 1374c849d0..63b3752853 100644 --- a/windows/src/upload/sequence.ts +++ b/windows/src/upload/sequence.ts @@ -278,6 +278,9 @@ class UploadSequence { performDrop(uploadDuration: number) { this.uploadDuration = uploadDuration; this.dropWall = this.now(); + // A drop always runs the sequence, even if something switched it off while + // the file was hovering — otherwise the bar sits at 0 % for good. + this.isActive = true; // Restart the canonical post-drop timeline however long the user hovered. // Spring state (position and velocity) is deliberately preserved. this.t = USC.T_DROP;