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:
Claude 2026-04-20 18:08:18 +02:00
parent 615161d747
commit 100bda60cd
9 changed files with 304 additions and 54 deletions

View File

@ -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",

View File

@ -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 ---

View File

@ -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(||

View File

@ -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 {

View File

@ -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
View 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,
}
}
}

View File

@ -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 {

View File

@ -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
View 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">&#128228;</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>