mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
fix(security): harden browser handoff boundaries
This commit is contained in:
+1
-1
Submodule Extensions/Browser updated: 637f97a0da...cc7d8580b4
Generated
+2
@@ -5487,6 +5487,8 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"http",
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
tower-http = { version = "0.7", features = ["cors"] }
|
tower-http = { version = "0.7", features = ["cors", "limit"] }
|
||||||
sysproxy = "0.3.0"
|
sysproxy = "0.3.0"
|
||||||
semver = "1.0.28"
|
semver = "1.0.28"
|
||||||
keepawake = "0.6.0"
|
keepawake = "0.6.0"
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::{oneshot, watch};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::{
|
||||||
|
cors::{Any, CorsLayer},
|
||||||
|
limit::RequestBodyLimitLayer,
|
||||||
|
};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
||||||
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
||||||
const MAX_URL_COUNT: usize = 200;
|
const MAX_URL_COUNT: usize = 200;
|
||||||
|
const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024;
|
||||||
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
||||||
const SERVER_HEADER: &str = "x-firelink-server";
|
const SERVER_HEADER: &str = "x-firelink-server";
|
||||||
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||||
@@ -33,11 +37,14 @@ const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
|||||||
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||||
const PROTOCOL_VERSION: &str = "4";
|
const PROTOCOL_VERSION: &str = "4";
|
||||||
|
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||||
|
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
pub type SharedExtensionToken = Arc<RwLock<String>>;
|
pub type SharedExtensionToken = Arc<RwLock<String>>;
|
||||||
pub type SharedFrontendReady = Arc<AtomicBool>;
|
pub type SharedFrontendReady = Arc<AtomicBool>;
|
||||||
pub type SharedServerPort = Arc<RwLock<Option<u16>>>;
|
pub type SharedServerPort = Arc<RwLock<Option<u16>>>;
|
||||||
|
pub type SharedExtensionAcks = Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>;
|
||||||
type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
|
type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -45,6 +52,7 @@ pub struct ServerState {
|
|||||||
pub app_handle: AppHandle,
|
pub app_handle: AppHandle,
|
||||||
pub pairing_token: SharedExtensionToken,
|
pub pairing_token: SharedExtensionToken,
|
||||||
pub frontend_ready: SharedFrontendReady,
|
pub frontend_ready: SharedFrontendReady,
|
||||||
|
pub extension_acks: SharedExtensionAcks,
|
||||||
pub replay_cache: ReplayCache,
|
pub replay_cache: ReplayCache,
|
||||||
pub bound_port: u16,
|
pub bound_port: u16,
|
||||||
}
|
}
|
||||||
@@ -78,6 +86,8 @@ pub struct ExtensionCookieScope {
|
|||||||
#[derive(Clone, Serialize, TS)]
|
#[derive(Clone, Serialize, TS)]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
pub struct ExtensionDownload {
|
pub struct ExtensionDownload {
|
||||||
|
#[ts(optional)]
|
||||||
|
request_id: Option<String>,
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
referer: Option<String>,
|
referer: Option<String>,
|
||||||
silent: bool,
|
silent: bool,
|
||||||
@@ -92,6 +102,7 @@ pub async fn start_server(
|
|||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
pairing_token: SharedExtensionToken,
|
pairing_token: SharedExtensionToken,
|
||||||
frontend_ready: SharedFrontendReady,
|
frontend_ready: SharedFrontendReady,
|
||||||
|
extension_acks: SharedExtensionAcks,
|
||||||
server_port: SharedServerPort,
|
server_port: SharedServerPort,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -100,6 +111,7 @@ pub async fn start_server(
|
|||||||
app_handle,
|
app_handle,
|
||||||
pairing_token,
|
pairing_token,
|
||||||
frontend_ready,
|
frontend_ready,
|
||||||
|
extension_acks,
|
||||||
replay_cache: Arc::new(Mutex::new(HashMap::new())),
|
replay_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||||
bound_port: port,
|
bound_port: port,
|
||||||
};
|
};
|
||||||
@@ -116,6 +128,7 @@ pub async fn start_server(
|
|||||||
.route("/ping", get(ping_handler))
|
.route("/ping", get(ping_handler))
|
||||||
.route("/download", post(download_handler))
|
.route("/download", post(download_handler))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
|
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
|
||||||
.layer(middleware::from_fn(add_server_identity))
|
.layer(middleware::from_fn(add_server_identity))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
@@ -245,10 +258,11 @@ async fn download_handler(
|
|||||||
State(state): State<ServerState>,
|
State(state): State<ServerState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
if !has_allowed_request_origin(&headers) || !has_valid_optional_nonce(&headers) {
|
let nonce = match required_client_nonce(&headers) {
|
||||||
return Err(StatusCode::FORBIDDEN);
|
Some(nonce) if has_allowed_request_origin(&headers) => nonce,
|
||||||
}
|
_ => return Err(StatusCode::FORBIDDEN),
|
||||||
|
};
|
||||||
|
|
||||||
let signature = match headers
|
let signature = match headers
|
||||||
.get("x-firelink-signature")
|
.get("x-firelink-signature")
|
||||||
@@ -303,15 +317,45 @@ async fn download_handler(
|
|||||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||||
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||||
|
let mut download = download;
|
||||||
|
download.request_id = Some(request_id.clone());
|
||||||
|
|
||||||
if state
|
if state
|
||||||
.app_handle
|
.app_handle
|
||||||
.emit("extension-add-download", download)
|
.emit("extension-add-download", download)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
|
remove_extension_ack(&state.extension_acks, &request_id);
|
||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
if tokio::time::timeout(EXTENSION_ACK_TIMEOUT, ack_receiver)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
remove_extension_ack(&state.extension_acks, &request_id);
|
||||||
|
// The event may already have reached the frontend even when its
|
||||||
|
// acknowledgement was delayed or lost. Do not return 503 here:
|
||||||
|
// extension callers retry 503 and could create a duplicate modal.
|
||||||
|
return Err(StatusCode::GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
let proof = sign_server_proof(timestamp_str, nonce, state.bound_port, &state.pairing_token)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let mut response = Response::new(Body::empty());
|
||||||
|
response.headers_mut().insert(
|
||||||
|
SERVER_PROOF_HEADER,
|
||||||
|
HeaderValue::from_str(&proof).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
SERVER_PORT_HEADER,
|
||||||
|
HeaderValue::from_str(&state.bound_port.to_string())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
||||||
@@ -324,12 +368,45 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn register_extension_ack(
|
||||||
|
registry: &SharedExtensionAcks,
|
||||||
|
request_id: String,
|
||||||
|
) -> Option<oneshot::Receiver<()>> {
|
||||||
|
let (sender, receiver) = oneshot::channel();
|
||||||
|
let mut pending = registry.lock().ok()?;
|
||||||
|
if pending.len() >= MAX_PENDING_EXTENSION_ACKS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pending.insert(request_id, sender);
|
||||||
|
Some(receiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acknowledge_extension_download(registry: &SharedExtensionAcks, request_id: &str) -> bool {
|
||||||
|
let Some(sender) = registry
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut pending| pending.remove(request_id))
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
sender.send(()).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
|
||||||
|
if let Ok(mut pending) = registry.lock() {
|
||||||
|
pending.remove(request_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||||
|
if payload.urls.len() > MAX_URL_COUNT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let urls = payload
|
let urls = payload
|
||||||
.urls
|
.urls
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.take(MAX_URL_COUNT)
|
|
||||||
.filter_map(|raw_url| normalize_url(&raw_url))
|
.filter_map(|raw_url| normalize_url(&raw_url))
|
||||||
.filter(|url| seen.insert(url.clone()))
|
.filter(|url| seen.insert(url.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -377,6 +454,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
|||||||
});
|
});
|
||||||
|
|
||||||
Some(ExtensionDownload {
|
Some(ExtensionDownload {
|
||||||
|
request_id: None,
|
||||||
urls,
|
urls,
|
||||||
referer,
|
referer,
|
||||||
silent: payload.silent,
|
silent: payload.silent,
|
||||||
@@ -519,6 +597,13 @@ fn has_valid_optional_nonce(headers: &HeaderMap) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn required_client_nonce(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
headers
|
||||||
|
.get(CLIENT_NONCE_HEADER)
|
||||||
|
.and_then(|nonce| nonce.to_str().ok())
|
||||||
|
.filter(|nonce| is_valid_client_nonce(nonce))
|
||||||
|
}
|
||||||
|
|
||||||
fn sign_server_proof(
|
fn sign_server_proof(
|
||||||
timestamp_text: &str,
|
timestamp_text: &str,
|
||||||
nonce: &str,
|
nonce: &str,
|
||||||
@@ -553,16 +638,22 @@ fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) ->
|
|||||||
Some(now) => now,
|
Some(now) => now,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
|
claim_request_at(signature, timestamp, replay_cache, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claim_request_at(signature: &str, timestamp: u64, replay_cache: &ReplayCache, now: u64) -> bool {
|
||||||
let mut cache = match replay_cache.lock() {
|
let mut cache = match replay_cache.lock() {
|
||||||
Ok(cache) => cache,
|
Ok(cache) => cache,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
cache.retain(|_, seen_at| now.saturating_sub(*seen_at) < SIGNATURE_MAX_AGE_MS);
|
cache.retain(|_, expires_at| now < *expires_at);
|
||||||
if cache.len() > 10_000 {
|
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
|
||||||
|
if cache.len() >= 10_000 && !cache.contains_key(&key) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let key = format!("{timestamp}:{}", signature.to_ascii_lowercase());
|
cache
|
||||||
cache.insert(key, now).is_none()
|
.insert(key, timestamp.saturating_add(SIGNATURE_MAX_AGE_MS))
|
||||||
|
.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_time_millis() -> Option<u64> {
|
fn current_time_millis() -> Option<u64> {
|
||||||
@@ -605,9 +696,10 @@ fn is_allowed_origin(origin: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce,
|
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||||
is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest,
|
has_allowed_request_origin, has_valid_optional_nonce, is_valid_client_nonce,
|
||||||
ExtensionCookieScope, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope,
|
||||||
|
ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
http::{HeaderMap, HeaderValue, StatusCode},
|
http::{HeaderMap, HeaderValue, StatusCode},
|
||||||
@@ -679,6 +771,27 @@ mod tests {
|
|||||||
assert!(!has_valid_optional_nonce(&headers));
|
assert!(!has_valid_optional_nonce(&headers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_a_valid_client_nonce_for_downloads() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
assert!(required_client_nonce(&headers).is_none());
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
"x-firelink-client-nonce",
|
||||||
|
HeaderValue::from_static("not-a-valid-nonce"),
|
||||||
|
);
|
||||||
|
assert!(required_client_nonce(&headers).is_none());
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
"x-firelink-client-nonce",
|
||||||
|
HeaderValue::from_static("0123456789abcdef0123456789abcdef"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
required_client_nonce(&headers),
|
||||||
|
Some("0123456789abcdef0123456789abcdef")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn media_handoffs_reject_non_http_page_urls() {
|
fn media_handoffs_reject_non_http_page_urls() {
|
||||||
let download = normalize_download(ExtensionRequest {
|
let download = normalize_download(ExtensionRequest {
|
||||||
@@ -695,13 +808,62 @@ mod tests {
|
|||||||
assert!(download.is_none());
|
assert!(download.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_url_lists_instead_of_truncating_them() {
|
||||||
|
let download = normalize_download(ExtensionRequest {
|
||||||
|
urls: (0..=MAX_URL_COUNT)
|
||||||
|
.map(|index| format!("https://example.com/file-{index}.bin"))
|
||||||
|
.collect(),
|
||||||
|
referer: None,
|
||||||
|
silent: false,
|
||||||
|
filename: None,
|
||||||
|
headers: None,
|
||||||
|
cookies: None,
|
||||||
|
cookie_scopes: None,
|
||||||
|
media: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(download.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_replayed_download_signature() {
|
fn rejects_replayed_download_signature() {
|
||||||
let cache = Arc::new(Mutex::new(HashMap::new()));
|
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let signature = "a".repeat(64);
|
let signature = "a".repeat(64);
|
||||||
|
let now = 1_000_000;
|
||||||
|
|
||||||
assert!(claim_request(&signature, 42, &cache));
|
assert!(claim_request_at(&signature, now, &cache, now));
|
||||||
assert!(!claim_request(&signature, 42, &cache));
|
assert!(!claim_request_at(&signature, now, &cache, now + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn future_timestamp_replay_claim_survives_cache_pruning_window() {
|
||||||
|
let cache = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let signature = "b".repeat(64);
|
||||||
|
let now = 1_000_000;
|
||||||
|
let future_timestamp = now + 30_000;
|
||||||
|
|
||||||
|
assert!(claim_request_at(&signature, future_timestamp, &cache, now));
|
||||||
|
assert!(!claim_request_at(
|
||||||
|
&signature,
|
||||||
|
future_timestamp,
|
||||||
|
&cache,
|
||||||
|
now + 70_000
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn acknowledges_and_removes_pending_extension_event() {
|
||||||
|
let registry = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
|
registry
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert("request-1".to_string(), sender);
|
||||||
|
|
||||||
|
assert!(acknowledge_extension_download(®istry, "request-1"));
|
||||||
|
assert!(!acknowledge_extension_download(®istry, "request-1"));
|
||||||
|
assert!(receiver.await.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -776,10 +938,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(download.cookies.as_deref(), Some("SID=mail-session"));
|
assert_eq!(download.cookies.as_deref(), Some("SID=mail-session"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
download
|
download.cookie_scopes.as_ref().map(|scopes| scopes.len()),
|
||||||
.cookie_scopes
|
|
||||||
.as_ref()
|
|
||||||
.map(|scopes| scopes.len()),
|
|
||||||
Some(2)
|
Some(2)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+19
-1
@@ -2705,6 +2705,7 @@ pub struct AppState {
|
|||||||
pub storage_layout: crate::storage::StorageLayout,
|
pub storage_layout: crate::storage::StorageLayout,
|
||||||
pub extension_pairing_token: extension_server::SharedExtensionToken,
|
pub extension_pairing_token: extension_server::SharedExtensionToken,
|
||||||
pub extension_frontend_ready: extension_server::SharedFrontendReady,
|
pub extension_frontend_ready: extension_server::SharedFrontendReady,
|
||||||
|
pub extension_acks: extension_server::SharedExtensionAcks,
|
||||||
pub extension_server_port: extension_server::SharedServerPort,
|
pub extension_server_port: extension_server::SharedServerPort,
|
||||||
pub extension_server_shutdown: tokio::sync::watch::Sender<bool>,
|
pub extension_server_shutdown: tokio::sync::watch::Sender<bool>,
|
||||||
pub aria2_port: std::sync::Arc<std::sync::atomic::AtomicU16>,
|
pub aria2_port: std::sync::Arc<std::sync::atomic::AtomicU16>,
|
||||||
@@ -6201,6 +6202,19 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn ack_extension_download(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
request_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if request_id.len() != 32 || !request_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
return Err("Invalid extension request id".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
extension_server::acknowledge_extension_download(&state.extension_acks, &request_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
@@ -7948,6 +7962,8 @@ pub fn run() {
|
|||||||
let server_pairing_token = extension_pairing_token.clone();
|
let server_pairing_token = extension_pairing_token.clone();
|
||||||
let extension_frontend_ready = Arc::new(AtomicBool::new(false));
|
let extension_frontend_ready = Arc::new(AtomicBool::new(false));
|
||||||
let server_frontend_ready = extension_frontend_ready.clone();
|
let server_frontend_ready = extension_frontend_ready.clone();
|
||||||
|
let extension_acks = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let server_extension_acks = extension_acks.clone();
|
||||||
let extension_server_port = Arc::new(RwLock::new(None));
|
let extension_server_port = Arc::new(RwLock::new(None));
|
||||||
let server_extension_port = extension_server_port.clone();
|
let server_extension_port = extension_server_port.clone();
|
||||||
let (extension_server_shutdown_tx, extension_server_shutdown_rx) =
|
let (extension_server_shutdown_tx, extension_server_shutdown_rx) =
|
||||||
@@ -8074,6 +8090,7 @@ pub fn run() {
|
|||||||
storage_layout,
|
storage_layout,
|
||||||
extension_pairing_token,
|
extension_pairing_token,
|
||||||
extension_frontend_ready,
|
extension_frontend_ready,
|
||||||
|
extension_acks,
|
||||||
extension_server_port,
|
extension_server_port,
|
||||||
extension_server_shutdown: extension_server_shutdown_tx.clone(),
|
extension_server_shutdown: extension_server_shutdown_tx.clone(),
|
||||||
aria2_port: aria2_port.clone(),
|
aria2_port: aria2_port.clone(),
|
||||||
@@ -8529,6 +8546,7 @@ pub fn run() {
|
|||||||
ext_app_handle.clone(),
|
ext_app_handle.clone(),
|
||||||
server_pairing_token.clone(),
|
server_pairing_token.clone(),
|
||||||
server_frontend_ready.clone(),
|
server_frontend_ready.clone(),
|
||||||
|
server_extension_acks.clone(),
|
||||||
server_extension_port.clone(),
|
server_extension_port.clone(),
|
||||||
extension_server_shutdown_rx.clone(),
|
extension_server_shutdown_rx.clone(),
|
||||||
).await {
|
).await {
|
||||||
@@ -8600,7 +8618,7 @@ pub fn run() {
|
|||||||
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
|
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||||
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
|
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_global_speed_limit, remove_download,
|
||||||
detach_download_for_reconfigure,
|
detach_download_for_reconfigure,
|
||||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||||
|
|||||||
+19
-9
@@ -137,7 +137,7 @@ function App() {
|
|||||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||||
const { addToast } = useToast();
|
const { addToast, removeToast } = useToast();
|
||||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||||
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
|
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
|
||||||
// Keep dialogs out of the titlebar area while platform detection is still
|
// Keep dialogs out of the titlebar area while platform detection is still
|
||||||
@@ -179,19 +179,20 @@ function App() {
|
|||||||
|
|
||||||
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
||||||
let timerId: number | null = null;
|
let timerId: number | null = null;
|
||||||
|
let toastId: string | null = null;
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
if (timerId !== null) {
|
clearPendingPostActionTimer();
|
||||||
window.clearTimeout(timerId);
|
timerId = null;
|
||||||
if (pendingPostActionTimer.current === timerId) {
|
if (toastId !== null) {
|
||||||
pendingPostActionTimer.current = null;
|
removeToast(toastId);
|
||||||
}
|
toastId = null;
|
||||||
timerId = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addToast({
|
toastId = addToast({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
|
onDismiss: clearPendingPostActionTimer,
|
||||||
message: (
|
message: (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span>{actionLabel} in 10 seconds.</span>
|
<span>{actionLabel} in 10 seconds.</span>
|
||||||
@@ -207,6 +208,10 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
timerId = window.setTimeout(() => {
|
timerId = window.setTimeout(() => {
|
||||||
|
if (toastId !== null) {
|
||||||
|
removeToast(toastId);
|
||||||
|
toastId = null;
|
||||||
|
}
|
||||||
if (pendingPostActionTimer.current === timerId) {
|
if (pendingPostActionTimer.current === timerId) {
|
||||||
pendingPostActionTimer.current = null;
|
pendingPostActionTimer.current = null;
|
||||||
}
|
}
|
||||||
@@ -233,7 +238,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
pendingPostActionTimer.current = timerId;
|
pendingPostActionTimer.current = timerId;
|
||||||
}, [addToast, clearPendingPostActionTimer]);
|
}, [addToast, clearPendingPostActionTimer, removeToast]);
|
||||||
|
|
||||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -326,6 +331,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlistenExtension = await listen('extension-add-download', (event) => {
|
unlistenExtension = await listen('extension-add-download', (event) => {
|
||||||
|
if (event.payload.request_id) {
|
||||||
|
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
|
||||||
|
console.error('Failed to acknowledge browser extension download:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||||
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||||
|
|
||||||
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
|
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface ToastMessage {
|
|||||||
variant?: ToastVariant;
|
variant?: ToastVariant;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
isActionable?: boolean;
|
isActionable?: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastState extends ToastMessage {
|
interface ToastState extends ToastMessage {
|
||||||
@@ -21,7 +22,7 @@ interface ToastState extends ToastMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ToastContextType {
|
interface ToastContextType {
|
||||||
addToast: (toast: Omit<ToastMessage, 'id'>) => void;
|
addToast: (toast: Omit<ToastMessage, 'id'>) => string;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +34,12 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
|||||||
|
|
||||||
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
||||||
nextToastId.current += 1;
|
nextToastId.current += 1;
|
||||||
|
const id = `toast-${nextToastId.current}`;
|
||||||
setToasts(prev => {
|
setToasts(prev => {
|
||||||
const next = [...prev, { ...toast, id: `toast-${nextToastId.current}` }];
|
const next = [...prev, { ...toast, id }];
|
||||||
return next.slice(-MAX_VISIBLE_TOASTS);
|
return next.slice(-MAX_VISIBLE_TOASTS);
|
||||||
});
|
});
|
||||||
|
return id;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeToast = useCallback((id: string) => {
|
const removeToast = useCallback((id: string) => {
|
||||||
@@ -70,6 +73,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const timerStartedAt = useRef<number | null>(null);
|
const timerStartedAt = useRef<number | null>(null);
|
||||||
const remainingDuration = useRef<number | null>(null);
|
const remainingDuration = useRef<number | null>(null);
|
||||||
|
const onDismissCalled = useRef(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const frame = requestAnimationFrame(() => setIsMounted(true));
|
const frame = requestAnimationFrame(() => setIsMounted(true));
|
||||||
@@ -99,6 +103,16 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
|||||||
};
|
};
|
||||||
}, [toast, isHovered, removeToast]);
|
}, [toast, isHovered, removeToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dismiss = () => {
|
||||||
|
if (onDismissCalled.current) return;
|
||||||
|
onDismissCalled.current = true;
|
||||||
|
toast.onDismiss?.();
|
||||||
|
};
|
||||||
|
if (toast.exiting) dismiss();
|
||||||
|
return dismiss;
|
||||||
|
}, [toast.exiting, toast.onDismiss]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast.exiting) {
|
if (toast.exiting) {
|
||||||
const fallbackTimer = setTimeout(() => {
|
const fallbackTimer = setTimeout(() => {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type CommandMap = {
|
|||||||
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
||||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||||
|
ack_extension_download: { args: { requestId: string }; result: void };
|
||||||
get_system_proxy: { args: undefined; result: string | null };
|
get_system_proxy: { args: undefined; result: string | null };
|
||||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||||
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
||||||
|
|||||||
Reference in New Issue
Block a user