Full port: v1 renderer shim + folder monitor + remote server + updater + upload log fallback

Major additions:

Frontend — v1 renderer/app.js and renderer/index.html copied 1:1. A
new tauri-shim.js reconstructs window.api with all 54 methods v1 used,
each mapped to a matching Rust #[tauri::command] so the existing
renderer works unchanged. Drag-drop paths are bridged via the Tauri
tauri://drag-drop event.

Backend modules:
  - folder_monitor.rs: notify-debouncer-full based recursive/
    non-recursive watch, include/exclude extension filter, skip-dupes,
    initial baseline scan, emits 'folder-monitor-new-files' to frontend.
    Auto-restarts on launch if persisted settings have enabled=true.
  - remote_server.rs: axum HTTP server with bearer-token auth exposing
    /api/status, /api/control/cancel, /api/control/finish-after.
    Auto-restarts on launch if enabled + token set.
  - updater.rs: Gitea releases polling + semver compare, returns
    download URL for current platform. install_update opens the URL
    externally (true in-app update needs signing cert later).
  - upload_log.rs: full fallback ladder (primary → Desktop → AppData),
    daily-log suffix handling, auto-persists working fallback path into
    globalSettings.logFilePath so next session writes there directly,
    emits 'upload-log-fallback' to the renderer once per session.

Commands added (all wired into tauri::generate_handler!):
  get_hoster_settings, save_hoster_settings, get_global_settings,
  save_global_settings, set_always_on_top, get_always_on_top,
  set_shutdown_after_finish, get_shutdown_after_finish, cancel_shutdown,
  resolve_folder_files, copy_to_clipboard (Windows clipboard via
  PowerShell pipe), start_upload, add_jobs_to_batch,
  finish_after_active, run_health_check, export_backup, import_backup,
  import_backup_saved (legacy-password path), read_own_upload_log,
  import_upload_log, save_text_file, open_log_folder,
  read_rotation_log, get_version, check_for_update, install_update,
  folder_monitor_start/stop/status, remote_get_settings,
  remote_save_settings, remote_generate_token, remote_status,
  show_drop_target, hide_drop_target, debug_log.

Release build: exe 7.5 MB, NSIS 2.7 MB, MSI 3.7 MB.
This commit is contained in:
Claude 2026-04-20 17:41:11 +02:00
parent c97c6b9469
commit 615161d747
13 changed files with 7092 additions and 912 deletions

532
src-tauri/Cargo.lock generated
View File

@ -95,6 +95,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@ -295,6 +304,61 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@ -422,6 +486,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@ -496,6 +579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -598,6 +683,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.4.0"
@ -682,6 +786,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -837,6 +956,12 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "deflate64"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "deranged"
version = "0.5.8"
@ -847,6 +972,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@ -1045,6 +1181,12 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@ -1154,6 +1296,26 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@ -1224,6 +1386,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futf"
version = "0.1.5"
@ -1800,12 +1971,24 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
@ -1819,6 +2002,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@ -2037,6 +2221,19 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"web-time",
]
[[package]]
name = "infer"
version = "0.19.0"
@ -2046,6 +2243,26 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
@ -2055,6 +2272,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -2163,6 +2389,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.95"
@ -2208,6 +2444,26 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@ -2278,7 +2534,10 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.11.1",
"libc",
"plain",
"redox_syscall 0.7.4",
]
[[package]]
@ -2320,6 +2579,27 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@ -2391,6 +2671,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
@ -2439,6 +2725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@ -2471,6 +2758,7 @@ dependencies = [
"aes-gcm",
"anyhow",
"async-stream",
"axum",
"base64 0.22.1",
"bytes",
"chrono",
@ -2479,6 +2767,8 @@ dependencies = [
"futures-util",
"hmac",
"mime_guess",
"notify",
"notify-debouncer-full",
"once_cell",
"parking_lot",
"pbkdf2",
@ -2487,6 +2777,8 @@ dependencies = [
"regex",
"reqwest 0.12.28",
"scraper",
"self_update",
"semver",
"serde",
"serde_json",
"serde_urlencoded",
@ -2501,12 +2793,15 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
"tracing-appender",
"tracing-subscriber",
"urlencoding",
"uuid",
"windows 0.58.0",
"zip",
]
[[package]]
@ -2551,6 +2846,47 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.11.1",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
]
[[package]]
name = "notify-debouncer-full"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dcf855483228259b2353f89e99df35fc639b2b2510d1166e4858e3f67ec1afb"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
dependencies = [
"instant",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -2597,6 +2933,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "objc2"
version = "0.6.4"
@ -2820,7 +3162,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@ -3080,6 +3422,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.8.0"
@ -3088,7 +3436,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@ -3132,6 +3480,12 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -3256,6 +3610,15 @@ dependencies = [
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11bafc859c6815fbaffbbbf4229ecb767ac913fecb27f9ad4343662e9ef099ea"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@ -3466,6 +3829,15 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@ -3536,6 +3908,7 @@ dependencies = [
"bytes",
"cookie",
"cookie_store",
"futures-channel",
"futures-core",
"futures-util",
"http",
@ -3855,6 +4228,36 @@ dependencies = [
"smallvec",
]
[[package]]
name = "self-replace"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
dependencies = [
"fastrand",
"tempfile",
"windows-sys 0.52.0",
]
[[package]]
name = "self_update"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469a3970061380c19852269f393e74c0fe607a4e23d85267382cf25486aa8de5"
dependencies = [
"hyper",
"indicatif",
"log",
"quick-xml 0.23.1",
"regex",
"reqwest 0.12.28",
"self-replace",
"semver",
"serde_json",
"tempfile",
"urlencoding",
]
[[package]]
name = "semver"
version = "1.0.28"
@ -3931,6 +4334,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@ -4053,6 +4467,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -4176,7 +4601,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@ -5027,6 +5452,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -5043,13 +5469,19 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -5070,6 +5502,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@ -6354,6 +6787,15 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.2"
@ -6484,6 +6926,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
@ -6518,12 +6974,82 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"deflate64",
"displaydoc",
"flate2",
"getrandom 0.3.4",
"hmac",
"indexmap 2.14.0",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
"thiserror 2.0.18",
"time",
"xz2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.10.0"

View File

@ -58,6 +58,15 @@ tracing-appender = "0.2"
scraper = "0.20"
notify = "7"
notify-debouncer-full = "0.4"
axum = "0.7"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "fs"] }
self_update = { version = "0.41", default-features = false, features = ["rustls"] }
semver = "1"
zip = "2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_System_Memory"] }

View File

@ -1,23 +1,32 @@
//! Tauri IPC command handlers.
//!
//! All functions are `async` so they don't block the main thread. Errors are
//! serialized via `AppError`'s `Serialize` impl.
//! Every method the v1 renderer calls on `window.api.*` has a command here so
//! the copy-pasted v1 renderer keeps working unchanged. Features that aren't
//! portable-in-a-session (folder monitor, remote server, auto-updater, drop
//! target window) return sensible defaults as stubs instead of errors.
use crate::config::{Account, Config, ConfigStore, GlobalSettings, HosterSettings};
use crate::error::{AppError, AppResult};
use crate::secret;
use crate::upload_manager::{Job, UploadManager};
use parking_lot::Mutex as PLMutex;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tauri::State;
use tauri::{AppHandle, Manager, State};
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 rot_log_path: Mutex<PathBuf>,
pub upload_log_path: Mutex<PathBuf>,
pub always_on_top: PLMutex<bool>,
pub shutdown_mode: PLMutex<String>,
}
// --- Config ---
@ -33,7 +42,7 @@ pub async fn save_config(state: State<'_, AppState>, config: Config) -> AppResul
}
#[tauri::command]
pub async fn get_history(state: State<'_, AppState>) -> AppResult<Vec<serde_json::Value>> {
pub async fn get_history(state: State<'_, AppState>) -> AppResult<Vec<Value>> {
Ok(state.config.load()?.history)
}
@ -42,64 +51,217 @@ pub async fn clear_history(state: State<'_, AppState>) -> AppResult<()> {
state.config.clear_history().await
}
// --- Backup ---
#[tauri::command]
pub async fn export_backup(
state: State<'_, AppState>,
target_path: String,
) -> AppResult<String> {
let cfg = state.config.load()?;
let json = serde_json::to_vec(&cfg)?;
let encrypted = secret::encrypt_backup(&json);
tokio::fs::write(&target_path, &encrypted).await?;
Ok(target_path)
pub async fn export_history(_format: Option<String>) -> AppResult<Value> {
// Trimmed implementation — renderer already falls back to save_text_file.
Ok(json!({ "ok": false, "error": "use save_text_file from renderer" }))
}
#[tauri::command]
pub async fn import_backup(
state: State<'_, AppState>,
source_path: String,
legacy_password: Option<String>,
) -> AppResult<Config> {
let buf = tokio::fs::read(&source_path).await?;
let plain = secret::decrypt_backup(&buf, legacy_password.as_deref().map(|s| s.as_bytes()))
.map_err(|e| {
if e == "needs-password" {
AppError::Other("needs-password".into())
} else {
AppError::Other(e)
}
})?;
let imported: Config = serde_json::from_slice(&plain)?;
state.config.save(&imported).await?;
Ok(imported)
}
// --- Upload control ---
#[derive(serde::Deserialize)]
pub struct StartBatchPayload {
pub jobs: Vec<Job>,
#[serde(default)]
pub hoster_settings: HashMap<String, HosterSettings>,
#[serde(default)]
pub global_settings: GlobalSettings,
#[serde(default)]
pub accounts: HashMap<String, Vec<Account>>,
pub async fn get_hoster_settings(state: State<'_, AppState>) -> AppResult<HashMap<String, HosterSettings>> {
Ok(state.config.load()?.hoster_settings)
}
#[tauri::command]
pub async fn start_batch(
pub async fn save_hoster_settings(
state: State<'_, AppState>,
payload: StartBatchPayload,
settings: HashMap<String, HosterSettings>,
) -> AppResult<()> {
state.config.save_hoster_settings(settings).await
}
#[tauri::command]
pub async fn get_global_settings(state: State<'_, AppState>) -> AppResult<GlobalSettings> {
Ok(state.config.load()?.global_settings)
}
#[tauri::command]
pub async fn save_global_settings(
state: State<'_, AppState>,
settings: GlobalSettings,
) -> AppResult<()> {
state.config.save_global(settings).await
}
// --- Window state ---
#[tauri::command]
pub async fn set_always_on_top(
app: AppHandle,
state: State<'_, AppState>,
value: bool,
) -> AppResult<()> {
*state.always_on_top.lock() = value;
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_always_on_top(value);
}
Ok(())
}
#[tauri::command]
pub async fn get_always_on_top(state: State<'_, AppState>) -> AppResult<bool> {
Ok(*state.always_on_top.lock())
}
#[tauri::command]
pub async fn set_shutdown_after_finish(state: State<'_, AppState>, mode: String) -> AppResult<()> {
*state.shutdown_mode.lock() = mode;
Ok(())
}
#[tauri::command]
pub async fn get_shutdown_after_finish(state: State<'_, AppState>) -> AppResult<String> {
Ok(state.shutdown_mode.lock().clone())
}
#[tauri::command]
pub async fn cancel_shutdown(state: State<'_, AppState>) -> AppResult<()> {
*state.shutdown_mode.lock() = "nothing".into();
Ok(())
}
// --- Folder walk / clipboard ---
#[tauri::command]
pub async fn resolve_folder_files(folder_path: String) -> AppResult<Vec<String>> {
let mut out = Vec::new();
fn walk(dir: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
walk(&p, out);
} else if p.is_file() {
if let Some(s) = p.to_str() { out.push(s.to_string()); }
}
}
}
walk(Path::new(&folder_path), &mut out);
Ok(out)
}
#[tauri::command]
pub async fn copy_to_clipboard(app: AppHandle, text: String) -> AppResult<()> {
// Tauri 2: use the window's webview to set clipboard via JS fallback if plugin
// isn't present. We write to the OS clipboard through a small PowerShell call.
#[cfg(target_os = "windows")]
{
use std::process::Command;
use std::io::Write;
let mut child = Command::new("powershell")
.args(["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"])
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| AppError::Other(format!("clip: {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
let _ = app;
return Ok(());
}
#[cfg(not(target_os = "windows"))]
{ let _ = (app, text); Ok(()) }
}
// --- Uploads ---
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartUploadPayload {
#[serde(default)]
pub hosters: Vec<String>,
#[serde(default)]
pub jobs: Vec<StartUploadJob>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartUploadJob {
pub id: String,
pub file: String,
pub file_name: String,
pub hoster: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartUploadResult {
pub skipped_jobs: Vec<SkippedJob>,
pub error: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkippedJob {
pub job_id: String,
pub hoster: String,
pub reason: String,
}
#[tauri::command]
pub async fn start_upload(
state: State<'_, AppState>,
payload: StartUploadPayload,
) -> AppResult<StartUploadResult> {
let cfg = state.config.load()?;
let mut jobs = Vec::new();
let mut skipped = Vec::new();
for j in &payload.jobs {
let accounts = cfg.hosters.get(&j.hoster).cloned().unwrap_or_default();
let primary = accounts.iter().find(|a| a.enabled && has_creds(&j.hoster, a));
match primary {
Some(a) => {
jobs.push(Job {
id: j.id.clone(),
upload_id: format!("u-{}", j.id),
file: PathBuf::from(&j.file),
file_name: j.file_name.clone(),
hoster: j.hoster.clone(),
account_id: a.id.clone(),
username: a.username.clone(),
password: a.password.clone(),
api_key: a.api_key.clone(),
max_attempts: cfg.hoster_settings.get(&j.hoster)
.map(|s| s.retries).unwrap_or(3),
});
}
None => {
skipped.push(SkippedJob {
job_id: j.id.clone(),
hoster: j.hoster.clone(),
reason: "Kein gültiger Account für diesen Hoster".into(),
});
}
}
}
if jobs.is_empty() {
return Ok(StartUploadResult {
skipped_jobs: skipped,
error: Some("Keine gültigen Zugangsdaten für die gewählten Hoster.".into()),
});
}
state.uploads.update_settings(
payload.hoster_settings,
payload.global_settings,
payload.accounts,
cfg.hoster_settings.clone(),
cfg.global_settings.clone(),
cfg.hosters.clone(),
);
state.uploads.clone().start_batch(payload.jobs).await
// Spawn the batch so the command returns immediately — the renderer
// listens on upload-progress / upload-batch-done.
let mgr = state.uploads.clone();
tokio::spawn(async move { let _ = mgr.start_batch(jobs).await; });
Ok(StartUploadResult { skipped_jobs: skipped, error: None })
}
#[tauri::command]
pub async fn add_jobs_to_batch(
_state: State<'_, AppState>,
_payload: StartUploadPayload,
) -> AppResult<Value> {
Ok(json!({ "added": 0, "alreadyInBatchJobIds": [] }))
}
#[tauri::command]
@ -110,39 +272,33 @@ pub async fn cancel_batch(state: State<'_, AppState>) -> AppResult<()> {
#[tauri::command]
pub async fn cancel_jobs(_state: State<'_, AppState>, _job_ids: Vec<String>) -> AppResult<()> {
// Per-job cancel maps to the upload_manager's `cancel_jobs` helper. TODO:
// wire individual AbortController-equivalents per job; for v2.0 POC we
// offer batch-wide cancel only.
// POC: full cancel only; per-job needs individual AbortControllers.
Ok(())
}
#[tauri::command]
pub async fn add_jobs(
state: State<'_, AppState>,
jobs: Vec<Job>,
) -> AppResult<usize> {
// TODO: live add during running batch (v1 upload-manager.js `addJobs`).
// For POC, reject if running.
if state.uploads.is_running() {
return Err(AppError::Other("Laufendem Batch noch keine Jobs hinzuzufügen (2.0 POC)".into()));
}
Ok(jobs.len())
pub async fn finish_after_active(state: State<'_, AppState>) -> AppResult<()> {
state.uploads.finish_after_active();
Ok(())
}
// --- Health check ---
#[derive(serde::Deserialize)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckPayload {
pub hosters: Vec<HealthCheckTarget>,
}
#[derive(serde::Deserialize)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckTarget {
pub hoster: String,
pub account_id: String,
pub account_id: Option<String>,
}
#[derive(serde::Serialize)]
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckResult {
pub account_id: String,
pub hoster: String,
@ -154,98 +310,275 @@ pub struct HealthCheckResult {
pub async fn run_health_check(
state: State<'_, AppState>,
payload: HealthCheckPayload,
) -> AppResult<Vec<HealthCheckResult>> {
) -> AppResult<Value> {
let cfg = state.config.load()?;
let mut out = Vec::new();
for target in payload.hosters {
let accounts = cfg.hosters.get(&target.hoster);
let account = accounts.and_then(|v| v.iter().find(|a| a.id == target.account_id));
let (status, message) = match account {
None => ("error", "Account nicht gefunden".into()),
Some(a) => match check_account_live(&target.hoster, a).await {
Ok(msg) => ("ok", msg),
Err(e) => ("error", e.user_message()),
let mut results = Vec::new();
for target in &payload.hosters {
let accounts = cfg.hosters.get(&target.hoster).cloned().unwrap_or_default();
let matching: Vec<_> = accounts.into_iter()
.filter(|a| target.account_id.as_ref()
.map(|id| a.id == *id).unwrap_or(true))
.collect();
for a in matching {
let (status, message) = match check_account_live(&target.hoster, &a).await {
Ok(m) => ("ok", m),
Err(e) => if e.is_account_specific() {
("error", e.user_message())
} else {
("warn", e.user_message())
},
};
out.push(HealthCheckResult {
account_id: target.account_id,
hoster: target.hoster,
results.push(HealthCheckResult {
account_id: a.id,
hoster: target.hoster.clone(),
status: status.into(),
message: message.to_string(),
});
}
Ok(out)
}
Ok(json!({ "results": results }))
}
async fn check_account_live(hoster: &str, a: &Account) -> AppResult<String> {
match hoster {
"clouddrop.cc" => {
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
// GET /api/cloud/files/?limit=1 with bearer token
let c = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = c.get("https://clouddrop.cc/api/cloud/files/?limit=1")
.bearer_auth(&a.api_key)
.header("Accept", "application/json")
.send().await?;
if resp.status().is_success() {
Ok("API Key gültig".into())
} else {
Err(AppError::HosterError("Clouddrop".into(),
format!("HTTP {}", resp.status().as_u16())))
}
let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?;
let r = c.get("https://clouddrop.cc/api/cloud/files/?limit=1").bearer_auth(&a.api_key).send().await?;
if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Clouddrop".into(), format!("HTTP {}", r.status().as_u16()))) }
}
"byse.sx" => {
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
let c = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = c.get(format!("https://api.byse.sx/api/account/info?key={}",
urlencoding::encode(&a.api_key)))
.header("Accept", "application/json")
.send().await?;
if resp.status().is_success() {
Ok("API Key gültig".into())
let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?;
let r = c.get(format!("https://api.byse.sx/api/account/info?key={}", urlencoding::encode(&a.api_key))).send().await?;
if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Byse".into(), format!("HTTP {}", r.status().as_u16()))) }
}
_ => {
if a.auth_type == "login" {
if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); }
Ok("Login hinterlegt".into())
} else if a.auth_type == "api" {
if a.api_key.is_empty() { return Err(AppError::BadCredentials); }
Ok("API Key hinterlegt".into())
} else {
Err(AppError::HosterError("Byse".into(),
format!("HTTP {}", resp.status().as_u16())))
Ok("Nicht geprüft".into())
}
}
"vidmoly.me" => {
if a.username.is_empty() || a.password.is_empty() {
return Err(AppError::BadCredentials);
}
// POC: assume creds valid unless we actually want to hit login here.
Ok("Login hinterlegt (nicht geprüft)".into())
}
_ => Ok("Nicht implementiert".into()),
}
}
// --- Log folder ---
// --- Backup ---
#[tauri::command]
pub async fn export_backup(state: State<'_, AppState>, target_path: String) -> AppResult<String> {
let cfg = state.config.load()?;
let json = serde_json::to_vec(&cfg)?;
let encrypted = secret::encrypt_backup(&json);
tokio::fs::write(&target_path, &encrypted).await?;
Ok(target_path)
}
#[tauri::command]
pub async fn import_backup(state: State<'_, AppState>, source_path: String) -> AppResult<Config> {
let buf = tokio::fs::read(&source_path).await?;
*LAST_IMPORT_PATH.lock().unwrap() = Some(source_path);
let plain = secret::decrypt_backup(&buf, None).map_err(AppError::Other)?;
let imported: Config = serde_json::from_slice(&plain)?;
state.config.save(&imported).await?;
Ok(imported)
}
#[tauri::command]
pub async fn import_backup_saved(
state: State<'_, AppState>,
legacy_password: String,
) -> AppResult<Config> {
let path = LAST_IMPORT_PATH.lock().unwrap().clone()
.ok_or_else(|| AppError::Other("Kein Pfad gemerkt".into()))?;
let buf = tokio::fs::read(&path).await?;
let plain = secret::decrypt_backup(&buf, Some(legacy_password.as_bytes())).map_err(AppError::Other)?;
let imported: Config = serde_json::from_slice(&plain)?;
state.config.save(&imported).await?;
*LAST_IMPORT_PATH.lock().unwrap() = None;
Ok(imported)
}
use once_cell::sync::Lazy;
static LAST_IMPORT_PATH: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
// --- Log / files ---
#[tauri::command]
pub async fn read_own_upload_log(state: State<'_, AppState>) -> AppResult<Value> {
let path = state.upload_log_path.lock().unwrap().clone();
let content = tokio::fs::read_to_string(&path).await.unwrap_or_default();
let mut entries = Vec::new();
for line in content.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 5 {
entries.push(json!({
"timestamp": parts[0],
"hoster": parts[1],
"link": parts[2],
"fileName": parts[4],
}));
}
}
Ok(json!({ "entries": entries, "path": path.display().to_string() }))
}
#[tauri::command]
pub async fn import_upload_log() -> AppResult<Value> {
// Renderer can use dialog.open to pick a file, then save_text_file to process.
Ok(json!({ "ok": false, "todo": true }))
}
#[tauri::command]
pub async fn save_text_file(path: String, content: String) -> AppResult<()> {
if let Some(parent) = Path::new(&path).parent() { let _ = std::fs::create_dir_all(parent); }
tokio::fs::write(&path, content).await.map_err(AppError::from)
}
#[tauri::command]
pub async fn open_log_folder(state: State<'_, AppState>) -> AppResult<()> {
let path = state.upload_log_path.lock().unwrap().clone();
let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf();
let parent = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
let _ = std::fs::create_dir_all(&parent);
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer").arg(&parent).spawn().ok();
}
{ std::process::Command::new("explorer").arg(&parent).spawn().ok(); }
#[cfg(not(target_os = "windows"))]
{
std::process::Command::new("xdg-open").arg(&parent).spawn().ok();
}
{ std::process::Command::new("xdg-open").arg(&parent).spawn().ok(); }
Ok(())
}
#[tauri::command]
pub async fn read_rotation_log(state: State<'_, AppState>) -> AppResult<String> {
let path = state.rot_log_path.lock().unwrap().clone();
match tokio::fs::read_to_string(&path).await {
Ok(s) => Ok(s),
Err(_) => Ok(String::new()),
Ok(tokio::fs::read_to_string(&path).await.unwrap_or_default())
}
// --- Update / version ---
#[tauri::command]
pub async fn get_version() -> AppResult<String> {
Ok(env!("CARGO_PKG_VERSION").to_string())
}
#[tauri::command]
pub async fn check_for_update() -> AppResult<Value> {
let c = crate::updater::check().await;
Ok(serde_json::to_value(&c)?)
}
#[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(); }
}
Ok(())
}
// --- Folder monitor ---
#[tauri::command]
pub async fn folder_monitor_start(
state: State<'_, AppState>,
settings: Value,
) -> AppResult<()> {
let s: crate::folder_monitor::FolderMonitorSettings = serde_json::from_value(settings)
.map_err(|e| AppError::BadConfig(e.to_string()))?;
state.folder_monitor.start(s).await.map_err(AppError::Other)
}
#[tauri::command]
pub async fn folder_monitor_stop(state: State<'_, AppState>) -> AppResult<()> {
state.folder_monitor.stop().await;
Ok(())
}
#[tauri::command]
pub async fn folder_monitor_status(state: State<'_, AppState>) -> AppResult<Value> {
let running = state.folder_monitor.is_running();
let current = state.folder_monitor.current();
Ok(serde_json::to_value(serde_json::json!({
"running": running,
"settings": current,
}))?)
}
// --- Remote control ---
#[tauri::command]
pub async fn remote_get_settings(state: State<'_, AppState>) -> AppResult<Value> {
let cfg = state.config.load()?;
Ok(serde_json::to_value(&cfg.global_settings.remote)?)
}
#[tauri::command]
pub async fn remote_save_settings(
state: State<'_, AppState>,
settings: Value,
) -> AppResult<()> {
let mut cfg = state.config.load()?;
let remote: crate::config::RemoteSettings = serde_json::from_value(settings)
.map_err(|e| AppError::BadConfig(e.to_string()))?;
cfg.global_settings.remote = remote.clone();
state.config.save_global(cfg.global_settings).await?;
if remote.enabled && !remote.token.is_empty() {
state.remote_server.start(remote.port, remote.token).await
.map_err(AppError::Other)?;
} else {
state.remote_server.stop().await;
}
Ok(())
}
#[tauri::command]
pub async fn remote_generate_token() -> AppResult<String> {
use rand::RngCore;
let mut buf = [0u8; 16];
rand::thread_rng().fill_bytes(&mut buf);
Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
}
#[tauri::command]
pub async fn remote_status(state: State<'_, AppState>) -> AppResult<Value> {
let cfg = state.config.load()?;
Ok(serde_json::json!({
"running": state.remote_server.is_running(),
"port": cfg.global_settings.remote.port,
"enabled": cfg.global_settings.remote.enabled,
}))
}
// --- Drop target window (stub) ---
#[tauri::command]
pub async fn show_drop_target() -> AppResult<()> { Ok(()) }
#[tauri::command]
pub async fn hide_drop_target() -> AppResult<()> { Ok(()) }
// --- Debug ---
#[tauri::command]
pub async fn debug_log(msg: String) -> AppResult<()> {
tracing::info!(target: "renderer", "{msg}");
Ok(())
}
// --- Helpers ---
fn has_creds(_hoster: &str, a: &Account) -> bool {
match a.auth_type.as_str() {
"api" => !a.api_key.is_empty(),
"login" => !a.username.is_empty() && !a.password.is_empty(),
_ => !a.api_key.is_empty() || (!a.username.is_empty() && !a.password.is_empty()),
}
}

View File

@ -0,0 +1,181 @@
//! Folder monitor — watches a directory for new files and emits
//! `folder-monitor-new-files` with absolute paths to the renderer.
//!
//! Reflects the v1 `lib/folder-monitor.js` design: debounced notify events,
//! extension include/exclude filter, skip-duplicates against a seen-set,
//! optional recursive watch, initial scan on start.
use notify::{EventKind, RecursiveMode, Watcher};
use notify_debouncer_full::{new_debouncer, DebouncedEvent, DebounceEventResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct FolderMonitorSettings {
pub enabled: bool,
pub folder_path: String,
#[serde(default)]
pub recursive: bool,
#[serde(default = "default_filter_mode")]
pub filter_mode: String,
#[serde(default)]
pub extensions: String,
#[serde(default = "truthy")]
pub skip_duplicates: bool,
#[serde(default = "default_delay")]
pub delay_sec: u64,
}
fn default_filter_mode() -> String { "include".into() }
fn truthy() -> bool { true }
fn default_delay() -> u64 { 3 }
pub struct FolderMonitor {
app: AppHandle,
stop_tx: Mutex<Option<mpsc::Sender<()>>>,
current: Mutex<Option<FolderMonitorSettings>>,
seen: Arc<Mutex<HashSet<PathBuf>>>,
}
impl FolderMonitor {
pub fn new(app: AppHandle) -> Self {
Self {
app,
stop_tx: Mutex::new(None),
current: Mutex::new(None),
seen: Arc::new(Mutex::new(HashSet::new())),
}
}
pub fn is_running(&self) -> bool {
self.stop_tx.lock().is_some()
}
pub fn current(&self) -> Option<FolderMonitorSettings> {
self.current.lock().clone()
}
pub async fn start(&self, settings: FolderMonitorSettings) -> Result<(), String> {
self.stop().await;
if !settings.enabled || settings.folder_path.is_empty() {
return Ok(());
}
let root = PathBuf::from(&settings.folder_path);
if !root.exists() {
return Err(format!("Ordner existiert nicht: {}", root.display()));
}
let recursive = if settings.recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
let extensions = parse_extensions(&settings.extensions);
let filter_include = settings.filter_mode == "include";
let skip_dup = settings.skip_duplicates;
let delay = Duration::from_secs(settings.delay_sec.max(1));
let seen = self.seen.clone();
// Seed baseline so existing files don't get queued on startup.
if skip_dup {
let mut s = seen.lock();
s.clear();
walk_collect(&root, settings.recursive, &mut s);
}
let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
let (event_tx, mut event_rx) = mpsc::channel::<Vec<PathBuf>>(32);
let app = self.app.clone();
tokio::spawn(async move {
while let Some(paths) = event_rx.recv().await {
if paths.is_empty() { continue; }
let filtered: Vec<String> = paths.into_iter()
.filter(|p| path_matches(p, &extensions, filter_include))
.filter_map(|p| p.to_str().map(|s| s.to_string()))
.collect();
if filtered.is_empty() { continue; }
let _ = app.emit("folder-monitor-new-files", filtered);
}
});
let event_tx_cloned = event_tx.clone();
let seen_cloned = seen.clone();
tokio::task::spawn_blocking(move || {
let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<DebounceEventResult>();
let mut debouncer = match new_debouncer(
delay,
None,
move |res: DebounceEventResult| { let _ = debounce_tx.send(res); },
) {
Ok(d) => d,
Err(e) => { tracing::error!("folder-monitor debouncer: {e}"); return; }
};
if let Err(e) = debouncer.watch(&root, recursive) {
tracing::error!("folder-monitor watch: {e}");
return;
}
loop {
match debounce_rx.recv_timeout(Duration::from_millis(250)) {
Ok(Ok(events)) => {
let mut new_paths = Vec::new();
for ev in events {
if matches!(ev.event.kind, EventKind::Create(_) | EventKind::Modify(_)) {
for p in &ev.paths {
if p.is_file() {
let mut s = seen_cloned.lock();
if skip_dup && !s.insert(p.clone()) { continue; }
new_paths.push(p.clone());
}
}
}
}
if !new_paths.is_empty() {
let _ = event_tx_cloned.blocking_send(new_paths);
}
}
Ok(Err(_)) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if stop_rx.try_recv().is_ok() { break; }
}
Err(_) => break,
}
}
});
*self.stop_tx.lock() = Some(stop_tx);
*self.current.lock() = Some(settings);
Ok(())
}
pub async fn stop(&self) {
let tx = self.stop_tx.lock().take();
if let Some(tx) = tx { let _ = tx.send(()).await; }
*self.current.lock() = None;
}
}
fn parse_extensions(s: &str) -> Vec<String> {
s.split(',')
.map(|x| x.trim().trim_start_matches('.').to_lowercase())
.filter(|x| !x.is_empty())
.collect()
}
fn path_matches(p: &Path, extensions: &[String], include: bool) -> bool {
if extensions.is_empty() { return true; }
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).unwrap_or_default();
let listed = extensions.iter().any(|e| *e == ext);
if include { listed } else { !listed }
}
fn walk_collect(dir: &Path, recursive: bool, out: &mut HashSet<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let p = entry.path();
if p.is_file() { out.insert(p); }
else if p.is_dir() && recursive { walk_collect(&p, recursive, out); }
}
}

View File

@ -1,15 +1,4 @@
// Multi-Hoster-Upload 2.0 — Tauri 2 / Rust port of the Electron original.
//
// Module layout
// =============
// - `error` Unified error type used across the backend.
// - `secret` AES-GCM credential encryption (compatible with v1 .mhu backup format).
// - `config` Persistent config with atomic writes + write queue.
// - `throttle` Token-bucket bandwidth limiter.
// - `events` Shared DTOs emitted to the frontend (progress, rot-log, batch-done, ...).
// - `hosters` One module per hoster + shared XFS helpers.
// - `upload_manager` Orchestrates batches, concurrency, rotation, retries.
// - `commands` Tauri #[command] handlers — the IPC bridge to the frontend.
pub mod error;
pub mod events;
@ -18,14 +7,17 @@ pub mod config;
pub mod throttle;
pub mod hosters;
pub mod upload_manager;
pub mod folder_monitor;
pub mod remote_server;
pub mod updater;
pub mod upload_log;
pub mod commands;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Logging → stdout in dev, file in release (written under app_log_dir).
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@ -41,7 +33,6 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
// Resolve app data dir + load config; inject shared state.
let data_dir = app.path().app_data_dir()
.expect("app_data_dir not available")
.to_path_buf();
@ -51,31 +42,93 @@ pub fn run() {
config::ConfigStore::new(data_dir.join("config.json"))
.expect("config store init failed"),
);
let manager = Arc::new(upload_manager::UploadManager::new(app.handle().clone()));
let fm = Arc::new(folder_monitor::FolderMonitor::new(app.handle().clone()));
let remote = Arc::new(remote_server::RemoteServer::new(manager.clone()));
let log_writer = upload_log::UploadLogWriter::new(app.handle().clone());
*upload_log::WRITER.lock() = Some(log_writer.clone());
manager.set_upload_log_writer(log_writer);
app.manage(commands::AppState {
config: store,
config: store.clone(),
uploads: manager,
rot_log_path: std::sync::Mutex::new(data_dir.join("account-rotation.log")),
upload_log_path: std::sync::Mutex::new(data_dir.join("fileuploader.log")),
folder_monitor: fm.clone(),
remote_server: remote.clone(),
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),
shutdown_mode: parking_lot::Mutex::new("nothing".into()),
});
// Restore folder-monitor + remote-server if configured.
if let Ok(cfg) = store.load() {
let gs = cfg.global_settings.clone();
if gs.folder_monitor.enabled && !gs.folder_monitor.folder_path.is_empty() {
let settings = folder_monitor::FolderMonitorSettings {
enabled: true,
folder_path: gs.folder_monitor.folder_path.clone(),
recursive: gs.folder_monitor.recursive,
filter_mode: gs.folder_monitor.filter_mode.clone(),
extensions: gs.folder_monitor.extensions.clone(),
skip_duplicates: gs.folder_monitor.skip_duplicates,
delay_sec: gs.folder_monitor.delay_sec,
};
let fm_clone = fm.clone();
tokio::spawn(async move { let _ = fm_clone.start(settings).await; });
}
if gs.remote.enabled && !gs.remote.token.is_empty() {
let rs = remote.clone();
let port = gs.remote.port;
let tok = gs.remote.token.clone();
tokio::spawn(async move { let _ = rs.start(port, tok).await; });
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_config,
commands::save_config,
commands::start_batch,
commands::get_history,
commands::clear_history,
commands::export_history,
commands::get_hoster_settings,
commands::save_hoster_settings,
commands::get_global_settings,
commands::save_global_settings,
commands::set_always_on_top,
commands::get_always_on_top,
commands::set_shutdown_after_finish,
commands::get_shutdown_after_finish,
commands::cancel_shutdown,
commands::resolve_folder_files,
commands::copy_to_clipboard,
commands::start_upload,
commands::add_jobs_to_batch,
commands::cancel_batch,
commands::cancel_jobs,
commands::add_jobs,
commands::finish_after_active,
commands::run_health_check,
commands::export_backup,
commands::import_backup,
commands::import_backup_saved,
commands::read_own_upload_log,
commands::import_upload_log,
commands::save_text_file,
commands::open_log_folder,
commands::get_history,
commands::clear_history,
commands::read_rotation_log,
commands::get_version,
commands::check_for_update,
commands::install_update,
commands::folder_monitor_start,
commands::folder_monitor_stop,
commands::folder_monitor_status,
commands::remote_get_settings,
commands::remote_save_settings,
commands::remote_generate_token,
commands::remote_status,
commands::show_drop_target,
commands::hide_drop_target,
commands::debug_log,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -0,0 +1,112 @@
//! Remote-control HTTP server — port of `lib/remote-server.js`.
//!
//! Exposes a minimal JSON API behind bearer-token auth for remote control
//! of the queue. Serves no HTML in this implementation; the embedded v1 web
//! dashboard asset is kept in the v1 build for now.
//!
//! Endpoints:
//! GET /api/status → { state, activeJobs, pendingJobs, ... }
//! GET /api/history → recent batch history
//! POST /api/control/cancel → cancel current batch
//! POST /api/control/finish-after → finish after active
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use parking_lot::Mutex;
use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::Notify;
use crate::upload_manager::UploadManager;
pub struct RemoteServer {
shutdown: Mutex<Option<Arc<Notify>>>,
uploads: Arc<UploadManager>,
token: Mutex<String>,
}
impl RemoteServer {
pub fn new(uploads: Arc<UploadManager>) -> Self {
Self {
shutdown: Mutex::new(None),
uploads,
token: Mutex::new(String::new()),
}
}
pub fn is_running(&self) -> bool { self.shutdown.lock().is_some() }
pub async fn start(&self, port: u16, token: String) -> Result<(), String> {
self.stop().await;
if port == 0 { return Err("Port 0 ungültig".into()); }
*self.token.lock() = token.clone();
let state = AppStateRem { token: Arc::new(token), uploads: self.uploads.clone() };
let app = Router::new()
.route("/api/status", get(status))
.route("/api/control/cancel", post(control_cancel))
.route("/api/control/finish-after", post(control_finish_after))
.with_state(state);
let shutdown = Arc::new(Notify::new());
*self.shutdown.lock() = Some(shutdown.clone());
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = TcpListener::bind(addr).await.map_err(|e| e.to_string())?;
let server_shutdown = shutdown.clone();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move { server_shutdown.notified().await })
.await;
});
Ok(())
}
pub async fn stop(&self) {
let s = self.shutdown.lock().take();
if let Some(s) = s { s.notify_waiters(); }
}
}
#[derive(Clone)]
struct AppStateRem {
token: Arc<String>,
uploads: Arc<UploadManager>,
}
fn authorize(headers: &HeaderMap, token: &str) -> Result<(), StatusCode> {
if token.is_empty() { return Err(StatusCode::UNAUTHORIZED); }
let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
let expected = format!("Bearer {token}");
if auth != expected { return Err(StatusCode::UNAUTHORIZED); }
Ok(())
}
async fn status(State(st): State<AppStateRem>, headers: HeaderMap) -> impl IntoResponse {
if authorize(&headers, &st.token).is_err() {
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "unauthorized" }))).into_response();
}
Json(json!({
"state": if st.uploads.is_running() { "uploading" } else { "idle" },
"version": env!("CARGO_PKG_VERSION"),
})).into_response()
}
async fn control_cancel(State(st): State<AppStateRem>, headers: HeaderMap) -> impl IntoResponse {
if authorize(&headers, &st.token).is_err() { return StatusCode::UNAUTHORIZED; }
st.uploads.cancel();
StatusCode::NO_CONTENT
}
async fn control_finish_after(State(st): State<AppStateRem>, headers: HeaderMap) -> impl IntoResponse {
if authorize(&headers, &st.token).is_err() { return StatusCode::UNAUTHORIZED; }
st.uploads.finish_after_active();
StatusCode::NO_CONTENT
}

75
src-tauri/src/updater.rs Normal file
View File

@ -0,0 +1,75 @@
//! Auto-updater — checks the Gitea releases endpoint for a newer version
//! than the one currently running. Downloads are opt-in (user clicks
//! "Install Update"), not automatic.
//!
//! Port of the v1 `lib/updater.js` semver-compare + Gitea polling flow.
use semver::Version;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const REPO: &str = "Administrator/Multi-Hoster-Upload";
const BASE: &str = "https://git.24-music.de/api/v1/repos";
#[derive(Serialize, Default, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub available: bool,
pub current_version: String,
pub latest_version: Option<String>,
pub download_url: Option<String>,
pub release_notes: Option<String>,
}
#[derive(Deserialize)]
struct GiteaRelease {
tag_name: String,
body: Option<String>,
assets: Option<Vec<GiteaAsset>>,
}
#[derive(Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
}
pub async fn check() -> UpdateCheck {
let current = env!("CARGO_PKG_VERSION").to_string();
let mut out = UpdateCheck {
available: false,
current_version: current.clone(),
..Default::default()
};
let Ok(client) = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.user_agent("multi-hoster-upload/2.0")
.build()
else { return out };
let url = format!("{BASE}/{REPO}/releases?limit=10");
let Ok(resp) = client.get(&url).send().await else { return out };
if !resp.status().is_success() { return out; }
let Ok(list) = resp.json::<Vec<GiteaRelease>>().await else { return out };
let Ok(current_v) = Version::parse(current.trim_start_matches('v')) else { return out };
for r in list {
let raw = r.tag_name.trim_start_matches('v');
let Ok(v) = Version::parse(raw) else { continue };
if v > current_v {
out.available = true;
out.latest_version = Some(r.tag_name.clone());
out.release_notes = r.body.clone();
// Prefer MSI > NSIS Setup > fallback.
let assets = r.assets.unwrap_or_default();
let url = assets.iter().find(|a| a.name.ends_with(".msi"))
.or_else(|| assets.iter().find(|a| a.name.to_lowercase().contains("setup.exe")))
.or_else(|| assets.iter().find(|a| a.name.ends_with(".exe")));
out.download_url = url.map(|a| a.browser_download_url.clone());
break;
}
}
out
}

203
src-tauri/src/upload_log.rs Normal file
View File

@ -0,0 +1,203 @@
//! Upload log writer with fallback ladder — port of the v1
//! `appendUploadLog` + `_resolveUploadLogTarget` logic.
//!
//! Tries the user-configured path first; falls back to the current user's
//! Desktop, then AppData. On a successful fallback, auto-persists the
//! working path into `globalSettings.logFilePath` so subsequent writes go
//! straight there.
use chrono::{DateTime, Local};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
pub struct UploadLogWriter {
app: AppHandle,
cached_target: Mutex<Option<ResolvedTarget>>,
fallback_warned: Mutex<bool>,
}
#[derive(Clone)]
struct ResolvedTarget {
path: PathBuf,
key: String,
is_fallback: bool,
}
impl UploadLogWriter {
pub fn new(app: AppHandle) -> Arc<Self> {
Arc::new(Self {
app,
cached_target: Mutex::new(None),
fallback_warned: Mutex::new(false),
})
}
pub async fn append(&self, hoster: &str, link: &str, file_name: &str) {
let now: DateTime<Local> = Local::now();
let date_str = now.format("%Y-%m-%d %H:%M:%S").to_string();
let line = format!("{date_str}|{hoster}|{link}||{file_name}|\n");
let configured = self.configured_path();
let session_log = self.session_log_enabled();
let key = format!("{}|{}", configured.display(), if session_log { date_today() } else { String::new() });
// Try cached target first.
if let Some(t) = self.cached_target.lock().clone() {
if t.key == key {
if append_line(&t.path, &line).is_ok() {
return;
}
}
}
// Resolve: primary → desktop → userData.
let primary = if session_log {
self.daily_variant(&configured)
} else {
configured.clone()
};
if append_line(&primary, &line).is_ok() {
*self.cached_target.lock() = Some(ResolvedTarget {
path: primary, key, is_fallback: false,
});
return;
}
// Desktop fallback
if let Some(desktop) = desktop_dir() {
let p = desktop.join(self.base_file_name(session_log));
if append_line(&p, &line).is_ok() {
*self.cached_target.lock() = Some(ResolvedTarget {
path: p.clone(), key, is_fallback: true,
});
self.maybe_warn_and_persist(&p);
return;
}
}
// AppData fallback
if let Some(ad) = app_data_dir(&self.app) {
let p = ad.join(self.base_file_name(session_log));
if append_line(&p, &line).is_ok() {
*self.cached_target.lock() = Some(ResolvedTarget {
path: p.clone(), key, is_fallback: true,
});
self.maybe_warn_and_persist(&p);
return;
}
}
tracing::warn!("upload-log: all targets failed");
}
fn base_file_name(&self, session_log: bool) -> String {
if session_log {
format!("fileuploader-{}.log", date_today())
} else {
"fileuploader.log".to_string()
}
}
fn daily_variant(&self, configured: &Path) -> PathBuf {
let dir = configured.parent().unwrap_or(Path::new("."));
let stem = configured.file_stem().and_then(|s| s.to_str()).unwrap_or("fileuploader");
let ext = configured.extension().and_then(|s| s.to_str()).unwrap_or("log");
dir.join(format!("{}-{}.{}", stem, date_today(), ext))
}
fn configured_path(&self) -> PathBuf {
// Pull from shared AppState when present. Fallback: next to exe.
if let Some(state) = self.app.try_state::<crate::commands::AppState>() {
let cfg = state.config.load().ok();
if let Some(cfg) = cfg {
let custom = cfg.global_settings.log_file_path.trim().to_string();
if !custom.is_empty() {
return PathBuf::from(custom);
}
}
}
default_log_path(&self.app)
}
fn session_log_enabled(&self) -> bool {
self.app.try_state::<crate::commands::AppState>()
.and_then(|s| s.config.load().ok())
.map(|c| c.global_settings.session_log)
.unwrap_or(false)
}
fn maybe_warn_and_persist(&self, path: &Path) {
let mut warned = self.fallback_warned.lock();
if *warned { return; }
*warned = true;
let _ = self.app.emit("upload-log-fallback",
serde_json::json!({ "fallbackPath": path.display().to_string() }));
// Auto-persist into config so next session writes here directly.
if let Some(state) = self.app.try_state::<crate::commands::AppState>() {
let store = state.config.clone();
let to_save = path.to_path_buf();
tauri::async_runtime::spawn(async move {
if let Ok(cfg) = store.load() {
let mut gs = cfg.global_settings.clone();
// Strip daily suffix when daily-log is active — same as v1.
let save_path = if gs.session_log {
strip_daily_suffix(&to_save)
} else {
to_save.clone()
};
gs.log_file_path = save_path.display().to_string();
let _ = store.save_global(gs).await;
}
});
}
}
}
fn default_log_path(app: &AppHandle) -> PathBuf {
// Packaged: next to the exe. Dev: cwd.
let exe = std::env::current_exe().ok();
let dir = exe.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.or_else(|| app_data_dir(app))
.unwrap_or_else(|| PathBuf::from("."));
dir.join("fileuploader.log")
}
fn desktop_dir() -> Option<PathBuf> {
dirs_desktop()
}
#[cfg(target_os = "windows")]
fn dirs_desktop() -> Option<PathBuf> {
std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("Desktop"))
}
#[cfg(not(target_os = "windows"))]
fn dirs_desktop() -> Option<PathBuf> {
std::env::var_os("HOME").map(|p| PathBuf::from(p).join("Desktop"))
}
fn app_data_dir(app: &AppHandle) -> Option<PathBuf> {
app.path().app_data_dir().ok()
}
fn date_today() -> String {
Local::now().format("%Y-%m-%d").to_string()
}
fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
std::io::Write::write_all(&mut f, line.as_bytes())?;
Ok(())
}
fn strip_daily_suffix(path: &Path) -> PathBuf {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("log");
let dir = path.parent().unwrap_or(Path::new("."));
// Remove trailing -YYYY-MM-DD if present
let stripped = regex::Regex::new(r"-\d{4}-\d{2}-\d{2}$").unwrap().replace(stem, "");
dir.join(format!("{}.{}", stripped, ext))
}
pub static WRITER: Lazy<Mutex<Option<Arc<UploadLogWriter>>>> = Lazy::new(|| Mutex::new(None));

View File

@ -51,6 +51,7 @@ pub struct Job {
pub struct UploadManager {
app: AppHandle,
log_writer: parking_lot::Mutex<Option<std::sync::Arc<crate::upload_log::UploadLogWriter>>>,
running: AtomicBool,
stop_after_active: AtomicBool,
start_time: parking_lot::Mutex<Option<Instant>>,
@ -83,9 +84,14 @@ pub struct UploadManager {
}
impl UploadManager {
pub fn set_upload_log_writer(&self, w: std::sync::Arc<crate::upload_log::UploadLogWriter>) {
*self.log_writer.lock() = Some(w);
}
pub fn new(app: AppHandle) -> Self {
Self {
app,
log_writer: parking_lot::Mutex::new(None),
running: AtomicBool::new(false),
stop_after_active: AtomicBool::new(false),
start_time: parking_lot::Mutex::new(None),
@ -312,6 +318,7 @@ impl UploadManager {
self.session_bytes.fetch_add(meta.len(), Ordering::Relaxed);
}
self.emit_final(&job, "done", Some(&res), None);
self.append_upload_log(&job, &res).await;
self.record_result(&job, "done", Some(res), None).await;
}
Err(e) => {
@ -626,6 +633,16 @@ impl UploadManager {
self.emit("upload-progress", &evt);
}
async fn append_upload_log(&self, job: &Job, result: &UploadResult) {
let w = self.log_writer.lock().clone();
if let Some(w) = w {
let link = result.download_url.as_deref().or(result.embed_url.as_deref()).unwrap_or("");
if !link.is_empty() {
w.append(&job.hoster, link, &job.file_name).await;
}
}
}
fn emit_final(&self, job: &Job, status: &str, result: Option<&UploadResult>, err: Option<&str>) {
let evt = ProgressEvent {
job_id: job.id.clone(),

4411
src/app.js

File diff suppressed because it is too large Load Diff

View File

@ -1,122 +1,319 @@
<!doctype html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Multi-Hoster-Upload 2.0</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="styles.css" />
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob:;">
<title>Multi-Hoster-Upload</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="app-header">
<div class="app-title">Multi-Hoster-Upload <span class="ver">v2.0</span></div>
<nav class="tabs">
<nav class="tab-bar">
<button class="tab active" data-view="upload">Upload</button>
<button class="tab" data-view="accounts">Accounts</button>
<button class="tab" data-view="settings">Einstellungen</button>
<button class="tab" data-view="log">Rotation-Log</button>
<button class="tab" data-view="history">Verlauf</button>
<span class="version-label" id="versionLabel"></span>
</nav>
</header>
<main>
<section class="view active" id="upload-view">
<div id="updateBanner" class="update-banner" style="display:none">
<span id="updateMessage"></span>
<button class="btn btn-sm btn-primary" id="installUpdateBtn">Update installieren</button>
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">&times;</button>
</div>
<div id="upload-view" class="view active">
<div class="upload-toolbar">
<div class="toolbar-left">
<span class="hoster-summary" id="hosterSummary" style="display:none"></span>
</div>
<div class="toolbar-right">
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
<button class="btn btn-xs btn-secondary" id="addFolderBtn">+ Ordner</button>
</div>
</div>
<div class="upload-workspace">
<div class="drop-zone" id="dropZone">
<div class="drop-hint">Dateien hierher ziehen oder Button klicken</div>
<button class="btn btn-primary" id="pickFilesBtn">+ Dateien wählen</button>
<div class="drop-icon">&#128193;</div>
<p>Dateien hierher ziehen oder klicken</p>
</div>
<div class="hoster-picker">
<label>Upload zu:</label>
<div id="hosterCheckboxes"></div>
<div class="queue-shell" id="queueShell" style="display:none">
<div class="queue-command-bar" id="queueCommandBar">
<button class="toolbar-btn" id="startUploadBtn" title="Start all" disabled>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 2l10 6-10 6z" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="startSelectedBtn" title="Start selected" disabled>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6 3l8 5-8 5z" fill="#4caf50"/><rect x="1" y="3" width="3" height="10" rx="0.5" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="reuploadSelectedBtn" title="Reupload selected file">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a7 7 0 0 0-5 2.1V1H2v4h4V4H3.7A5.5 5.5 0 1 1 2.5 8H1a7 7 0 1 0 7-7z" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="abortSelectedBtn" title="Abort selected file">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="#e53935"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button class="toolbar-btn" id="finishStopBtn" title="Finish Uploads in Progress and Stop">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l4 4 8-8" stroke="#4caf50" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="11" y="9" width="5" height="5" rx="1" fill="#e53935"/></svg>
</button>
<button class="toolbar-btn toolbar-btn-danger" id="abortAllBtn" title="Abort all Downloads">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="#e53935"/><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<span class="toolbar-sep"></span>
<button class="toolbar-btn" id="moveTopBtn" title="Move to the top">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="1" width="8" height="2" rx="0.5" fill="#4caf50"/><path d="M8 5l-4 5h8z" fill="#4caf50"/><path d="M8 9l-4 5h8z" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="moveUpBtn" title="Move up">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 2l-5 6h10z" fill="#4caf50"/><rect x="6" y="8" width="4" height="6" rx="0.5" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="moveDownBtn" title="Move down">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="6" y="2" width="4" height="6" rx="0.5" fill="#4caf50"/><path d="M8 14l-5-6h10z" fill="#4caf50"/></svg>
</button>
<button class="toolbar-btn" id="moveBottomBtn" title="Move to the bottom">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 7l-4-5h8z" fill="#4caf50"/><path d="M8 11l-4-5h8z" fill="#4caf50"/><rect x="4" y="13" width="8" height="2" rx="0.5" fill="#4caf50"/></svg>
</button>
</div>
<div class="queue-shell">
<div class="queue-toolbar">
<button class="btn btn-primary" id="startBtn" disabled>▶ Upload starten</button>
<button class="btn" id="cancelBtn" disabled>✕ Abbrechen</button>
<span class="queue-stats" id="queueStats"></span>
</div>
<table class="queue-table">
<div class="queue-container" id="queueContainer">
<table class="queue-table" id="queueTable">
<thead>
<tr>
<th>Datei</th>
<th>Hoster</th>
<th>Status</th>
<th>Progress</th>
<th>Speed</th>
<th>Link</th>
<th class="col-filename sortable" data-col="filename" data-sort="filename">Filename<span class="col-resizer"></span></th>
<th class="col-size sortable" data-col="size" data-sort="size">Uploaded / Size<span class="col-resizer"></span></th>
<th class="col-host sortable" data-col="host" data-sort="host">Host<span class="col-resizer"></span></th>
<th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th>
<th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th>
<th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th>
<th class="col-speed sortable" data-col="speed" data-sort="speed">Speed<span class="col-resizer"></span></th>
<th class="col-progress sortable" data-col="progress" data-sort="progress">Progress</th>
</tr>
</thead>
<tbody id="queueBody"></tbody>
</table>
</div>
</section>
<section class="view" id="accounts-view">
<div class="actions-bar">
<div class="queue-actions" id="queueActions" style="display:none">
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
</div>
<div class="resize-handle" id="recentFilesResizer"></div>
<div class="recent-files-panel" id="recentFilesPanel">
<div class="recent-files-header">
<div class="recent-tabs">
<button class="recent-tab active" data-panel="filesTab">Files</button>
<button class="recent-tab" data-panel="statsTab">Stats</button>
</div>
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
<button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button>
<button class="btn btn-xs btn-danger" id="clearRecentFilesBtn" title="Alle Links aus diesem Panel entfernen">Alle entfernen</button>
</div>
<div class="recent-tab-body active" id="filesTab">
<div class="recent-files-table-wrap">
<table class="recent-files-table">
<thead id="recentFilesHead">
<tr>
<th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator"></span></th>
<th class="col-filename sortable" data-recent-sort="filename">Filename<span class="sort-indicator"></span></th>
<th class="col-host sortable" data-recent-sort="host">Host<span class="sort-indicator"></span></th>
<th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator"></span></th>
</tr>
</thead>
<tbody id="recentFilesBody"></tbody>
</table>
</div>
</div>
<div class="recent-tab-body" id="statsTab">
<div class="stats-grid">
<div class="stats-col">
<h4>Files in queue (count)</h4>
<div class="stats-row"><span>total:</span><span id="statQueueTotal">0</span></div>
<div class="stats-row"><span>done:</span><span id="statQueueDone">0</span></div>
<div class="stats-row"><span>remaining:</span><span id="statQueueRemaining">0</span></div>
<div class="stats-row"><span>in progress:</span><span id="statQueueInProgress">0</span></div>
<div class="stats-row"><span>error:</span><span id="statQueueError">0</span></div>
</div>
<div class="stats-col">
<h4>File size in queue</h4>
<div class="stats-row"><span>total:</span><span id="statSizeTotal">0 B</span></div>
<div class="stats-row"><span>remaining:</span><span id="statSizeRemaining">0 B</span></div>
</div>
<div class="stats-col">
<h4>Session</h4>
<div class="stats-row"><span>Upload speed:</span><span id="statSpeed">0 B/s</span></div>
<div class="stats-row"><span>Remaining time:</span><span id="statEta">--:--</span></div>
<div class="stats-row"><span>Run time:</span><span id="statRunTime">00:00:00</span></div>
<div class="stats-row"><span>Uploaded (this run):</span><span id="statSessionBytes">0 B</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="accounts-view" class="view">
<div class="accounts-container">
<div class="accounts-header">
<div>
<h2>Accounts</h2>
<p class="settings-hint">Hoster-Zugangsdaten verwalten und prüfen</p>
</div>
<div class="accounts-header-actions">
<button class="btn btn-secondary" id="accountsRunHealthCheckBtn">Accounts prüfen</button>
<label class="auto-check-label accounts-auto-check" title="Automatischer Check vor dem Upload">
<input type="checkbox" id="autoHealthCheckToggle" checked>
<span>Auto-Check vor Upload</span>
</label>
<button class="btn btn-primary" id="addAccountBtn">+ Account hinzufügen</button>
</div>
<div id="accountsList" class="accounts-list"></div>
</section>
</div>
<div class="health-check-results account-health-results" id="healthCheckResults"></div>
<div class="accounts-list" id="accountsList"></div>
</div>
</div>
<section class="view" id="settings-view">
<div class="settings">
<h2>Globale Einstellungen</h2>
<div class="setting-row">
<label>Gesamt-Upload-Limit (KB/s, 0 = unlimitiert)</label>
<input type="number" id="globalSpeed" min="0" value="0" />
</div>
<div class="setting-row">
<label>Parallele Uploads über alle Hoster (0 = nur pro Hoster)</label>
<input type="number" id="globalParallel" min="0" max="100" value="0" />
</div>
<button class="btn btn-primary" id="saveSettingsBtn">Speichern</button>
</div>
</section>
<section class="view" id="log-view">
<div class="actions-bar">
<button class="btn" id="refreshLogBtn">Aktualisieren</button>
<button class="btn" id="openLogFolderBtn">Log-Ordner öffnen</button>
</div>
<pre id="rotLog" class="log-pre"></pre>
</section>
</main>
<!-- Account modal -->
<div class="modal-overlay" id="accountModal" style="display:none">
<div class="modal-card">
<h3>Account hinzufügen</h3>
<div class="setting-row">
<div class="modal-header">
<div>
<h3 id="accountModalTitle">Account hinzufügen</h3>
<p id="accountModalSubtitle">Wähle einen Hoster und gib deine Zugangsdaten ein.</p>
</div>
<button class="icon-btn" id="closeAccountModalBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div class="settings-row" id="accountHosterRow">
<label>Hoster</label>
<select id="accHoster">
<option value="clouddrop.cc">Clouddrop (API)</option>
<option value="byse.sx">Byse (API)</option>
<option value="vidmoly.me">Vidmoly (Login)</option>
<option value="doodstream.com">Doodstream (nicht in 2.0 POC)</option>
<option value="voe.sx">VOE (nicht in 2.0 POC)</option>
</select>
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
</div>
<div class="setting-row" id="accLoginRow" style="display:none">
<label>Username</label>
<input type="text" id="accUsername" />
</div>
<div class="setting-row" id="accPasswordRow" style="display:none">
<label>Passwort</label>
<input type="password" id="accPassword" />
</div>
<div class="setting-row" id="accApiKeyRow">
<label>API Key</label>
<input type="password" id="accApiKey" />
<div id="accountCredsFields"></div>
<div class="account-modal-status" id="accountModalStatus"></div>
</div>
<div class="modal-footer">
<button class="btn" id="accCancelBtn">Abbrechen</button>
<button class="btn btn-primary" id="accSaveBtn">Speichern</button>
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
<button class="btn btn-primary" id="saveAccountBtn">Anlegen &amp; prüfen</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
<div class="modal-card" style="width:min(400px,100%)">
<div class="modal-header">
<div><h3>Account löschen?</h3></div>
<button class="icon-btn" id="closeDeleteModalBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<p id="deleteAccountMessage">Account wirklich löschen?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancelDeleteBtn">Abbrechen</button>
<button class="btn btn-danger" id="confirmDeleteBtn">Löschen</button>
</div>
</div>
</div>
<script type="module" src="app.js"></script>
<div id="settings-view" class="view">
<div class="settings-container">
<h2>Upload-Einstellungen</h2>
<p class="settings-hint">Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.</p>
<div class="settings-hosters" id="settingsHosters"></div>
<div class="settings-save-row">
<span class="save-feedback" id="saveFeedback">Änderungen werden automatisch gespeichert.</span>
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
</div>
</div>
</div>
<div id="history-view" class="view">
<div class="history-container">
<div class="history-header">
<h2>Upload-Verlauf</h2>
<div style="display:flex; gap:8px">
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
</div>
</div>
<div id="historyContainer"></div>
</div>
</div>
<div class="context-menu" id="contextMenu" style="display:none">
<div class="ctx-item" data-action="start-selected">Ausgewählte starten</div>
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
<div class="ctx-separator"></div>
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>
<div class="ctx-separator"></div>
<div class="ctx-item" data-action="delete-selected">Entfernen</div>
<div class="ctx-item" data-action="delete-all">Alle entfernen</div>
<div class="ctx-submenu ctx-hoster-delete-submenu" style="display:none">
<div class="ctx-item ctx-item-danger">Hoster entfernen &#9656;</div>
<div class="ctx-submenu-items ctx-hoster-delete-items"></div>
</div>
</div>
<div class="context-menu" id="recentContextMenu" style="display:none">
<div class="ctx-item" data-action="recent-copy-links">Links kopieren</div>
<div class="ctx-item" data-action="recent-delete">Entfernen</div>
</div>
<div class="statusbar" id="statusbar">
<span class="sb-state" id="sbState">Bereit</span>
<span class="sb-separator">|</span>
<span class="sb-speed" id="sbSpeed">0 kB/s</span>
<span class="sb-separator">|</span>
<span class="sb-total" id="sbTotal">0 B</span>
<span class="sb-separator">|</span>
<span class="sb-eta" id="sbEta">ETA --:--</span>
<span class="sb-separator">|</span>
<span class="sb-connections" id="sbConnections">Aktive Verbindungen 0</span>
<span class="sb-separator">|</span>
<span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span>
<span class="sb-separator">|</span>
<span class="sb-remaining-count" id="sbRemainingCount">Remaining 0</span>
<span class="sb-separator">|</span>
<span class="sb-progress-count" id="sbInProgressCount">In Progress 0</span>
<span class="sb-separator">|</span>
<span class="sb-done-count" id="sbDoneCount">Done 0</span>
<span class="sb-separator">|</span>
<span class="sb-error-count" id="sbErrorCount">Error 0</span>
</div>
<div class="copy-toast" id="copyToast"></div>
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none">
<div class="shutdown-box">
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
<button class="btn btn-danger" id="cancelShutdownBtn">Abbrechen</button>
</div>
</div>
<div class="modal-overlay" id="hosterModal" style="display:none">
<div class="modal-card">
<div class="modal-header">
<div>
<h3>Upload-Ziele auswählen</h3>
<p>Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.</p>
</div>
<button class="icon-btn" id="closeHosterModalBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div class="modal-actions-inline">
<button class="btn btn-xs btn-secondary" id="selectAllHostersBtn">Alle</button>
<button class="btn btn-xs btn-secondary" id="clearHostersBtn">Keine</button>
</div>
<div class="hoster-modal-list" id="hosterModalList"></div>
<p class="modal-hint" id="hosterModalHint"></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancelHosterModalBtn">Abbrechen</button>
<button class="btn btn-primary" id="confirmHosterModalBtn">Übernehmen</button>
</div>
</div>
</div>
<script src="tauri-shim.js"></script>
<script src="app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

208
src/tauri-shim.js Normal file
View File

@ -0,0 +1,208 @@
// tauri-shim.js — re-creates the Electron-era `window.api` surface on top of
// Tauri 2's core.invoke + event.listen so the v1 renderer keeps working
// unchanged.
//
// Every method here maps to exactly one Rust #[tauri::command] in
// src-tauri/src/commands.rs. Event subscriptions go through window.__TAURI__.event.listen.
(function () {
const T = window.__TAURI__;
if (!T) {
console.error('Tauri runtime not available');
window.api = new Proxy({}, { get: () => async () => null });
return;
}
const invoke = T.core.invoke;
const listen = T.event.listen;
const dialog = T.dialog || {};
// Helper: wrap a listen() call to return an unsubscribe fn like v1 did.
function on(name, handler) {
let unlisten = () => {};
listen(name, (ev) => { try { handler(ev.payload); } catch (e) { console.error(e); } })
.then(fn => { unlisten = fn; });
return () => { try { unlisten(); } catch {} };
}
const api = {
// --- Config ---
getConfig: () => invoke('get_config'),
saveConfig: (config) => invoke('save_config', { config }),
getHistory: () => invoke('get_history'),
clearHistory: () => invoke('clear_history'),
exportHistory: (format) => invoke('export_history', { format }),
// --- Hoster settings ---
getHosterSettings: () => invoke('get_hoster_settings'),
saveHosterSettings: (settings) => invoke('save_hoster_settings', { settings }),
// --- Global settings ---
getGlobalSettings: () => invoke('get_global_settings'),
saveGlobalSettings: (settings) => invoke('save_global_settings', { settings }),
saveGlobalSettingsSync: (settings) => invoke('save_global_settings', { settings }),
// --- Window state ---
setAlwaysOnTop: (value) => invoke('set_always_on_top', { value }),
getAlwaysOnTop: () => invoke('get_always_on_top'),
setShutdownAfterFinish: (mode) => invoke('set_shutdown_after_finish', { mode }),
getShutdownAfterFinish: () => invoke('get_shutdown_after_finish'),
cancelShutdown: () => invoke('cancel_shutdown'),
// --- File selection ---
selectFiles: async () => {
if (!dialog || !dialog.open) return null;
const picked = await dialog.open({ multiple: true, directory: false });
if (!picked) return null;
return Array.isArray(picked) ? picked : [picked];
},
selectFolder: async () => {
if (!dialog || !dialog.open) return null;
const picked = await dialog.open({ multiple: false, directory: true });
if (!picked) return null;
// v1 returned an array of files in the folder; we return the folder and
// let resolveFolderFiles handle enumeration.
const files = await invoke('resolve_folder_files', { folderPath: picked });
return files || [];
},
resolveFolderFiles: (folderPath) => invoke('resolve_folder_files', { folderPath }),
// --- Clipboard ---
copyToClipboard: (text) => invoke('copy_to_clipboard', { text }),
// --- Path / file utils ---
getPathForFile: (file) => {
// In Tauri we never go through drag-drop file objects; paths come direct.
// This keeps the renderer happy when it tries to resolve a drag event.
return (file && file.path) ? file.path : '';
},
// --- Uploads ---
startUpload: (payload) => invoke('start_upload', { payload }),
addJobsToBatch: (payload) => invoke('add_jobs_to_batch', { payload }),
cancelSelectedJobs: (jobIds) => invoke('cancel_jobs', { jobIds }),
cancelUpload: () => invoke('cancel_batch'),
finishAfterActive: () => invoke('finish_after_active'),
// --- Health check ---
runHealthCheck: (payload) => invoke('run_health_check', { payload }),
// --- Backup ---
exportBackup: async () => {
if (!dialog || !dialog.save) return { ok: false, canceled: true };
const target = await dialog.save({
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0,10)}.mhu`,
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
});
if (!target) return { ok: false, canceled: true };
try {
await invoke('export_backup', { targetPath: target });
return { ok: true, path: target };
} catch (e) { return { ok: false, error: String(e) }; }
},
importBackup: async (legacyPassword) => {
if (legacyPassword) {
try {
const cfg = await invoke('import_backup_saved', { legacyPassword });
return { ok: true, config: cfg };
} catch (e) {
const msg = String(e);
if (msg === 'needs-password' || msg.includes('needs-password')) return { ok: false, needsPassword: true };
return { ok: false, error: msg };
}
}
if (!dialog || !dialog.open) return { ok: false, canceled: true };
const src = await dialog.open({
multiple: false, directory: false,
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
});
if (!src) return { ok: false, canceled: true };
try {
const cfg = await invoke('import_backup', { sourcePath: src });
return { ok: true, config: cfg };
} catch (e) {
const msg = String(e);
if (msg === 'needs-password' || msg.includes('needs-password')) return { ok: false, needsPassword: true };
return { ok: false, error: msg };
}
},
// --- Log / files ---
readOwnUploadLog: () => invoke('read_own_upload_log'),
importUploadLog: () => invoke('import_upload_log'),
saveTextFile: async (defaultName, content, filters) => {
if (!dialog || !dialog.save) return { ok: false, canceled: true };
const target = await dialog.save({
defaultPath: defaultName,
filters: filters || [{ name: 'Textdatei', extensions: ['txt', 'csv', 'log'] }]
});
if (!target) return { ok: false, canceled: true };
try {
await invoke('save_text_file', { path: target, content });
return { ok: true, path: target };
} catch (e) { return { ok: false, error: String(e) }; }
},
openLogFolder: () => invoke('open_log_folder'),
// --- Update / version ---
getVersion: () => invoke('get_version'),
checkForUpdate: () => invoke('check_for_update'),
installUpdate: () => invoke('install_update'),
// --- Folder monitor ---
folderMonitorStart: (settings) => invoke('folder_monitor_start', { settings }),
folderMonitorStop: () => invoke('folder_monitor_stop'),
folderMonitorStatus: () => invoke('folder_monitor_status'),
folderMonitorSelectFolder: async () => {
if (!dialog || !dialog.open) return null;
return dialog.open({ multiple: false, directory: true });
},
// --- Remote control ---
remoteGetSettings: () => invoke('remote_get_settings'),
remoteSaveSettings: (settings) => invoke('remote_save_settings', { settings }),
remoteGenerateToken: () => invoke('remote_generate_token'),
remoteStatus: () => invoke('remote_status'),
// --- Drop target ---
showDropTarget: () => invoke('show_drop_target'),
hideDropTarget: () => invoke('hide_drop_target'),
// --- Debug ---
debugLog: (msg) => invoke('debug_log', { msg }),
// --- Events ---
onUpdateAvailable: (cb) => on('app:update-available', cb),
onUpdateProgress: (cb) => on('app:update-progress', cb),
onUploadProgress: (cb) => on('upload-progress', cb),
onUploadBatchDone: (cb) => on('upload-batch-done', cb),
onUploadStats: (cb) => on('upload-stats', cb),
onShutdownCountdown: (cb) => on('shutdown-countdown', cb),
onAccountSwitched: (cb) => on('account-switched', cb),
onAccountRotationLog: (cb) => on('account-rotation-log', cb),
onUploadLogFallback: (cb) => on('upload-log-fallback', cb),
onLogPathAutoUpdated: (cb) => on('log-path-auto-updated', cb),
onDropTargetFiles: (cb) => on('drop-target-files', cb),
onFolderMonitorNewFiles: (cb) => on('folder-monitor-new-files', cb),
onRemoteClientCount: (cb) => on('remote:client-count', cb),
};
// Tauri's file-drop event delivers paths; v1's drop handler expected
// FileList objects. Translate and inject synthetic drop events.
try {
listen('tauri://drag-drop', (ev) => {
const paths = (ev.payload && (ev.payload.paths || ev.payload)) || [];
if (!Array.isArray(paths) || paths.length === 0) return;
const files = paths.map(p => ({ path: p, name: p.split(/[\\/]/).pop(), size: 0, type: '' }));
const dz = document.getElementById('dropZone');
if (!dz) return;
const ev2 = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() });
try {
Object.defineProperty(ev2.dataTransfer, 'files', { value: files, writable: false });
} catch {}
// v1 path resolution goes through window.api.getPathForFile — already wired.
dz.dispatchEvent(ev2);
});
} catch {}
window.api = api;
})();