mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 22:59:00 -10:00
Merge f-split-qa2c (10f3435b0) into f-split-int: Linux drag-drop through the page widget's GTK drag signals; export header test
This commit is contained in:
commit
a3024ed10a
5 files changed
+183
No files matched your search
Generated
+2
@@ -3526,6 +3526,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
|
||||
name = "strata-desktop"
|
||||
version = "0.2.0-beta"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -3533,6 +3534,7 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"webkit2gtk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -6,6 +6,11 @@ edition = "2021"
|
||||
rust-version = "1.77"
|
||||
license = "MIT"
|
||||
|
||||
# Linux: the drop of a file on the window is read straight from the GTK drag signals (see hook_linux_drops in main.rs); the versions are the ones Tauri itself uses.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
||||
@@ -914,9 +914,118 @@ fn open_window(app: &tauri::App, port: u16, shell_token: &str) -> Result<(), Box
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
hook_linux_drops(&window, port, shell_token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file:// address from a drag, as a path (percent-escapes decoded); anything else (http, mailto, text) is not a dropped file.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn file_uri_to_path(uri: &str) -> Option<String> {
|
||||
let rest = uri.trim().strip_prefix("file://")?;
|
||||
// file://host/path: only an empty or local host is a file on this computer
|
||||
let rest = match rest.find('/') { Some(0) => rest, Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..], _ => return None };
|
||||
let bytes = rest.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 Some(v) = std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()) { out.push(v); i += 3; continue; }
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
let path = String::from_utf8(out).ok()?;
|
||||
if path.contains('\0') { None } else { Some(path) }
|
||||
}
|
||||
|
||||
/// Linux: WebKitGTK 2.52 gives the page a file drag without the files, and the window library's own drop handler never sees one, so the
|
||||
/// shell reads the drop itself from the GTK drag signals of the page widget: the file:// addresses arrive with the drag data, the drop hands
|
||||
/// them to the host exactly as the other systems do (hand_over_drop), and the page shows its drop box meanwhile (QA, drop on the chat).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn hook_linux_drops(window: &tauri::WebviewWindow, port: u16, token: &str) {
|
||||
use gtk::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
let target = window.clone();
|
||||
let token = token.to_string();
|
||||
let _ = window.with_webview(move |platform| {
|
||||
let view = platform.inner();
|
||||
let paths: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let hovering = Rc::new(Cell::new(false));
|
||||
let generation = Rc::new(Cell::new(0u32));
|
||||
{
|
||||
let (paths, hovering, generation, target) = (paths.clone(), hovering.clone(), generation.clone(), target.clone());
|
||||
view.connect_drag_data_received(move |_, _, _, _, data, _, _| {
|
||||
let list: Vec<String> = data.uris().iter().filter_map(|u| file_uri_to_path(u.as_str())).collect();
|
||||
if list.is_empty() { return; }
|
||||
*paths.borrow_mut() = list;
|
||||
generation.set(generation.get() + 1);
|
||||
if !hovering.replace(true) { let _ = target.eval(&drag_script("enter")); }
|
||||
});
|
||||
}
|
||||
{
|
||||
// WebKit only accepts a drag when the page says so, and the page is not given the files: once the addresses of a file drag are known,
|
||||
// the shell accepts it (as a copy) itself, so GTK goes on to deliver the drop.
|
||||
let paths = paths.clone();
|
||||
view.connect_drag_motion(move |_, ctx, _, _, time| {
|
||||
if paths.borrow().is_empty() { return false; }
|
||||
ctx.drag_status(gtk::gdk::DragAction::COPY, time);
|
||||
true
|
||||
});
|
||||
}
|
||||
{
|
||||
let (paths, hovering, generation, target) = (paths.clone(), hovering.clone(), generation.clone(), target.clone());
|
||||
let token = token.clone();
|
||||
view.connect_drag_drop(move |_, ctx, _, _, time| {
|
||||
generation.set(generation.get() + 1);
|
||||
let list = std::mem::take(&mut *paths.borrow_mut());
|
||||
if list.is_empty() { return false; } // not a file: the page deals with it
|
||||
hovering.set(false);
|
||||
ctx.drop_finish(true, time);
|
||||
let _ = target.eval(&drag_script("leave"));
|
||||
hand_over_drop(target.clone(), port, token.clone(), list);
|
||||
true
|
||||
});
|
||||
}
|
||||
{
|
||||
let (paths, hovering, target, token) = (paths.clone(), hovering.clone(), target.clone(), token.clone());
|
||||
view.connect_drag_leave(move |widget, _, _| {
|
||||
// WebKit's own handling of a drop never reaches this widget's drop signal, but GTK does say "leave" at the moment of the drop:
|
||||
// a leave with every mouse button already up (and a file drag known) is the drop. A leave with the button still down is the
|
||||
// pointer going out of the window.
|
||||
if hovering.get() && !paths.borrow().is_empty() && pointer_buttons_up(widget) {
|
||||
let list = std::mem::take(&mut *paths.borrow_mut());
|
||||
hovering.set(false);
|
||||
let _ = target.eval(&drag_script("leave"));
|
||||
hand_over_drop(target.clone(), port, token.clone(), list);
|
||||
}
|
||||
});
|
||||
}
|
||||
view.connect_drag_leave(move |_, _, _| {
|
||||
// only a leave that nothing follows ends the drag (a drop is handled above and clears the state)
|
||||
let seen = generation.get();
|
||||
let (paths, hovering, generation, target) = (paths.clone(), hovering.clone(), generation.clone(), target.clone());
|
||||
gtk::glib::idle_add_local_once(move || {
|
||||
if generation.get() == seen && hovering.replace(false) {
|
||||
paths.borrow_mut().clear();
|
||||
let _ = target.eval(&drag_script("leave"));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// True when no mouse button is held down (GTK drag drop: the button is already up when the drop arrives).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pointer_buttons_up(widget: &webkit2gtk::WebView) -> bool {
|
||||
use gtk::prelude::*;
|
||||
let Some(window) = widget.window() else { return false };
|
||||
let Some(pointer) = gtk::gdk::Display::default().and_then(|d| d.default_seat()).and_then(|s| s.pointer()) else { return false };
|
||||
let (_, _, _, mask) = window.device_position(&pointer);
|
||||
!mask.intersects(gtk::gdk::ModifierType::BUTTON1_MASK | gtk::gdk::ModifierType::BUTTON2_MASK | gtk::gdk::ModifierType::BUTTON3_MASK)
|
||||
}
|
||||
|
||||
/// The script that tells the page a file drag is over the window (`state` is "enter" or "leave"); the page shows its drop box (apps/host DropZone).
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn drag_script(state: &str) -> String {
|
||||
@@ -1013,6 +1122,15 @@ fn run_window_process() -> ! {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dropped_file_addresses_become_paths_and_nothing_else_does() {
|
||||
assert_eq!(file_uri_to_path("file:///home/me/My%20Pictures/a%2Bb.png").as_deref(), Some("/home/me/My Pictures/a+b.png"));
|
||||
assert_eq!(file_uri_to_path("file://localhost/tmp/x.txt").as_deref(), Some("/tmp/x.txt"));
|
||||
assert_eq!(file_uri_to_path(" file:///tmp/%C4%8C.txt\r\n").as_deref(), Some("/tmp/\u{10C}.txt"));
|
||||
for bad in ["http://example.com/a.png", "file://otherhost/tmp/x", "mailto:[email protected]", "/tmp/plain", "file://", "file:///tmp/a%00b"] { assert_eq!(file_uri_to_path(bad), None, "{bad}"); }
|
||||
assert_eq!(file_uri_to_path("file:///100%").as_deref(), Some("/100%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scripts_the_shell_sends_the_page_for_a_file_drop() {
|
||||
assert_eq!(drag_script("enter"), "window.dispatchEvent(new CustomEvent('strata:native-drag', { detail: 'enter' }));");
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// The saved Monitor picture when a model is loaded (the normal case) starts with the Strata logo alone and the models/time line.
|
||||
// (QA follow-up: it was checked with a loaded model through the real page; the picture is the one of the Monitor screen, which shows the
|
||||
// loaded models. NativeMonitor.tsx is not used by any screen, so it needs no header.)
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
|
||||
const read = path => readFile(new URL(path, import.meta.url), 'utf8').then(s => s.replace(/\r\n/g, '\n'));
|
||||
|
||||
test('with a model loaded the picture is headed by the logo and the line "<model> · <time>"; with none, "N loaded models"', async () => {
|
||||
const source = await read('../src/screens/Monitor.tsx');
|
||||
assert.match(source, /const scopeLabel = selectedInstance \? instanceLabel\(selectedInstance\) : `\$\{runtime\?\.instances\.length \?\? 0\} loaded models`;/);
|
||||
const header = /\{tr\(frozen \? <header className="section-toolbar">(.*?)<\/header> : null\)\}/s.exec(source)?.[1];
|
||||
assert.ok(header);
|
||||
assert.match(header, /<Brand variant="lockup"/); assert.doesNotMatch(header, /<h1/);
|
||||
assert.match(header, /frozen\.modelLabel.*frozen\.time/s);
|
||||
assert.match(source, /setFrozen\(\{ data: structuredClone\(scoped \?\? live\), hardware: structuredClone\(inventory\), time: new Date\(\)\.toLocaleString\(localeTag\(\)\), modelLabel: scopeLabel \}\)/);
|
||||
});
|
||||
|
||||
test('no screen renders NativeMonitor, so the Monitor screen is the only picture source', async () => {
|
||||
const dirs = ['../src/screens/', '../src/app/', '../src/components/'];
|
||||
for (const dir of dirs) {
|
||||
for (const name of await readdir(new URL(dir, import.meta.url))) {
|
||||
if (!/\.tsx?$/.test(name) || name === 'NativeMonitor.tsx') continue;
|
||||
assert.doesNotMatch(await read(dir + name), /<NativeMonitor\b|from '\.\/NativeMonitor'|from '\.\.\/components\/NativeMonitor'/, `${dir}${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Linux file drop (found on z440 under Xvfb with a real GTK drag, WebKitGTK 2.52): the page gets no files and WebKit never raises the drop signal,
|
||||
// so the shell reads the file:// addresses from the drag data, accepts the drag itself, takes the "leave" with every button up as the drop and
|
||||
// hands the paths to the host. The behaviour itself is checked on a real X display (scripts in phaseA-tests); these keep the pieces in place.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const read = path => readFile(new URL(path, import.meta.url), 'utf8').then(s => s.replace(/\r\n/g, '\n'));
|
||||
|
||||
test('the Linux shell hooks the drag signals of the page and keeps Windows and macOS on their own paths', async () => {
|
||||
const rust = await read('../../desktop/src-tauri/src/main.rs');
|
||||
assert.match(rust, /#\[cfg\(target_os = "linux"\)\]\s+hook_linux_drops\(&window, port, shell_token\);/);
|
||||
assert.match(rust, /view\.connect_drag_data_received\(/); assert.match(rust, /file_uri_to_path\(u\.as_str\(\)\)/);
|
||||
assert.match(rust, /ctx\.drag_status\(gtk::gdk::DragAction::COPY, time\)/);
|
||||
assert.match(rust, /pointer_buttons_up\(widget\)/);
|
||||
assert.match(rust, /hand_over_drop\(target\.clone\(\), port, token\.clone\(\), list\)/);
|
||||
assert.match(rust, /fn file_uri_to_path\(uri: &str\) -> Option<String>/);
|
||||
assert.match(rust, /#\[cfg\(windows\)\]\s+let builder = builder\.disable_drag_drop_handler\(\);/);
|
||||
const toml = await read('../../desktop/src-tauri/Cargo.toml');
|
||||
assert.match(toml, /\[target\.'cfg\(target_os = "linux"\)'\.dependencies\]\ngtk = "0\.18"\nwebkit2gtk = "2\.0"/);
|
||||
});
|
||||
|
||||
test('only file:// addresses reach the host, which applies the same limits as for every drop', async () => {
|
||||
const rust = await read('../../desktop/src-tauri/src/main.rs');
|
||||
assert.match(rust, /strip_prefix\("file:\/\/"\)\?/);
|
||||
const host = await read('../server/native-drop.mjs');
|
||||
assert.match(host, /MAX_FILES = 10, MAX_BYTES = 25 \* 1024 \* 1024/);
|
||||
assert.match(host, /!isAbsolute\(path\)/);
|
||||
});
|
||||
Reference in new issue
Block a user