diff --git a/scripts/check-updates.js b/scripts/check-updates.js index f1a2480..55446b7 100644 --- a/scripts/check-updates.js +++ b/scripts/check-updates.js @@ -177,14 +177,14 @@ function escapeRegExp(value) { } async function latestBtbnFfmpegStableBuild(stableVersion) { - if (!/^\d+\.\d+\.\d+$/.test(stableVersion)) { + if (!/^\d+\.\d+(?:\.\d+)?$/.test(stableVersion)) { throw new Error(`unsupported FFmpeg stable version: ${stableVersion}`); } const stableSeries = stableVersion.split('.').slice(0, 2).join('.'); const versionPattern = escapeRegExp(stableVersion); const seriesPattern = escapeRegExp(stableSeries); const assetPattern = new RegExp( - `^ffmpeg-n(${versionPattern}-\\d+-g[0-9a-f]+)-(win64|linux64)-gpl-${seriesPattern}\\.(?:zip|tar\\.xz)$` + `^ffmpeg-n(${versionPattern}(?:-\\d+-g[0-9a-f]+)?)-(win64|linux64)-gpl-${seriesPattern}\\.(?:zip|tar\\.xz)$` ); const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10'); if (!Array.isArray(releases)) throw new Error('BtbN releases response is not an array'); @@ -449,7 +449,12 @@ async function main() { async () => { const build = await latestMartinRiedlMacArm64Release(); const stableVersion = await ffmpegStablePromise; - if (!build?.version || !build.url || !build.sha256 || build.version !== stableVersion) { + if ( + !build?.version || + !build.url || + !build.sha256 || + compareVersions(build.version, stableVersion) !== 0 + ) { throw new Error('Martin Riedl FFmpeg provider response has no complete matching macOS arm64 stable build'); } return build; diff --git a/scripts/check-updates.node-test.js b/scripts/check-updates.node-test.js index 7b35d73..42e974f 100644 --- a/scripts/check-updates.node-test.js +++ b/scripts/check-updates.node-test.js @@ -179,6 +179,33 @@ test('selects a complete BtbN build for the current stable series', async () => assert.equal(result.hashes.linux, 'b'.repeat(64)); }); +test('selects a complete BtbN build for a two-part stable version and tag-exact assets', async () => { + const digest = value => `sha256:${value.repeat(64)}`; + const release = { + tag_name: 'autobuild-test', + assets: [ + { + name: 'ffmpeg-n9.0-win64-gpl-9.0.zip', + browser_download_url: 'https://example.test/windows-exact.zip', + digest: digest('e'), + }, + { + name: 'ffmpeg-n9.0-linux64-gpl-9.0.tar.xz', + browser_download_url: 'https://example.test/linux-exact.tar.xz', + digest: digest('f'), + }, + ], + }; + const result = await withMockFetch( + async () => new Response(JSON.stringify([release]), { status: 200 }), + () => latestBtbnFfmpegStableBuild('9.0'), + ); + + assert.equal(result.version, '9.0'); + assert.equal(result.urls.windows, 'https://example.test/windows-exact.zip'); + assert.equal(result.hashes.linux, 'f'.repeat(64)); +}); + test('rejects an incomplete BtbN stable target tuple', async () => { const release = { tag_name: 'autobuild-test', diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6f3ad6e..d957033 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4616,6 +4616,14 @@ fn parse_ffmpeg_version(output: &str) -> Option { without_url .split('-') .next() + .map(|version| { + if let Some(rest) = version.strip_prefix(['n', 'N']) { + if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return rest; + } + } + version + }) .filter(|version| !version.trim().is_empty()) .map(str::to_string) } @@ -17932,6 +17940,13 @@ mod tests { assert_eq!(parse_ffmpeg_version(output), Some("9.0.1".to_string())); } + #[test] + fn parses_btbn_ffmpeg_prefixed_version() { + let output = "ffmpeg version n9.0.1-11-ge47273f4d9 Copyright (c) 2000-2026 the FFmpeg developers"; + + assert_eq!(parse_ffmpeg_version(output), Some("9.0.1".to_string())); + } + #[test] fn uses_fragment_progress_instead_of_temporary_hls_size_estimates() { let line = format!( diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index b0536e6..6a6cb9c 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -100,22 +100,7 @@ async fn native_system_proxy(runner: &dyn ProxyCommandRunner) -> Result Result, String> { - let mode = runner - .stdout( - "gsettings", - &string_args(&["get", "org.gnome.system.proxy", "mode"]), - ) - .await?; - if strip_gsettings_string(&mode) != "manual" { - return Ok(None); - } - - for (service, scheme) in [("https", "http"), ("http", "http"), ("socks", "socks5")] { - if let Some(proxy) = linux_gsettings_proxy(runner, service, scheme).await { - return Ok(Some(proxy)); - } - } - Ok(None) + linux_system_proxy(runner).await } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] @@ -127,20 +112,28 @@ fn string_args(args: &[&str]) -> Vec { args.iter().map(|value| (*value).to_string()).collect() } +#[cfg_attr( + all(not(target_os = "windows"), not(target_os = "linux"), not(target_os = "macos")), + allow(dead_code) +)] +fn is_probe_unavailable(error: &str) -> bool { + error.contains("is unavailable") || error.contains("exited unsuccessfully") +} + fn proxy_from_environment() -> Option { [ - "HTTPS_PROXY", - "https_proxy", - "HTTP_PROXY", - "http_proxy", - "ALL_PROXY", - "all_proxy", + ("HTTPS_PROXY", "http"), + ("https_proxy", "http"), + ("HTTP_PROXY", "http"), + ("http_proxy", "http"), + ("ALL_PROXY", "socks5"), + ("all_proxy", "socks5"), ] .into_iter() - .find_map(|name| { + .find_map(|(name, scheme)| { std::env::var(name) .ok() - .and_then(|value| normalize_proxy_address(&value, "http")) + .and_then(|value| normalize_proxy_address(&value, scheme)) }) } @@ -180,7 +173,8 @@ fn proxy_from_host_port(host: &str, port: &str, scheme: &str) -> Option }; let port = port.trim().parse::().ok().filter(|port| *port != 0)?; if host.is_empty() - || host.contains(['/', '@', '?', '#']) + || host.eq_ignore_ascii_case("(null)") + || host.contains(['/', '@', '?', '#', '(', ')']) || host.chars().any(char::is_whitespace) { return None; @@ -224,13 +218,64 @@ fn parse_windows_proxy_server(value: &str) -> Option { https.or(http).or(socks) } -#[cfg(target_os = "macos")] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn scutil_dict_value<'a>(output: &'a str, key: &str) -> Option<&'a str> { + for line in output.lines() { + let trimmed = line.trim(); + if let Some((k, v)) = trimmed.split_once(':') { + if k.trim().eq_ignore_ascii_case(key) { + let val = v.trim(); + if !val.is_empty() { + return Some(val); + } + } + } + } + None +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn parse_macos_scutil_proxy(output: &str) -> Option { + for (enable_key, proxy_key, port_key, scheme) in [ + ("HTTPSEnable", "HTTPSProxy", "HTTPSPort", "http"), + ("HTTPEnable", "HTTPProxy", "HTTPPort", "http"), + ("SOCKSEnable", "SOCKSProxy", "SOCKSPort", "socks5"), + ] { + if scutil_dict_value(output, enable_key) == Some("1") { + if let (Some(server), Some(port)) = ( + scutil_dict_value(output, proxy_key), + scutil_dict_value(output, port_key), + ) { + if let Some(proxy) = proxy_from_host_port(server, port, scheme) { + return Some(proxy); + } + } + } + } + None +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] async fn macos_system_proxy(runner: &dyn ProxyCommandRunner) -> Result, String> { - let services_output = runner + if let Ok(scutil_output) = runner.stdout("scutil", &string_args(&["--proxy"])).await { + if let Some(proxy) = parse_macos_scutil_proxy(&scutil_output) { + return Ok(Some(proxy)); + } + } + + let services_output = match runner .stdout("networksetup", &string_args(&["-listallnetworkservices"])) - .await?; + .await + { + Ok(output) => output, + Err(error) => { + if is_probe_unavailable(&error) { + return Ok(None); + } + return Err(error); + } + }; let services = parse_macos_network_services(&services_output)?; - let mut successful_probe = false; for (target, scheme) in [ ("securewebproxy", "http"), ("webproxy", "http"), @@ -239,18 +284,13 @@ async fn macos_system_proxy(runner: &dyn ProxyCommandRunner) -> Result(output: &'a str, key: &str) -> Option<&'a str> { .filter(|value| !value.is_empty()) } -#[cfg(target_os = "linux")] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +async fn linux_system_proxy(runner: &dyn ProxyCommandRunner) -> Result, String> { + let mode = match runner + .stdout( + "gsettings", + &string_args(&["get", "org.gnome.system.proxy", "mode"]), + ) + .await + { + Ok(mode) => mode, + Err(error) => { + if is_probe_unavailable(&error) { + return Ok(None); + } + return Err(error); + } + }; + if strip_gsettings_string(&mode) != "manual" { + return Ok(None); + } + + for (service, scheme) in [("https", "http"), ("http", "http"), ("socks", "socks5")] { + if let Some(proxy) = linux_gsettings_proxy(runner, service, scheme).await { + return Ok(Some(proxy)); + } + } + Ok(None) +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] async fn linux_gsettings_proxy( runner: &dyn ProxyCommandRunner, service: &str, @@ -322,9 +391,9 @@ fn strip_gsettings_string(value: &str) -> String { .to_string() } -#[cfg(target_os = "windows")] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] async fn windows_system_proxy(runner: &dyn ProxyCommandRunner) -> Result, String> { - let output = runner + let output = match runner .stdout( "reg", &string_args(&[ @@ -334,7 +403,16 @@ async fn windows_system_proxy(runner: &dyn ProxyCommandRunner) -> Result output, + Err(error) => { + if is_probe_unavailable(&error) { + return Ok(None); + } + return Err(error); + } + }; let enabled = registry_value(&output, "ProxyEnable") .as_deref() .is_some_and(windows_proxy_enabled); @@ -342,7 +420,7 @@ async fn windows_system_proxy(runner: &dyn ProxyCommandRunner) -> Result Result output, + Err(error) => { + if is_probe_unavailable(&error) { + return Ok(None); + } + return Err(error); + } + }; Ok(registry_value(&output, "ProxyServer").and_then(|value| parse_windows_proxy_server(&value))) } @@ -395,9 +482,9 @@ fn registry_value(output: &str, name: &str) -> Option { mod proxy_tests { use super::{ bounded_native_system_proxy, normalize_proxy_address, parse_macos_network_services, - parse_macos_networksetup_proxy, parse_windows_proxy_server, proxy_from_host_port, - registry_value, strip_gsettings_string, windows_proxy_enabled, ProxyCommandRunner, - SystemProxyCommandRunner, + parse_macos_networksetup_proxy, parse_macos_scutil_proxy, parse_windows_proxy_server, + proxy_from_host_port, registry_value, strip_gsettings_string, windows_proxy_enabled, + ProxyCommandRunner, SystemProxyCommandRunner, }; use std::time::Duration; @@ -407,32 +494,57 @@ mod proxy_tests { #[cfg(target_os = "macos")] struct SlowProxyCommandRunner; + #[cfg(target_os = "macos")] + struct ScutilProxyCommandRunner; + #[cfg(target_os = "macos")] #[async_trait::async_trait] impl ProxyCommandRunner for MockProxyCommandRunner { async fn stdout(&self, program: &str, args: &[String]) -> Result { - assert_eq!(program, "networksetup"); - match args - .iter() - .map(String::as_str) - .collect::>() - .as_slice() - { - ["-listallnetworkservices"] => Ok("Wi-Fi\nEthernet\n".to_string()), - ["-getsecurewebproxy", "Wi-Fi"] => { - Ok("Enabled: No\nServer: ignored.example\nPort: 443\n".to_string()) - } - ["-getsecurewebproxy", "Ethernet"] => { - Ok("Enabled: Yes\nServer: secure.example\nPort: 8443\n".to_string()) - } - ["-getwebproxy", _] | ["-getsocksfirewallproxy", _] => { - panic!("lower-priority proxy was queried after HTTPS succeeded") + match program { + "scutil" => { + assert_eq!(args, &["--proxy"]); + Ok(" {\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n".to_string()) } + "networksetup" => match args + .iter() + .map(String::as_str) + .collect::>() + .as_slice() + { + ["-listallnetworkservices"] => Ok("Wi-Fi\nEthernet\n".to_string()), + ["-getsecurewebproxy", "Wi-Fi"] => { + Ok("Enabled: No\nServer: ignored.example\nPort: 443\n".to_string()) + } + ["-getsecurewebproxy", "Ethernet"] => { + Ok("Enabled: Yes\nServer: secure.example\nPort: 8443\n".to_string()) + } + ["-getwebproxy", _] | ["-getsocksfirewallproxy", _] => { + panic!("lower-priority proxy was queried after HTTPS succeeded") + } + _ => Err("unexpected command".to_string()), + }, _ => Err("unexpected command".to_string()), } } } + #[cfg(target_os = "macos")] + #[async_trait::async_trait] + impl ProxyCommandRunner for ScutilProxyCommandRunner { + async fn stdout(&self, program: &str, args: &[String]) -> Result { + assert_eq!(program, "scutil"); + assert_eq!(args, &["--proxy"]); + Ok(r#" { + HTTPSEnable : 1 + HTTPSPort : 8443 + HTTPSProxy : scutil.example +} +"# + .to_string()) + } + } + #[cfg(target_os = "macos")] #[async_trait::async_trait] impl ProxyCommandRunner for SlowProxyCommandRunner { @@ -594,6 +706,132 @@ HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings assert!(!windows_proxy_enabled("0X0")); assert!(windows_proxy_enabled("0X1")); } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn selects_active_scutil_proxy_before_networksetup() { + assert_eq!( + super::macos_system_proxy(&ScutilProxyCommandRunner) + .await + .unwrap() + .as_deref(), + Some("http://scutil.example:8443") + ); + } + + #[test] + fn parses_macos_scutil_proxy_outputs() { + let scutil_https = r#" { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 1 + HTTPSPort : 8443 + HTTPSProxy : secure.local +} +"#; + assert_eq!( + parse_macos_scutil_proxy(scutil_https).as_deref(), + Some("http://secure.local:8443") + ); + + let scutil_socks = r#" { + HTTPEnable : 0 + HTTPSEnable : 0 + SOCKSEnable : 1 + SOCKSPort : 1080 + SOCKSProxy : 127.0.0.1 +} +"#; + assert_eq!( + parse_macos_scutil_proxy(scutil_socks).as_deref(), + Some("socks5://127.0.0.1:1080") + ); + + let scutil_ipv6 = r#" { + HTTPEnable : 1 + HTTPPort : 8080 + HTTPProxy : 2001:db8::1 +} +"#; + assert_eq!( + parse_macos_scutil_proxy(scutil_ipv6).as_deref(), + Some("http://[2001:db8::1]:8080") + ); + + let scutil_disabled = r#" { + HTTPEnable : 0 + HTTPSEnable : 0 + SOCKSEnable : 0 +} +"#; + assert_eq!(parse_macos_scutil_proxy(scutil_disabled), None); + } + + struct MockWindowsMissingRegRunner; + + #[async_trait::async_trait] + impl ProxyCommandRunner for MockWindowsMissingRegRunner { + async fn stdout(&self, _program: &str, _args: &[String]) -> Result { + Err("reg exited unsuccessfully".to_string()) + } + } + + struct MockWindowsEnabledRegRunner; + + #[async_trait::async_trait] + impl ProxyCommandRunner for MockWindowsEnabledRegRunner { + async fn stdout(&self, program: &str, args: &[String]) -> Result { + assert_eq!(program, "reg"); + if args.iter().any(|a| a == "ProxyEnable") { + Ok(" ProxyEnable REG_DWORD 0x1\n".to_string()) + } else if args.iter().any(|a| a == "ProxyServer") { + Ok(" ProxyServer REG_SZ 127.0.0.1:8080\n".to_string()) + } else { + Err("reg exited unsuccessfully".to_string()) + } + } + } + + #[tokio::test] + async fn windows_system_proxy_returns_none_when_value_is_missing() { + assert_eq!( + super::windows_system_proxy(&MockWindowsMissingRegRunner) + .await + .unwrap(), + None + ); + } + + #[tokio::test] + async fn windows_system_proxy_returns_configured_proxy() { + assert_eq!( + super::windows_system_proxy(&MockWindowsEnabledRegRunner) + .await + .unwrap() + .as_deref(), + Some("http://127.0.0.1:8080") + ); + } + + struct MockLinuxUnavailableGsettingsRunner; + + #[async_trait::async_trait] + impl ProxyCommandRunner for MockLinuxUnavailableGsettingsRunner { + async fn stdout(&self, _program: &str, _args: &[String]) -> Result { + Err("gsettings is unavailable: not found".to_string()) + } + } + + #[tokio::test] + async fn linux_system_proxy_returns_none_when_gsettings_is_unavailable() { + assert_eq!( + super::linux_system_proxy(&MockLinuxUnavailableGsettingsRunner) + .await + .unwrap(), + None + ); + } } #[tauri::command]