mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-28 03:27:12 +00:00
feat(desktop): implement advanced transfer, tray icon, media cookies, and extension tcp server
This commit is contained in:
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use std::net::TcpListener;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::thread;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
struct ExtensionPayload {
|
||||||
|
url: String,
|
||||||
|
token: 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
|
||||||
|
};
|
||||||
|
|
||||||
|
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(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -371,6 +371,9 @@ async fn start_download(
|
|||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
headers: Option<String>,
|
headers: Option<String>,
|
||||||
|
checksum: Option<String>,
|
||||||
|
cookies: Option<String>,
|
||||||
|
mirrors: Option<String>,
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
max_tries: Option<i32>,
|
max_tries: Option<i32>,
|
||||||
proxy: Option<String>,
|
proxy: Option<String>,
|
||||||
@@ -432,6 +435,16 @@ async fn start_download(
|
|||||||
cmd.arg(format!("--header={}", hdr));
|
cmd.arg(format!("--header={}", hdr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(chk) = checksum {
|
||||||
|
if !chk.is_empty() {
|
||||||
|
cmd.arg(format!("--checksum={}", chk));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cks) = cookies {
|
||||||
|
if !cks.is_empty() {
|
||||||
|
cmd.arg(format!("--header=Cookie: {}", cks));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(ua) = user_agent {
|
if let Some(ua) = user_agent {
|
||||||
if !ua.is_empty() {
|
if !ua.is_empty() {
|
||||||
cmd.arg(format!("--user-agent={}", ua));
|
cmd.arg(format!("--user-agent={}", ua));
|
||||||
@@ -447,6 +460,18 @@ async fn start_download(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.arg(&url);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cmd.stdout(Stdio::piped());
|
cmd.stdout(Stdio::piped());
|
||||||
cmd.stderr(Stdio::null());
|
cmd.stderr(Stdio::null());
|
||||||
// We remove kill_on_drop(true) so we can gracefully SIGTERM it
|
// We remove kill_on_drop(true) so we can gracefully SIGTERM it
|
||||||
@@ -527,6 +552,7 @@ async fn start_media_download(
|
|||||||
destination: String,
|
destination: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
format_selector: Option<String>,
|
format_selector: Option<String>,
|
||||||
|
cookie_source: Option<String>,
|
||||||
speed_limit: Option<String>,
|
speed_limit: Option<String>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
@@ -575,6 +601,12 @@ async fn start_media_download(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(cs) = cookie_source {
|
||||||
|
if !cs.is_empty() && cs != "none" {
|
||||||
|
cmd.arg("--cookies-from-browser").arg(cs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(format) = format_selector {
|
if let Some(format) = format_selector {
|
||||||
cmd.arg("-f").arg(format);
|
cmd.arg("-f").arg(format);
|
||||||
// If the filename implies an audio format, use it as audio output
|
// If the filename implies an audio format, use it as audio output
|
||||||
@@ -958,9 +990,51 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> {
|
||||||
|
use tauri::tray::TrayIconBuilder;
|
||||||
|
use tauri::menu::{Menu, MenuItem};
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
if show {
|
||||||
|
if app_handle.tray_by_id("main").is_none() {
|
||||||
|
let quit_i = MenuItem::with_id(&app_handle, "quit", "Quit", true, None::<&str>).map_err(|e| e.to_string())?;
|
||||||
|
let show_i = MenuItem::with_id(&app_handle, "show", "Show Firelink", true, None::<&str>).map_err(|e| e.to_string())?;
|
||||||
|
let menu = Menu::with_items(&app_handle, &[&show_i, &quit_i]).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let _tray = TrayIconBuilder::with_id("main")
|
||||||
|
.icon(app_handle.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"quit" => {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
"show" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.build(&app_handle)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(_tray) = app_handle.tray_by_id("main") {
|
||||||
|
let _ = app_handle.remove_tray_by_id("main");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
extension_server::start_server(app.handle().clone());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
tasks: Mutex::new(HashMap::new()),
|
tasks: Mutex::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
@@ -973,8 +1047,9 @@ pub fn run() {
|
|||||||
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
||||||
request_automation_permission, open_automation_settings,
|
request_automation_permission, open_automation_settings,
|
||||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||||
check_file_exists, delete_file
|
check_file_exists, delete_file, toggle_tray_icon
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
mod extension_server;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function App() {
|
|||||||
const activeView = useSettingsStore(state => state.activeView);
|
const activeView = useSettingsStore(state => state.activeView);
|
||||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||||
|
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||||
const downloads = useDownloadStore(state => state.downloads);
|
const downloads = useDownloadStore(state => state.downloads);
|
||||||
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
|
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
|
||||||
const queuedCount = downloads.filter(download => download.status === 'queued').length;
|
const queuedCount = downloads.filter(download => download.status === 'queued').length;
|
||||||
@@ -43,6 +44,10 @@ function App() {
|
|||||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||||
}, [showDockBadge, activeDownloadCount]);
|
}, [showDockBadge, activeDownloadCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
|
||||||
|
}, [showMenuBarIcon]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousSpeedLimit.current === globalSpeedLimit) return;
|
if (previousSpeedLimit.current === globalSpeedLimit) return;
|
||||||
previousSpeedLimit.current = globalSpeedLimit;
|
previousSpeedLimit.current = globalSpeedLimit;
|
||||||
@@ -172,10 +177,21 @@ 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenProgress.then(f => f());
|
unlistenProgress.then(f => f());
|
||||||
unlistenComplete.then(f => f());
|
unlistenComplete.then(f => f());
|
||||||
unlistenFailed.then(f => f());
|
unlistenFailed.then(f => f());
|
||||||
|
unlistenExtension.then(f => f());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ const isMediaUrl = (url: string) => {
|
|||||||
|
|
||||||
|
|
||||||
export const AddDownloadsModal = () => {
|
export const AddDownloadsModal = () => {
|
||||||
const { isAddModalOpen, toggleAddModal, addDownload, queues } = useDownloadStore();
|
const { isAddModalOpen, pendingAddUrls, toggleAddModal, addDownload, queues } = useDownloadStore();
|
||||||
const { defaultDownloadPath } = useSettingsStore();
|
const { defaultDownloadPath } = useSettingsStore();
|
||||||
|
|
||||||
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
|
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
|
||||||
@@ -286,12 +286,12 @@ export const AddDownloadsModal = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAddModalOpen) {
|
if (isAddModalOpen) {
|
||||||
setSaveLocation(defaultDownloadPath);
|
setSaveLocation(defaultDownloadPath);
|
||||||
setUrls('');
|
setUrls(pendingAddUrls || '');
|
||||||
setParsedItems([]);
|
setParsedItems([]);
|
||||||
setSelectedItemIndex(null);
|
setSelectedItemIndex(null);
|
||||||
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
|
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
|
||||||
}
|
}
|
||||||
}, [isAddModalOpen, defaultDownloadPath]);
|
}, [isAddModalOpen, pendingAddUrls, defaultDownloadPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!saveLocation) return;
|
if (!saveLocation) return;
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ export const PropertiesModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setHeaders(activeItem.headers || '');
|
setHeaders(activeItem.headers || '');
|
||||||
|
setChecksumEnabled(!!activeItem.checksum);
|
||||||
|
if (activeItem.checksum) {
|
||||||
|
const [algo, val] = activeItem.checksum.split('=');
|
||||||
|
if (val) {
|
||||||
|
setChecksumAlgorithm(algo);
|
||||||
|
setChecksumValue(val);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setChecksumAlgorithm('SHA-256');
|
||||||
|
setChecksumValue('');
|
||||||
|
}
|
||||||
|
setCookies(activeItem.cookies || '');
|
||||||
|
setMirrors(activeItem.mirrors || '');
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
} else {
|
} else {
|
||||||
setItem(null);
|
setItem(null);
|
||||||
@@ -115,6 +128,9 @@ export const PropertiesModal = () => {
|
|||||||
username: loginMode === 'custom' ? username.trim() : null,
|
username: loginMode === 'custom' ? username.trim() : null,
|
||||||
password: loginMode === 'custom' ? password.trim() : null,
|
password: loginMode === 'custom' ? password.trim() : null,
|
||||||
headers: headers.trim() || null,
|
headers: headers.trim() || null,
|
||||||
|
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : null,
|
||||||
|
cookies: cookies.trim() || null,
|
||||||
|
mirrors: mirrors.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateDownload(item.id, updates);
|
updateDownload(item.id, updates);
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export interface DownloadItem {
|
|||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
headers?: string | null;
|
headers?: string | null;
|
||||||
|
checksum?: string | null;
|
||||||
|
cookies?: string | null;
|
||||||
|
mirrors?: string | null;
|
||||||
destination?: string;
|
destination?: string;
|
||||||
isMedia?: boolean;
|
isMedia?: boolean;
|
||||||
mediaFormatSelector?: string;
|
mediaFormatSelector?: string;
|
||||||
@@ -114,8 +117,10 @@ interface DownloadState {
|
|||||||
downloads: DownloadItem[];
|
downloads: DownloadItem[];
|
||||||
queues: Queue[];
|
queues: Queue[];
|
||||||
isAddModalOpen: boolean;
|
isAddModalOpen: boolean;
|
||||||
|
pendingAddUrls: string;
|
||||||
selectedPropertiesDownloadId: string | null;
|
selectedPropertiesDownloadId: string | null;
|
||||||
toggleAddModal: (isOpen: boolean) => void;
|
toggleAddModal: (isOpen: boolean) => void;
|
||||||
|
openAddModalWithUrls: (urls: string) => void;
|
||||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||||
addDownload: (item: DownloadItem) => void;
|
addDownload: (item: DownloadItem) => void;
|
||||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||||
@@ -135,8 +140,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
downloads: [],
|
downloads: [],
|
||||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||||
isAddModalOpen: false,
|
isAddModalOpen: false,
|
||||||
|
pendingAddUrls: '',
|
||||||
selectedPropertiesDownloadId: null,
|
selectedPropertiesDownloadId: null,
|
||||||
toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }),
|
toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }),
|
||||||
|
openAddModalWithUrls: (urls) => set({ isAddModalOpen: true, pendingAddUrls: urls }),
|
||||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||||
addDownload: (item) => {
|
addDownload: (item) => {
|
||||||
set((state) => ({ downloads: [...state.downloads, item] }));
|
set((state) => ({ downloads: [...state.downloads, item] }));
|
||||||
@@ -317,6 +324,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
destination: destPath,
|
destination: destPath,
|
||||||
filename: item.fileName,
|
filename: item.fileName,
|
||||||
formatSelector: item.mediaFormatSelector || null,
|
formatSelector: item.mediaFormatSelector || null,
|
||||||
|
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||||
speedLimit,
|
speedLimit,
|
||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword
|
password: item.password || keychainPassword
|
||||||
@@ -337,6 +345,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
headers: item.headers || null,
|
headers: item.headers || null,
|
||||||
|
checksum: item.checksum || null,
|
||||||
|
cookies: item.cookies || null,
|
||||||
|
mirrors: item.mirrors || null,
|
||||||
userAgent: settings.customUserAgent || null,
|
userAgent: settings.customUserAgent || null,
|
||||||
maxTries: settings.maxAutomaticRetries,
|
maxTries: settings.maxAutomaticRetries,
|
||||||
proxy: getProxyArgs(settings)
|
proxy: getProxyArgs(settings)
|
||||||
|
|||||||
Reference in New Issue
Block a user