7591425f6f
Surface THIRD-PARTY-NOTICES.txt in every GUI client (the desktop packages already
ship it as a file; this adds the on-glass screen):
- Linux: Preferences -> About -> Third-party licenses (adw::AboutDialog with the app
license + Legal sections; include_str! the root notices).
- Apple: macOS About tab / iOS+tvOS Acknowledgements link; notices bundled as
PunktfunkKit SPM resources, read via Bundle.module (the Xcode app links the SPM
product, so they ride along - no .pbxproj edit).
- Android: Settings -> About -> Open-source licenses (reads the bundled asset).
- (Windows landed earlier in d1d2ca2: Settings -> About -> Third-party licenses.)
gen-third-party-notices.sh now copies the generated file into the Apple Resources/
and Android assets/ trees so the in-tree copies never drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
9.8 KiB
Rust
270 lines
9.8 KiB
Rust
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||
//! capture behavior. Written back to disk when the dialog closes.
|
||
|
||
use crate::trust::Settings;
|
||
use adw::prelude::*;
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
|
||
const RESOLUTIONS: &[(u32, u32)] = &[
|
||
(0, 0),
|
||
(1280, 720),
|
||
(1920, 1080),
|
||
(2560, 1440),
|
||
(3840, 2160),
|
||
];
|
||
/// `0` = the monitor's native refresh, resolved at connect.
|
||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
|
||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||
|
||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||
const APP_LICENSE: &str = concat!(
|
||
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||
"================================ MIT ================================\n\n",
|
||
include_str!("../../../LICENSE-MIT"),
|
||
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||
include_str!("../../../LICENSE-APACHE"),
|
||
);
|
||
/// Third-party software notices for the linked Rust crates (generated by
|
||
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
|
||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||
|
||
/// Show the About dialog (app license + the third-party-software Legal section).
|
||
fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||
let about = adw::AboutDialog::builder()
|
||
.application_name("punktfunk")
|
||
.developer_name("unom")
|
||
.version(env!("CARGO_PKG_VERSION"))
|
||
.website("https://git.unom.io/unom/punktfunk")
|
||
.license_type(gtk::License::Custom)
|
||
.license(APP_LICENSE)
|
||
.build();
|
||
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
|
||
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
|
||
about.add_legal_section(
|
||
"Third-party software (Rust crates)",
|
||
None,
|
||
gtk::License::Custom,
|
||
Some(THIRD_PARTY_NOTICES),
|
||
);
|
||
about.add_legal_section(
|
||
"Third-party software (system libraries)",
|
||
None,
|
||
gtk::License::Custom,
|
||
Some(
|
||
"This application dynamically links system libraries under their own licenses, \
|
||
including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \
|
||
and SDL 3 (Zlib). Their full license texts are available from each project.",
|
||
),
|
||
);
|
||
about.present(Some(parent));
|
||
}
|
||
|
||
pub fn show(
|
||
parent: &impl IsA<gtk::Widget>,
|
||
settings: Rc<RefCell<Settings>>,
|
||
gamepads: &crate::gamepad::GamepadService,
|
||
) {
|
||
let page = adw::PreferencesPage::new();
|
||
|
||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||
let res_names: Vec<String> = RESOLUTIONS
|
||
.iter()
|
||
.map(|&(w, h)| {
|
||
if w == 0 {
|
||
"Native display".to_string()
|
||
} else {
|
||
format!("{w} × {h}")
|
||
}
|
||
})
|
||
.collect();
|
||
let res_row = adw::ComboRow::builder()
|
||
.title("Resolution")
|
||
.subtitle("The host creates a virtual output at exactly this size")
|
||
.model(>k::StringList::new(
|
||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
))
|
||
.build();
|
||
let hz_names: Vec<String> = REFRESH
|
||
.iter()
|
||
.map(|&r| {
|
||
if r == 0 {
|
||
"Native".to_string()
|
||
} else {
|
||
format!("{r} Hz")
|
||
}
|
||
})
|
||
.collect();
|
||
let hz_row = adw::ComboRow::builder()
|
||
.title("Refresh rate")
|
||
.model(>k::StringList::new(
|
||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
))
|
||
.build();
|
||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||
bitrate_row.set_title("Bitrate");
|
||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||
let compositor_row = adw::ComboRow::builder()
|
||
.title("Host compositor")
|
||
.subtitle("Advisory — the host falls back to auto-detect when unavailable")
|
||
.model(>k::StringList::new(&[
|
||
"Automatic",
|
||
"KWin",
|
||
"wlroots (Sway/Hyprland)",
|
||
"Mutter (GNOME)",
|
||
"gamescope",
|
||
]))
|
||
.build();
|
||
stream.add(&res_row);
|
||
stream.add(&hz_row);
|
||
stream.add(&bitrate_row);
|
||
stream.add(&compositor_row);
|
||
|
||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||
// Which physical controller forwards as pad 0: automatic = the most recently
|
||
// connected; pinning survives until the app exits (Swift parity).
|
||
let pads = gamepads.pads();
|
||
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||
pad_names.extend(pads.iter().map(|p| {
|
||
let kind = p.kind_label();
|
||
if kind.is_empty() {
|
||
p.name.clone()
|
||
} else {
|
||
format!("{} · {kind}", p.name)
|
||
}
|
||
}));
|
||
let forward_row = adw::ComboRow::builder()
|
||
.title("Forwarded controller")
|
||
.subtitle(if pads.is_empty() {
|
||
"No controllers detected"
|
||
} else {
|
||
"Exactly one controller is forwarded to the host"
|
||
})
|
||
.model(>k::StringList::new(
|
||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
))
|
||
.build();
|
||
let pinned_i = gamepads
|
||
.pinned()
|
||
.and_then(|id| pads.iter().position(|p| p.id == id))
|
||
.map_or(0, |i| i + 1);
|
||
forward_row.set_selected(pinned_i as u32);
|
||
{
|
||
let svc = gamepads.clone();
|
||
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
|
||
forward_row.connect_selected_notify(move |row| {
|
||
let sel = row.selected() as usize;
|
||
svc.set_pinned(if sel == 0 {
|
||
None
|
||
} else {
|
||
ids.get(sel - 1).copied()
|
||
});
|
||
});
|
||
}
|
||
let pad_row = adw::ComboRow::builder()
|
||
.title("Gamepad type")
|
||
.subtitle("The virtual pad the host creates — Automatic matches the physical pad")
|
||
.model(>k::StringList::new(&[
|
||
"Automatic",
|
||
"Xbox 360",
|
||
"DualSense",
|
||
"Xbox One",
|
||
"DualShock 4",
|
||
]))
|
||
.build();
|
||
let inhibit_row = adw::SwitchRow::builder()
|
||
.title("Capture system shortcuts")
|
||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||
.build();
|
||
input.add(&forward_row);
|
||
input.add(&pad_row);
|
||
input.add(&inhibit_row);
|
||
|
||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||
let surround_row = adw::ComboRow::builder()
|
||
.title("Audio channels")
|
||
.subtitle("Request stereo or surround (the host downmixes if its output has fewer)")
|
||
.model(>k::StringList::new(&[
|
||
"Stereo",
|
||
"5.1 Surround",
|
||
"7.1 Surround",
|
||
]))
|
||
.build();
|
||
audio.add(&surround_row);
|
||
let mic_row = adw::SwitchRow::builder()
|
||
.title("Stream microphone")
|
||
.subtitle("Send the default input device to the host's virtual microphone")
|
||
.build();
|
||
audio.add(&mic_row);
|
||
|
||
let about = adw::PreferencesGroup::builder().title("About").build();
|
||
let licenses_row = adw::ActionRow::builder()
|
||
.title("Third-party licenses")
|
||
.subtitle("Open-source software used by punktfunk")
|
||
.activatable(true)
|
||
.build();
|
||
licenses_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||
{
|
||
let about_parent: gtk::Widget = parent.clone().upcast();
|
||
licenses_row.connect_activated(move |_| show_about(&about_parent));
|
||
}
|
||
about.add(&licenses_row);
|
||
|
||
page.add(&stream);
|
||
page.add(&input);
|
||
page.add(&audio);
|
||
page.add(&about);
|
||
|
||
// Seed from the current settings.
|
||
{
|
||
let s = settings.borrow();
|
||
let res_i = RESOLUTIONS
|
||
.iter()
|
||
.position(|&(w, h)| w == s.width && h == s.height)
|
||
.unwrap_or(0);
|
||
res_row.set_selected(res_i as u32);
|
||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||
hz_row.set_selected(hz_i as u32);
|
||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||
pad_row.set_selected(pad_i as u32);
|
||
let comp_i = COMPOSITORS
|
||
.iter()
|
||
.position(|&c| c == s.compositor)
|
||
.unwrap_or(0);
|
||
compositor_row.set_selected(comp_i as u32);
|
||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||
mic_row.set_active(s.mic_enabled);
|
||
surround_row.set_selected(match s.audio_channels {
|
||
6 => 1,
|
||
8 => 2,
|
||
_ => 0,
|
||
});
|
||
}
|
||
|
||
let dialog = adw::PreferencesDialog::new();
|
||
dialog.set_title("Preferences");
|
||
dialog.add(&page);
|
||
dialog.connect_closed(move |_| {
|
||
let mut s = settings.borrow_mut();
|
||
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
|
||
(s.width, s.height) = (w, h);
|
||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||
.to_string();
|
||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||
s.mic_enabled = mic_row.is_active();
|
||
s.audio_channels = match surround_row.selected() {
|
||
1 => 6,
|
||
2 => 8,
|
||
_ => 2,
|
||
};
|
||
s.save();
|
||
});
|
||
dialog.present(Some(parent));
|
||
}
|