mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(settings): harden persisted settings and update checks
This commit is contained in:
+12
-23
@@ -1688,14 +1688,9 @@ async fn fetch_media_metadata(
|
|||||||
(Err(error), Some(browser))
|
(Err(error), Some(browser))
|
||||||
if !browser.trim().is_empty() && is_browser_cookie_extraction_error(&error) =>
|
if !browser.trim().is_empty() && is_browser_cookie_extraction_error(&error) =>
|
||||||
{
|
{
|
||||||
log::warn!(
|
Err(format!(
|
||||||
"yt-dlp could not read browser cookies from {}; retrying media metadata without browser cookies",
|
"Browser cookie extraction failed for {browser}: {error}"
|
||||||
browser
|
))
|
||||||
);
|
|
||||||
fetch_media_metadata_uncached(
|
|
||||||
app_handle, url, None, user_agent, username, password, headers, cookies, proxy,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
(result, _) => result,
|
(result, _) => result,
|
||||||
};
|
};
|
||||||
@@ -3025,8 +3020,7 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
|
|
||||||
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
let max_retries = max_tries.unwrap_or(0).max(0) as usize;
|
||||||
let mut strike = 0_usize;
|
let mut strike = 0_usize;
|
||||||
let mut effective_cookie_source = cookie_source.clone();
|
let effective_cookie_source = cookie_source.clone();
|
||||||
let mut browser_cookie_fallback_used = false;
|
|
||||||
|
|
||||||
while strike <= max_retries {
|
while strike <= max_retries {
|
||||||
let mut processing_started = false;
|
let mut processing_started = false;
|
||||||
@@ -3309,20 +3303,15 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
|
if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) {
|
||||||
cleanup_media_artifacts(&out_path, false).await;
|
cleanup_media_artifacts(&out_path, false).await;
|
||||||
}
|
}
|
||||||
if !browser_cookie_fallback_used
|
if effective_cookie_source
|
||||||
&& effective_cookie_source
|
.as_deref()
|
||||||
.as_deref()
|
.is_some_and(|source| !source.trim().is_empty() && source != "none")
|
||||||
.is_some_and(|source| !source.trim().is_empty() && source != "none")
|
|
||||||
&& is_browser_cookie_extraction_error(&failure_reason)
|
&& is_browser_cookie_extraction_error(&failure_reason)
|
||||||
{
|
{
|
||||||
let source = effective_cookie_source.clone().unwrap_or_default();
|
let source = effective_cookie_source.clone().unwrap_or_default();
|
||||||
log::warn!(
|
return Err(format!(
|
||||||
"yt-dlp could not read browser cookies from {}; retrying media download without browser cookies",
|
"Browser cookie extraction failed for {source}: {failure_reason}"
|
||||||
source
|
));
|
||||||
);
|
|
||||||
effective_cookie_source = None;
|
|
||||||
browser_cookie_fallback_used = true;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if !(transient && strikes_left) {
|
if !(transient && strikes_left) {
|
||||||
return Err(failure_reason);
|
return Err(failure_reason);
|
||||||
@@ -4583,7 +4572,7 @@ async fn set_concurrent_limit(
|
|||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state.queue_manager.set_capacity(limit);
|
state.queue_manager.set_capacity(limit.clamp(1, 12));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5843,7 +5832,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_browser_cookie_database_errors_for_fallback() {
|
fn classifies_browser_cookie_database_errors() {
|
||||||
assert!(is_browser_cookie_extraction_error(
|
assert!(is_browser_cookie_extraction_error(
|
||||||
"ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271"
|
"ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271"
|
||||||
));
|
));
|
||||||
|
|||||||
+152
-4
@@ -20,6 +20,7 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
|
|||||||
let document = decode_document(stored)?;
|
let document = decode_document(stored)?;
|
||||||
let mut state = settings_state(&document)?.clone();
|
let mut state = settings_state(&document)?.clone();
|
||||||
migrate_location_settings(&mut state)?;
|
migrate_location_settings(&mut state)?;
|
||||||
|
sanitize_persisted_setting_values(&mut state);
|
||||||
let mut merged = serde_json::to_value(default_settings())
|
let mut merged = serde_json::to_value(default_settings())
|
||||||
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
|
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
|
||||||
merge_json(&mut merged, &state);
|
merge_json(&mut merged, &state);
|
||||||
@@ -153,13 +154,108 @@ fn merge_json(target: &mut Value, source: &Value) {
|
|||||||
(Value::Object(target), Value::Object(source)) => {
|
(Value::Object(target), Value::Object(source)) => {
|
||||||
for (key, value) in source {
|
for (key, value) in source {
|
||||||
if let Some(target_value) = target.get_mut(key) {
|
if let Some(target_value) = target.get_mut(key) {
|
||||||
merge_json(target_value, value);
|
if json_value_types_match(target_value, value) {
|
||||||
|
merge_json(target_value, value);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
target.insert(key.clone(), value.clone());
|
target.insert(key.clone(), value.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(target, source) => *target = source.clone(),
|
(target, source) if json_value_types_match(target, source) => *target = source.clone(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_value_types_match(left: &Value, right: &Value) -> bool {
|
||||||
|
matches!(
|
||||||
|
(left, right),
|
||||||
|
(Value::Null, Value::Null)
|
||||||
|
| (Value::Bool(_), Value::Bool(_))
|
||||||
|
| (Value::Number(_), Value::Number(_))
|
||||||
|
| (Value::String(_), Value::String(_))
|
||||||
|
| (Value::Array(_), Value::Array(_))
|
||||||
|
| (Value::Object(_), Value::Object(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||||
|
let Some(state) = state.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
||||||
|
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
||||||
|
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||||
|
sanitize_allowed_string(
|
||||||
|
state,
|
||||||
|
"theme",
|
||||||
|
&["system", "light", "dark", "dracula", "nord"],
|
||||||
|
);
|
||||||
|
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
||||||
|
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
||||||
|
sanitize_allowed_string(state, "activeSettingsTab", &[
|
||||||
|
"downloads",
|
||||||
|
"lookandfeel",
|
||||||
|
"network",
|
||||||
|
"locations",
|
||||||
|
"sitelogins",
|
||||||
|
"power",
|
||||||
|
"engine",
|
||||||
|
"integrations",
|
||||||
|
"about",
|
||||||
|
]);
|
||||||
|
sanitize_allowed_string(state, "proxyMode", &["none", "system", "custom"]);
|
||||||
|
sanitize_allowed_string(
|
||||||
|
state,
|
||||||
|
"mediaCookieSource",
|
||||||
|
&[
|
||||||
|
"none", "safari", "chrome", "chromium", "firefox", "edge", "brave", "opera",
|
||||||
|
"vivaldi", "whale",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(scheduler) = state.get_mut("scheduler").and_then(Value::as_object_mut) {
|
||||||
|
sanitize_allowed_string(
|
||||||
|
scheduler,
|
||||||
|
"postQueueAction",
|
||||||
|
&["none", "sleep", "restart", "shutdown"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
|
||||||
|
logins.retain(|login| {
|
||||||
|
let Some(login) = login.as_object() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
["id", "urlPattern", "username"]
|
||||||
|
.into_iter()
|
||||||
|
.all(|key| login.get(key).and_then(Value::as_str).is_some())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_integer_setting(
|
||||||
|
state: &mut serde_json::Map<String, Value>,
|
||||||
|
key: &str,
|
||||||
|
is_valid: impl Fn(&Value) -> bool,
|
||||||
|
) {
|
||||||
|
if state.get(key).is_some_and(|value| !is_valid(value)) {
|
||||||
|
state.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_allowed_string(
|
||||||
|
state: &mut serde_json::Map<String, Value>,
|
||||||
|
key: &str,
|
||||||
|
allowed: &[&str],
|
||||||
|
) {
|
||||||
|
let valid = state
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|value| allowed.contains(&value));
|
||||||
|
if state.contains_key(key) && !valid {
|
||||||
|
state.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +263,9 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
|||||||
if settings.max_concurrent_downloads == 0 {
|
if settings.max_concurrent_downloads == 0 {
|
||||||
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
|
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
|
||||||
}
|
}
|
||||||
|
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||||
|
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||||
|
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_category_subfolders() -> HashMap<String, String> {
|
fn default_category_subfolders() -> HashMap<String, String> {
|
||||||
@@ -334,7 +433,7 @@ fn default_settings() -> PersistedSettings {
|
|||||||
per_server_connections: 16,
|
per_server_connections: 16,
|
||||||
max_automatic_retries: 3,
|
max_automatic_retries: 3,
|
||||||
show_notifications: true,
|
show_notifications: true,
|
||||||
play_completion_sound: true,
|
play_completion_sound: false,
|
||||||
app_font_size: AppFontSize::Standard,
|
app_font_size: AppFontSize::Standard,
|
||||||
list_row_density: ListRowDensity::Standard,
|
list_row_density: ListRowDensity::Standard,
|
||||||
show_dock_badge: true,
|
show_dock_badge: true,
|
||||||
@@ -356,7 +455,8 @@ fn default_settings() -> PersistedSettings {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
decode_stored_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys,
|
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
||||||
|
preserve_scheduler_runtime_keys,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -545,6 +645,54 @@ mod tests {
|
|||||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamps_out_of_range_download_settings() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {
|
||||||
|
"maxConcurrentDownloads": 99,
|
||||||
|
"perServerConnections": -4,
|
||||||
|
"maxAutomaticRetries": 99
|
||||||
|
},
|
||||||
|
"version": 3
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(settings.max_concurrent_downloads, 12);
|
||||||
|
assert_eq!(settings.per_server_connections, 1);
|
||||||
|
assert_eq!(settings.max_automatic_retries, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_malformed_setting_types_without_dropping_valid_values() {
|
||||||
|
let stored = json!({
|
||||||
|
"state": {
|
||||||
|
"baseDownloadFolder": "/Users/test/Downloads",
|
||||||
|
"maxConcurrentDownloads": "not-a-number",
|
||||||
|
"perServerConnections": 5,
|
||||||
|
"showNotifications": "yes",
|
||||||
|
"theme": "not-a-theme",
|
||||||
|
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||||
|
},
|
||||||
|
"version": 3
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
|
||||||
|
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||||
|
assert_eq!(settings.per_server_connections, 5);
|
||||||
|
assert!(settings.show_notifications);
|
||||||
|
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||||
|
assert_eq!(settings.site_logins.len(), 1);
|
||||||
|
assert_eq!(settings.site_logins[0].id, "valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_sound_default_matches_the_frontend_default() {
|
||||||
|
assert!(!default_settings().play_completion_sound);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_legacy_extension_pairing_token_field() {
|
fn ignores_legacy_extension_pairing_token_field() {
|
||||||
// Older standard installs persisted `extensionPairingToken` as
|
// Older standard installs persisted `extensionPairingToken` as
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { usePlatformInfo } from '../utils/platform';
|
|||||||
|
|
||||||
export const KeychainPermissionModal: React.FC = () => {
|
export const KeychainPermissionModal: React.FC = () => {
|
||||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||||
const setShowKeychainModal = useSettingsStore(state => state.setShowKeychainModal);
|
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
const [isGranting, setIsGranting] = useState(false);
|
const [isGranting, setIsGranting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -14,11 +14,11 @@ export const KeychainPermissionModal: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showKeychainModal || isGranting) return;
|
if (!showKeychainModal || isGranting) return;
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') setShowKeychainModal(false);
|
if (event.key === 'Escape') dismissKeychainPrompt();
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleEscape);
|
window.addEventListener('keydown', handleEscape);
|
||||||
return () => window.removeEventListener('keydown', handleEscape);
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
}, [isGranting, setShowKeychainModal, showKeychainModal]);
|
}, [dismissKeychainPrompt, isGranting, showKeychainModal]);
|
||||||
|
|
||||||
if (!showKeychainModal) {
|
if (!showKeychainModal) {
|
||||||
return null;
|
return null;
|
||||||
@@ -53,9 +53,10 @@ export const KeychainPermissionModal: React.FC = () => {
|
|||||||
useSettingsStore.setState({
|
useSettingsStore.setState({
|
||||||
keychainAccessGranted: true,
|
keychainAccessGranted: true,
|
||||||
extensionPairingToken: result.token,
|
extensionPairingToken: result.token,
|
||||||
isPairingTokenPersistent: true
|
isPairingTokenPersistent: true,
|
||||||
|
keychainPromptDismissed: false,
|
||||||
|
showKeychainModal: false
|
||||||
});
|
});
|
||||||
setShowKeychainModal(false);
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || `${storeName} is unavailable.`);
|
setError(result.error || `${storeName} is unavailable.`);
|
||||||
}
|
}
|
||||||
@@ -67,7 +68,7 @@ export const KeychainPermissionModal: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLater = () => {
|
const handleLater = () => {
|
||||||
setShowKeychainModal(false);
|
dismissKeychainPrompt();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
subfolderFromDerivedCategoryPath
|
subfolderFromDerivedCategoryPath
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { usePlatformInfo } from '../utils/platform';
|
import { usePlatformInfo } from '../utils/platform';
|
||||||
|
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||||
|
|
||||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||||
@@ -275,7 +276,7 @@ const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null
|
|||||||
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||||
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
|
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
|
||||||
const engineRunId = useRef(0);
|
const engineRunId = useRef(0);
|
||||||
const [appVersion, setAppVersion] = useState('1.0.1');
|
const [appVersion, setAppVersion] = useState('Unknown');
|
||||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||||
|
|
||||||
// Local state for adding site login
|
// Local state for adding site login
|
||||||
@@ -283,6 +284,11 @@ const [extensionServerPort, setExtensionServerPort] = useState<number | null>(nu
|
|||||||
const [loginUser, setLoginUser] = useState('');
|
const [loginUser, setLoginUser] = useState('');
|
||||||
const [loginPass, setLoginPass] = useState('');
|
const [loginPass, setLoginPass] = useState('');
|
||||||
const [loginError, setLoginError] = useState('');
|
const [loginError, setLoginError] = useState('');
|
||||||
|
const [loginFieldErrors, setLoginFieldErrors] = useState<{
|
||||||
|
pattern?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
// Toast notifications
|
// Toast notifications
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
@@ -431,6 +437,9 @@ runEngineChecks(false);
|
|||||||
localVersion: result.local_version
|
localVersion: result.local_version
|
||||||
});
|
});
|
||||||
} else if (result.type === 'UpdateAvailable') {
|
} else if (result.type === 'UpdateAvailable') {
|
||||||
|
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||||
|
throw new Error('The update check returned an untrusted release URL.');
|
||||||
|
}
|
||||||
setManualUpdateStatus({
|
setManualUpdateStatus({
|
||||||
type: 'update-available',
|
type: 'update-available',
|
||||||
version: result.update.version,
|
version: result.update.version,
|
||||||
@@ -512,10 +521,24 @@ runEngineChecks(false);
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLogin = async () => {
|
const handleAddLogin = async () => {
|
||||||
if (!loginPattern.trim() || !loginUser.trim()) {
|
const fieldErrors: typeof loginFieldErrors = {};
|
||||||
setLoginError("Please enter a URL pattern and a username.");
|
if (!loginPattern.trim()) {
|
||||||
|
fieldErrors.pattern = 'URL pattern is required.';
|
||||||
|
} else if (/\s/.test(loginPattern.trim())) {
|
||||||
|
fieldErrors.pattern = 'URL pattern cannot contain whitespace.';
|
||||||
|
}
|
||||||
|
if (!loginUser.trim()) {
|
||||||
|
fieldErrors.username = 'Username is required.';
|
||||||
|
}
|
||||||
|
if (!loginPass) {
|
||||||
|
fieldErrors.password = 'Password is required.';
|
||||||
|
}
|
||||||
|
if (Object.keys(fieldErrors).length > 0) {
|
||||||
|
setLoginFieldErrors(fieldErrors);
|
||||||
|
setLoginError('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLoginFieldErrors({});
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
if (loginPass) {
|
if (loginPass) {
|
||||||
@@ -1057,10 +1080,15 @@ runEngineChecks(false);
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={loginPattern}
|
value={loginPattern}
|
||||||
onChange={(e) => setLoginPattern(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setLoginPattern(e.target.value);
|
||||||
|
setLoginFieldErrors(current => ({ ...current, pattern: undefined }));
|
||||||
|
}}
|
||||||
placeholder="e.g. *.example.com or example.com/downloads"
|
placeholder="e.g. *.example.com or example.com/downloads"
|
||||||
|
aria-invalid={Boolean(loginFieldErrors.pattern)}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
{loginFieldErrors.pattern && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.pattern}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
@@ -1068,10 +1096,15 @@ runEngineChecks(false);
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={loginUser}
|
value={loginUser}
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setLoginUser(e.target.value);
|
||||||
|
setLoginFieldErrors(current => ({ ...current, username: undefined }));
|
||||||
|
}}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
|
aria-invalid={Boolean(loginFieldErrors.username)}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
{loginFieldErrors.username && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.username}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
@@ -1079,10 +1112,15 @@ runEngineChecks(false);
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={loginPass}
|
value={loginPass}
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setLoginPass(e.target.value);
|
||||||
|
setLoginFieldErrors(current => ({ ...current, password: undefined }));
|
||||||
|
}}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
|
aria-invalid={Boolean(loginFieldErrors.password)}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
{loginFieldErrors.password && <p className="text-red-500 text-xs mt-1">{loginFieldErrors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
@@ -1215,12 +1253,12 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold text-green-500 m-0">
|
<h4 className="text-sm font-semibold text-green-500 m-0">
|
||||||
{platform.portable ? 'Portable Pairing Enabled' : 'Credential Storage Available'}
|
{platform.portable ? 'Portable Pairing Enabled' : 'Pairing Token Persisted'}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-text-secondary m-0 mt-0.5">
|
<p className="text-xs text-text-secondary m-0 mt-0.5">
|
||||||
{platform.portable
|
{platform.portable
|
||||||
? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.'
|
? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.'
|
||||||
: "Your pairing token is securely saved in this system's credential store and will persist across restarts."}
|
: 'Your pairing token is persisted in Firelink settings and will persist across restarts.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+127
-13
@@ -22,6 +22,50 @@ let settingsSave = Promise.resolve();
|
|||||||
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
|
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
|
||||||
|
|
||||||
|
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
|
||||||
|
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
|
||||||
|
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
|
||||||
|
const PROXY_MODE_VALUES = ['none', 'system', 'custom'] as const;
|
||||||
|
const MEDIA_COOKIE_SOURCE_VALUES = [
|
||||||
|
'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale'
|
||||||
|
] as const;
|
||||||
|
const SETTINGS_TAB_VALUES = [
|
||||||
|
'downloads', 'lookandfeel', 'network', 'locations', 'sitelogins', 'power', 'engine', 'integrations', 'about'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PersistedSettingsSnapshot = PersistedSettings & {
|
||||||
|
keychainPromptDismissed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampSettingInteger = (
|
||||||
|
value: unknown,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
fallback: number
|
||||||
|
) => {
|
||||||
|
const numeric = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return fallback;
|
||||||
|
return Math.min(maximum, Math.max(minimum, Math.trunc(numeric)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedSetting = <T extends string>(values: readonly T[], value: unknown): value is T =>
|
||||||
|
typeof value === 'string' && values.includes(value as T);
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const sanitizeSiteLogins = (value: unknown): SiteLogin[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(isRecord).filter((login): login is SiteLogin =>
|
||||||
|
typeof login.id === 'string'
|
||||||
|
&& typeof login.urlPattern === 'string'
|
||||||
|
&& typeof login.username === 'string'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistedBoolean = (value: unknown, fallback: boolean) =>
|
||||||
|
typeof value === 'boolean' ? value : fallback;
|
||||||
|
|
||||||
const tauriStorage: StateStorage = {
|
const tauriStorage: StateStorage = {
|
||||||
getItem: async (name: string): Promise<string | null> => {
|
getItem: async (name: string): Promise<string | null> => {
|
||||||
if (name === 'firelink-settings') {
|
if (name === 'firelink-settings') {
|
||||||
@@ -113,6 +157,7 @@ export interface SettingsState {
|
|||||||
extensionPairingToken: string;
|
extensionPairingToken: string;
|
||||||
isPairingTokenPersistent: boolean;
|
isPairingTokenPersistent: boolean;
|
||||||
keychainAccessGranted: boolean;
|
keychainAccessGranted: boolean;
|
||||||
|
keychainPromptDismissed: boolean;
|
||||||
autoCheckUpdates: boolean;
|
autoCheckUpdates: boolean;
|
||||||
showKeychainModal: boolean;
|
showKeychainModal: boolean;
|
||||||
|
|
||||||
@@ -158,6 +203,7 @@ export interface SettingsState {
|
|||||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||||
hydratePairingToken: () => Promise<boolean>;
|
hydratePairingToken: () => Promise<boolean>;
|
||||||
setShowKeychainModal: (show: boolean) => void;
|
setShowKeychainModal: (show: boolean) => void;
|
||||||
|
dismissKeychainPrompt: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateSecureToken = () => {
|
const generateSecureToken = () => {
|
||||||
@@ -188,7 +234,7 @@ const generateSecureToken = () => {
|
|||||||
|
|
||||||
export const useSettingsStore = create<SettingsState>()(
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, _get) => ({
|
(set, get) => ({
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
baseDownloadFolder: '~/Downloads',
|
baseDownloadFolder: '~/Downloads',
|
||||||
categorySubfoldersEnabled: true,
|
categorySubfoldersEnabled: true,
|
||||||
@@ -236,8 +282,9 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
mediaCookieSource: 'none',
|
mediaCookieSource: 'none',
|
||||||
siteLogins: [],
|
siteLogins: [],
|
||||||
extensionPairingToken: '',
|
extensionPairingToken: '',
|
||||||
isPairingTokenPersistent: true,
|
isPairingTokenPersistent: false,
|
||||||
keychainAccessGranted: false,
|
keychainAccessGranted: false,
|
||||||
|
keychainPromptDismissed: false,
|
||||||
autoCheckUpdates: true,
|
autoCheckUpdates: true,
|
||||||
showKeychainModal: false,
|
showKeychainModal: false,
|
||||||
|
|
||||||
@@ -257,7 +304,9 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
},
|
},
|
||||||
setMaxConcurrentDownloads: (max) => {
|
setMaxConcurrentDownloads: (max) => {
|
||||||
info('Settings updated: maxConcurrentDownloads');
|
info('Settings updated: maxConcurrentDownloads');
|
||||||
set({ maxConcurrentDownloads: max });
|
set({
|
||||||
|
maxConcurrentDownloads: clampSettingInteger(max, 1, 12, 3)
|
||||||
|
});
|
||||||
},
|
},
|
||||||
setGlobalSpeedLimit: (limit) => {
|
setGlobalSpeedLimit: (limit) => {
|
||||||
info('Settings updated: globalSpeedLimit');
|
info('Settings updated: globalSpeedLimit');
|
||||||
@@ -275,8 +324,12 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||||
|
|
||||||
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
|
setPerServerConnections: (perServerConnections) => set({
|
||||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({ maxAutomaticRetries }),
|
perServerConnections: clampSettingInteger(perServerConnections, 1, 16, 16)
|
||||||
|
}),
|
||||||
|
setMaxAutomaticRetries: (maxAutomaticRetries) => set({
|
||||||
|
maxAutomaticRetries: clampSettingInteger(maxAutomaticRetries, 0, 10, 3)
|
||||||
|
}),
|
||||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||||
@@ -344,15 +397,17 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
const result = await invoke('hydrate_extension_pairing_token');
|
const result = await invoke('hydrate_extension_pairing_token');
|
||||||
set({
|
set({
|
||||||
extensionPairingToken: result.token,
|
extensionPairingToken: result.token,
|
||||||
isPairingTokenPersistent: result.persistent
|
isPairingTokenPersistent: result.persistent,
|
||||||
|
showKeychainModal: !result.persistent && !get().keychainPromptDismissed
|
||||||
});
|
});
|
||||||
if (!result.persistent) {
|
|
||||||
set({ showKeychainModal: true });
|
|
||||||
}
|
|
||||||
return result.tokenChanged;
|
return result.tokenChanged;
|
||||||
},
|
},
|
||||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => set({ autoCheckUpdates }),
|
setAutoCheckUpdates: (autoCheckUpdates: boolean) => set({ autoCheckUpdates }),
|
||||||
setShowKeychainModal: (show: boolean) => set({ showKeychainModal: show }),
|
setShowKeychainModal: (show: boolean) => set({ showKeychainModal: show }),
|
||||||
|
dismissKeychainPrompt: () => set({
|
||||||
|
keychainPromptDismissed: true,
|
||||||
|
showKeychainModal: false
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'firelink-settings',
|
name: 'firelink-settings',
|
||||||
@@ -391,7 +446,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
logsEnabled: persisted.logsEnabled === true
|
logsEnabled: persisted.logsEnabled === true
|
||||||
} as SettingsState;
|
} as SettingsState;
|
||||||
},
|
},
|
||||||
partialize: (state): PersistedSettings => ({
|
partialize: (state): PersistedSettingsSnapshot => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
baseDownloadFolder: state.baseDownloadFolder,
|
baseDownloadFolder: state.baseDownloadFolder,
|
||||||
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
|
||||||
@@ -429,6 +484,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
siteLogins: state.siteLogins,
|
siteLogins: state.siteLogins,
|
||||||
extensionPairingToken: state.extensionPairingToken,
|
extensionPairingToken: state.extensionPairingToken,
|
||||||
keychainAccessGranted: state.keychainAccessGranted,
|
keychainAccessGranted: state.keychainAccessGranted,
|
||||||
|
keychainPromptDismissed: state.keychainPromptDismissed,
|
||||||
autoCheckUpdates: state.autoCheckUpdates
|
autoCheckUpdates: state.autoCheckUpdates
|
||||||
}),
|
}),
|
||||||
merge: (persistedState: unknown, currentState) => {
|
merge: (persistedState: unknown, currentState) => {
|
||||||
@@ -440,6 +496,66 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
...currentState,
|
...currentState,
|
||||||
...persisted,
|
...persisted,
|
||||||
...locations,
|
...locations,
|
||||||
|
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||||
|
? persisted.theme
|
||||||
|
: currentState.theme,
|
||||||
|
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
|
||||||
|
? persisted.appFontSize
|
||||||
|
: currentState.appFontSize,
|
||||||
|
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
|
||||||
|
? persisted.listRowDensity
|
||||||
|
: currentState.listRowDensity,
|
||||||
|
proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode)
|
||||||
|
? persisted.proxyMode
|
||||||
|
: currentState.proxyMode,
|
||||||
|
mediaCookieSource: isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, persisted.mediaCookieSource)
|
||||||
|
? persisted.mediaCookieSource
|
||||||
|
: 'none',
|
||||||
|
activeSettingsTab: isAllowedSetting(SETTINGS_TAB_VALUES, persisted.activeSettingsTab)
|
||||||
|
? persisted.activeSettingsTab
|
||||||
|
: currentState.activeSettingsTab,
|
||||||
|
extensionPairingToken: typeof persisted.extensionPairingToken === 'string'
|
||||||
|
? persisted.extensionPairingToken
|
||||||
|
: currentState.extensionPairingToken,
|
||||||
|
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
|
||||||
|
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
|
||||||
|
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
|
||||||
|
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
|
||||||
|
askWhereToSaveEachFile: persistedBoolean(
|
||||||
|
persisted.askWhereToSaveEachFile,
|
||||||
|
currentState.askWhereToSaveEachFile
|
||||||
|
),
|
||||||
|
preventsSleepWhileDownloading: persistedBoolean(
|
||||||
|
persisted.preventsSleepWhileDownloading,
|
||||||
|
currentState.preventsSleepWhileDownloading
|
||||||
|
),
|
||||||
|
keychainAccessGranted: persistedBoolean(
|
||||||
|
persisted.keychainAccessGranted,
|
||||||
|
currentState.keychainAccessGranted
|
||||||
|
),
|
||||||
|
keychainPromptDismissed: persistedBoolean(
|
||||||
|
persisted.keychainPromptDismissed,
|
||||||
|
currentState.keychainPromptDismissed
|
||||||
|
),
|
||||||
|
autoCheckUpdates: persistedBoolean(persisted.autoCheckUpdates, currentState.autoCheckUpdates),
|
||||||
|
maxConcurrentDownloads: clampSettingInteger(
|
||||||
|
persisted.maxConcurrentDownloads,
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
currentState.maxConcurrentDownloads
|
||||||
|
),
|
||||||
|
perServerConnections: clampSettingInteger(
|
||||||
|
persisted.perServerConnections,
|
||||||
|
1,
|
||||||
|
16,
|
||||||
|
currentState.perServerConnections
|
||||||
|
),
|
||||||
|
maxAutomaticRetries: clampSettingInteger(
|
||||||
|
persisted.maxAutomaticRetries,
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
currentState.maxAutomaticRetries
|
||||||
|
),
|
||||||
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
|
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
|
||||||
? persisted.speedLimitPresetValues
|
? persisted.speedLimitPresetValues
|
||||||
: currentState.speedLimitPresetValues,
|
: currentState.speedLimitPresetValues,
|
||||||
@@ -455,10 +571,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
? persisted.scheduler.selectedQueueIds
|
? persisted.scheduler.selectedQueueIds
|
||||||
: currentState.scheduler.selectedQueueIds
|
: currentState.scheduler.selectedQueueIds
|
||||||
},
|
},
|
||||||
appFontSize: persisted.appFontSize || currentState.appFontSize,
|
|
||||||
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
|
|
||||||
siteLogins: Array.isArray(persisted.siteLogins)
|
siteLogins: Array.isArray(persisted.siteLogins)
|
||||||
? persisted.siteLogins
|
? sanitizeSiteLogins(persisted.siteLogins)
|
||||||
: currentState.siteLogins
|
: currentState.siteLogins
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isTrustedFirelinkReleaseUrl } from './releaseUrls';
|
||||||
|
|
||||||
|
describe('Firelink release URLs', () => {
|
||||||
|
it('accepts the repository release page and exact release tags', () => {
|
||||||
|
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases')).toBe(true);
|
||||||
|
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects lookalike paths and URLs with authority or path tricks', () => {
|
||||||
|
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases-evil')).toBe(false);
|
||||||
|
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases%2F..%2Fother')).toBe(false);
|
||||||
|
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5%5C..%5Cuser%5Crepo')).toBe(false);
|
||||||
|
expect(isTrustedFirelinkReleaseUrl('https://github.com.evil.example/nimbold/Firelink/releases')).toBe(false);
|
||||||
|
expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5?redirect=evil')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const FIRELINK_RELEASES_PATH = '/nimbold/Firelink/releases';
|
||||||
|
const FIRELINK_RELEASE_TAG_PATTERN = new RegExp(
|
||||||
|
`^${FIRELINK_RELEASES_PATH}/tag/[A-Za-z0-9._-]+$`
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isTrustedFirelinkReleaseUrl = (value: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (
|
||||||
|
parsed.protocol !== 'https:'
|
||||||
|
|| parsed.hostname !== 'github.com'
|
||||||
|
|| parsed.port
|
||||||
|
|| parsed.username
|
||||||
|
|| parsed.password
|
||||||
|
|| parsed.search
|
||||||
|
|| parsed.hash
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = decodeURIComponent(parsed.pathname);
|
||||||
|
return pathname === FIRELINK_RELEASES_PATH
|
||||||
|
|| FIRELINK_RELEASE_TAG_PATTERN.test(pathname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user