From 909dfa3d8b4b041f8389003f9c65d50db7130a0d Mon Sep 17 00:00:00 2001 From: Jonathan Aziagbegnon Date: Wed, 7 Oct 2026 02:09:42 +0000 Subject: [PATCH] Windows: accept files dropped on the island Explorer drops never reached the island because WebView2 keeps the drop for itself. The page now receives the file and the copy lands in the inbox. Co-authored-by: Cursor --- windows/src-tauri/Cargo.toml | 1 - windows/src-tauri/src/files.rs | 80 ++++++++++++--- windows/src-tauri/src/island.rs | 115 +++++++++++++++++++--- windows/src-tauri/src/lib.rs | 23 ++++- windows/src-tauri/src/platform/linux.rs | 12 ++- windows/src-tauri/src/platform/windows.rs | 98 ++++++++++++------ windows/src-tauri/tauri.conf.json | 2 +- windows/src-tauri/tauri.linux.conf.json | 2 +- windows/src/core/bridge.ts | 73 +++++++++++++- windows/src/island/island.ts | 50 +++++++--- windows/src/style.css | 11 +++ windows/src/upload/sequence.ts | 3 + 12 files changed, 389 insertions(+), 81 deletions(-) diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..e84df5a38 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 144907152..9e11c8923 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 92c9cd4cb..258e44046 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 714103e59..b567ec914 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 e748a336a..c711266f0 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 6e1200a8a..611fa94c4 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 2196b8159..5d2f0282c 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 2e6424200..47721d6ca 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 24aaf55ff..52750b352 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 7fdb61802..2283e55f2 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 b2cf6756c..4ff1ad8f1 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 1374c849d..63b375285 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;