mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
feat(desktop): modernize browser extension integration
This commit is contained in:
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
@@ -1655,6 +1656,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-deep-link": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz",
|
||||
"integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
|
||||
Generated
+133
-1
@@ -484,6 +484,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -577,6 +597,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -710,6 +736,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -779,6 +806,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -1461,6 +1497,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1500,6 +1542,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
@@ -1623,7 +1674,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2527,6 +2578,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3089,6 +3150,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3823,17 +3894,21 @@ dependencies = [
|
||||
name = "tauri-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"keyring",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -3915,6 +3990,27 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry 0.5.3",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
@@ -3998,6 +4094,22 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.2"
|
||||
@@ -4204,6 +4316,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -5126,6 +5247,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
|
||||
@@ -29,4 +29,7 @@ reqwest = "0.12"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
sysinfo = "0.39.3"
|
||||
keyring = "3"
|
||||
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,57 +1,644 @@
|
||||
use std::net::TcpListener;
|
||||
use std::io::{Read, Write};
|
||||
use std::thread;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
struct ExtensionPayload {
|
||||
url: String,
|
||||
token: String,
|
||||
pub const EXTENSION_SERVER_PORT: u16 = 23522;
|
||||
const MAX_HEADER_BYTES: usize = 16 * 1024;
|
||||
const MAX_REQUEST_BYTES: usize = 128 * 1024;
|
||||
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(Deserialize)]
|
||||
struct ExtensionRequest {
|
||||
urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
referer: Option<String>,
|
||||
#[serde(default)]
|
||||
silent: bool,
|
||||
#[serde(default)]
|
||||
filename: Option<String>,
|
||||
}
|
||||
|
||||
pub fn start_server(app_handle: AppHandle) {
|
||||
thread::spawn(move || {
|
||||
let listener = match TcpListener::bind("127.0.0.1:23522") {
|
||||
Ok(l) => l,
|
||||
Err(_) => return, // Port might be in use, ignore for now
|
||||
};
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ExtensionDownload {
|
||||
urls: Vec<String>,
|
||||
referer: Option<String>,
|
||||
silent: bool,
|
||||
filename: Option<String>,
|
||||
}
|
||||
|
||||
struct HttpRequest {
|
||||
method: String,
|
||||
path: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl HttpRequest {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.get(&name.to_ascii_lowercase())
|
||||
.map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RequestError {
|
||||
BadRequest,
|
||||
LengthRequired,
|
||||
PayloadTooLarge,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum HttpStatus {
|
||||
Ok,
|
||||
NoContent,
|
||||
BadRequest,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
MethodNotAllowed,
|
||||
LengthRequired,
|
||||
PayloadTooLarge,
|
||||
UnsupportedMediaType,
|
||||
ServiceUnavailable,
|
||||
InternalServerError,
|
||||
}
|
||||
|
||||
impl HttpStatus {
|
||||
fn code(self) -> u16 {
|
||||
match self {
|
||||
Self::Ok => 200,
|
||||
Self::NoContent => 204,
|
||||
Self::BadRequest => 400,
|
||||
Self::Forbidden => 403,
|
||||
Self::NotFound => 404,
|
||||
Self::MethodNotAllowed => 405,
|
||||
Self::LengthRequired => 411,
|
||||
Self::PayloadTooLarge => 413,
|
||||
Self::UnsupportedMediaType => 415,
|
||||
Self::ServiceUnavailable => 503,
|
||||
Self::InternalServerError => 500,
|
||||
}
|
||||
}
|
||||
|
||||
fn reason(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ok => "OK",
|
||||
Self::NoContent => "No Content",
|
||||
Self::BadRequest => "Bad Request",
|
||||
Self::Forbidden => "Forbidden",
|
||||
Self::NotFound => "Not Found",
|
||||
Self::MethodNotAllowed => "Method Not Allowed",
|
||||
Self::LengthRequired => "Length Required",
|
||||
Self::PayloadTooLarge => "Payload Too Large",
|
||||
Self::UnsupportedMediaType => "Unsupported Media Type",
|
||||
Self::ServiceUnavailable => "Service Unavailable",
|
||||
Self::InternalServerError => "Internal Server Error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_server(
|
||||
app_handle: AppHandle,
|
||||
pairing_token: SharedExtensionToken,
|
||||
frontend_ready: SharedFrontendReady,
|
||||
) -> Result<(), String> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", EXTENSION_SERVER_PORT))
|
||||
.map_err(|error| format!("Failed to bind 127.0.0.1:{EXTENSION_SERVER_PORT}: {error}"))?;
|
||||
let replay_cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(mut stream) => {
|
||||
let mut buffer = [0; 1024];
|
||||
if let Ok(size) = stream.read(&mut buffer) {
|
||||
let request = String::from_utf8_lossy(&buffer[..size]);
|
||||
|
||||
// Parse simple HTTP POST
|
||||
if request.starts_with("POST") {
|
||||
if let Some(body_start) = request.find("\r\n\r\n") {
|
||||
let body = &request[body_start + 4..];
|
||||
if let Ok(payload) = serde_json::from_str::<ExtensionPayload>(body) {
|
||||
// Emit event to frontend
|
||||
let _ = app_handle.emit("extension-add-download", payload);
|
||||
|
||||
// Send success response
|
||||
let response = "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\nContent-Type: application/json\r\n\r\n{\"success\":true}";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OPTIONS for CORS
|
||||
if request.starts_with("OPTIONS") {
|
||||
let response = "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
} else {
|
||||
let response = "HTTP/1.1 400 Bad Request\r\nAccess-Control-Allow-Origin: *\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
Ok(stream) => handle_connection(
|
||||
stream,
|
||||
&app_handle,
|
||||
&pairing_token,
|
||||
&frontend_ready,
|
||||
&replay_cache,
|
||||
),
|
||||
Err(error) => eprintln!("Browser extension connection failed: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_connection(
|
||||
mut stream: TcpStream,
|
||||
app_handle: &AppHandle,
|
||||
pairing_token: &SharedExtensionToken,
|
||||
frontend_ready: &SharedFrontendReady,
|
||||
replay_cache: &ReplayCache,
|
||||
) {
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
||||
|
||||
let request = match read_http_request(&mut stream) {
|
||||
Ok(request) => request,
|
||||
Err(RequestError::BadRequest) => {
|
||||
write_response(&mut stream, HttpStatus::BadRequest, None);
|
||||
return;
|
||||
}
|
||||
Err(RequestError::LengthRequired) => {
|
||||
write_response(&mut stream, HttpStatus::LengthRequired, None);
|
||||
return;
|
||||
}
|
||||
Err(RequestError::PayloadTooLarge) => {
|
||||
write_response(&mut stream, HttpStatus::PayloadTooLarge, None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let origin = request.header("origin").map(str::to_string);
|
||||
let status = process_request(
|
||||
request,
|
||||
app_handle,
|
||||
pairing_token,
|
||||
frontend_ready,
|
||||
replay_cache,
|
||||
);
|
||||
write_response(
|
||||
&mut stream,
|
||||
status,
|
||||
origin.as_deref().filter(|value| is_allowed_origin(value)),
|
||||
);
|
||||
}
|
||||
|
||||
fn process_request(
|
||||
request: HttpRequest,
|
||||
app_handle: &AppHandle,
|
||||
pairing_token: &SharedExtensionToken,
|
||||
frontend_ready: &SharedFrontendReady,
|
||||
replay_cache: &ReplayCache,
|
||||
) -> HttpStatus {
|
||||
if !is_local_host(request.header("host")) {
|
||||
return HttpStatus::Forbidden;
|
||||
}
|
||||
|
||||
let origin = request.header("origin");
|
||||
if request.method == "OPTIONS" {
|
||||
return if origin.is_some_and(is_allowed_origin) {
|
||||
HttpStatus::NoContent
|
||||
} else {
|
||||
HttpStatus::Forbidden
|
||||
};
|
||||
}
|
||||
if origin.is_some_and(|value| !is_allowed_origin(value)) {
|
||||
return HttpStatus::Forbidden;
|
||||
}
|
||||
|
||||
let timestamp = match verify_signature(&request, pairing_token) {
|
||||
Ok(timestamp) => timestamp,
|
||||
Err(()) => return HttpStatus::Forbidden,
|
||||
};
|
||||
|
||||
if request.path == "/ping" {
|
||||
return if request.method == "GET" {
|
||||
if frontend_ready.load(Ordering::Acquire) {
|
||||
HttpStatus::Ok
|
||||
} else {
|
||||
HttpStatus::ServiceUnavailable
|
||||
}
|
||||
} else {
|
||||
HttpStatus::MethodNotAllowed
|
||||
};
|
||||
}
|
||||
|
||||
if request.path != "/download" {
|
||||
return HttpStatus::NotFound;
|
||||
}
|
||||
if request.method != "POST" {
|
||||
return HttpStatus::MethodNotAllowed;
|
||||
}
|
||||
if !request
|
||||
.header("content-type")
|
||||
.is_some_and(|value| value.to_ascii_lowercase().contains("application/json"))
|
||||
{
|
||||
return HttpStatus::UnsupportedMediaType;
|
||||
}
|
||||
|
||||
let payload = match serde_json::from_slice::<ExtensionRequest>(&request.body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => return HttpStatus::BadRequest,
|
||||
};
|
||||
let download = match normalize_download(payload) {
|
||||
Some(download) => download,
|
||||
None => return HttpStatus::BadRequest,
|
||||
};
|
||||
if !frontend_ready.load(Ordering::Acquire) {
|
||||
return HttpStatus::ServiceUnavailable;
|
||||
}
|
||||
|
||||
let signature = match request.header("x-firelink-signature") {
|
||||
Some(signature) => signature,
|
||||
None => return HttpStatus::Forbidden,
|
||||
};
|
||||
if !claim_request(signature, timestamp, replay_cache) {
|
||||
return HttpStatus::Forbidden;
|
||||
}
|
||||
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
if app_handle.emit("extension-add-download", download).is_err() {
|
||||
return HttpStatus::InternalServerError;
|
||||
}
|
||||
|
||||
HttpStatus::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(
|
||||
request: &HttpRequest,
|
||||
pairing_token: &SharedExtensionToken,
|
||||
) -> Result<u64, ()> {
|
||||
let signature = decode_hex(request.header("x-firelink-signature").ok_or(())?)?;
|
||||
let timestamp_text = request.header("x-firelink-timestamp").ok_or(())?;
|
||||
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(&request.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_local_host(host: Option<&str>) -> bool {
|
||||
matches!(
|
||||
host,
|
||||
Some(value)
|
||||
if value == format!("127.0.0.1:{EXTENSION_SERVER_PORT}")
|
||||
|| value == format!("localhost:{EXTENSION_SERVER_PORT}")
|
||||
|| value == "127.0.0.1"
|
||||
|| value == "localhost"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_allowed_origin(origin: &str) -> bool {
|
||||
Url::parse(origin)
|
||||
.ok()
|
||||
.is_some_and(|url| matches!(url.scheme(), "moz-extension" | "chrome-extension"))
|
||||
}
|
||||
|
||||
fn read_http_request(reader: &mut impl Read) -> Result<HttpRequest, RequestError> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
let mut expected_length = None;
|
||||
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut chunk)
|
||||
.map_err(|_| RequestError::BadRequest)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk[..read]);
|
||||
if bytes.len() > MAX_REQUEST_BYTES {
|
||||
return Err(RequestError::PayloadTooLarge);
|
||||
}
|
||||
|
||||
if expected_length.is_none() {
|
||||
if let Some(header_end) = find_bytes(&bytes, b"\r\n\r\n") {
|
||||
if header_end > MAX_HEADER_BYTES {
|
||||
return Err(RequestError::PayloadTooLarge);
|
||||
}
|
||||
let headers = parse_headers(&bytes[..header_end])?;
|
||||
if headers.contains_key("transfer-encoding") {
|
||||
return Err(RequestError::BadRequest);
|
||||
}
|
||||
let method = parse_request_line(&bytes[..header_end])?.0;
|
||||
let content_length = parse_content_length(&headers)?;
|
||||
if method == "POST" && content_length.is_none() {
|
||||
return Err(RequestError::LengthRequired);
|
||||
}
|
||||
let body_length = content_length.unwrap_or(0);
|
||||
let total_length = header_end + 4 + body_length;
|
||||
if total_length > MAX_REQUEST_BYTES {
|
||||
return Err(RequestError::PayloadTooLarge);
|
||||
}
|
||||
expected_length = Some(total_length);
|
||||
} else if bytes.len() > MAX_HEADER_BYTES {
|
||||
return Err(RequestError::PayloadTooLarge);
|
||||
}
|
||||
}
|
||||
|
||||
if expected_length.is_some_and(|length| bytes.len() >= length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let header_end = find_bytes(&bytes, b"\r\n\r\n").ok_or(RequestError::BadRequest)?;
|
||||
let (method, path) = parse_request_line(&bytes[..header_end])?;
|
||||
let headers = parse_headers(&bytes[..header_end])?;
|
||||
let body_length = parse_content_length(&headers)?.unwrap_or(0);
|
||||
let body_start = header_end + 4;
|
||||
let body_end = body_start + body_length;
|
||||
if bytes.len() < body_end {
|
||||
return Err(RequestError::BadRequest);
|
||||
}
|
||||
|
||||
Ok(HttpRequest {
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body: bytes[body_start..body_end].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_request_line(header_bytes: &[u8]) -> Result<(String, String), RequestError> {
|
||||
let headers = std::str::from_utf8(header_bytes).map_err(|_| RequestError::BadRequest)?;
|
||||
let mut parts = headers
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or(RequestError::BadRequest)?
|
||||
.split_whitespace();
|
||||
let method = parts.next().ok_or(RequestError::BadRequest)?;
|
||||
let raw_path = parts.next().ok_or(RequestError::BadRequest)?;
|
||||
let version = parts.next().ok_or(RequestError::BadRequest)?;
|
||||
if parts.next().is_some() || !version.starts_with("HTTP/1.") {
|
||||
return Err(RequestError::BadRequest);
|
||||
}
|
||||
let path = raw_path.split('?').next().unwrap_or_default();
|
||||
Ok((method.to_ascii_uppercase(), path.to_string()))
|
||||
}
|
||||
|
||||
fn parse_headers(header_bytes: &[u8]) -> Result<HashMap<String, String>, RequestError> {
|
||||
let headers = std::str::from_utf8(header_bytes).map_err(|_| RequestError::BadRequest)?;
|
||||
let mut parsed = HashMap::new();
|
||||
for line in headers.lines().skip(1) {
|
||||
let (name, value) = line.split_once(':').ok_or(RequestError::BadRequest)?;
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() || parsed.contains_key(&name) {
|
||||
return Err(RequestError::BadRequest);
|
||||
}
|
||||
parsed.insert(name, value.trim().to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn parse_content_length(
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Result<Option<usize>, RequestError> {
|
||||
headers
|
||||
.get("content-length")
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| RequestError::BadRequest)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
fn write_response(stream: &mut impl Write, status: HttpStatus, origin: Option<&str>) {
|
||||
let mut headers = vec![
|
||||
format!("HTTP/1.1 {} {}", status.code(), status.reason()),
|
||||
"Content-Length: 0".to_string(),
|
||||
"Connection: close".to_string(),
|
||||
"X-Firelink-Server: 1".to_string(),
|
||||
];
|
||||
if let Some(origin) = origin {
|
||||
headers.push(format!("Access-Control-Allow-Origin: {origin}"));
|
||||
headers.push("Vary: Origin".to_string());
|
||||
headers.push("Access-Control-Allow-Methods: GET, POST, OPTIONS".to_string());
|
||||
headers.push(
|
||||
"Access-Control-Allow-Headers: Content-Type, X-Firelink-Signature, X-Firelink-Timestamp"
|
||||
.to_string(),
|
||||
);
|
||||
headers.push("Access-Control-Allow-Private-Network: true".to_string());
|
||||
headers.push("Access-Control-Expose-Headers: X-Firelink-Server".to_string());
|
||||
}
|
||||
let response = headers.join("\r\n") + "\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
struct ChunkedReader {
|
||||
bytes: Cursor<Vec<u8>>,
|
||||
chunk_size: usize,
|
||||
}
|
||||
|
||||
impl Read for ChunkedReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
let limit = buffer.len().min(self.chunk_size);
|
||||
self.bytes.read(&mut buffer[..limit])
|
||||
}
|
||||
}
|
||||
|
||||
fn token(value: &str) -> SharedExtensionToken {
|
||||
Arc::new(RwLock::new(value.to_string()))
|
||||
}
|
||||
|
||||
fn sign(value: &str, timestamp: &str, body: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(value.as_bytes()).unwrap();
|
||||
mac.update(timestamp.as_bytes());
|
||||
mac.update(body);
|
||||
mac.finalize()
|
||||
.into_bytes()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_fragmented_signed_request() {
|
||||
let body = br#"{"urls":["https://example.com/file.zip"],"silent":true}"#;
|
||||
let request = format!(
|
||||
"POST /download HTTP/1.1\r\nHost: 127.0.0.1:6412\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
std::str::from_utf8(body).unwrap()
|
||||
);
|
||||
let mut reader = ChunkedReader {
|
||||
bytes: Cursor::new(request.into_bytes()),
|
||||
chunk_size: 7,
|
||||
};
|
||||
|
||||
let parsed = read_http_request(&mut reader).expect("request should parse");
|
||||
|
||||
assert_eq!(parsed.method, "POST");
|
||||
assert_eq!(parsed.path, "/download");
|
||||
assert_eq!(parsed.body, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_hmac_over_timestamp_and_exact_body() {
|
||||
let body = br#"{"urls":["https://example.com/file.zip"]}"#.to_vec();
|
||||
let timestamp = current_time_millis().unwrap().to_string();
|
||||
let signature = sign("secret", ×tamp, &body);
|
||||
let request = HttpRequest {
|
||||
method: "POST".to_string(),
|
||||
path: "/download".to_string(),
|
||||
headers: HashMap::from([
|
||||
("x-firelink-signature".to_string(), signature),
|
||||
("x-firelink-timestamp".to_string(), timestamp),
|
||||
]),
|
||||
body,
|
||||
};
|
||||
|
||||
assert!(verify_signature(&request, &token("secret")).is_ok());
|
||||
assert!(verify_signature(&request, &token("wrong")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_replayed_download_signature() {
|
||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||
let timestamp = current_time_millis().unwrap();
|
||||
|
||||
assert!(claim_request("signature", timestamp, &cache));
|
||||
assert!(!claim_request("signature", timestamp, &cache));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_payload_and_sanitizes_filename() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"https://example.com/file.zip".to_string(),
|
||||
"https://example.com/file.zip".to_string(),
|
||||
"javascript:alert(1)".to_string(),
|
||||
],
|
||||
referer: Some("https://example.com/page".to_string()),
|
||||
silent: true,
|
||||
filename: Some("../archive.zip".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(download.urls, vec!["https://example.com/file.zip"]);
|
||||
assert_eq!(
|
||||
download.referer.as_deref(),
|
||||
Some("https://example.com/page")
|
||||
);
|
||||
assert_eq!(download.filename.as_deref(), Some("archive.zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_content_length_for_post() {
|
||||
let mut reader =
|
||||
Cursor::new(b"POST /download HTTP/1.1\r\nHost: localhost\r\n\r\n{}".to_vec());
|
||||
|
||||
assert!(matches!(
|
||||
read_http_request(&mut reader),
|
||||
Err(RequestError::LengthRequired)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,10 +344,13 @@ async fn show_in_folder(path: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
struct AppState {
|
||||
tasks: Mutex<HashMap<String, u32>>,
|
||||
extension_pairing_token: extension_server::SharedExtensionToken,
|
||||
extension_frontend_ready: extension_server::SharedFrontendReady,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -358,6 +361,17 @@ struct DownloadProgressEvent {
|
||||
eta: String,
|
||||
}
|
||||
|
||||
fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec<String> {
|
||||
let mut uris = Vec::new();
|
||||
for uri in std::iter::once(url).chain(mirrors.into_iter().flat_map(str::lines)) {
|
||||
let uri = uri.trim();
|
||||
if !uri.is_empty() && !uris.iter().any(|existing| existing == uri) {
|
||||
uris.push(uri.to_string());
|
||||
}
|
||||
}
|
||||
uris
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -459,17 +473,8 @@ async fn start_download(
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(&url);
|
||||
|
||||
if let Some(m) = mirrors {
|
||||
if !m.is_empty() {
|
||||
for mirror_url in m.lines() {
|
||||
let trimmed = mirror_url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
cmd.arg(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
for uri in collect_download_uris(&url, mirrors.as_deref()) {
|
||||
cmd.arg(uri);
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
@@ -556,6 +561,7 @@ async fn start_media_download(
|
||||
speed_limit: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
headers: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
println!("start_media_download called for id: {}", id);
|
||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||
@@ -638,6 +644,11 @@ async fn start_media_download(
|
||||
cmd.arg("--password").arg(&pass);
|
||||
}
|
||||
}
|
||||
if let Some(headers) = headers {
|
||||
for header in headers.lines().map(str::trim).filter(|header| !header.is_empty()) {
|
||||
cmd.arg("--add-headers").arg(header);
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(&url);
|
||||
cmd.stdout(Stdio::piped());
|
||||
@@ -1028,15 +1039,92 @@ fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), Stri
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_extension_pairing_token(
|
||||
state: tauri::State<'_, AppState>,
|
||||
token: String,
|
||||
) -> Result<(), String> {
|
||||
if token.is_empty() || token.len() > 512 {
|
||||
return Err("Invalid extension pairing token".to_string());
|
||||
}
|
||||
|
||||
let mut pairing_token = state
|
||||
.extension_pairing_token
|
||||
.write()
|
||||
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?;
|
||||
*pairing_token = token;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_extension_frontend_ready(
|
||||
state: tauri::State<'_, AppState>,
|
||||
ready: bool,
|
||||
) {
|
||||
state
|
||||
.extension_frontend_ready
|
||||
.store(ready, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::collect_download_uris;
|
||||
|
||||
#[test]
|
||||
fn collects_primary_url_and_unique_mirrors_in_order() {
|
||||
let uris = collect_download_uris(
|
||||
"https://primary.example/file.zip",
|
||||
Some(
|
||||
"\nhttps://mirror-one.example/file.zip\n\
|
||||
https://primary.example/file.zip\n\
|
||||
https://mirror-two.example/file.zip\n",
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
uris,
|
||||
vec![
|
||||
"https://primary.example/file.zip",
|
||||
"https://mirror-one.example/file.zip",
|
||||
"https://mirror-two.example/file.zip",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let extension_pairing_token = Arc::new(RwLock::new(String::new()));
|
||||
let server_pairing_token = extension_pairing_token.clone();
|
||||
let extension_frontend_ready = Arc::new(AtomicBool::new(false));
|
||||
let server_frontend_ready = extension_frontend_ready.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
extension_server::start_server(app.handle().clone());
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.manage(AppState {
|
||||
tasks: Mutex::new(HashMap::new()),
|
||||
extension_pairing_token,
|
||||
extension_frontend_ready,
|
||||
})
|
||||
.setup(move |app| {
|
||||
match extension_server::start_server(
|
||||
app.handle().clone(),
|
||||
server_pairing_token.clone(),
|
||||
server_frontend_ready.clone(),
|
||||
) {
|
||||
Ok(()) => println!(
|
||||
"Browser extension server listening on 127.0.0.1:{}",
|
||||
extension_server::EXTENSION_SERVER_PORT
|
||||
),
|
||||
Err(error) => eprintln!("Browser extension server unavailable: {error}"),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -1047,7 +1135,8 @@ pub fn run() {
|
||||
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
||||
request_automation_permission, open_automation_settings,
|
||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||
check_file_exists, delete_file, toggle_tray_icon
|
||||
check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token,
|
||||
set_extension_frontend_ready
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "tauri-app",
|
||||
"productName": "Firelink",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.nima.tauri-app",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "tauri-app",
|
||||
"title": "Firelink",
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"minWidth": 800,
|
||||
@@ -39,5 +39,12 @@
|
||||
"binaries/*",
|
||||
"binaries/**/*"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["firelink"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
|
||||
@@ -19,6 +20,22 @@ const localDateKey = (date: Date) => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const handleDeepLinks = (deepLinks: string[]) => {
|
||||
for (const rawDeepLink of deepLinks) {
|
||||
try {
|
||||
const deepLink = new URL(rawDeepLink);
|
||||
if (deepLink.protocol !== 'firelink:' || deepLink.hostname !== 'add') continue;
|
||||
const urls = deepLink.searchParams.get('url') || '';
|
||||
if (urls.length > 0 && urls.length < 65_536) {
|
||||
useDownloadStore.getState().openAddModalWithUrls(urls);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Ignored invalid Firelink deep link:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const updateDownload = useDownloadStore(state => state.updateDownload);
|
||||
@@ -28,6 +45,7 @@ function App() {
|
||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
|
||||
const queuedCount = downloads.filter(download => download.status === 'queued').length;
|
||||
@@ -48,6 +66,25 @@ function App() {
|
||||
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
|
||||
}, [showMenuBarIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
|
||||
console.error('Failed to configure browser extension pairing token:', error);
|
||||
});
|
||||
}, [extensionPairingToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = onOpenUrl(handleDeepLinks);
|
||||
getCurrent()
|
||||
.then(urls => {
|
||||
if (urls) handleDeepLinks(urls);
|
||||
})
|
||||
.catch(error => console.error('Failed to read startup deep link:', error));
|
||||
|
||||
return () => {
|
||||
unlisten.then(dispose => dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSpeedLimit.current === globalSpeedLimit) return;
|
||||
previousSpeedLimit.current = globalSpeedLimit;
|
||||
@@ -178,16 +215,14 @@ function App() {
|
||||
});
|
||||
|
||||
const unlistenExtension = listen('extension-add-download', (event: any) => {
|
||||
const { url, token } = event.payload;
|
||||
const settings = useSettingsStore.getState();
|
||||
if (settings.extensionPairingToken && token === settings.extensionPairingToken) {
|
||||
useDownloadStore.getState().openAddModalWithUrls(url);
|
||||
} else {
|
||||
console.warn('Extension add download rejected: invalid token');
|
||||
}
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload);
|
||||
});
|
||||
unlistenExtension
|
||||
.then(() => invoke('set_extension_frontend_ready', { ready: true }))
|
||||
.catch(error => console.error('Failed to activate browser extension integration:', error));
|
||||
|
||||
return () => {
|
||||
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
|
||||
unlistenProgress.then(f => f());
|
||||
unlistenComplete.then(f => f());
|
||||
unlistenFailed.then(f => f());
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { DownloadCategory } from '../store/useDownloadStore';
|
||||
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
|
||||
function determineCategory(fileName: string): DownloadCategory {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies';
|
||||
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics';
|
||||
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents';
|
||||
if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures';
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed';
|
||||
return 'Other';
|
||||
}
|
||||
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
|
||||
|
||||
interface RawMediaFormat {
|
||||
format_id?: string;
|
||||
@@ -238,19 +227,16 @@ const parseMediaFormats = (jsonStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const MEDIA_DOMAINS = ['youtube.com', 'youtu.be', 'twitter.com', 'x.com', 'twitch.tv', 'vimeo.com', 'instagram.com', 'tiktok.com', 'reddit.com', 'soundcloud.com', 'facebook.com'];
|
||||
const isMediaUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return MEDIA_DOMAINS.some(d => u.hostname.includes(d));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const AddDownloadsModal = () => {
|
||||
const { isAddModalOpen, pendingAddUrls, toggleAddModal, addDownload, queues } = useDownloadStore();
|
||||
const {
|
||||
isAddModalOpen,
|
||||
pendingAddUrls,
|
||||
pendingAddReferer,
|
||||
pendingAddFilename,
|
||||
toggleAddModal,
|
||||
addDownload,
|
||||
queues
|
||||
} = useDownloadStore();
|
||||
const { defaultDownloadPath } = useSettingsStore();
|
||||
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
|
||||
@@ -290,8 +276,24 @@ export const AddDownloadsModal = () => {
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setAdvancedExpanded(false);
|
||||
setChecksumEnabled(false);
|
||||
setChecksumAlgo('SHA-256');
|
||||
setChecksumValue('');
|
||||
setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : '');
|
||||
setCookies('');
|
||||
setMirrors('');
|
||||
}
|
||||
}, [isAddModalOpen, pendingAddUrls, defaultDownloadPath]);
|
||||
}, [
|
||||
isAddModalOpen,
|
||||
pendingAddUrls,
|
||||
pendingAddReferer,
|
||||
defaultDownloadPath,
|
||||
queues
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveLocation) return;
|
||||
@@ -306,8 +308,9 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
// Immediately display items in loading state
|
||||
const initialItems: ParsedDownloadItem[] = lines.map(url => {
|
||||
let fallbackFile = 'URL';
|
||||
try { fallbackFile = new URL(url).pathname.split('/').pop() || 'download'; } catch {}
|
||||
const fallbackFile = lines.length === 1 && pendingAddFilename
|
||||
? pendingAddFilename
|
||||
: fileNameFromUrl(url);
|
||||
return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) };
|
||||
});
|
||||
setParsedItems(initialItems);
|
||||
@@ -378,7 +381,13 @@ export const AddDownloadsModal = () => {
|
||||
username: login?.username || null,
|
||||
password: keychainPassword
|
||||
});
|
||||
updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' };
|
||||
updatedItems[i] = {
|
||||
url,
|
||||
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
|
||||
size: meta.size,
|
||||
sizeBytes: meta.size_bytes,
|
||||
status: 'Ready'
|
||||
};
|
||||
}
|
||||
if (firstReadyIndex === null) firstReadyIndex = i;
|
||||
} catch (e) {
|
||||
@@ -394,7 +403,7 @@ export const AddDownloadsModal = () => {
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [urls]); // Re-fetch only on urls change
|
||||
}, [urls, pendingAddFilename]);
|
||||
|
||||
if (!isAddModalOpen) return null;
|
||||
|
||||
@@ -563,13 +572,18 @@ export const AddDownloadsModal = () => {
|
||||
url: item.url,
|
||||
fileName: finalFile,
|
||||
status: startImmediately ? 'queued' : 'paused',
|
||||
category: determineCategory(finalFile),
|
||||
category: categoryForFileName(finalFile),
|
||||
dateAdded: new Date().toISOString(),
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
: undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: finalLocation,
|
||||
isMedia: item.isMedia,
|
||||
mediaFormatSelector: formatSelector,
|
||||
|
||||
@@ -777,7 +777,7 @@ export default function SettingsView() {
|
||||
<Puzzle size={16} className="text-green-500" />
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-text-primary mb-1">Paste & Connect</h4>
|
||||
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste thecopied token.</p>
|
||||
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste the copied token.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import {
|
||||
categoryForFileName,
|
||||
fileNameFromUrl,
|
||||
isMediaUrl,
|
||||
type DownloadCategory
|
||||
} from '../utils/downloads';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
const getProxyArgs = (settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
if (settings.proxyMode === 'custom' && settings.proxyHost) {
|
||||
@@ -77,8 +85,6 @@ const effectiveSpeedLimit = (
|
||||
};
|
||||
|
||||
export type DownloadStatus = 'downloading' | 'paused' | 'completed' | 'failed' | 'queued';
|
||||
export type DownloadCategory = 'Musics' | 'Movies' | 'Compressed' | 'Documents' | 'Pictures' | 'Applications' | 'Other';
|
||||
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
export interface Queue {
|
||||
@@ -113,14 +119,24 @@ export interface DownloadItem {
|
||||
queueId: string;
|
||||
}
|
||||
|
||||
export interface ExtensionDownloadRequest {
|
||||
urls: string[];
|
||||
referer?: string | null;
|
||||
silent?: boolean;
|
||||
filename?: string | null;
|
||||
}
|
||||
|
||||
interface DownloadState {
|
||||
downloads: DownloadItem[];
|
||||
queues: Queue[];
|
||||
isAddModalOpen: boolean;
|
||||
pendingAddUrls: string;
|
||||
pendingAddReferer: string;
|
||||
pendingAddFilename: string;
|
||||
selectedPropertiesDownloadId: string | null;
|
||||
toggleAddModal: (isOpen: boolean) => void;
|
||||
openAddModalWithUrls: (urls: string) => void;
|
||||
openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void;
|
||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
|
||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||
addDownload: (item: DownloadItem) => void;
|
||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||
@@ -141,9 +157,59 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
isAddModalOpen: false,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: '',
|
||||
selectedPropertiesDownloadId: null,
|
||||
toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }),
|
||||
openAddModalWithUrls: (urls) => set({ isAddModalOpen: true, pendingAddUrls: urls }),
|
||||
toggleAddModal: (isOpen) => set({
|
||||
isAddModalOpen: isOpen,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: ''
|
||||
}),
|
||||
openAddModalWithUrls: (urls, referer, filename) => set({
|
||||
isAddModalOpen: true,
|
||||
pendingAddUrls: urls,
|
||||
pendingAddReferer: referer?.trim() || '',
|
||||
pendingAddFilename: filename?.trim() || ''
|
||||
}),
|
||||
handleExtensionDownload: (request) => {
|
||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||
if (urls.length === 0) return;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
if (!request.silent || settings.askWhereToSaveEachFile) {
|
||||
get().openAddModalWithUrls(
|
||||
urls.join('\n'),
|
||||
request.referer,
|
||||
urls.length === 1 ? request.filename : null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const referer = request.referer?.trim();
|
||||
const headers = referer ? `Referer: ${referer}` : undefined;
|
||||
const dateAdded = new Date().toISOString();
|
||||
const downloads = urls.map((url, index): DownloadItem => {
|
||||
const fileName = index === 0 && urls.length === 1 && request.filename?.trim()
|
||||
? request.filename.trim()
|
||||
: fileNameFromUrl(url);
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
fileName,
|
||||
status: 'queued',
|
||||
category: categoryForFileName(fileName),
|
||||
dateAdded,
|
||||
connections: settings.perServerConnections,
|
||||
headers,
|
||||
isMedia: isMediaUrl(url),
|
||||
queueId: MAIN_QUEUE_ID
|
||||
};
|
||||
});
|
||||
|
||||
set(state => ({ downloads: [...state.downloads, ...downloads] }));
|
||||
void get().processQueue();
|
||||
},
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
addDownload: (item) => {
|
||||
set((state) => ({ downloads: [...state.downloads, item] }));
|
||||
@@ -327,7 +393,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
speedLimit,
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword
|
||||
password: item.password || keychainPassword,
|
||||
headers: item.headers || null
|
||||
});
|
||||
} else {
|
||||
const speedLimit = effectiveSpeedLimit(
|
||||
|
||||
@@ -102,6 +102,30 @@ const defaultDirectories = {
|
||||
Other: '~/Downloads/Other'
|
||||
};
|
||||
|
||||
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
|
||||
if (!directories || typeof directories !== 'object') {
|
||||
return { ...defaultDirectories };
|
||||
}
|
||||
|
||||
const values = directories as Record<string, unknown>;
|
||||
const directory = (current: string, legacy?: string) => {
|
||||
const value = values[current] ?? (legacy ? values[legacy] : undefined);
|
||||
return typeof value === 'string' && value.length > 0
|
||||
? value
|
||||
: defaultDirectories[current as keyof typeof defaultDirectories];
|
||||
};
|
||||
|
||||
return {
|
||||
Musics: directory('Musics', 'Audio'),
|
||||
Movies: directory('Movies', 'Video'),
|
||||
Compressed: directory('Compressed', 'Archives'),
|
||||
Documents: directory('Documents'),
|
||||
Pictures: directory('Pictures', 'Images'),
|
||||
Applications: directory('Applications', 'Apps'),
|
||||
Other: directory('Other')
|
||||
};
|
||||
};
|
||||
|
||||
const generateSecureToken = () => {
|
||||
try {
|
||||
const cryptoObj = typeof window !== 'undefined' ? (window.crypto || (window as any).msCrypto) : null;
|
||||
@@ -165,15 +189,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
askWhereToSaveEachFile: false,
|
||||
preventsSleepWhileDownloading: true,
|
||||
mediaCookieSource: 'none',
|
||||
downloadDirectories: {
|
||||
'Video': '~/Downloads/Video',
|
||||
'Audio': '~/Downloads/Audio',
|
||||
'Documents': '~/Downloads/Documents',
|
||||
'Apps': '~/Downloads/Apps',
|
||||
'Images': '~/Downloads/Images',
|
||||
'Archives': '~/Downloads/Compressed',
|
||||
'Other': '~/Downloads/Other'
|
||||
},
|
||||
downloadDirectories: { ...defaultDirectories },
|
||||
siteLogins: [],
|
||||
extensionPairingToken: generateSecureToken(),
|
||||
|
||||
@@ -255,9 +271,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...persistedState,
|
||||
appFontSize: persistedState?.appFontSize === 'extra-large' ? 'large' : (persistedState?.appFontSize || currentState.appFontSize),
|
||||
listRowDensity: persistedState?.listRowDensity === 'spacious' ? 'relaxed' : (persistedState?.listRowDensity || currentState.listRowDensity),
|
||||
downloadDirectories: (persistedState && typeof persistedState === 'object' && persistedState.downloadDirectories)
|
||||
? persistedState.downloadDirectories
|
||||
: currentState.downloadDirectories,
|
||||
downloadDirectories: normalizeDownloadDirectories(persistedState?.downloadDirectories),
|
||||
siteLogins: (persistedState && typeof persistedState === 'object' && Array.isArray(persistedState.siteLogins))
|
||||
? persistedState.siteLogins
|
||||
: currentState.siteLogins
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export type DownloadCategory =
|
||||
| 'Musics'
|
||||
| 'Movies'
|
||||
| 'Compressed'
|
||||
| 'Documents'
|
||||
| 'Pictures'
|
||||
| 'Applications'
|
||||
| 'Other';
|
||||
|
||||
const MEDIA_DOMAINS = [
|
||||
'youtube.com',
|
||||
'youtu.be',
|
||||
'twitter.com',
|
||||
'x.com',
|
||||
'twitch.tv',
|
||||
'vimeo.com',
|
||||
'instagram.com',
|
||||
'tiktok.com',
|
||||
'reddit.com',
|
||||
'soundcloud.com',
|
||||
'facebook.com'
|
||||
];
|
||||
|
||||
export const categoryForFileName = (fileName: string): DownloadCategory => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies';
|
||||
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics';
|
||||
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents';
|
||||
if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures';
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed';
|
||||
return 'Other';
|
||||
};
|
||||
|
||||
export const fileNameFromUrl = (rawUrl: string): string => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||
if (pathName) {
|
||||
const decoded = decodeURIComponent(pathName).trim();
|
||||
if (decoded && decoded !== '.' && decoded !== '..') {
|
||||
return decoded.replace(/[\/\\?%*:|"<>]/g, '-');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the stable generic name.
|
||||
}
|
||||
return 'download';
|
||||
};
|
||||
|
||||
export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
return MEDIA_DOMAINS.some(domain =>
|
||||
url.hostname === domain || url.hostname.endsWith(`.${domain}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user