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",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capability for the main window",
|
"description": "Default capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main", "drop-target"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
|
|||||||
@ -17,12 +17,15 @@ use std::collections::HashMap;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Arc<ConfigStore>,
|
pub config: Arc<ConfigStore>,
|
||||||
pub uploads: Arc<UploadManager>,
|
pub uploads: Arc<UploadManager>,
|
||||||
pub folder_monitor: Arc<crate::folder_monitor::FolderMonitor>,
|
pub folder_monitor: Arc<crate::folder_monitor::FolderMonitor>,
|
||||||
pub remote_server: Arc<crate::remote_server::RemoteServer>,
|
pub remote_server: Arc<crate::remote_server::RemoteServer>,
|
||||||
|
pub otp_broker: Arc<crate::otp::OtpBroker>,
|
||||||
pub rot_log_path: Mutex<PathBuf>,
|
pub rot_log_path: Mutex<PathBuf>,
|
||||||
pub upload_log_path: Mutex<PathBuf>,
|
pub upload_log_path: Mutex<PathBuf>,
|
||||||
pub always_on_top: PLMutex<bool>,
|
pub always_on_top: PLMutex<bool>,
|
||||||
@ -471,17 +474,13 @@ pub async fn check_for_update() -> AppResult<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn install_update() -> AppResult<()> {
|
pub async fn install_update(app: AppHandle) -> AppResult<()> {
|
||||||
// MVP: open the release page in the OS browser — user downloads installer
|
install_update_now(app).await
|
||||||
// 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 {
|
#[tauri::command]
|
||||||
#[cfg(target_os = "windows")]
|
pub async fn install_update_now(app: AppHandle) -> AppResult<()> {
|
||||||
{ std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn().ok(); }
|
crate::updater::download_and_launch(app).await.map_err(AppError::Other)
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{ std::process::Command::new("xdg-open").arg(&url).spawn().ok(); }
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Folder monitor ---
|
// --- 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]
|
#[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]
|
#[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 ---
|
// --- Debug ---
|
||||||
|
|
||||||
|
|||||||
@ -39,44 +39,7 @@ pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult<UploadResult>
|
|||||||
return Err(AppError::BadCredentials);
|
return Err(AppError::BadCredentials);
|
||||||
}
|
}
|
||||||
let c = build_client()?;
|
let c = build_client()?;
|
||||||
|
login_with_otp(&c, &task, &ctx).await?;
|
||||||
// --- 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Extract sess_id from /?op=upload ---
|
// --- Extract sess_id from /?op=upload ---
|
||||||
let upload_page = c.get(format!("{BASE_URL}/?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
|
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(||
|
static SESS_RE_INPUT: Lazy<Regex> = Lazy::new(||
|
||||||
Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap());
|
Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap());
|
||||||
static SESS_RE_VUE: Lazy<Regex> = Lazy::new(||
|
static SESS_RE_VUE: Lazy<Regex> = Lazy::new(||
|
||||||
|
|||||||
@ -27,7 +27,8 @@ pub struct UploadTask {
|
|||||||
pub api_key: String,
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct UploadCtx {
|
pub struct UploadCtx {
|
||||||
pub abort: Arc<Notify>,
|
pub abort: Arc<Notify>,
|
||||||
@ -37,6 +38,8 @@ pub struct UploadCtx {
|
|||||||
/// Fires whenever another chunk of bytes has been accepted for transmission.
|
/// Fires whenever another chunk of bytes has been accepted for transmission.
|
||||||
/// Signature: (bytes_uploaded, bytes_total)
|
/// Signature: (bytes_uploaded, bytes_total)
|
||||||
pub on_progress: Arc<dyn Fn(u64, u64) + Send + Sync>,
|
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 {
|
impl UploadCtx {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ pub mod folder_monitor;
|
|||||||
pub mod remote_server;
|
pub mod remote_server;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod upload_log;
|
pub mod upload_log;
|
||||||
|
pub mod otp;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@ -49,11 +50,15 @@ pub fn run() {
|
|||||||
*upload_log::WRITER.lock() = Some(log_writer.clone());
|
*upload_log::WRITER.lock() = Some(log_writer.clone());
|
||||||
manager.set_upload_log_writer(log_writer);
|
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 {
|
app.manage(commands::AppState {
|
||||||
config: store.clone(),
|
config: store.clone(),
|
||||||
uploads: manager,
|
uploads: manager,
|
||||||
folder_monitor: fm.clone(),
|
folder_monitor: fm.clone(),
|
||||||
remote_server: remote.clone(),
|
remote_server: remote.clone(),
|
||||||
|
otp_broker,
|
||||||
rot_log_path: Mutex::new(data_dir.join("account-rotation.log")),
|
rot_log_path: Mutex::new(data_dir.join("account-rotation.log")),
|
||||||
upload_log_path: Mutex::new(data_dir.join("fileuploader.log")),
|
upload_log_path: Mutex::new(data_dir.join("fileuploader.log")),
|
||||||
always_on_top: parking_lot::Mutex::new(false),
|
always_on_top: parking_lot::Mutex::new(false),
|
||||||
@ -129,6 +134,9 @@ pub fn run() {
|
|||||||
commands::show_drop_target,
|
commands::show_drop_target,
|
||||||
commands::hide_drop_target,
|
commands::hide_drop_target,
|
||||||
commands::debug_log,
|
commands::debug_log,
|
||||||
|
commands::provide_otp,
|
||||||
|
commands::cancel_otp,
|
||||||
|
commands::install_update_now,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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,
|
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 {
|
pub async fn check() -> UpdateCheck {
|
||||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||||
let mut out = UpdateCheck {
|
let mut out = UpdateCheck {
|
||||||
|
|||||||
@ -52,6 +52,7 @@ pub struct Job {
|
|||||||
pub struct UploadManager {
|
pub struct UploadManager {
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
log_writer: parking_lot::Mutex<Option<std::sync::Arc<crate::upload_log::UploadLogWriter>>>,
|
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,
|
running: AtomicBool,
|
||||||
stop_after_active: AtomicBool,
|
stop_after_active: AtomicBool,
|
||||||
start_time: parking_lot::Mutex<Option<Instant>>,
|
start_time: parking_lot::Mutex<Option<Instant>>,
|
||||||
@ -88,10 +89,15 @@ impl UploadManager {
|
|||||||
*self.log_writer.lock() = Some(w);
|
*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 {
|
pub fn new(app: AppHandle) -> Self {
|
||||||
Self {
|
Self {
|
||||||
app,
|
app,
|
||||||
log_writer: parking_lot::Mutex::new(None),
|
log_writer: parking_lot::Mutex::new(None),
|
||||||
|
otp_broker: parking_lot::Mutex::new(None),
|
||||||
running: AtomicBool::new(false),
|
running: AtomicBool::new(false),
|
||||||
stop_after_active: AtomicBool::new(false),
|
stop_after_active: AtomicBool::new(false),
|
||||||
start_time: parking_lot::Mutex::new(None),
|
start_time: parking_lot::Mutex::new(None),
|
||||||
@ -578,6 +584,8 @@ impl UploadManager {
|
|||||||
throttle_hoster: hoster_throttle,
|
throttle_hoster: hoster_throttle,
|
||||||
throttle_global: self.global_throttle.lock().clone(),
|
throttle_global: self.global_throttle.lock().clone(),
|
||||||
on_progress,
|
on_progress,
|
||||||
|
otp_broker: self.otp_broker.lock().clone(),
|
||||||
|
app: Some(self.app.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
hosters::upload_file(task, ctx).await
|
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