Add OTP flow, drop-target floating window, in-app auto-update installer
OTP (Doodstream two-factor):
- src/otp.rs: OtpBroker registers per-request oneshot channels with
180s timeout, survives abort via Cancelled answer.
- Doodstream login now loops: first attempt without OTP; if server
says OTP required, emit 'otp-required' event to the renderer with
a request_id, wait for provide_otp/cancel_otp commands, re-POST
with the code. Renderer can pop a modal on otp-required.
- UploadCtx carries the broker + app handle so any future hoster can
do the same pattern.
Drop-target floating window:
- src/drop-target.html: minimal always-on-top borderless window with
dashed drop-zone. Emits 'drop-target-files' to the main window on
drag-drop.
- show_drop_target / hide_drop_target commands create/close the
'drop-target' webview on demand.
- Capabilities updated for dual-window use.
In-app auto-update:
- updater::download_and_launch: fetches the NSIS/MSI from Gitea to
%TEMP%, launches detached, exits the app so the installer can
replace the running exe.
- Commands install_update + install_update_now both go through the
new helper. Renderer clicks 'Install Update' → Rust downloads and
hands off, then process exit.
Härtetest results:
- exe: 7.54 MB
- NSIS: 2.70 MB
- MSI: 3.69 MB
- RAM idle: 33 MB (vs Electron ~300 MB)
- All 3 unit tests pass (secret encryption round-trips).
This commit is contained in:
parent
615161d747
commit
100bda60cd
@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "drop-target"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
|
||||
@ -17,12 +17,15 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
#[allow(unused_imports)]
|
||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Arc<ConfigStore>,
|
||||
pub uploads: Arc<UploadManager>,
|
||||
pub folder_monitor: Arc<crate::folder_monitor::FolderMonitor>,
|
||||
pub remote_server: Arc<crate::remote_server::RemoteServer>,
|
||||
pub otp_broker: Arc<crate::otp::OtpBroker>,
|
||||
pub rot_log_path: Mutex<PathBuf>,
|
||||
pub upload_log_path: Mutex<PathBuf>,
|
||||
pub always_on_top: PLMutex<bool>,
|
||||
@ -471,17 +474,13 @@ pub async fn check_for_update() -> AppResult<Value> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_update() -> AppResult<()> {
|
||||
// MVP: open the release page in the OS browser — user downloads installer
|
||||
// and runs it manually. True in-app update with signing cert is follow-up.
|
||||
let c = crate::updater::check().await;
|
||||
if let Some(url) = c.download_url {
|
||||
#[cfg(target_os = "windows")]
|
||||
{ std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn().ok(); }
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{ std::process::Command::new("xdg-open").arg(&url).spawn().ok(); }
|
||||
pub async fn install_update(app: AppHandle) -> AppResult<()> {
|
||||
install_update_now(app).await
|
||||
}
|
||||
Ok(())
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_update_now(app: AppHandle) -> AppResult<()> {
|
||||
crate::updater::download_and_launch(app).await.map_err(AppError::Other)
|
||||
}
|
||||
|
||||
// --- Folder monitor ---
|
||||
@ -557,13 +556,54 @@ pub async fn remote_status(state: State<'_, AppState>) -> AppResult<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Drop target window (stub) ---
|
||||
// --- Drop target floating window ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_drop_target() -> AppResult<()> { Ok(()) }
|
||||
pub async fn show_drop_target(app: AppHandle) -> AppResult<()> {
|
||||
if app.get_webview_window("drop-target").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let url = tauri::WebviewUrl::App("drop-target.html".into());
|
||||
tauri::WebviewWindowBuilder::new(&app, "drop-target", url)
|
||||
.title("Drop-Target")
|
||||
.inner_size(220.0, 160.0)
|
||||
.min_inner_size(180.0, 120.0)
|
||||
.resizable(true)
|
||||
.always_on_top(true)
|
||||
.decorations(false)
|
||||
.skip_taskbar(true)
|
||||
.transparent(true)
|
||||
.build()
|
||||
.map_err(|e| AppError::Other(format!("drop-target window: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hide_drop_target() -> AppResult<()> { Ok(()) }
|
||||
pub async fn hide_drop_target(app: AppHandle) -> AppResult<()> {
|
||||
if let Some(w) = app.get_webview_window("drop-target") {
|
||||
let _ = w.close();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- OTP ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn provide_otp(
|
||||
state: State<'_, AppState>,
|
||||
request_id: String,
|
||||
code: String,
|
||||
) -> AppResult<bool> {
|
||||
Ok(state.otp_broker.provide(&request_id, code))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_otp(
|
||||
state: State<'_, AppState>,
|
||||
request_id: String,
|
||||
) -> AppResult<bool> {
|
||||
Ok(state.otp_broker.cancel(&request_id))
|
||||
}
|
||||
|
||||
// --- Debug ---
|
||||
|
||||
|
||||
@ -39,44 +39,7 @@ pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult>
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
let c = build_client()?;
|
||||
|
||||
// --- Login ---
|
||||
let _ = c.get(BASE_URL).send().await; // warm up
|
||||
|
||||
let login_body = serde_urlencoded::to_string([
|
||||
("op", "login_ajax"),
|
||||
("login", task.username.as_str()),
|
||||
("password", task.password.as_str()),
|
||||
("loginotp", ""),
|
||||
]).unwrap_or_default();
|
||||
|
||||
let res = c.post(format!("{BASE_URL}/"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Referer", format!("{BASE_URL}/"))
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.body(login_body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
|
||||
// Redirect response (3xx auto-followed by client) or dashboard hit → login OK.
|
||||
// Otherwise try JSON shape.
|
||||
if !body.contains("Dashboard") {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(s) = v.get("status").and_then(|x| x.as_str()) {
|
||||
if s == "fail" {
|
||||
let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen");
|
||||
if msg.to_lowercase().contains("otp") {
|
||||
return Err(AppError::Other(format!("Doodstream: OTP erforderlich ({msg}) — OTP-Login in 2.0 POC noch nicht implementiert")));
|
||||
}
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
}
|
||||
} else if !status.is_redirection() && !status.is_success() {
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
}
|
||||
login_with_otp(&c, &task, &ctx).await?;
|
||||
|
||||
// --- Extract sess_id from /?op=upload ---
|
||||
let upload_page = c.get(format!("{BASE_URL}/?op=upload"))
|
||||
@ -125,6 +88,86 @@ pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult>
|
||||
parse_upload_response(&raw, &file_name, &c).await
|
||||
}
|
||||
|
||||
/// Login flow with OTP handshake.
|
||||
///
|
||||
/// 1. Warm up with a GET /
|
||||
/// 2. POST / with op=login_ajax (no OTP)
|
||||
/// 3. If response says OTP required → ask renderer via OtpBroker
|
||||
/// 4. POST / again with the OTP code; re-check.
|
||||
async fn login_with_otp(c: &reqwest::Client, task: &UploadTask, ctx: &UploadCtx) -> AppResult<()> {
|
||||
let _ = c.get(BASE_URL).send().await;
|
||||
let mut attempted_otp = false;
|
||||
let mut otp_code = String::new();
|
||||
|
||||
loop {
|
||||
let body = serde_urlencoded::to_string([
|
||||
("op", "login_ajax"),
|
||||
("login", task.username.as_str()),
|
||||
("password", task.password.as_str()),
|
||||
("loginotp", otp_code.as_str()),
|
||||
]).unwrap_or_default();
|
||||
|
||||
let res = c.post(format!("{BASE_URL}/"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Referer", format!("{BASE_URL}/"))
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.body(body).send().await?;
|
||||
let status = res.status();
|
||||
let resp = res.text().await.unwrap_or_default();
|
||||
|
||||
if resp.contains("Dashboard") { return Ok(()); }
|
||||
if status.is_redirection() || status.is_success() {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&resp) {
|
||||
if let Some(s) = v.get("status").and_then(|x| x.as_str()) {
|
||||
if s == "success" { return Ok(()); }
|
||||
if s == "fail" {
|
||||
let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen");
|
||||
let needs_otp = msg.to_lowercase().contains("otp");
|
||||
|
||||
if needs_otp {
|
||||
if attempted_otp {
|
||||
return Err(AppError::HosterError("Doodstream".into(),
|
||||
format!("OTP-Code abgelehnt: {msg}")));
|
||||
}
|
||||
attempted_otp = true;
|
||||
otp_code = request_otp_code(ctx, &task.username, msg).await?;
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-JSON 2xx/3xx without Dashboard — treat as success-ish and let
|
||||
// the next step (sess_id extraction) decide.
|
||||
return Ok(());
|
||||
}
|
||||
return Err(AppError::BadCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_otp_code(ctx: &UploadCtx, username: &str, prompt: &str) -> AppResult<String> {
|
||||
use tauri::Emitter;
|
||||
let (broker, app) = match (ctx.otp_broker.as_ref(), ctx.app.as_ref()) {
|
||||
(Some(b), Some(a)) => (b, a),
|
||||
_ => return Err(AppError::Other(
|
||||
"Doodstream: OTP erforderlich, aber kein OTP-Broker/AppHandle im Context".into())),
|
||||
};
|
||||
let (request_id, rx) = broker.register();
|
||||
let req = crate::otp::OtpRequest {
|
||||
request_id: request_id.clone(),
|
||||
hoster: "doodstream.com".into(),
|
||||
account_id: username.into(),
|
||||
username: username.into(),
|
||||
prompt: prompt.into(),
|
||||
};
|
||||
let _ = app.emit("otp-required", &req);
|
||||
match crate::otp::OtpBroker::wait(rx).await {
|
||||
crate::otp::OtpAnswer::Code(c) if !c.is_empty() => Ok(c),
|
||||
crate::otp::OtpAnswer::Code(_) => Err(AppError::Other("Leerer OTP-Code".into())),
|
||||
crate::otp::OtpAnswer::Cancelled => Err(AppError::Aborted),
|
||||
}
|
||||
}
|
||||
|
||||
static SESS_RE_INPUT: Lazy<Regex> = Lazy::new(||
|
||||
Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap());
|
||||
static SESS_RE_VUE: Lazy<Regex> = Lazy::new(||
|
||||
|
||||
@ -27,7 +27,8 @@ pub struct UploadTask {
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Shared context: abort signal + optional throttles + progress callback.
|
||||
/// Shared context: abort signal + optional throttles + progress callback +
|
||||
/// OTP broker for hosters that need a second-factor prompt.
|
||||
#[derive(Clone)]
|
||||
pub struct UploadCtx {
|
||||
pub abort: Arc<Notify>,
|
||||
@ -37,6 +38,8 @@ pub struct UploadCtx {
|
||||
/// Fires whenever another chunk of bytes has been accepted for transmission.
|
||||
/// Signature: (bytes_uploaded, bytes_total)
|
||||
pub on_progress: Arc<dyn Fn(u64, u64) + Send + Sync>,
|
||||
pub otp_broker: Option<Arc<crate::otp::OtpBroker>>,
|
||||
pub app: Option<tauri::AppHandle>,
|
||||
}
|
||||
|
||||
impl UploadCtx {
|
||||
|
||||
@ -11,6 +11,7 @@ pub mod folder_monitor;
|
||||
pub mod remote_server;
|
||||
pub mod updater;
|
||||
pub mod upload_log;
|
||||
pub mod otp;
|
||||
pub mod commands;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
@ -49,11 +50,15 @@ pub fn run() {
|
||||
*upload_log::WRITER.lock() = Some(log_writer.clone());
|
||||
manager.set_upload_log_writer(log_writer);
|
||||
|
||||
let otp_broker = otp::OtpBroker::new();
|
||||
manager.set_otp_broker(otp_broker.clone(), app.handle().clone());
|
||||
|
||||
app.manage(commands::AppState {
|
||||
config: store.clone(),
|
||||
uploads: manager,
|
||||
folder_monitor: fm.clone(),
|
||||
remote_server: remote.clone(),
|
||||
otp_broker,
|
||||
rot_log_path: Mutex::new(data_dir.join("account-rotation.log")),
|
||||
upload_log_path: Mutex::new(data_dir.join("fileuploader.log")),
|
||||
always_on_top: parking_lot::Mutex::new(false),
|
||||
@ -129,6 +134,9 @@ pub fn run() {
|
||||
commands::show_drop_target,
|
||||
commands::hide_drop_target,
|
||||
commands::debug_log,
|
||||
commands::provide_otp,
|
||||
commands::cancel_otp,
|
||||
commands::install_update_now,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
69
src-tauri/src/otp.rs
Normal file
69
src-tauri/src/otp.rs
Normal file
@ -0,0 +1,69 @@
|
||||
//! OTP coordinator — when a hoster login needs a second factor, the uploader
|
||||
//! emits `otp-required` with a request id, and waits on a oneshot channel for
|
||||
//! the renderer to reply via the `provide_otp` command.
|
||||
//!
|
||||
//! Port of the v1 flow where main-process errors with `otpRequired: true` and
|
||||
//! the renderer popped a modal asking for the code.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OtpRequest {
|
||||
pub request_id: String,
|
||||
pub hoster: String,
|
||||
pub account_id: String,
|
||||
pub username: String,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OtpBroker {
|
||||
waiting: Mutex<HashMap<String, oneshot::Sender<OtpAnswer>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OtpAnswer {
|
||||
Code(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl OtpBroker {
|
||||
pub fn new() -> Arc<Self> { Arc::new(Self::default()) }
|
||||
|
||||
/// Create a request id + channel; return (id, receiver). Caller emits the
|
||||
/// request to the renderer and awaits the receiver.
|
||||
pub fn register(&self) -> (String, oneshot::Receiver<OtpAnswer>) {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.waiting.lock().insert(id.clone(), tx);
|
||||
(id, rx)
|
||||
}
|
||||
|
||||
pub fn provide(&self, request_id: &str, code: String) -> bool {
|
||||
if let Some(tx) = self.waiting.lock().remove(request_id) {
|
||||
let _ = tx.send(OtpAnswer::Code(code));
|
||||
true
|
||||
} else { false }
|
||||
}
|
||||
|
||||
pub fn cancel(&self, request_id: &str) -> bool {
|
||||
if let Some(tx) = self.waiting.lock().remove(request_id) {
|
||||
let _ = tx.send(OtpAnswer::Cancelled);
|
||||
true
|
||||
} else { false }
|
||||
}
|
||||
|
||||
/// Await with a timeout so a silent renderer doesn't hang the upload forever.
|
||||
pub async fn wait(rx: oneshot::Receiver<OtpAnswer>) -> OtpAnswer {
|
||||
match tokio::time::timeout(Duration::from_secs(180), rx).await {
|
||||
Ok(Ok(answer)) => answer,
|
||||
_ => OtpAnswer::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,51 @@ struct GiteaAsset {
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
pub async fn download_and_launch(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let info = check().await;
|
||||
if !info.available {
|
||||
return Err("Kein Update verfügbar".into());
|
||||
}
|
||||
let url = info.download_url.ok_or("Kein Download-URL im Release")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5 * 60))
|
||||
.user_agent("multi-hoster-upload/2.0")
|
||||
.build().map_err(|e| e.to_string())?;
|
||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Download HTTP {}", resp.status().as_u16()));
|
||||
}
|
||||
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
|
||||
let tmp = std::env::temp_dir();
|
||||
let file_name = url.rsplit('/').next().unwrap_or("Multi-Hoster-Upload-Setup.exe");
|
||||
let target = tmp.join(format!("multi-hoster-update-{}-{file_name}",
|
||||
chrono::Utc::now().timestamp()));
|
||||
std::fs::write(&target, &bytes).map_err(|e| e.to_string())?;
|
||||
|
||||
// Notify UI and launch the installer detached. We then exit so the NSIS
|
||||
// installer can replace the running exe.
|
||||
let _ = tauri::Emitter::emit(&app, "app:update-installing",
|
||||
&serde_json::json!({ "path": target.display().to_string() }));
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
std::process::Command::new(&target)
|
||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::process::Command::new(&target).spawn().map_err(|e| e.to_string())?;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check() -> UpdateCheck {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
let mut out = UpdateCheck {
|
||||
|
||||
@ -52,6 +52,7 @@ pub struct Job {
|
||||
pub struct UploadManager {
|
||||
app: AppHandle,
|
||||
log_writer: parking_lot::Mutex<Option<std::sync::Arc<crate::upload_log::UploadLogWriter>>>,
|
||||
otp_broker: parking_lot::Mutex<Option<std::sync::Arc<crate::otp::OtpBroker>>>,
|
||||
running: AtomicBool,
|
||||
stop_after_active: AtomicBool,
|
||||
start_time: parking_lot::Mutex<Option<Instant>>,
|
||||
@ -88,10 +89,15 @@ impl UploadManager {
|
||||
*self.log_writer.lock() = Some(w);
|
||||
}
|
||||
|
||||
pub fn set_otp_broker(&self, b: std::sync::Arc<crate::otp::OtpBroker>, _app: AppHandle) {
|
||||
*self.otp_broker.lock() = Some(b);
|
||||
}
|
||||
|
||||
pub fn new(app: AppHandle) -> Self {
|
||||
Self {
|
||||
app,
|
||||
log_writer: parking_lot::Mutex::new(None),
|
||||
otp_broker: parking_lot::Mutex::new(None),
|
||||
running: AtomicBool::new(false),
|
||||
stop_after_active: AtomicBool::new(false),
|
||||
start_time: parking_lot::Mutex::new(None),
|
||||
@ -578,6 +584,8 @@ impl UploadManager {
|
||||
throttle_hoster: hoster_throttle,
|
||||
throttle_global: self.global_throttle.lock().clone(),
|
||||
on_progress,
|
||||
otp_broker: self.otp_broker.lock().clone(),
|
||||
app: Some(self.app.clone()),
|
||||
};
|
||||
|
||||
hosters::upload_file(task, ctx).await
|
||||
|
||||
34
src/drop-target.html
Normal file
34
src/drop-target.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drop Target</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: rgba(18, 22, 29, 0.92);
|
||||
color: #e6e8eb; font: 12px "Segoe UI", system-ui, sans-serif; user-select: none; }
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
border: 2px dashed rgba(126, 220, 255, 0.32); border-radius: 12px; margin: 2px;
|
||||
transition: all 0.15s; -webkit-app-region: drag; }
|
||||
body.hover { border-color: rgba(126, 220, 255, 0.72); background: rgba(62, 167, 255, 0.08); }
|
||||
.icon { font-size: 32px; margin-bottom: 6px; }
|
||||
.txt { color: #9aa3ae; letter-spacing: 0.04em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="icon">📤</div>
|
||||
<div class="txt">Dateien hier ablegen</div>
|
||||
<script>
|
||||
const T = window.__TAURI__;
|
||||
const { listen, emit } = T.event;
|
||||
listen('tauri://drag-enter', () => document.body.classList.add('hover'));
|
||||
listen('tauri://drag-leave', () => document.body.classList.remove('hover'));
|
||||
listen('tauri://drag-drop', (ev) => {
|
||||
document.body.classList.remove('hover');
|
||||
const paths = (ev.payload && (ev.payload.paths || ev.payload)) || [];
|
||||
if (paths.length) {
|
||||
emit('drop-target-files', paths);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user