refactor(repo): promote tauri app to repository root

This commit is contained in:
NimBold
2026-06-15 10:33:40 +03:30
parent ab7550d39e
commit 6593f9e76a
346 changed files with 1021 additions and 1490 deletions
+112
View File
@@ -0,0 +1,112 @@
use rusqlite::{Connection, Result, params};
use std::sync::Mutex;
use std::path::PathBuf;
use tauri::Manager;
pub struct DbState {
pub conn: Mutex<Connection>,
}
pub fn init_db(app_handle: &tauri::AppHandle) -> Result<Connection> {
let app_dir = app_handle.path().app_data_dir().unwrap_or_else(|_| PathBuf::from("."));
if !app_dir.exists() {
let _ = std::fs::create_dir_all(&app_dir);
}
let db_path = app_dir.join("firelink.sqlite");
let conn = Connection::open(db_path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS downloads (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
queue_id TEXT NOT NULL,
data TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS queues (
id TEXT PRIMARY KEY,
data TEXT NOT NULL
)",
[],
)?;
Ok(conn)
}
// Downloads CRUD
pub fn insert_download(conn: &Connection, id: &str, status: &str, queue_id: &str, data: &str) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)",
params![id, status, queue_id, data],
)?;
Ok(())
}
pub fn delete_download(conn: &Connection, id: &str) -> Result<()> {
conn.execute("DELETE FROM downloads WHERE id = ?1", params![id])?;
Ok(())
}
pub fn get_all_downloads(conn: &Connection) -> Result<Vec<String>> {
let mut stmt = conn.prepare("SELECT data FROM downloads")?;
let iter = stmt.query_map([], |row| row.get(0))?;
let mut res = Vec::new();
for data in iter {
res.push(data?);
}
Ok(res)
}
// Settings CRUD
pub fn get_settings(conn: &Connection) -> Result<Option<String>> {
let mut stmt = conn.prepare("SELECT data FROM settings WHERE id = 1")?;
let mut iter = stmt.query_map([], |row| row.get(0))?;
if let Some(row) = iter.next() {
Ok(Some(row?))
} else {
Ok(None)
}
}
pub fn save_settings(conn: &Connection, data: &str) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO settings (id, data) VALUES (1, ?1)",
params![data],
)?;
Ok(())
}
// Queues CRUD
pub fn insert_queue(conn: &Connection, id: &str, data: &str) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO queues (id, data) VALUES (?1, ?2)",
params![id, data],
)?;
Ok(())
}
pub fn delete_queue(conn: &Connection, id: &str) -> Result<()> {
conn.execute("DELETE FROM queues WHERE id = ?1", params![id])?;
Ok(())
}
pub fn get_all_queues(conn: &Connection) -> Result<Vec<String>> {
let mut stmt = conn.prepare("SELECT data FROM queues")?;
let iter = stmt.query_map([], |row| row.get(0))?;
let mut res = Vec::new();
for data in iter {
res.push(data?);
}
Ok(res)
}
+640
View File
@@ -0,0 +1,640 @@
use crate::DownloadProgressEvent;
use futures_util::StreamExt;
use reqwest::{
header::{self, HeaderMap, HeaderName, HeaderValue},
Client, StatusCode,
};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
str::FromStr,
time::{Duration, Instant},
};
use tauri::{AppHandle, Emitter};
use tokio::{
fs::{self, OpenOptions},
io::{AsyncWriteExt, BufWriter},
sync::{mpsc, watch},
};
use uuid::Uuid;
const PROGRESS_INTERVAL: Duration = Duration::from_millis(150);
const WRITE_BUFFER_CAPACITY: usize = 256 * 1024;
#[derive(Debug)]
pub enum DownloadCmd {
Start(DownloadPayload),
Pause(Uuid),
Cancel(Uuid),
CaptureUrls(Vec<String>),
FrontendReady(bool),
}
#[derive(Clone, Debug, PartialEq)]
pub enum DownloadEvent {
Progress {
id: Uuid,
fraction: f64,
completed: u64,
total: Option<u64>,
},
Completed(Uuid),
Failed {
id: Uuid,
error: String,
},
}
#[derive(Debug)]
pub struct DownloadPayload {
pub id: Uuid,
pub urls: Vec<String>,
pub output_path: PathBuf,
pub speed_limit: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub headers: Option<String>,
pub cookies: Option<String>,
pub user_agent: Option<String>,
pub max_tries: u32,
pub proxy: Option<String>,
}
#[derive(Clone)]
pub struct DownloadCoordinator {
tx: mpsc::Sender<DownloadCmd>,
media_tx: mpsc::Sender<MediaCmd>,
}
impl DownloadCoordinator {
pub fn spawn(app_handle: AppHandle) -> Self {
Self::spawn_with_events(CoordinatorEventSink::Tauri(app_handle))
}
pub fn spawn_headless() -> (Self, mpsc::UnboundedReceiver<DownloadEvent>) {
let (event_tx, event_rx) = mpsc::unbounded_channel();
(
Self::spawn_with_events(CoordinatorEventSink::Headless(event_tx)),
event_rx,
)
}
fn spawn_with_events(events: CoordinatorEventSink) -> Self {
let (tx, rx) = mpsc::channel(128);
let (media_tx, media_rx) = mpsc::channel(32);
tauri::async_runtime::spawn(run_coordinator(events, rx, media_rx));
Self { tx, media_tx }
}
pub async fn send(&self, command: DownloadCmd) -> Result<(), String> {
self.tx
.send(command)
.await
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn register_media(&self, id: String) -> Result<watch::Receiver<bool>, String> {
let (cancel_tx, cancel_rx) = watch::channel(false);
self.media_tx
.send(MediaCmd::Register { id, cancel_tx })
.await
.map_err(|_| "download coordinator is unavailable".to_string())?;
Ok(cancel_rx)
}
pub async fn pause_media(&self, id: String) -> Result<(), String> {
self.media_tx
.send(MediaCmd::Pause(id))
.await
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn finish_media(&self, id: String) {
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
}
}
#[derive(Clone)]
enum CoordinatorEventSink {
Tauri(AppHandle),
Headless(mpsc::UnboundedSender<DownloadEvent>),
}
impl CoordinatorEventSink {
fn emit_progress(
&self,
id: Uuid,
completed: u64,
total: Option<u64>,
interval_bytes: u64,
interval: Duration,
) {
let speed_bytes = if interval.is_zero() {
0.0
} else {
interval_bytes as f64 / interval.as_secs_f64()
};
let fraction = total
.filter(|total| *total > 0)
.map(|total| completed as f64 / total as f64)
.unwrap_or(0.0)
.clamp(0.0, 1.0);
match self {
Self::Tauri(app_handle) => {
let eta = total
.filter(|total| speed_bytes > 0.0 && *total > completed)
.map(|total| format_duration((total - completed) as f64 / speed_bytes))
.unwrap_or_else(|| "-".to_string());
let _ = app_handle.emit(
"download-progress",
DownloadProgressEvent {
id: id.to_string(),
fraction,
speed: format_speed(speed_bytes),
eta,
},
);
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Progress {
id,
fraction,
completed,
total,
});
}
}
}
fn emit_completed(&self, id: Uuid) {
match self {
Self::Tauri(app_handle) => {
let _ = app_handle.emit("download-complete", id.to_string());
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Completed(id));
}
}
}
fn emit_failed(&self, id: Uuid, error: String) {
match self {
Self::Tauri(app_handle) => {
eprintln!("download {id} failed: {error}");
let _ = app_handle.emit("download-failed", id.to_string());
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Failed { id, error });
}
}
}
fn emit_captured_urls(&self, payload: String) -> bool {
match self {
Self::Tauri(app_handle) => app_handle.emit("deep-link-add-download", payload).is_ok(),
Self::Headless(_) => true,
}
}
}
enum MediaCmd {
Register {
id: String,
cancel_tx: watch::Sender<bool>,
},
Pause(String),
Finished(String),
}
#[derive(Debug, Clone, Copy)]
enum DownloadControl {
Pause,
Cancel,
Replace,
}
struct ActiveDownload {
generation: u64,
control_tx: mpsc::Sender<DownloadControl>,
}
enum WorkerEvent {
Finished {
id: Uuid,
generation: u64,
outcome: DownloadOutcome,
},
}
enum DownloadOutcome {
Completed,
Paused,
Cancelled,
Failed(String),
}
async fn run_coordinator(
events: CoordinatorEventSink,
mut command_rx: mpsc::Receiver<DownloadCmd>,
mut media_rx: mpsc::Receiver<MediaCmd>,
) {
let (worker_tx, mut worker_rx) = mpsc::channel(128);
let mut active = HashMap::<Uuid, ActiveDownload>::new();
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
let mut pending_captured_urls = Vec::<String>::new();
let mut frontend_ready = false;
let mut next_generation = 0_u64;
loop {
tokio::select! {
command = command_rx.recv() => {
let Some(command) = command else {
break;
};
match command {
DownloadCmd::Start(payload) => {
if let Some(previous) = active.remove(&payload.id) {
let _ = previous.control_tx.send(DownloadControl::Replace).await;
}
next_generation = next_generation.wrapping_add(1);
let generation = next_generation;
let id = payload.id;
let (control_tx, control_rx) = mpsc::channel(1);
active.insert(id, ActiveDownload { generation, control_tx });
let events = events.clone();
let worker_tx = worker_tx.clone();
tauri::async_runtime::spawn(async move {
let outcome = download_file(events, payload, control_rx).await;
let _ = worker_tx
.send(WorkerEvent::Finished { id, generation, outcome })
.await;
});
}
DownloadCmd::Pause(id) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Pause).await;
}
}
DownloadCmd::Cancel(id) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
}
}
DownloadCmd::CaptureUrls(urls) => {
append_unique_urls(&mut pending_captured_urls, urls);
if frontend_ready && !pending_captured_urls.is_empty() {
let payload = pending_captured_urls.join("\n");
if events.emit_captured_urls(payload) {
pending_captured_urls.clear();
}
}
}
DownloadCmd::FrontendReady(ready) => {
frontend_ready = ready;
if ready && !pending_captured_urls.is_empty() {
let payload = pending_captured_urls.join("\n");
if events.emit_captured_urls(payload) {
pending_captured_urls.clear();
}
}
}
}
}
event = worker_rx.recv() => {
let Some(WorkerEvent::Finished { id, generation, outcome }) = event else {
continue;
};
let is_current = active
.get(&id)
.is_some_and(|download| download.generation == generation);
if is_current {
active.remove(&id);
}
match (is_current, outcome) {
(true, DownloadOutcome::Completed) => {
events.emit_completed(id);
}
(true, DownloadOutcome::Failed(error)) => {
events.emit_failed(id, error);
}
_ => {}
}
}
command = media_rx.recv() => {
let Some(command) = command else {
continue;
};
match command {
MediaCmd::Register { id, cancel_tx } => {
if let Some(previous) = active_media.insert(id, cancel_tx) {
let _ = previous.send(true);
}
}
MediaCmd::Pause(id) => {
if let Some(cancel_tx) = active_media.remove(&id) {
let _ = cancel_tx.send(true);
}
}
MediaCmd::Finished(id) => {
active_media.remove(&id);
}
}
}
}
}
for (_, download) in active {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
}
for (_, cancel_tx) in active_media {
let _ = cancel_tx.send(true);
}
}
fn append_unique_urls(target: &mut Vec<String>, urls: Vec<String>) {
let mut seen = target.iter().cloned().collect::<HashSet<_>>();
target.extend(urls.into_iter().filter(|url| seen.insert(url.clone())));
}
async fn download_file(
events: CoordinatorEventSink,
payload: DownloadPayload,
mut control_rx: mpsc::Receiver<DownloadControl>,
) -> DownloadOutcome {
if let Some(parent) = payload.output_path.parent() {
if let Err(error) = fs::create_dir_all(parent).await {
return DownloadOutcome::Failed(error.to_string());
}
}
let client = match build_client(&payload) {
Ok(client) => client,
Err(error) => return DownloadOutcome::Failed(error),
};
let attempts_per_url = payload.max_tries.max(1);
let mut last_error = "no download URL was provided".to_string();
for url in &payload.urls {
for _ in 0..attempts_per_url {
match download_attempt(&events, &client, &payload, url, &mut control_rx).await {
Ok(()) => return DownloadOutcome::Completed,
Err(AttemptError::Controlled(DownloadControl::Pause)) => {
return DownloadOutcome::Paused;
}
Err(AttemptError::Controlled(DownloadControl::Cancel)) => {
let _ = fs::remove_file(&payload.output_path).await;
return DownloadOutcome::Cancelled;
}
Err(AttemptError::Controlled(DownloadControl::Replace)) => {
return DownloadOutcome::Cancelled;
}
Err(AttemptError::Failed(error)) => last_error = error,
}
}
}
DownloadOutcome::Failed(last_error)
}
enum AttemptError {
Controlled(DownloadControl),
Failed(String),
}
async fn download_attempt(
events: &CoordinatorEventSink,
client: &Client,
payload: &DownloadPayload,
url: &str,
control_rx: &mut mpsc::Receiver<DownloadControl>,
) -> Result<(), AttemptError> {
let existing_len = fs::metadata(&payload.output_path)
.await
.map(|metadata| metadata.len())
.unwrap_or(0);
let mut request = client.get(url);
if existing_len > 0 {
request = request.header(header::RANGE, format!("bytes={existing_len}-"));
}
if let Some(username) = payload
.username
.as_deref()
.filter(|value| !value.is_empty())
{
request = request.basic_auth(username, payload.password.as_deref());
}
let response = tokio::select! {
control = control_rx.recv() => {
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
response = request.send() => {
response.map_err(|error| AttemptError::Failed(error.to_string()))?
}
};
if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) {
return Err(AttemptError::Failed(format!(
"{url} returned HTTP {}",
response.status()
)));
}
let resumed = existing_len > 0 && response.status() == StatusCode::PARTIAL_CONTENT;
let completed_at_start = if resumed { existing_len } else { 0 };
let total_len = response
.content_length()
.map(|remaining| remaining.saturating_add(completed_at_start));
let file = OpenOptions::new()
.create(true)
.write(true)
.append(resumed)
.truncate(!resumed)
.open(&payload.output_path)
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
let mut stream = response.bytes_stream();
let mut last_emitted_at = Instant::now();
let mut last_emitted_bytes = completed_at_start;
let mut completed = completed_at_start;
let speed_limit = payload.speed_limit.as_deref().and_then(parse_speed_limit);
let transfer_started_at = Instant::now();
let mut transferred_this_attempt = 0_u64;
loop {
tokio::select! {
control = control_rx.recv() => {
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
chunk = stream.next() => {
match chunk {
Some(Ok(bytes)) => {
writer
.write_all(&bytes)
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
completed = completed.saturating_add(bytes.len() as u64);
transferred_this_attempt =
transferred_this_attempt.saturating_add(bytes.len() as u64);
if let Some(bytes_per_second) = speed_limit {
let expected_elapsed =
Duration::from_secs_f64(transferred_this_attempt as f64 / bytes_per_second as f64);
let actual_elapsed = transfer_started_at.elapsed();
if expected_elapsed > actual_elapsed {
tokio::select! {
control = control_rx.recv() => {
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
_ = tokio::time::sleep(expected_elapsed - actual_elapsed) => {}
}
}
}
let now = Instant::now();
let interval = now.duration_since(last_emitted_at);
if interval >= PROGRESS_INTERVAL {
events.emit_progress(
payload.id,
completed,
total_len,
completed.saturating_sub(last_emitted_bytes),
interval,
);
last_emitted_at = now;
last_emitted_bytes = completed;
}
}
Some(Err(error)) => {
writer.flush().await.map_err(|flush_error| AttemptError::Failed(flush_error.to_string()))?;
return Err(AttemptError::Failed(error.to_string()));
}
None => break,
}
}
}
}
writer
.flush()
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
events.emit_progress(
payload.id,
completed,
total_len,
completed.saturating_sub(last_emitted_bytes),
last_emitted_at.elapsed(),
);
Ok(())
}
fn build_client(payload: &DownloadPayload) -> Result<Client, String> {
let mut headers = HeaderMap::new();
if let Some(raw_headers) = payload.headers.as_deref() {
for line in raw_headers
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let (name, value) = line
.split_once(':')
.ok_or_else(|| format!("invalid HTTP header: {line}"))?;
headers.insert(
HeaderName::from_str(name.trim()).map_err(|error| error.to_string())?,
HeaderValue::from_str(value.trim()).map_err(|error| error.to_string())?,
);
}
}
if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) {
headers.insert(
header::COOKIE,
HeaderValue::from_str(cookies).map_err(|error| error.to_string())?,
);
}
let mut builder = Client::builder().default_headers(headers);
if let Some(user_agent) = payload
.user_agent
.as_deref()
.filter(|value| !value.is_empty())
{
builder = builder.user_agent(user_agent);
}
if let Some(proxy) = payload.proxy.as_deref().filter(|value| !value.is_empty()) {
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?);
}
builder.build().map_err(|error| error.to_string())
}
fn format_speed(bytes_per_second: f64) -> String {
if bytes_per_second >= 1024.0 * 1024.0 {
format!("{:.1} MB/s", bytes_per_second / (1024.0 * 1024.0))
} else if bytes_per_second >= 1024.0 {
format!("{:.1} KB/s", bytes_per_second / 1024.0)
} else {
format!("{bytes_per_second:.0} B/s")
}
}
fn format_duration(seconds: f64) -> String {
if seconds >= 3600.0 {
format!("{:.0}h {:.0}m", seconds / 3600.0, (seconds % 3600.0) / 60.0)
} else if seconds >= 60.0 {
format!("{:.0}m {:.0}s", seconds / 60.0, seconds % 60.0)
} else {
format!("{seconds:.0}s")
}
}
fn parse_speed_limit(value: &str) -> Option<u64> {
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() || normalized == "0" {
return None;
}
let (number, multiplier) = if let Some(number) = normalized.strip_suffix("kb/s") {
(number, 1024.0)
} else if let Some(number) = normalized.strip_suffix("mb/s") {
(number, 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix("gb/s") {
(number, 1024.0 * 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix('k') {
(number, 1024.0)
} else if let Some(number) = normalized.strip_suffix('m') {
(number, 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix('g') {
(number, 1024.0 * 1024.0 * 1024.0)
} else {
(normalized.as_str(), 1.0)
};
number
.trim()
.parse::<f64>()
.ok()
.filter(|number| *number > 0.0)
.map(|number| (number * multiplier) as u64)
}
#[cfg(test)]
mod tests {
use super::parse_speed_limit;
#[test]
fn parses_aria_style_speed_limits() {
assert_eq!(parse_speed_limit("512K"), Some(512 * 1024));
assert_eq!(parse_speed_limit("1.5M"), Some(1_572_864));
assert_eq!(parse_speed_limit("2 MB/s"), Some(2 * 1024 * 1024));
assert_eq!(parse_speed_limit("0"), None);
}
}
+33
View File
@@ -0,0 +1,33 @@
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Network error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("Internal error: {0}")]
Internal(String),
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl From<String> for AppError {
fn from(s: String) -> Self {
AppError::Internal(s)
}
}
impl From<&str> for AppError {
fn from(s: &str) -> Self {
AppError::Internal(s.to_string())
}
}
+286
View File
@@ -0,0 +1,286 @@
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode, Method},
routing::{get, post},
Router,
};
use hmac::{Hmac, Mac};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager};
use tower_http::cors::{Any, CorsLayer};
use ts_rs::TS;
pub const EXTENSION_SERVER_PORT: u16 = 23522;
const MAX_URL_COUNT: usize = 200;
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>;
pub type SharedFrontendReady = Arc<AtomicBool>;
type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
#[derive(Clone)]
pub struct ServerState {
pub app_handle: AppHandle,
pub pairing_token: SharedExtensionToken,
pub frontend_ready: SharedFrontendReady,
pub replay_cache: ReplayCache,
}
#[derive(Deserialize)]
struct ExtensionRequest {
urls: Vec<String>,
#[serde(default)]
referer: Option<String>,
#[serde(default)]
silent: bool,
#[serde(default)]
filename: Option<String>,
}
#[derive(Clone, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct ExtensionDownload {
urls: Vec<String>,
referer: Option<String>,
silent: bool,
filename: Option<String>,
}
pub async fn start_server(
app_handle: AppHandle,
pairing_token: SharedExtensionToken,
frontend_ready: SharedFrontendReady,
) -> Result<(), String> {
let state = ServerState {
app_handle,
pairing_token,
frontend_ready,
replay_cache: Arc::new(Mutex::new(HashMap::new())),
};
let cors = CorsLayer::new()
.allow_origin(tower_http::cors::AllowOrigin::predicate(|origin, _| {
is_allowed_origin(origin.to_str().unwrap_or(""))
}))
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers(Any)
.expose_headers(Any);
let app = Router::new()
.route("/ping", get(ping_handler))
.route("/download", post(download_handler))
.layer(cors)
.with_state(state);
let listener = tokio::net::TcpListener::bind(("127.0.0.1", EXTENSION_SERVER_PORT))
.await
.map_err(|e| format!("Failed to bind to {}: {}", EXTENSION_SERVER_PORT, e))?;
axum::serve(listener, app)
.await
.map_err(|e| format!("Server error: {}", e))?;
Ok(())
}
async fn ping_handler(
State(state): State<ServerState>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
if !state.frontend_ready.load(Ordering::Acquire) {
return StatusCode::SERVICE_UNAVAILABLE;
}
let signature = match headers.get("x-firelink-signature").and_then(|v| v.to_str().ok()) {
Some(v) => v,
None => return StatusCode::FORBIDDEN,
};
let timestamp_str = match headers.get("x-firelink-timestamp").and_then(|v| v.to_str().ok()) {
Some(v) => v,
None => return StatusCode::FORBIDDEN,
};
if verify_signature(signature, timestamp_str, &body, &state.pairing_token).is_err() {
return StatusCode::FORBIDDEN;
}
StatusCode::OK
}
async fn download_handler(
State(state): State<ServerState>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, StatusCode> {
if !state.frontend_ready.load(Ordering::Acquire) {
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
let signature = match headers.get("x-firelink-signature").and_then(|v| v.to_str().ok()) {
Some(v) => v,
None => return Err(StatusCode::FORBIDDEN),
};
let timestamp_str = match headers.get("x-firelink-timestamp").and_then(|v| v.to_str().ok()) {
Some(v) => v,
None => return Err(StatusCode::FORBIDDEN),
};
let timestamp = match verify_signature(signature, timestamp_str, &body, &state.pairing_token) {
Ok(v) => v,
Err(_) => return Err(StatusCode::FORBIDDEN),
};
if !claim_request(signature, timestamp, &state.replay_cache) {
return Err(StatusCode::FORBIDDEN);
}
let payload: ExtensionRequest = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => return Err(StatusCode::BAD_REQUEST),
};
let download = match normalize_download(payload) {
Some(v) => v,
None => return Err(StatusCode::BAD_REQUEST),
};
if let Some(window) = state.app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
if state.app_handle.emit("extension-add-download", download).is_err() {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(StatusCode::OK)
}
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
let mut seen = HashSet::new();
let urls = payload
.urls
.into_iter()
.take(MAX_URL_COUNT)
.filter_map(|raw_url| normalize_url(&raw_url))
.filter(|url| seen.insert(url.clone()))
.collect::<Vec<_>>();
if urls.is_empty() {
return None;
}
let referer = payload.referer.and_then(|value| {
let url = Url::parse(value.trim()).ok()?;
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
});
let filename = payload.filename.and_then(|value| sanitize_filename(&value));
Some(ExtensionDownload {
urls,
referer,
silent: payload.silent,
filename,
})
}
fn normalize_url(raw_url: &str) -> Option<String> {
let url = Url::parse(raw_url.trim()).ok()?;
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string())
}
fn sanitize_filename(filename: &str) -> Option<String> {
let normalized = filename.trim().replace('\\', "/");
let basename = Path::new(&normalized).file_name()?.to_str()?.trim();
if basename.is_empty() || basename == "." || basename == ".." || basename.len() > 255 {
return None;
}
Some(basename.to_string())
}
fn verify_signature(
signature_hex: &str,
timestamp_text: &str,
body: &[u8],
pairing_token: &SharedExtensionToken,
) -> Result<u64, ()> {
let signature = decode_hex(signature_hex)?;
let timestamp = timestamp_text.parse::<u64>().map_err(|_| ())?;
let now = current_time_millis().ok_or(())?;
if now.abs_diff(timestamp) >= SIGNATURE_MAX_AGE_MS {
return Err(());
}
let token = pairing_token.read().map_err(|_| ())?;
if token.is_empty() {
return Err(());
}
let mut mac = HmacSha256::new_from_slice(token.as_bytes()).map_err(|_| ())?;
mac.update(timestamp_text.as_bytes());
mac.update(body);
mac.verify_slice(&signature).map_err(|_| ())?;
Ok(timestamp)
}
fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) -> bool {
let now = match current_time_millis() {
Some(now) => now,
None => return false,
};
let mut cache = match replay_cache.lock() {
Ok(cache) => cache,
Err(_) => return false,
};
cache.retain(|_, seen_at| now.saturating_sub(*seen_at) < SIGNATURE_MAX_AGE_MS);
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
cache.insert(key, now).is_none()
}
fn current_time_millis() -> Option<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| u64::try_from(duration.as_millis()).ok())
}
fn decode_hex(value: &str) -> Result<Vec<u8>, ()> {
if value.len() != 64 || !value.is_ascii() {
return Err(());
}
value
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let high = hex_digit(pair[0]).ok_or(())?;
let low = hex_digit(pair[1]).ok_or(())?;
Ok((high << 4) | low)
})
.collect()
}
fn hex_digit(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn is_allowed_origin(origin: &str) -> bool {
Url::parse(origin)
.ok()
.is_some_and(|url| matches!(url.scheme(), "moz-extension" | "chrome-extension"))
}
+236
View File
@@ -0,0 +1,236 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use ts_rs::TS;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum DownloadStatus {
Downloading,
Paused,
Completed,
Failed,
Queued,
}
impl DownloadStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Downloading => "downloading",
Self::Paused => "paused",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Queued => "queued",
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub enum DownloadCategory {
Musics,
Movies,
Compressed,
Documents,
Pictures,
Applications,
Other,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct Queue {
pub id: String,
pub name: String,
pub is_main: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct DownloadItem {
pub id: String,
pub url: String,
pub file_name: String,
pub status: DownloadStatus,
#[ts(optional)]
pub fraction: Option<f64>,
#[ts(optional)]
pub speed: Option<String>,
#[ts(optional)]
pub eta: Option<String>,
#[ts(optional)]
pub size: Option<String>,
pub category: DownloadCategory,
pub date_added: String,
#[ts(optional)]
pub connections: Option<i32>,
#[ts(optional)]
pub speed_limit: Option<String>,
#[ts(optional)]
pub username: Option<String>,
#[ts(optional)]
pub password: Option<String>,
#[ts(optional)]
pub headers: Option<String>,
#[ts(optional)]
pub checksum: Option<String>,
#[ts(optional)]
pub cookies: Option<String>,
#[ts(optional)]
pub mirrors: Option<String>,
#[ts(optional)]
pub destination: Option<String>,
#[ts(optional)]
pub is_media: Option<bool>,
#[ts(optional)]
pub media_format_selector: Option<String>,
pub queue_id: String,
#[serde(rename = "_dispatched")]
#[ts(optional)]
pub dispatched: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct SiteLogin {
pub id: String,
pub url_pattern: String,
pub username: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum AppFontSize {
Small,
Standard,
Large,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum ListRowDensity {
Compact,
Standard,
Relaxed,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum PostQueueAction {
None,
Sleep,
Restart,
Shutdown,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum Theme {
Dark,
Light,
System,
Dracula,
Nord,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum ActiveView {
Downloads,
Settings,
Scheduler,
#[serde(rename = "speedLimiter")]
SpeedLimiter,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum SettingsTab {
Downloads,
Lookandfeel,
Network,
Locations,
Sitelogins,
Power,
Engine,
Integrations,
About,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum ProxyMode {
None,
System,
Custom,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum MediaCookieSource {
None,
Safari,
Chrome,
Firefox,
Edge,
Brave,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct SchedulerSettings {
pub enabled: bool,
pub start_time: String,
pub stop_time_enabled: bool,
pub stop_time: String,
pub everyday: bool,
pub selected_days: Vec<u32>,
pub post_queue_action: PostQueueAction,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings {
pub theme: Theme,
pub default_download_path: String,
pub max_concurrent_downloads: usize,
pub global_speed_limit: String,
pub is_sidebar_visible: bool,
pub active_settings_tab: SettingsTab,
pub scheduler: SchedulerSettings,
pub scheduler_last_start_key: String,
pub scheduler_last_stop_key: String,
pub last_custom_speed_limit_ki_b: u32,
pub per_server_connections: i32,
pub max_automatic_retries: i32,
pub show_notifications: bool,
pub play_completion_sound: bool,
pub app_font_size: AppFontSize,
pub list_row_density: ListRowDensity,
pub show_dock_badge: bool,
pub show_menu_bar_icon: bool,
pub proxy_mode: ProxyMode,
pub proxy_host: String,
pub proxy_port: u16,
pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool,
pub prevents_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource,
pub download_directories: HashMap<String, String>,
pub site_logins: Vec<SiteLogin>,
pub extension_pairing_token: String,
pub auto_check_updates: bool,
}
+1545
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
firelink_lib::run()
}
+165
View File
@@ -0,0 +1,165 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::ipc::DownloadCategory;
#[tauri::command]
pub async fn get_system_proxy() -> Result<Option<String>, String> {
match sysproxy::Sysproxy::get_system_proxy() {
Ok(proxy) => {
if proxy.enable {
// Determine protocol, usually sysproxy returns the host and port
// We'll default to http:// unless the user has configured something specific
Ok(Some(format!("http://{}:{}", proxy.host, proxy.port)))
} else {
Ok(None)
}
}
Err(_) => Ok(None),
}
}
#[tauri::command]
pub fn get_file_category(filename: String) -> DownloadCategory {
let ext = std::path::Path::new(&filename)
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
let music_exts = ["aac", "aif", "aiff", "alac", "amr", "ape", "au", "caf", "flac", "m4a", "m4b", "mid", "midi", "mp3", "oga", "ogg", "opus", "ra", "wav", "weba", "wma"];
let movie_exts = ["3g2", "3gp", "avi", "divx", "f4v", "flv", "m2ts", "m4v", "mkv", "mov", "mp4", "mpeg", "mpg", "mts", "ogm", "ogv", "rm", "rmvb", "ts", "vob", "webm", "wmv"];
let compressed_exts = ["7z", "ace", "alz", "apk", "appx", "ar", "arc", "arj", "bz", "bz2", "cab", "cpio", "deb", "dmg", "gz", "gzip", "iso", "jar", "lha", "lzh", "lz", "lz4", "lzip", "lzma", "pak", "pkg", "rar", "rpm", "sit", "sitx", "tar", "tbz", "tbz2", "tgz", "tlz", "txz", "war", "whl", "xar", "xz", "z", "zip", "zipx", "zst"];
let picture_exts = ["ai", "apng", "avif", "bmp", "cr2", "cr3", "dng", "emf", "eps", "gif", "heic", "heif", "ico", "indd", "jfif", "jpeg", "jpg", "jxl", "nef", "orf", "pbm", "pgm", "png", "pnm", "ppm", "psd", "raw", "rw2", "svg", "tga", "tif", "tiff", "webp", "wmf"];
let document_exts = ["azw", "azw3", "csv", "djvu", "doc", "docm", "docx", "dot", "dotx", "epub", "fb2", "htm", "html", "ics", "key", "log", "md", "mobi", "pdf", "numbers", "odp", "ods", "odt", "pages", "pot", "potx", "pps", "ppsx", "ppt", "pptm", "pptx", "rtf", "tex", "txt", "vcf", "xls", "xlsm", "xlsx", "xml", "xps", "yaml", "yml"];
if music_exts.contains(&ext.as_str()) {
DownloadCategory::Musics
} else if movie_exts.contains(&ext.as_str()) {
DownloadCategory::Movies
} else if compressed_exts.contains(&ext.as_str()) {
DownloadCategory::Compressed
} else if picture_exts.contains(&ext.as_str()) {
DownloadCategory::Pictures
} else if document_exts.contains(&ext.as_str()) {
DownloadCategory::Documents
} else {
DownloadCategory::Other
}
}
#[derive(Serialize, Deserialize, Clone, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct AvailableReleaseUpdate {
pub version: String,
pub tag_name: String,
pub title: String,
pub release_notes: String,
pub release_url: String,
pub published_at: Option<String>,
}
#[derive(Serialize, TS)]
#[serde(tag = "type")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum ReleaseCheckOutcome {
UpdateAvailable { update: AvailableReleaseUpdate },
UpToDate { latest_version: String, local_version: String },
}
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
html_url: String,
draft: bool,
prerelease: bool,
published_at: Option<String>,
}
#[tauri::command]
pub async fn check_for_updates(app_handle: tauri::AppHandle) -> Result<ReleaseCheckOutcome, String> {
let current_version = app_handle.package_info().version.to_string();
let client = reqwest::Client::new();
let res = client.get("https://api.github.com/repos/nimbold/Firelink/releases?per_page=30")
.header("User-Agent", "Firelink")
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("GitHub returned HTTP {}", res.status().as_u16()));
}
let releases: Vec<GitHubRelease> = res.json().await.map_err(|e| e.to_string())?;
let latest_stable = releases.into_iter()
.filter(|r| !r.draft && !r.prerelease)
.max_by(|a, b| cmp_versions(&a.tag_name, &b.tag_name));
let release = match latest_stable {
Some(r) => r,
None => return Err("No stable release was found.".to_string()),
};
let latest_version = release.tag_name.trim_start_matches(|c| c == 'v' || c == 'V').to_string();
if cmp_versions(&latest_version, &current_version) == std::cmp::Ordering::Greater {
Ok(ReleaseCheckOutcome::UpdateAvailable {
update: AvailableReleaseUpdate {
version: latest_version.clone(),
tag_name: release.tag_name.clone(),
title: release.name.unwrap_or(release.tag_name),
release_notes: release.body.unwrap_or_else(|| "No release notes were provided for this version.".to_string()),
release_url: release.html_url,
published_at: release.published_at,
}
})
} else {
Ok(ReleaseCheckOutcome::UpToDate {
latest_version,
local_version: current_version,
})
}
}
fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
use semver::Version;
let a_clean = a.trim_start_matches(|c| c == 'v' || c == 'V');
let b_clean = b.trim_start_matches(|c| c == 'v' || c == 'V');
let a_ver = Version::parse(a_clean).unwrap_or_else(|_| Version::new(0, 0, 0));
let b_ver = Version::parse(b_clean).unwrap_or_else(|_| Version::new(0, 0, 0));
a_ver.cmp(&b_ver)
}
#[tauri::command]
pub fn is_supported_media(url: String) -> bool {
if let Ok(parsed_url) = reqwest::Url::parse(&url) {
if let Some(host) = parsed_url.host_str() {
let host_lower = host.to_lowercase();
let supported_domains = [
"youtube.com", "youtu.be",
"twitter.com", "x.com",
"vimeo.com",
"twitch.tv",
"instagram.com",
"tiktok.com",
"facebook.com", "fb.watch",
"reddit.com", "v.redd.it",
"soundcloud.com"
];
for domain in supported_domains.iter() {
if host_lower == *domain || host_lower.ends_with(&format!(".{}", domain)) {
return true;
}
}
}
}
false
}
+86
View File
@@ -0,0 +1,86 @@
use tauri::{Manager, Emitter};
use chrono::{Local, Datelike};
use std::time::Duration;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct SchedulerSettings {
enabled: bool,
#[serde(rename = "startTime")]
start_time: String,
#[serde(rename = "stopTimeEnabled")]
stop_time_enabled: bool,
#[serde(rename = "stopTime")]
stop_time: String,
everyday: bool,
#[serde(rename = "selectedDays")]
selected_days: Vec<u32>,
#[serde(rename = "postQueueAction")]
post_queue_action: String,
}
pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
loop {
interval.tick().await;
let settings_opt = {
let state = app_handle.state::<crate::db::DbState>();
let conn = state.conn.lock().unwrap();
crate::db::get_settings(&conn).unwrap_or(None)
};
if let Some(settings_str) = settings_opt {
if let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&settings_str) {
if let Ok(scheduler) = serde_json::from_value::<SchedulerSettings>(settings.get("scheduler").unwrap_or(&serde_json::json!({})).clone()) {
if !scheduler.enabled {
continue;
}
let now = Local::now();
let current_time = now.format("%H:%M").to_string();
let current_day = now.weekday().num_days_from_sunday();
let allowed_today = scheduler.everyday || scheduler.selected_days.contains(&current_day);
if !allowed_today {
continue;
}
let date_key = now.format("%Y-%m-%d").to_string();
let trigger_key = format!("{}-{}", date_key, current_time);
let last_start_key = settings.get("schedulerLastStartKey").and_then(|v| v.as_str()).unwrap_or("").to_string();
let last_stop_key = settings.get("schedulerLastStopKey").and_then(|v| v.as_str()).unwrap_or("").to_string();
if scheduler.start_time == current_time && last_start_key != trigger_key {
settings["schedulerLastStartKey"] = serde_json::json!(trigger_key.clone());
settings["schedulerRunning"] = serde_json::json!(true);
let _ = app_handle.emit("schedule-trigger", "start");
if let Ok(updated) = serde_json::to_string(&settings) {
let state = app_handle.state::<crate::db::DbState>();
let conn = state.conn.lock().unwrap();
let _ = crate::db::save_settings(&conn, &updated);
}
}
if scheduler.stop_time_enabled && scheduler.stop_time == current_time && last_stop_key != trigger_key {
settings["schedulerLastStopKey"] = serde_json::json!(trigger_key.clone());
settings["schedulerRunning"] = serde_json::json!(false);
let _ = app_handle.emit("schedule-trigger", "stop");
if let Ok(updated) = serde_json::to_string(&settings) {
let state = app_handle.state::<crate::db::DbState>();
let conn = state.conn.lock().unwrap();
let _ = crate::db::save_settings(&conn, &updated);
}
}
}
}
}
}
});
}