Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 64 additions & 16 deletions windows/src-tauri/src/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,13 @@ pub fn ingest(source: &str) -> Result<DroppedFile, String> {
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
Expand All @@ -68,6 +54,68 @@ pub fn ingest(source: &str) -> Result<DroppedFile, String> {
})
}

/// 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<DroppedFile, String> {
// 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<PathBuf, String> {
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.
Expand Down
117 changes: 106 additions & 11 deletions windows/src-tauri/src/island.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -150,14 +153,19 @@ 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 };

let scale = m.scale_factor();
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;
Expand All @@ -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<PollGate>) {
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::<crate::Shared>()
.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
Expand All @@ -187,13 +274,13 @@ 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<PollGate>) {
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;
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));
Expand Down Expand Up @@ -225,6 +312,16 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc<PollGate>) {
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;
}
Expand All @@ -247,22 +344,20 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc<PollGate>) {
// 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
&& x <= size.0
&& 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);
Expand Down
23 changes: 22 additions & 1 deletion windows/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ fn set_collapsed(app: AppHandle, shared: State<Shared>, 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.
Expand Down Expand Up @@ -256,6 +257,23 @@ fn ingest_file(path: String) -> Result<DroppedFile, String> {
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<DroppedFile, String> {
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 {
Expand Down Expand Up @@ -394,6 +412,7 @@ pub fn run() {
chat_send,
chat_reset,
ingest_file,
ingest_bytes,
secret_present,
secret_set,
secret_clear,
Expand All @@ -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);

Expand All @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions windows/src-tauri/src/platform/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,6 @@ fn gtk_window_ptr(win: &gtk::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.
Expand Down Expand Up @@ -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
}
Loading