fix: fail on refused portal requests and catch black recordings
Rust checks / windows-cross-check (push) Failing after 40s
Rust checks / linux (push) Failing after 5m38s

The portal reports refusals only in OBS's log; a denied, cancelled or failed
source selection now fails with an actionable reason instead of waiting
forever. check --expect-motion rejects black or frozen footage, and the
hardware script checks only runs that recorded, tests restore tokens in their
own step, and treats closing a window as informational: on Hyprland the
stream keeps its last frame, so sessions must end on game exit.
This commit is contained in:
2026-10-08 15:01:25 +02:00
parent 91f119e6b6
commit 06377de707
7 changed files with 208 additions and 83 deletions
+46 -7
View File
@@ -270,9 +270,9 @@ pub enum RecorderEvent {
pub struct Health {
pub source_width: u32,
pub source_height: u32,
/// For PipeWire capture, whether its stream is delivering frames. `None`
/// for sources that report loss through their size instead.
pub source_streaming: Option<bool>,
/// PipeWire capture's portal and stream state. `None` for other sources,
/// which report loss through their size, and before the portal answers.
pub pipewire: Option<PipeWireState>,
pub output_active: bool,
pub total_frames: u64,
pub dropped_frames: u64,
@@ -284,6 +284,15 @@ pub struct Health {
pub disk_available_bytes: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PipeWireState {
Streaming = 1,
/// The stream exists but is not delivering frames.
Stopped = 2,
/// The portal request was denied, cancelled by the user, or failed.
Refused = 3,
}
/// Derives the status shown to the user from health samples and output events.
///
/// Recording is reported only while the source has a picture and encoded data
@@ -334,9 +343,16 @@ impl StatusTracker {
self.last_written = health.written_bytes;
self.last_progress = now;
}
if health.pipewire == Some(PipeWireState::Refused) {
self.status = RecordingStatus::Failed {
reason: "screen capture was not allowed: the source selection was denied, cancelled or failed"
.into(),
};
return &self.status;
}
let has_picture = health.source_width > 0
&& health.source_height > 0
&& health.source_streaming != Some(false);
&& matches!(health.pipewire, None | Some(PipeWireState::Streaming));
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
if let Some(available) = health.disk_available_bytes
&& available < self.min_free_bytes
@@ -450,7 +466,7 @@ mod tests {
Health {
source_width: 1920,
source_height: 1080,
source_streaming: None,
pipewire: None,
output_active: true,
total_frames: bytes / 1000,
dropped_frames: 0,
@@ -562,7 +578,7 @@ mod tests {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
let streaming = Health {
source_streaming: Some(true),
pipewire: Some(PipeWireState::Streaming),
..healthy(5000)
};
assert_eq!(
@@ -570,7 +586,7 @@ mod tests {
&RecordingStatus::Recording
);
let paused = Health {
source_streaming: Some(false),
pipewire: Some(PipeWireState::Stopped),
..healthy(9000)
};
assert!(matches!(
@@ -579,6 +595,29 @@ mod tests {
));
}
#[test]
fn a_refused_portal_request_fails_while_waiting_for_capture() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
let waiting = Health {
source_width: 0,
source_height: 0,
..healthy(5000)
};
assert_eq!(
tracker.sample(&waiting, start),
&RecordingStatus::WaitingForCapture
);
let refused = Health {
pipewire: Some(PipeWireState::Refused),
..waiting
};
assert!(matches!(
tracker.sample(&refused, start),
RecordingStatus::Failed { reason } if reason.contains("not allowed")
));
}
#[test]
fn output_errors_fail_and_clean_stops_go_idle() {
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);