From b33a8d1761ec7c0fced537e103a9abbcaf4a4f25 Mon Sep 17 00:00:00 2001 From: hieudepzai14122007-ship-it <304381484+hieudepzai14122007-ship-it@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:10:41 +0700 Subject: [PATCH 1/4] Windows: fix dropping files on the island Dragging a file onto the island did nothing: no drag event ever reached the app. Four things stood in the way. - The native drop hook never fired. The innermost windows a drag passes over (Chrome_WidgetWin_1, Chrome_RenderWidgetHostHWND) belong to the msedgewebview2 process, and OLE stops at WebView2's own target there before reaching the one wry registers on Chrome_WidgetWin_0. Revoking it from our process is not reliable for a window another process owns. dragDropEnabled is now off and the page takes the drop as plain HTML5 drag and drop; the file's contents come over as a raw IPC body to a new ingest_bytes command, which writes them into the inbox like ingest does. unblock_webview_drops is gone. - The wake strip ended up click-through. A cursor-poll tick asleep while the island collapsed recomputed "not on the island" for the new strip and turned click-through back on. The poll is now stopped before the flag is reset, and it treats the collapsed strip as always taking the mouse. - A 6 px strip is too thin to drop on. While the island is hidden and a drag is in flight (button held, press outside the zone, moved 8 px, not a window move or resize), the strip grows into a 720x150 invisible zone at the top centre and shrinks back on release. - An always-on-top status bar along the top edge can sit above the strip. The island re-asserts HWND_TOPMOST when it is placed and every 2 s. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/files.rs | 80 +++++++++++++---- windows/src-tauri/src/island.rs | 105 +++++++++++++++++++--- windows/src-tauri/src/lib.rs | 23 ++++- windows/src-tauri/src/platform/linux.rs | 12 ++- windows/src-tauri/src/platform/windows.rs | 80 ++++++++++------- windows/src-tauri/tauri.conf.json | 2 +- windows/src-tauri/tauri.linux.conf.json | 2 +- windows/src/core/bridge.ts | 69 ++++++++++++-- windows/src/island/island.ts | 21 +++-- 9 files changed, 315 insertions(+), 79 deletions(-) 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 7d98f1c2f..ef0a1a35c 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; @@ -168,6 +176,85 @@ 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; // Linux: no global button state to watch. + } + 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; // the island took over + was_down = false; + armed_at = None; + continue; + } + let down = left_button_down(); + if !down && !was_down { + continue; // the common case: nothing held, nothing to do + } + 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 { + // Released without the file entering: back to the strip, unless + // the island opened in the meantime. + 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 @@ -187,7 +274,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; @@ -247,14 +333,7 @@ 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 @@ -262,7 +341,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 020d4214b..c9fae973b 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. @@ -344,3 +341,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..c30ed0a42 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -6,16 +6,15 @@ 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, + GetCursorPos, GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, }; @@ -173,37 +172,56 @@ 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. +pub fn raise_topmost(win: &WebviewWindow) { + let Some(hwnd) = hwnd_of(win) else { return }; + raise_hwnd(hwnd); } -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 raise_hwnd(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() +} + +/// 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; // HWND isn't Send; the handle itself is just a number + 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 d292d1b19..101f53ce2 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 d2b5531fb..7c965d929 100644 --- a/windows/src-tauri/tauri.linux.conf.json +++ b/windows/src-tauri/tauri.linux.conf.json @@ -46,7 +46,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..461ebcba6 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,74 @@ 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 (dragDropEnabled) 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 without + * fuss, 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(); // without this the page refuses the drop + 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..1a25d0c5e 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, 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, @@ -341,8 +341,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": @@ -367,13 +367,12 @@ 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; } } @@ -384,10 +383,10 @@ export class Island { * 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 +403,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 }; From 09639660a923f139003c3ddbbe8cfc4e1e81bece Mon Sep 17 00:00:00 2001 From: hieudepzai14122007-ship-it <304381484+hieudepzai14122007-ship-it@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:25:17 +0700 Subject: [PATCH 2/4] Windows: close the drop view when a drag ends outside the island HTML5 drag and drop says when a drag leaves the page, but not when it is then dropped somewhere else, so dragging a file over the island and releasing it elsewhere left the "Drop your files here" view up for good. The cursor poll now emits pointer-released on the button's falling edge, and the island closes the drop view if the file had already left and was never dropped. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/island.rs | 12 +++++++++++- windows/src/island/island.ts | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index ef0a1a35c..5f0640b2e 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -280,6 +280,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(16)); @@ -311,6 +312,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; } @@ -333,7 +344,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. - let down = left_button_down(); let dragging = down && x >= 0.0 diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 1a25d0c5e..ca5316557 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, type DragDropPayload, type DroppedFile } 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, @@ -378,6 +378,18 @@ export class Island { } } + /** + * 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() { + if (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 @@ -549,6 +561,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`. From 604c09352ded61d88f8b46bd086e4512d7df41ff Mon Sep 17 00:00:00 2001 From: hieudepzai14122007-ship-it <304381484+hieudepzai14122007-ship-it@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:23 +0700 Subject: [PATCH 3/4] Windows: keep the drop sequence running and the "+" panel open Two issues found while testing this branch (thanks Grizzy8): - Dropping onto a hidden island could leave "Uploading 0%" on screen. Waking the island passes through the home view, and leaving the drop views on the way switches the upload sequence off, so the drop then ran with no sequence. The sequence is switched back on after waking, and performDrop now always activates it. - Clicking "+" opened the drop panel and the button release closed it again. Only a release that ends a file drag which entered the island now closes the drop view. Co-Authored-By: Claude Opus 5.5 --- windows/src/island/island.ts | 12 +++++++++++- windows/src/upload/sequence.ts | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index ca5316557..f004da72b 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -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; @@ -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": { @@ -384,7 +390,11 @@ export class Island { * than wait for a file that is never coming. */ private onPointerReleased() { - if (State.fileDragOver || State.view !== "upload" || UploadSeq.dropped) return; + // 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()); 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; From 4972152f5591c32a5e66ca309972b888f041e58a Mon Sep 17 00:00:00 2001 From: hieudepzai14122007-ship-it <304381484+hieudepzai14122007-ship-it@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:49:24 +0700 Subject: [PATCH 4/4] Windows: let clicks reach the drop card's Ask and Cancel buttons The choose card is painted on the canvas, and its clicks are caught by invisible .upload-hit buttons inside #upload-layer. #content comes after that layer in the DOM and covers the whole island, so it swallowed the click: Cancel did nothing and Ask only reacted where the hidden DOM button happened to overlap it. While the drop sequence owns the body, #content and the hidden views now let clicks through; the header opts back in. Diagnosis and fix by Grizzy8 in the PR thread. Co-Authored-By: Claude Opus 5.5 --- windows/src/island/island.ts | 6 +++++- windows/src/style.css | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index f004da72b..4547e75d7 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -737,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); @@ -859,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 1da05cf94..580b0b9a6 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;