Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ User-facing changes to Beam are documented in this file.

### Added

- Hyprland can record a separate cursor for monitor and region captures through compositor IPC when its portal provides no cursor metadata.

- Recording and editor settings offer a shared project location preserving `projects/studio`, `projects/instant` and `projects/screenshot`. New captures use the chosen root immediately; the project picker retains previous locations and refreshes when they change.
- Video and image exports share a separate folder preference, with a fixed destination or the last successfully used folder and a searchable list of recent folders.
- Safari frames offer Auto, Light and Dark browser chrome independently of the frame color, with matching previews, screenshots and video exports.
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/electron/lifecycle/hyprland-window-rules.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const path = require('node:path');

const APP_CLASS = '^com\\.beam\\.app$';
const OVERLAY_TITLE = '^Beam (Recorder|Countdown|Quick Snip|Region Selection|Recording Region|Teleprompter)$';
const MATCH_RULE = `/keyword windowrule border_size 0, no_shadow on, match:class ${APP_CLASS}, match:title ${OVERLAY_TITLE}`;

function hyprlandRequest(socketPath, command, timeoutMs) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -35,7 +36,7 @@ function overlayCommands(tag) {
];
}
if (minor >= 53) {
return [`/keyword windowrule border_size 0, no_shadow on, match:class ${APP_CLASS}, match:title ${OVERLAY_TITLE}`];
return [MATCH_RULE];
}
return ['noborder', 'noshadow'].map(
(rule) => `/keyword windowrulev2 ${rule}, class:${APP_CLASS}, title:${OVERLAY_TITLE}`,
Expand All @@ -51,7 +52,10 @@ async function applyHyprlandWindowRules(env = process.env, platform = process.pl
const commands = overlayCommands(version.tag);
if (!commands.length) return false;
for (const command of commands) {
if ((await hyprlandRequest(socketPath, command, timeoutMs)) !== 'ok') return false;
const response = await hyprlandRequest(socketPath, command, timeoutMs);
if (command.startsWith('/eval ') && response === 'eval is only supported with the lua config manager') {
if ((await hyprlandRequest(socketPath, MATCH_RULE, timeoutMs)) !== 'ok') return false;
} else if (response !== 'ok') return false;
}
return true;
} catch {
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,5 @@ Architecture changes must explain the affected boundary, the data contract, fail
## Background catalogue lifetime

Desktop preferences own hidden background identities and one persisted deletion receipt. The narrow `preferences:background-catalog` IPC reads and patches the latest state synchronously before notification, and undo/redo matches the receipt identity so stale windows cannot undo a different deletion. Preset drafts do not overwrite these metadata keys. Removing an item from the catalogue retains imported source files, authored project background values and custom gradient array indices; rendering and CLI resource resolution remain valid. This catalogue history is independent of engine document history.

On Hyprland without Portal cursor metadata, Rust requests Hidden mode for monitor capture and owns a bounded compositor IPC sampler. Portal mapping IDs and typed monitor geometry anchor positions to the selected output. The PipeWire callback reads only a recent cached position, using the existing crop and transform mapping; Electron never polls global cursor coordinates for capture. Native cursor shapes and window cursor metadata remain Portal features.
4 changes: 4 additions & 0 deletions docs/dev/linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Electron windows use X11 (`--ozone-platform=x11`) on Linux, including XWayland o

Video editor export uses WebCodecs, independently of the native capture backend. See [GPU drivers and WebCodecs export](gpu-drivers.md) for driver packages, codec/buffer checks and measured Intel results.

### Hyprland

When the portal cannot supply cursor metadata, monitor and region recording use Hidden cursor mode plus Hyprland IPC position sampling. The portal mapping ID selects the captured output, including fractional scaling, offsets and rotation. Older portals without a mapping ID require one monitor or a uniquely sized output; ambiguous outputs fail with a diagnostic rather than guessing from focus. IPC sampling runs outside the PipeWire frame callback and stops with the capture; stale positions are discarded. Native cursor shapes and separate-cursor window capture still require portal metadata. Clicks and shortcuts use the protected input helper independently.

### Cinnamon on X11

Linux Mint's default Cinnamon/X11 session uses the XApp portal backend, which does not implement the ScreenCast interface. Installing or restarting `xdg-desktop-portal` or `xdg-desktop-portal-xapp` alone cannot make Beam record in that session. Beam currently has no direct X11 capture backend.
Expand Down
19 changes: 17 additions & 2 deletions packages/capture/src/screen/linux/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,28 @@ pub struct LinuxNativeCapabilities {
pub recording_available: bool,
}

/// Evaluates native Linux capture capabilities from portal properties, PipeWire, and FFmpeg availability.
#[must_use]
pub fn evaluate_capabilities(
portal: PortalProperties,
pipewire_available: bool,
ffmpeg_available: bool,
) -> LinuxNativeCapabilities {
evaluate_capabilities_with_compositor(portal, pipewire_available, ffmpeg_available, false)
}

/// Evaluates native Linux capture capabilities including compositor-specific fallbacks (such as Hyprland).
pub fn evaluate_capabilities_with_compositor(
portal: PortalProperties,
pipewire_available: bool,
ffmpeg_available: bool,
hyprland_available: bool,
) -> LinuxNativeCapabilities {
let portal_selection = portal.version >= MIN_PORTAL_VERSION
&& (portal.monitor || portal.window)
&& pipewire_available;
let separate_cursor = portal_selection
&& (portal.metadata_cursor || (portal.hidden_cursor && hyprland_available));
LinuxNativeCapabilities {
backend: "xdg-portal-pipewire".into(),
portal_version: portal.version,
Expand All @@ -51,22 +64,24 @@ pub fn evaluate_capabilities(
portal_selection,
hidden_cursor: portal.hidden_cursor,
embedded_cursor: portal.embedded_cursor,
separate_cursor: portal_selection && portal.metadata_cursor,
separate_cursor,
cursor_clicks: super::input_helper_supported(),
cursor_shapes: portal_selection && portal.metadata_cursor,
pipewire_available,
recording_available: portal_selection && ffmpeg_available,
}
}

/// Probes the system for portal, PipeWire, FFmpeg, and compositor capabilities within the given timeout.
pub fn probe_native_capabilities(
timeout: Duration,
) -> Result<LinuxNativeCapabilities, CaptureError> {
let portal = probe_portal_properties(timeout)?;
Ok(evaluate_capabilities(
Ok(evaluate_capabilities_with_compositor(
portal,
probe_pipewire(),
super::probe_ffmpeg().is_ok(),
super::hyprland::is_hyprland(),
))
}

Expand Down
12 changes: 8 additions & 4 deletions packages/capture/src/screen/linux/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use crate::model::{
};

use super::{
FfmpegCapabilities, LinuxNativeCapabilities, PortalProperties, evaluate_capabilities,
probe_ffmpeg, probe_pipewire, probe_portal_properties,
FfmpegCapabilities, LinuxNativeCapabilities, PortalProperties,
evaluate_capabilities_with_compositor, probe_ffmpeg, probe_pipewire, probe_portal_properties,
};

pub(crate) struct LinuxCaptureProbe {
Expand All @@ -21,8 +21,12 @@ pub(crate) fn probe_capture_environment(timeout: Duration) -> LinuxCaptureProbe
let pipewire_available = probe_pipewire();
let ffmpeg_result = probe_ffmpeg();
let portal_properties = portal_result.as_ref().copied().unwrap_or_default();
let capabilities =
evaluate_capabilities(portal_properties, pipewire_available, ffmpeg_result.is_ok());
let capabilities = evaluate_capabilities_with_compositor(
portal_properties,
pipewire_available,
ffmpeg_result.is_ok(),
super::hyprland::is_hyprland(),
);
let recording_available = capabilities.recording_available;
let portal = match portal_result {
Ok(properties) => portal_diagnostic(properties),
Expand Down
210 changes: 210 additions & 0 deletions packages/capture/src/screen/linux/hyprland.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
use std::{
io::{Read, Write},
os::unix::{fs::FileTypeExt, net::UnixStream},
path::{Path, PathBuf},
sync::{Arc, Mutex, mpsc},
thread::{self, JoinHandle},
time::{Duration, Instant},
};

use super::{
pipewire::{CursorMetadata, NegotiatedFormat, VideoTransform},
portal::PreparedPortal,
};
use crate::{CaptureError, NativeCaptureErrorCode};

#[path = "hyprland_types.rs"]
mod types;
use types::{CursorSnapshot, HyprlandMonitor};

const IPC_TIMEOUT: Duration = Duration::from_millis(20);
const SAMPLE_INTERVAL: Duration = Duration::from_millis(16);
const MAX_SAMPLE_AGE: Duration = Duration::from_millis(250);

fn socket_path() -> Option<PathBuf> {
let runtime_dir = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
let signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
if !runtime_dir.is_absolute()
|| signature.is_empty()
|| !signature
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return None;
}
let path = runtime_dir
.join("hypr")
.join(signature)
.join(".socket.sock");
path.metadata()
.ok()?
.file_type()
.is_socket()
.then_some(path)
}

pub(crate) fn is_hyprland() -> bool {
socket_path().is_some()
}

fn query(path: &Path, command: &[u8]) -> Option<Vec<u8>> {
let mut stream = UnixStream::connect(path).ok()?;
let deadline = Instant::now() + IPC_TIMEOUT;
stream.set_write_timeout(Some(IPC_TIMEOUT)).ok()?;
stream.write_all(command).ok()?;
let mut response = Vec::new();
let mut chunk = [0; 1024];
loop {
let remaining = deadline.checked_duration_since(Instant::now())?;
stream.set_read_timeout(Some(remaining)).ok()?;
let read = stream.read(&mut chunk).ok()?;
if read == 0 {
return Some(response);
}
response.extend_from_slice(&chunk[..read]);
if response.len() > 32768 {
return None;
}
}
}

fn parse_cursor_pos(response: &[u8]) -> Option<(i32, i32)> {
let text = std::str::from_utf8(response).ok()?;
let (x, y) = text.trim().split_once(',')?;
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
}

fn query_monitors(path: &Path) -> Option<Vec<HyprlandMonitor>> {
serde_json::from_slice(&query(path, b"j/monitors")?).ok()
}

fn select_monitor(
monitors: &[HyprlandMonitor],
mapping_id: Option<&str>,
size: Option<(i32, i32)>,
) -> Option<HyprlandMonitor> {
let candidates: Vec<_> = monitors
.iter()
.filter(|monitor| {
if !monitor.valid() {
return false;
}
if let Some(name) = mapping_id {
return monitor.name == name;
}
monitors.len() == 1 || size.is_some_and(|size| monitor.matches_size(size))
})
.collect();
match candidates.as_slice() {
[monitor] => Some((**monitor).clone()),
_ => None,
}
}

pub(crate) struct HyprlandCursor {
snapshot: Arc<Mutex<Option<CursorSnapshot>>>,
stop: mpsc::Sender<()>,
worker: Option<JoinHandle<()>>,
}

impl HyprlandCursor {
pub(super) fn for_portal(portal: &PreparedPortal) -> Result<Option<Self>, CaptureError> {
if !portal.hyprland_cursor {
return Ok(None);
}
let path = socket_path().ok_or_else(|| cursor_error("Hyprland IPC is unavailable"))?;
let monitors =
query_monitors(&path).ok_or_else(|| cursor_error("Cannot query Hyprland monitors"))?;
let monitor = select_monitor(&monitors, portal.mapping_id.as_deref(), portal.size)
.ok_or_else(|| {
cursor_error("Cannot identify the selected monitor for separate cursor capture")
})?;
Self::start(path, monitor).map(Some)
}

fn start(path: PathBuf, mut monitor: HyprlandMonitor) -> Result<Self, CaptureError> {
let point = query(&path, b"/cursorpos")
.and_then(|response| parse_cursor_pos(&response))
.ok_or_else(|| cursor_error("Cannot query the Hyprland cursor position"))?;
let snapshot = Arc::new(Mutex::new(Some(CursorSnapshot {
point,
monitor: monitor.clone(),
received: Instant::now(),
})));
let output = snapshot.clone();
let (stop, receiver) = mpsc::channel();
let worker = thread::Builder::new()
.name("beam-hyprland-cursor".into())
.spawn(move || {
let mut refreshed = Instant::now();
while matches!(
receiver.recv_timeout(SAMPLE_INTERVAL),
Err(mpsc::RecvTimeoutError::Timeout)
) {
if refreshed.elapsed() >= Duration::from_secs(1) {
let updated = query_monitors(&path).and_then(|monitors| {
select_monitor(&monitors, Some(&monitor.name), None)
});
let Some(updated) = updated else {
if let Ok(mut output) = output.lock() {
*output = None;
}
break;
};
monitor = updated;
refreshed = Instant::now();
}
let point = query(&path, b"/cursorpos")
.and_then(|response| parse_cursor_pos(&response));
if let Ok(mut output) = output.lock() {
*output = point.map(|point| CursorSnapshot {
point,
monitor: monitor.clone(),
received: Instant::now(),
});
}
}
})
.map_err(|error| {
cursor_error(&format!("Cannot start Hyprland cursor sampling: {error}"))
})?;
Ok(Self {
snapshot,
stop,
worker: Some(worker),
})
}

pub(super) fn metadata(
&self,
format: NegotiatedFormat,
transform: VideoTransform,
) -> Option<CursorMetadata> {
let snapshot = self.snapshot.lock().ok()?;
let snapshot = snapshot.as_ref()?;
if snapshot.received.elapsed() > MAX_SAMPLE_AGE {
return None;
}
snapshot.metadata(format, transform)
}
}

impl Drop for HyprlandCursor {
fn drop(&mut self) {
let _ = self.stop.send(());
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}

fn cursor_error(message: &str) -> CaptureError {
CaptureError::native(
NativeCaptureErrorCode::PortalCursorMetadataUnavailable,
message,
)
}

#[cfg(test)]
#[path = "hyprland_tests.rs"]
mod tests;
Loading
Loading