From 7e155c88cd8b3ab0068f6cf06507de5c2b4179b2 Mon Sep 17 00:00:00 2001 From: 404Simon Date: Sat, 20 Jun 2026 10:51:59 +0200 Subject: [PATCH] feat: add custom request header support for reverse proxy auth Adds an 'Advanced' dropdown to the server-address overlay form allowing users to configure custom HTTP headers (key/value pairs) for reverse proxy authentication. --- src/config/src/lib.rs | 34 +++++++ src/jfn_cef/src/business_overlay.rs | 43 +++++++++ src/jfn_cef/src/business_web.rs | 16 ++++ src/jfn_cef/src/client_impl.rs | 6 ++ src/jfn_cef/src/client_impl/request.rs | 27 ++++++ .../src/client_impl/resource_request.rs | 45 ++++++++++ src/jfn_cef/src/injection.rs | 8 ++ src/mpv/src/api.rs | 30 +++++++ src/web/overlay.css | 89 +++++++++++++++++++ src/web/overlay.html | 7 ++ src/web/overlay.js | 65 ++++++++++++++ src/web/overlay.lang.js | 14 ++- 12 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/jfn_cef/src/client_impl/request.rs create mode 100644 src/jfn_cef/src/client_impl/resource_request.rs diff --git a/src/config/src/lib.rs b/src/config/src/lib.rs index a09f4f5b5..0c894d8b7 100644 --- a/src/config/src/lib.rs +++ b/src/config/src/lib.rs @@ -59,6 +59,7 @@ struct SettingsData { force_transcoding: bool, window_decorations: Option, hide_scrollbar: bool, + custom_headers: Vec<(String, String)>, } impl Default for SettingsData { @@ -77,6 +78,7 @@ impl Default for SettingsData { force_transcoding: false, window_decorations: None, hide_scrollbar: true, + custom_headers: Vec::new(), } } } @@ -154,6 +156,17 @@ impl SettingsData { if let Some(b) = v.get("hideScrollbar").and_then(Value::as_bool) { self.hide_scrollbar = b; } + if let Some(arr) = v.get("customHeaders").and_then(Value::as_array) { + self.custom_headers = arr + .iter() + .filter_map(|entry| { + let obj = entry.as_object()?; + let key = obj.get("key")?.as_str()?.to_string(); + let value = obj.get("value")?.as_str()?.to_string(); + Some((key, value)) + }) + .collect(); + } } fn to_json(&self) -> Value { @@ -223,6 +236,19 @@ impl SettingsData { if !self.device_name.is_empty() { o.insert("deviceName".into(), Value::String(self.device_name.clone())); } + if !self.custom_headers.is_empty() { + let arr: Vec = self + .custom_headers + .iter() + .map(|(k, v)| { + let mut entry = Map::new(); + entry.insert("key".into(), Value::String(k.clone())); + entry.insert("value".into(), Value::String(v.clone())); + Value::Object(entry) + }) + .collect(); + o.insert("customHeaders".into(), Value::Array(arr)); + } Value::Object(o) } @@ -548,6 +574,14 @@ pub fn titlebar_theme_color() -> bool { } bool_accessors!(hide_scrollbar, set_hide_scrollbar, hide_scrollbar); +pub fn custom_headers() -> Vec<(String, String)> { + state().lock().data.custom_headers.clone() +} + +pub fn set_custom_headers(headers: Vec<(String, String)>) { + state().lock().data.custom_headers = headers; +} + pub fn window_geometry() -> JfnWindowGeometry { state().lock().data.window } diff --git a/src/jfn_cef/src/business_overlay.rs b/src/jfn_cef/src/business_overlay.rs index e592df981..5e5092c01 100644 --- a/src/jfn_cef/src/business_overlay.rs +++ b/src/jfn_cef/src/business_overlay.rs @@ -175,6 +175,40 @@ fn handle_message(message: BrowserMessage) -> bool { } true } + "getCustomHeaders" => { + let Some(frame) = message.main_frame() else { + return true; + }; + let headers = jfn_config::custom_headers(); + send_to_renderer(&frame, "customHeaders", |args| { + // Send as JSON string — simple and avoids complex IPC list encoding + let json = serde_json::json!( + headers + .iter() + .map(|(k, v)| { serde_json::json!({"key": k, "value": v}) }) + .collect::>() + ); + args.set_string(0, Some(&CefString::from(json.to_string().as_str()))); + }); + true + } + "setCustomHeaders" => { + let Some(args) = args else { return true }; + let json_str = list_string(args, 0); + if let Ok(arr) = serde_json::from_str::>(&json_str) { + let headers: Vec<(String, String)> = arr + .iter() + .filter_map(|v| { + let key = v.get("key")?.as_str()?.to_string(); + let value = v.get("value")?.as_str()?.to_string(); + Some((key, value)) + }) + .collect(); + jfn_config::set_custom_headers(headers); + jfn_config::settings_save_async(); + } + true + } _ => false, } } @@ -318,6 +352,15 @@ fn make_request(method: &str, url: &str, client: UrlrequestClient) -> Option = hdrs.iter().map(|(k, v)| format!("{k}: {v}")).collect(); + Some(joined.join("\n")) + } + }; + let headers_c = headers_str + .as_deref() + .and_then(|s| std::ffi::CString::new(s).ok()); let opts = JfnMpvLoadOptions { start_secs: start_ms as f64 / 1000.0, video_track: video_idx, @@ -260,6 +272,10 @@ fn handle_player_load(args: &ListValue) { external_audio_url: ext_audio_c.as_ptr(), external_sub_url: ext_sub_c.as_ptr(), is_infinite_stream, + http_headers: headers_c + .as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(std::ptr::null()), }; unsafe { jfn_mpv_load_file(url_c.as_ptr(), &opts) }; } diff --git a/src/jfn_cef/src/client_impl.rs b/src/jfn_cef/src/client_impl.rs index 8199d399a..0a8644b74 100644 --- a/src/jfn_cef/src/client_impl.rs +++ b/src/jfn_cef/src/client_impl.rs @@ -12,12 +12,15 @@ mod load; mod os_ffi; mod process_message; mod render; +mod request; +mod resource_request; use context_menu::JfnContextMenuHandlerBuilder; use display::JfnDisplayHandlerBuilder; use keyboard::JfnKeyboardHandlerBuilder; use lifespan::JfnLifeSpanHandlerBuilder; use load::JfnLoadHandlerBuilder; use render::JfnRenderHandlerBuilder; +use request::JfnRequestHandlerBuilder; pub fn make_client(inner: Arc) -> Client { JfnClientBuilder::new(inner) @@ -47,6 +50,9 @@ wrap_client! { fn keyboard_handler(&self) -> Option { Some(JfnKeyboardHandlerBuilder::new(self.inner.clone())) } + fn request_handler(&self) -> Option { + Some(JfnRequestHandlerBuilder::new(self.inner.clone())) + } fn on_process_message_received( &self, browser: Option<&mut Browser>, diff --git a/src/jfn_cef/src/client_impl/request.rs b/src/jfn_cef/src/client_impl/request.rs new file mode 100644 index 000000000..586735c26 --- /dev/null +++ b/src/jfn_cef/src/client_impl/request.rs @@ -0,0 +1,27 @@ +use cef::*; +use std::sync::Arc; + +use crate::client::Inner; + +use super::resource_request::JfnResourceRequestHandlerBuilder; + +wrap_request_handler! { + pub struct JfnRequestHandlerBuilder { + inner: Arc, + } + + impl RequestHandler { + fn resource_request_handler( + &self, + _browser: Option<&mut Browser>, + _frame: Option<&mut Frame>, + _request: Option<&mut Request>, + _is_navigation: ::std::os::raw::c_int, + _is_download: ::std::os::raw::c_int, + _request_initiator: Option<&CefString>, + _disable_default_handling: Option<&mut ::std::os::raw::c_int>, + ) -> Option { + Some(JfnResourceRequestHandlerBuilder::new(self.inner.clone())) + } + } +} diff --git a/src/jfn_cef/src/client_impl/resource_request.rs b/src/jfn_cef/src/client_impl/resource_request.rs new file mode 100644 index 000000000..f3a46dad3 --- /dev/null +++ b/src/jfn_cef/src/client_impl/resource_request.rs @@ -0,0 +1,45 @@ +use cef::*; +use std::sync::Arc; + +use crate::app::userfree_to_string; +use crate::client::Inner; + +wrap_resource_request_handler! { + pub struct JfnResourceRequestHandlerBuilder { + inner: Arc, + } + + impl ResourceRequestHandler { + fn on_before_resource_load( + &self, + _browser: Option<&mut Browser>, + _frame: Option<&mut Frame>, + request: Option<&mut Request>, + _callback: Option<&mut Callback>, + ) -> ReturnValue { + let Some(req) = request else { return ReturnValue::CONTINUE }; + + let server_url = jfn_config::server_url(); + if server_url.is_empty() { + return ReturnValue::CONTINUE; + } + + let url = userfree_to_string(&req.url()); + if !url.starts_with(&server_url) { + return ReturnValue::CONTINUE; + } + + for (key, value) in &jfn_config::custom_headers() { + if !key.is_empty() { + req.set_header_by_name( + Some(&CefString::from(key.as_str())), + Some(&CefString::from(value.as_str())), + 1, + ); + } + } + + ReturnValue::CONTINUE + } + } +} diff --git a/src/jfn_cef/src/injection.rs b/src/jfn_cef/src/injection.rs index 66876fb1c..aea6ee161 100644 --- a/src/jfn_cef/src/injection.rs +++ b/src/jfn_cef/src/injection.rs @@ -64,6 +64,8 @@ pub(crate) enum NativeFunction { CsdReady, MenuItemSelected, MenuDismissed, + GetCustomHeaders, + SetCustomHeaders, } impl NativeFunction { @@ -114,6 +116,8 @@ impl NativeFunction { "csdReady" => Self::CsdReady, "menuItemSelected" => Self::MenuItemSelected, "menuDismissed" => Self::MenuDismissed, + "getCustomHeaders" => Self::GetCustomHeaders, + "setCustomHeaders" => Self::SetCustomHeaders, _ => return None, }) } @@ -165,6 +169,8 @@ impl NativeFunction { Self::CsdReady => "csdReady", Self::MenuItemSelected => "menuItemSelected", Self::MenuDismissed => "menuDismissed", + Self::GetCustomHeaders => "getCustomHeaders", + Self::SetCustomHeaders => "setCustomHeaders", } } } @@ -273,6 +279,8 @@ const OVERLAY_FUNCTIONS: &[NativeFunction] = &[ NativeFunction::DismissOverlay, NativeFunction::CheckServerConnectivity, NativeFunction::CancelServerConnectivity, + NativeFunction::GetCustomHeaders, + NativeFunction::SetCustomHeaders, ]; const ABOUT_FUNCTIONS: &[NativeFunction] = diff --git a/src/mpv/src/api.rs b/src/mpv/src/api.rs index 2228916f1..f0f255dc5 100644 --- a/src/mpv/src/api.rs +++ b/src/mpv/src/api.rs @@ -135,6 +135,22 @@ pub unsafe fn jfn_mpv_set_property_string_async(name: *const c_char, value: *con } } +pub unsafe fn jfn_mpv_set_property_string(name: *const c_char, value: *const c_char) { + let h = raw(); + if h.is_null() { + return; + } + let Some(n) = (unsafe { cstr(name) }) else { + return; + }; + let Some(v) = (unsafe { cstr(value) }) else { + return; + }; + unsafe { + sys::mpv_set_property_string(h, n.as_ptr(), v.as_ptr()); + } +} + /// Sync int property read. Writes the value into `*out` and returns /// libmpv's error code (0 on success, negative on failure). NULL `out` /// or missing handle returns `MPV_ERROR_INVALID_PARAMETER` (-4). @@ -372,6 +388,7 @@ pub struct JfnMpvLoadOptions { pub external_audio_url: *const c_char, pub external_sub_url: *const c_char, pub is_infinite_stream: bool, + pub http_headers: *const c_char, } struct PendingTrack { @@ -437,6 +454,19 @@ pub unsafe fn jfn_mpv_load_file(path: *const c_char, opts: *const JfnMpvLoadOpti s.valid = true; } + // Set custom HTTP headers for the stream before loading. + // Set both the global option and file-local to cover all backends. + if !o.http_headers.is_null() + && let Some(h) = unsafe { cstr(o.http_headers) } + { + let global = CString::new("http-header-fields").unwrap_or_default(); + let local = CString::new("file-local-options/http-header-fields").unwrap_or_default(); + unsafe { + sys::mpv_set_property_string(raw(), global.as_ptr(), h.as_ptr()); + sys::mpv_set_property_string(raw(), local.as_ptr(), h.as_ptr()); + } + } + let mut opts_str = format!("start={},pause=yes", o.start_secs); if defer_audio { // Per-file enable so mpv's demuxer picks the format-correct diff --git a/src/web/overlay.css b/src/web/overlay.css index d674ce743..d8d74b3ee 100644 --- a/src/web/overlay.css +++ b/src/web/overlay.css @@ -184,3 +184,92 @@ button:disabled { opacity: 1; } } + +#advanced-details { + width: 100%; + max-width: 450px; + margin-top: 0.5em; + color: #999; + font-size: 0.85em; +} +#advanced-details[open] { + color: #ddd; +} +#advanced-toggle { + cursor: pointer; + user-select: none; + padding: 0.3em 0; + color: inherit; + font-family: inherit; + font-size: 1em; +} +#advanced-toggle:hover { + color: #00a4dc; +} +#advanced-body { + margin-top: 0.5em; + display: flex; + flex-direction: column; + gap: 0.4em; +} +.header-row { + display: flex; + gap: 0.4em; + align-items: center; +} +.header-row input { + flex: 1; + font-size: 0.85em; + padding: 0.4em 0.5em; + background: #292929; + border: 0.16em solid #292929; + border-radius: 0.2em; + color: inherit; + font-family: inherit; + outline: none; +} +.header-row input:focus { + border-color: #00a4dc; +} +.header-row input::placeholder { + color: #555; +} +.header-row .remove-header { + background: none; + border: none; + color: #999; + cursor: pointer; + font-size: 1.1em; + padding: 0.2em 0.3em; + width: auto; + text-transform: none; + letter-spacing: normal; + font-weight: normal; +} +.header-row .remove-header:hover { + color: #cb272a; +} +#add-header-btn { + background: #333; + color: #999; + border: 0.16em dashed #555; + border-radius: 0.2em; + padding: 0.4em; + font-size: 0.85em; + cursor: pointer; + font-family: inherit; + width: auto; + text-transform: none; + letter-spacing: normal; + font-weight: normal; +} +#add-header-btn:hover { + border-color: #00a4dc; + color: #ddd; + background: #383838; +} +#headers-list { + display: flex; + flex-direction: column; + gap: 0.4em; +} diff --git a/src/web/overlay.html b/src/web/overlay.html index 41bc1a9aa..ab5a190ea 100644 --- a/src/web/overlay.html +++ b/src/web/overlay.html @@ -14,6 +14,13 @@

+
+ +
+
+ +
+
diff --git a/src/web/overlay.js b/src/web/overlay.js index 703d22eee..4f54f9405 100644 --- a/src/web/overlay.js +++ b/src/web/overlay.js @@ -12,6 +12,71 @@ const savedServerUrlReady = new Promise((resolve) => { }); window.jmpNative.getSavedServerUrl(); +// Custom request headers for reverse-proxy auth. Loaded from native config +// at startup; saved whenever the user modifies them. +let customHeaders = []; + +function buildHeaderRows() { + const list = document.getElementById('headers-list'); + list.innerHTML = ''; + customHeaders.forEach((h, i) => { + const row = document.createElement('div'); + row.className = 'header-row'; + const keyInput = document.createElement('input'); + keyInput.type = 'text'; + keyInput.placeholder = headerKeyPlaceholder; + keyInput.value = h.key; + keyInput.addEventListener('input', () => { + customHeaders[i].key = keyInput.value; + saveHeaders(); + }); + const valInput = document.createElement('input'); + valInput.type = 'text'; + valInput.placeholder = headerValuePlaceholder; + valInput.value = h.value; + valInput.addEventListener('input', () => { + customHeaders[i].value = valInput.value; + saveHeaders(); + }); + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-header'; + removeBtn.innerHTML = '×'; + removeBtn.addEventListener('click', () => { + customHeaders.splice(i, 1); + buildHeaderRows(); + saveHeaders(); + }); + row.appendChild(keyInput); + row.appendChild(valInput); + row.appendChild(removeBtn); + list.appendChild(row); + }); +} + +function saveHeaders() { + if (window.jmpNative && window.jmpNative.setCustomHeaders) { + window.jmpNative.setCustomHeaders(JSON.stringify(customHeaders)); + } +} + +// Load headers from native config +window._onCustomHeaders = (json) => { + try { + customHeaders = JSON.parse(json) || []; + } catch (_) { + customHeaders = []; + } + buildHeaderRows(); +}; +window.jmpNative.getCustomHeaders(); + +document.getElementById('add-header-btn').addEventListener('click', () => { + customHeaders.push({ key: '', value: '' }); + buildHeaderRows(); + saveHeaders(); +}); + // True whenever the main browser is loading the URL we currently care about. // Set by the auto-connect path once we know a saved URL exists (main.cpp // has already pre-loaded it), by navigateMain on user-initiated success, diff --git a/src/web/overlay.lang.js b/src/web/overlay.lang.js index fd7058fc4..e38acb3e5 100644 --- a/src/web/overlay.lang.js +++ b/src/web/overlay.lang.js @@ -161,7 +161,11 @@ const languages = [ "Connect": "Connect", "HeaderConnectionFailure": "Connection Failure", "MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.", - "ButtonGotIt": "Got It" + "ButtonGotIt": "Got It", + "LabelAdvanced": "Advanced", + "LabelAddHeader": "Add Header", + "HeaderKeyPlaceholder": "Header name", + "HeaderValuePlaceholder": "Value" }, { "lang": "eo", @@ -978,3 +982,11 @@ document.getElementById('address').placeholder = languageStrings.LabelServerHost document.getElementById('connect-button').innerText = connectText; document.getElementById('connect-button').setAttribute('data-original-text', connectText); window.cancelButtonText = 'Cancel'; + +const advancedToggleText = languageStrings.LabelAdvanced || fallbackStrings.LabelAdvanced || 'Advanced'; +const addHeaderText = languageStrings.LabelAddHeader || fallbackStrings.LabelAddHeader || 'Add Header'; +const headerKeyPlaceholder = languageStrings.HeaderKeyPlaceholder || fallbackStrings.HeaderKeyPlaceholder || 'Header name'; +const headerValuePlaceholder = languageStrings.HeaderValuePlaceholder || fallbackStrings.HeaderValuePlaceholder || 'Value'; + +document.getElementById('advanced-toggle').innerText = advancedToggleText; +document.getElementById('add-header-btn').innerText = addHeaderText;