Compare commits

..
Author SHA1 Message Date
molluskandClaude Opus 4.8 70a0e6798f W16 custom backgrounds: core + render layer (Settings UI pending)
Pure src/background.rs (process_background downscale→PNG, scrim_color; 5 tests),
AppConfig.background/background_dim + background_path(), cached AppState.background_image,
PickBackgroundFile/BackgroundFilePicked/RemoveBackground/SetBackgroundDim handlers,
view_with_background stack(image Cover→scrim→ui) + transparent screen roots.
Lib builds clean. Remaining: Settings UI controls + full build/clippy/test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:29:09 -04:00
molluskandClaude Opus 4.8 a30d9d5dbf Add plain-English Windows install/join guide for end users
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A non-technical walkthrough to send alongside the installer: covers the
SmartScreen "unknown publisher" warning, the firewall/desktop-shortcut
checkboxes, and joining/creating a call via room tickets. Uses the actual UI
labels (Join Room / Create New Room / Copy Ticket / Leave Room).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:46:07 -04:00
molluskandClaude Opus 4.8 2a6e6401ad Add Windows installer (Inno Setup) + GUI-subsystem release builds
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Package PeerSpeak for Windows as a single self-contained binary. The GUI
icon, notification chimes, and avatar presets are already embedded via
include_bytes!, and the cross-compiled .exe is statically linked (no extra
DLLs), so the installer payload is just peerspeak.exe plus an .ico.

- src/main.rs: set windows_subsystem = "windows" for release builds so the
  GUI launches without a stray console window (debug keeps the console for
  stderr/panics).
- packaging/windows/: Inno Setup script (peerspeak.iss), multi-resolution
  app icon (peerspeak.ico), and a build README. The installer drops a
  Start-menu/desktop shortcut, optionally adds a Windows Firewall allow-rule
  (iroh UDP hole-punching), and provides an uninstaller.
- win-cross-build.sh: promote the cross-build helper from a throwaway to the
  documented installer build step; .gitignore the staged exe + compiled
  setup.exe build artifacts.

Built with Inno Setup 6.7.1 under Wine; binary is unsigned (SmartScreen will
warn until code-signed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:27:03 -04:00
molluskandClaude Opus 4.8 a0a5922389 Merge reconnect-resilience: post-grace peer recovery coordinator
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Fixes the field-observed dead-end where a network outage longer than the
45s anti-flap grace evicted the peer with no path back (required manual
Leave + re-ticket). On grace expiry the peer is now torn down cleanly and a
bounded per-session recovery coordinator re-bootstraps the gossip overlay
via GossipSender::join_peers on retained authenticated addresses, with
immediate-then-1/2/4/8/15/30/60s capped backoff. Readmission still requires
a fresh authenticated signed Announce, preserving the S8/S11 membership
boundary; a transport link alone cannot readmit a grace-expired peer.

Field-verified 2026-06-20 on a 2-machine Linux-host <-> Windows-VM call
through a 93s link outage on the libvirt NAT path: both UIs auto-recovered
to "2 in room" with no manual Leave/Join, exactly one Reconnected chime,
host showed "reconnecting" during the outage, and the VM log captured the
full sequence (grace expiry -> rebootstrap 1->2->4 backoff -> NeighborUp ->
authenticated Announce -> readmit -> audio link up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:01:47 -04:00
11 changed files with 516 additions and 3 deletions
+5
View File
@@ -6,3 +6,8 @@
/packaging/peerspeak/
/packaging/*.pkg.tar.*
/packaging/*.log
# Windows installer build artifacts (the staged exe + compiled setup.exe);
# the .iss script and .ico are the tracked sources.
/packaging/windows/peerspeak.exe
/packaging/windows/output/
+82
View File
@@ -0,0 +1,82 @@
# PeerSpeak — how to install and join a call (Windows)
PeerSpeak is a little voice-chat app — like a private phone call over the
internet, with no account, no signup, and no company in the middle. You install
it once, then you and I connect directly to each other.
---
## 1. Install it
1. Double-click **`peerspeak-0.2.0-setup.exe`** (the file I sent you).
2. **Windows will probably show a blue "Windows protected your PC" warning.**
This is normal — it shows up for any app that isn't from a big company with a
paid certificate. It is **not** a virus warning.
- Click **More info**
- Then click **Run anyway**
3. Windows will ask *"Do you want to allow this app to make changes?"* — click
**Yes**.
4. The setup window opens. Just keep clicking **Next**. Two checkboxes you'll
see along the way:
- **"Allow PeerSpeak through Windows Firewall"** — leave this **checked**
(it lets the call connect without interruptions).
- **"Create a desktop shortcut"** — check it if you'd like an icon on your
desktop.
5. Click **Install**, then **Finish**. PeerSpeak opens.
That's it — it's installed. You can find it again any time from the **Start
menu** (search "PeerSpeak").
---
## 2. Get on a call with me
PeerSpeak connects two people using a **room ticket** — a long code that acts
like a one-time phone number for a specific call.
**The simple way (I host):**
1. I'll create a room and send you a **ticket** (a long jumble of letters and
numbers).
2. Copy the whole ticket I sent you.
3. In PeerSpeak, paste it into the **"Join Room"** box near the bottom and press
**Join**.
4. You're in — you should see both our names listed, and we can talk.
**If you want to host instead:**
1. Type a room name and click **Create New Room**.
2. PeerSpeak gives you a **ticket** — click **Copy Ticket** and send it to me.
3. I paste it on my end and join you.
Either way works the same; it just depends on who makes the room.
---
## 3. While you're on a call
- **Your microphone** is on by default. There's a **mute** button if you need
it.
- The first time, Windows might ask for permission to use your **microphone**
click **Yes / Allow**.
- If you can't hear me or I can't hear you, open **Settings** (top right) and
check that the right **microphone** and **speakers/headphones** are selected.
- To hang up, click **Leave Room**.
---
## Troubleshooting
- **"I don't hear anything."** Open Settings and pick the correct microphone and
output device. Headphones are best — they prevent echo.
- **"It won't connect."** Make sure you pasted the *entire* ticket (they're
long and easy to cut off). If it still won't connect, we may just need a fresh
ticket — they're meant to be used right away.
- **The blue warning again.** Same as install: **More info → Run anyway**. It's
the unsigned-app warning, not malware.
Any trouble, just message me and we'll sort it out.
+72
View File
@@ -0,0 +1,72 @@
# PeerSpeak — Windows installer
This directory builds a Windows setup installer for PeerSpeak using
[Inno Setup](https://jrsoftware.org/isinfo.php).
PeerSpeak ships as a **single self-contained `peerspeak.exe`** — the GUI icon,
notification chimes, and avatar presets are all embedded in the binary
(`include_bytes!`), and the executable is statically linked against the GNU
runtime, so there are no extra DLLs to bundle. The installer payload is just the
`.exe` plus an `.ico` for the Start-menu / desktop shortcuts.
## Files
| File | Tracked | Purpose |
|------|---------|---------|
| `peerspeak.iss` | yes | Inno Setup script |
| `peerspeak.ico` | yes | multi-resolution app icon (from `assets/icons/*.png`) |
| `README.md` | yes | this file |
| `peerspeak.exe` | no (gitignored) | staged build artifact, copied from `target/x86_64-pc-windows-gnu/release/` |
| `output/peerspeak-<ver>-setup.exe` | no (gitignored) | the compiled installer |
## Build steps
1. **Cross-compile the Windows binary** (from the repo root, inside the
`peerspeak-win` archlinux distrobox):
```sh
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
```
This needs the `rust-src` component and the `x86_64-pc-windows-gnu` target
installed in that toolchain. The result is a statically-linked,
GUI-subsystem `.exe` (no stray console window).
2. **Stage the binary** next to the script:
```sh
cp target/x86_64-pc-windows-gnu/release/peerspeak.exe packaging/windows/
```
3. **Regenerate the icon** if the source PNGs changed:
```sh
magick assets/icons/peerspeak-16.png assets/icons/peerspeak-24.png \
assets/icons/peerspeak-32.png assets/icons/peerspeak-48.png \
assets/icons/peerspeak-64.png assets/icons/peerspeak-128.png \
assets/icons/peerspeak-256.png packaging/windows/peerspeak.ico
```
4. **Compile the installer** with Inno Setup. On Linux this runs under Wine:
```sh
cd packaging/windows
wine ~/.wine/drive_c/InnoSetup6/ISCC.exe peerspeak.iss
```
The installer lands at `output/peerspeak-<version>-setup.exe`.
## What the installer does
- Installs `peerspeak.exe` to `Program Files\PeerSpeak` (requires admin / one
UAC prompt).
- Creates a Start-menu shortcut, with an optional desktop shortcut.
- Optionally adds a Windows Firewall allow-rule for PeerSpeak (recommended —
iroh uses UDP hole-punching, so this avoids a mid-call firewall prompt). The
rule is removed on uninstall.
- Provides a standard uninstaller.
> **Note:** the installer and the binary are **not code-signed**, so Windows
> SmartScreen will show an "unknown publisher" warning on first run. The user
> clicks *More info → Run anyway*. Removing this warning requires a paid
> code-signing certificate.
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

+63
View File
@@ -0,0 +1,63 @@
; Inno Setup script for PeerSpeak (Windows installer).
;
; PeerSpeak is a single self-contained binary: the GUI icon, notification
; chimes, and avatar presets are all embedded in the .exe (include_bytes!),
; so the only payload here is peerspeak.exe plus an .ico for the shortcuts.
;
; Build (under Wine on Linux, or native Windows):
; wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" peerspeak.iss
; Output lands in .\output\peerspeak-<version>-setup.exe
;
; The peerspeak.exe is cross-compiled with win-cross-build.sh
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.2.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
[Setup]
; A stable AppId keeps upgrades/uninstall tracking consistent across versions.
AppId={{2754D6C1-C8A4-4B13-9824-2D303439739D}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
UninstallDisplayIcon={app}\{#MyAppExeName}
SetupIconFile=peerspeak.ico
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
OutputDir=output
OutputBaseFilename=peerspeak-{#MyAppVersion}-setup
; Program Files install + firewall rule both need elevation.
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "firewall"; Description: "Allow PeerSpeak through Windows Firewall (recommended for voice calls)"; GroupDescription: "Network:"
[Files]
Source: "peerspeak.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "peerspeak.ico"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"; Tasks: desktopicon
[Run]
; iroh uses UDP hole-punching; pre-authorizing avoids a mid-call firewall prompt.
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PeerSpeak"" dir=in action=allow program=""{app}\{#MyAppExeName}"" enable=yes profile=any"; Flags: runhidden; Tasks: firewall
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PeerSpeak"""; Flags: runhidden; RunOnceId: "DelPeerSpeakFirewall"
+138 -3
View File
@@ -290,6 +290,15 @@ pub enum AppMessage {
/// Result of the avatar file picker: the chosen file's raw bytes, or `None`
/// if the user cancelled.
AvatarFilePicked(Option<Vec<u8>>),
/// Open the native file picker to choose a custom UI background image (W16).
PickBackgroundFile,
/// Result of the background file picker: the chosen file's raw bytes, or
/// `None` if the user cancelled.
BackgroundFilePicked(Option<Vec<u8>>),
/// Clear the custom background, reverting to the theme background (W16).
RemoveBackground,
/// Set the background legibility scrim strength (0.0..=1.0) (W16).
SetBackgroundDim(f32),
/// Toggle the Chat drawer open/closed (drawer layout).
ToggleDrawerChat,
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
@@ -335,6 +344,10 @@ pub struct AppState {
selected_input: Option<AudioDevice>,
selected_output: Option<AudioDevice>,
config: AppConfig,
/// Decoded bytes of the custom background image (W16), cached so `view()`
/// doesn't read the file from disk on every redraw. Loaded on startup and
/// refreshed when the background is changed/removed. `None` = no custom bg.
background_image: Option<bytes::Bytes>,
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
@@ -470,6 +483,7 @@ impl Default for AppState {
let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned();
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
let background_image = load_background_bytes(&config);
Self {
// Pre-fill the nickname with the last one used (or "Peer" by default).
@@ -489,6 +503,7 @@ impl Default for AppState {
selected_input,
selected_output,
config,
background_image,
peers: HashMap::new(),
peer_volumes: HashMap::new(),
audio_levels: HashMap::new(),
@@ -533,6 +548,15 @@ fn theme(state: &AppState) -> Theme {
state.config.theme.base_theme()
}
/// Read the custom background PNG (W16) from disk into memory, if one is set and
/// readable. Called once on startup and whenever the background changes, so the
/// per-frame `view()` never touches the filesystem. A missing/unreadable file
/// silently yields `None` (the UI falls back to the theme background).
fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
let path = config.background.as_deref()?;
std::fs::read(path).ok().map(bytes::Bytes::from)
}
pub fn run_gui() -> iced::Result {
// Restore the last window size (saved on close). Position is restored too,
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
@@ -540,7 +564,7 @@ pub fn run_gui() -> iced::Result {
let saved = AppConfig::load();
let init_size = iced::Size::new(saved.window_width, saved.window_height);
let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland());
iced::application(AppState::default, update, view)
iced::application(AppState::default, update, view_with_background)
.title("PeerSpeak P2P Voice Chat")
.theme(theme)
.subscription(subscription)
@@ -1347,6 +1371,73 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
}
AppMessage::PickBackgroundFile => {
// Native picker off the UI thread; result returns as BackgroundFilePicked.
return Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
.set_title("Choose a background image")
.pick_file()
.await;
match handle {
Some(h) => Some(h.read().await),
None => None,
}
},
AppMessage::BackgroundFilePicked,
);
}
AppMessage::BackgroundFilePicked(picked) => {
if let Some(bytes) = picked {
match crate::background::process_background(&bytes) {
Ok(png) => match AppConfig::background_path() {
Some(path) => {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match std::fs::write(&path, &png) {
Ok(()) => {
state.config.background =
Some(path.to_string_lossy().into_owned());
state.config.save();
// Refresh the in-memory cache from the bytes we
// just wrote (avoids re-reading from disk).
state.background_image = Some(bytes::Bytes::from(png));
state.status_message = "Background updated.".to_string();
}
Err(e) => {
state.status_message =
format!("Couldn't save background: {e}");
}
}
}
None => {
state.status_message =
"Couldn't find a config directory to save the background."
.to_string();
}
},
Err(e) => {
state.status_message = e;
}
}
}
}
AppMessage::RemoveBackground => {
// Best-effort delete of our stored copy; clear the config + cache.
if let Some(path) = AppConfig::background_path() {
let _ = std::fs::remove_file(path);
}
state.config.background = None;
state.config.save();
state.background_image = None;
state.status_message = "Background removed.".to_string();
}
AppMessage::SetBackgroundDim(dim) => {
state.config.background_dim = dim.clamp(0.0, 1.0);
state.config.save();
}
AppMessage::ToggleDrawerChat => {
state.drawer_chat_open = !state.drawer_chat_open;
}
@@ -2018,6 +2109,40 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
.into()
}
/// Wrap the main [`view`] with the custom background layer (W16). When a
/// background image is set, render `stack![ image(Cover), scrim, ui ]` so the
/// photo sits behind the whole UI with a legibility scrim (the theme base colour
/// at `background_dim` alpha) between them; otherwise return the UI untouched. The
/// three screen roots go transparent (`root_bg` in `view`) so the image shows
/// through the gaps between panels. This is the registered top-level view.
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
let content = view(state);
let Some(bytes) = state.background_image.clone() else {
return content;
};
let pal = state.config.theme.palette();
let dim = state.config.background_dim;
let image_layer = iced::widget::image(cached_image_handle(bytes))
.content_fit(iced::ContentFit::Cover)
.width(iced::Length::Fill)
.height(iced::Length::Fill);
let scrim = container(
iced::widget::Space::new()
.width(iced::Length::Fill)
.height(iced::Length::Fill),
)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))),
..Default::default()
});
iced::widget::stack![image_layer, scrim, content]
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.into()
}
fn view(state: &AppState) -> Element<'_, AppMessage> {
// Theme colours — sourced from the active palette (see `src/theme.rs`), so
// all styling below re-themes when the user picks a different theme.
@@ -2036,6 +2161,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let color_green = pal.green;
let color_yellow = pal.yellow;
// The window backdrop fill for the three screen roots. When a custom
// background image is set (W16), the root goes transparent so the image +
// scrim layered behind by `view_with_background` shows through the gaps
// between panels; otherwise it's the usual opaque `crust`.
let root_bg = if state.background_image.is_some() {
Color::TRANSPARENT
} else {
color_crust
};
// Style Helpers
let c_style = move |bg: Color, b_color: Color, radius: f32| {
move |_theme: &Theme| container::Style {
@@ -2830,7 +2965,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill)
.padding(24)
.center_x(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
return with_regenerate_confirm(settings_screen.into(), state);
}
@@ -2889,7 +3024,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
with_hotkey_info(with_layout_picker(home.into(), state), state)
} else {
+107
View File
@@ -0,0 +1,107 @@
//! Custom UI background (W16): turn a user-picked image into a capped PNG to
//! render behind the whole UI, plus the legibility scrim drawn over it.
//!
//! This is the pure, unit-testable seam — `process_background` decodes/downscales
//! arbitrary input defensively (same caution as avatar uploads) and `scrim_color`
//! computes the overlay tint. The file I/O, the `rfd` picker, and the iced
//! `stack!` that layers image → scrim → UI all live at the app edge in
//! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so
//! there's no gossip-frame budget here (hence a much larger size cap than avatars).
use iced::Color;
/// Longest side a custom background is downscaled to on ingest (aspect preserved,
/// never upscaled). Big enough to look crisp filling the window, small enough to
/// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap.
pub const BACKGROUND_MAX_PX: u32 = 1920;
/// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's
/// fully hidden behind the theme's base colour. Half keeps a photo clearly visible
/// while text and cards stay readable over it.
pub const DEFAULT_DIM: f32 = 0.5;
/// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is
/// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is,
/// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is
/// bounded by the `image` crate's defaults so a malformed/huge file is rejected
/// rather than exhausting memory. Errors come back as a message for the UI.
pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
// Only ever shrink. `resize` preserves aspect, fitting within the box; a
// higher-quality filter than `thumbnail` since a background fills the window.
let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX {
img.resize(
BACKGROUND_MAX_PX,
BACKGROUND_MAX_PX,
image::imageops::FilterType::Lanczos3,
)
} else {
img
};
let mut png = std::io::Cursor::new(Vec::new());
scaled
.write_to(&mut png, image::ImageFormat::Png)
.map_err(|e| format!("Couldn't encode image: {e}"))?;
Ok(png.into_inner())
}
/// The legibility scrim drawn between the background image and the UI: the active
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
/// recedes the image so body text and panel chrome stay readable, and it re-tints
/// per theme since `base` comes from the active palette.
pub fn scrim_color(base: Color, dim: f32) -> Color {
Color { a: dim.clamp(0.0, 1.0), ..base }
}
#[cfg(test)]
mod tests {
use super::*;
/// A valid PNG of the given size, as raw bytes (test helper).
fn make_png(w: u32, h: u32) -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(w, h);
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
buf.into_inner()
}
#[test]
fn process_background_downscales_oversized() {
// A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX,
// aspect preserved, and the result re-decodes as a PNG within bounds.
let raw = make_png(4000, 2000);
let png = process_background(&raw).expect("should process");
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX);
assert_eq!(decoded.width(), BACKGROUND_MAX_PX);
assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept
}
#[test]
fn process_background_leaves_small_images_unscaled() {
let raw = make_png(640, 480);
let png = process_background(&raw).expect("should process");
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!((decoded.width(), decoded.height()), (640, 480));
}
#[test]
fn process_background_rejects_non_image() {
assert!(process_background(b"definitely not an image").is_err());
}
#[test]
fn scrim_color_sets_alpha_and_keeps_rgb() {
let base = Color::from_rgb(0.1, 0.2, 0.3);
let s = scrim_color(base, 0.5);
assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3));
assert!((s.a - 0.5).abs() < f32::EPSILON);
}
#[test]
fn scrim_color_clamps_dim() {
let base = Color::BLACK;
assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON);
assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON);
}
}
+27
View File
@@ -106,6 +106,10 @@ fn default_true() -> bool {
true
}
fn default_background_dim() -> f32 {
crate::background::DEFAULT_DIM
}
fn default_volume() -> f32 {
1.0
}
@@ -185,6 +189,16 @@ pub struct AppConfig {
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
#[serde(default)]
pub avatar: crate::avatar::Avatar,
/// Custom UI background image (W16): path to the downscaled PNG we wrote into
/// the config dir (see `background_path`). `None` = use the theme background.
/// Local-only; never sent to peers.
#[serde(default)]
pub background: Option<String>,
/// Scrim strength drawn over the custom background for legibility (0.0 = image
/// at full strength, 1.0 = fully hidden behind the theme base). See
/// `crate::background::scrim_color`.
#[serde(default = "default_background_dim")]
pub background_dim: f32,
/// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)]
pub recording_mode: RecordingMode,
@@ -280,6 +294,8 @@ impl Default for AppConfig {
room_layout: RoomLayout::default(),
theme: AppTheme::default(),
avatar: crate::avatar::Avatar::default(),
background: None,
background_dim: default_background_dim(),
recording_mode: RecordingMode::default(),
custom_sound_self_join: None,
custom_sound_peer_join: None,
@@ -348,6 +364,17 @@ impl AppConfig {
})
}
/// Path the processed custom-background PNG (W16) is written to, alongside
/// `config.json` in the app config dir. We store our own downscaled copy here
/// (rather than base64 in the config) so the JSON stays small.
pub fn background_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut p| {
p.push("peerspeak");
p.push("background.png");
p
})
}
pub fn load() -> Self {
if let Some(path) = Self::config_path()
&& let Ok(contents) = fs::read_to_string(&path)
+1
View File
@@ -15,6 +15,7 @@ pub mod notify;
pub mod screenshare;
pub mod sanitize;
pub mod avatar;
pub mod background;
pub mod recents;
pub mod discovery;
pub mod hotkeys;
+4
View File
@@ -1,3 +1,7 @@
// On Windows, suppress the extra console window for release GUI builds while
// keeping it in debug builds so stderr/panics stay visible during development.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
if let Err(e) = peerspeak::app::run_gui() {
eprintln!("Error running GUI: {:?}", e);
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Cross-compile peerspeak to x86_64-pc-windows-gnu (run inside the peerspeak-win distrobox).
# Produces a statically-linked, self-contained .exe (no extra DLLs) for the
# Windows installer in packaging/windows/. Requires the rust-src component and
# the x86_64-pc-windows-gnu target; invoke with build-std for the static link:
# RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
set -euo pipefail
cd "$(dirname "$0")"
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
# Bundled libopus declares cmake_minimum_required < 3.5; cmake 4.x refuses it.
export CMAKE_POLICY_VERSION_MINIMUM=3.5
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak "$@"