From 06377de7070c9565051906d0cd3f1a4ebf52ab98 Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 8 Oct 2026 15:01:25 +0200 Subject: [PATCH] fix: fail on refused portal requests and catch black recordings 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. --- crates/clipbin-obs/src/ffi.rs | 85 +++++++++++++++++++----------- crates/clipbin-obs/src/lib.rs | 4 +- crates/clipbin-obs/src/recorder.rs | 53 ++++++++++++++++--- crates/clipbin-probe/src/main.rs | 30 +++++++++-- docs/validation.md | 35 +++++++++++- scripts/validate/linux-hardware.sh | 71 ++++++++++++++----------- scripts/validate/linux-rig.sh | 13 ++--- 7 files changed, 208 insertions(+), 83 deletions(-) diff --git a/crates/clipbin-obs/src/ffi.rs b/crates/clipbin-obs/src/ffi.rs index d2611ce..8248763 100644 --- a/crates/clipbin-obs/src/ffi.rs +++ b/crates/clipbin-obs/src/ffi.rs @@ -19,7 +19,7 @@ use libobs as sys; use crate::{ capture::{SettingValue, SourceSpec}, - recorder::{Health, RecorderEvent}, + recorder::{Health, PipeWireState, RecorderEvent}, }; fn cstring(value: &str) -> CString { @@ -149,13 +149,8 @@ unsafe extern "C" fn log_to_stderr( _ => "debug", }; if let Some(text) = owned(message.array) { - if let Some(state) = pipewire_stream_state(&text) { - let code = if state == "streaming" { - STREAMING - } else { - NOT_STREAMING - }; - PIPEWIRE_STREAM.store(code, Ordering::Relaxed); + if let Some(state) = pipewire_state(&text) { + PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed); } eprintln!("obs {label}: {text}"); } @@ -163,22 +158,38 @@ unsafe extern "C" fn log_to_stderr( unsafe { sys::bfree(message.array.cast()) }; } -/// The state of the PipeWire capture stream, as last logged by OBS. +/// The PipeWire capture state, as last logged by OBS (0 = nothing logged). /// /// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering -/// its last frame after the stream stops, and exposes the stream state only -/// through this log line. Clipbin records one capture source at a time. -static PIPEWIRE_STREAM: AtomicU8 = AtomicU8::new(NO_PIPEWIRE_STREAM); -const NO_PIPEWIRE_STREAM: u8 = 0; -const STREAMING: u8 = 1; -const NOT_STREAMING: u8 = 2; +/// its last frame after its stream stops, and reports portal refusals and +/// stream states only in its log (linux-pipewire's screencast-portal.c and +/// pipewire.c). Clipbin records one capture source at a time. +static PIPEWIRE_STATE: AtomicU8 = AtomicU8::new(0); -/// Parses `[pipewire] Stream 0x... state: "streaming" (error: none)` from -/// linux-pipewire's on_state_changed_cb. -fn pipewire_stream_state(message: &str) -> Option<&str> { - let rest = message.strip_prefix("[pipewire] Stream ")?; - let state = rest.split_once(" state: \"")?.1; - state.split_once('"').map(|(state, _)| state) +fn pipewire_state(message: &str) -> Option { + if let Some(rest) = message.strip_prefix("[pipewire] Stream ") { + // `[pipewire] Stream 0x... state: "streaming" (error: none)` + let state = rest.split_once(" state: \"")?.1.split_once('"')?.0; + return Some(if state == "streaming" { + PipeWireState::Streaming + } else { + PipeWireState::Stopped + }); + } + // The user denied or cancelled the portal request, or it failed. + let refused = message.starts_with("[pipewire] Error ") + || (message.starts_with("[pipewire] Failed to ") + && message.ends_with("denied or cancelled by user")); + refused.then_some(PipeWireState::Refused) +} + +fn load_pipewire_state() -> Option { + match PIPEWIRE_STATE.load(Ordering::Relaxed) { + state if state == PipeWireState::Streaming as u8 => Some(PipeWireState::Streaming), + state if state == PipeWireState::Stopped as u8 => Some(PipeWireState::Stopped), + state if state == PipeWireState::Refused as u8 => Some(PipeWireState::Refused), + _ => None, + } } pub(crate) fn startup(spec: &StartupSpec) -> Result { @@ -568,7 +579,7 @@ impl Pipeline { events: Sender, current_segment: Arc>>, ) -> Result { - PIPEWIRE_STREAM.store(NO_PIPEWIRE_STREAM, Ordering::Relaxed); + PIPEWIRE_STATE.store(0, Ordering::Relaxed); // OBS releases objects asynchronously, so names must not be reused. static RECORDINGS: AtomicU64 = AtomicU64::new(0); let id = RECORDINGS.fetch_add(1, Ordering::Relaxed); @@ -745,14 +756,10 @@ impl Pipeline { sys::obs_source_get_height(self.video_source), ) }; - let source_streaming = match PIPEWIRE_STREAM.load(Ordering::Relaxed) { - NO_PIPEWIRE_STREAM => None, - state => Some(state == STREAMING), - }; Health { source_width: width, source_height: height, - source_streaming, + pipewire: load_pipewire_state(), output_active: sys::obs_output_active(self.output), total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64, dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64, @@ -852,13 +859,27 @@ mod tests { use super::*; #[test] - fn pipewire_stream_state_is_read_from_the_plugin_log() { - let line = r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#; - assert_eq!(pipewire_stream_state(line), Some("paused")); + fn pipewire_state_is_read_from_the_plugin_log() { + let state = |line| pipewire_state(line); assert_eq!( - pipewire_stream_state("[pipewire] Screencast session created"), - None + state(r#"[pipewire] Stream 0x7fe128037440 state: "streaming" (error: none)"#), + Some(PipeWireState::Streaming) ); + assert_eq!( + state(r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#), + Some(PipeWireState::Stopped) + ); + assert_eq!( + state("[pipewire] Failed to select source, denied or cancelled by user"), + Some(PipeWireState::Refused) + ); + assert_eq!( + state("[pipewire] Error selecting screencast source: Timeout"), + Some(PipeWireState::Refused) + ); + assert_eq!(state("[pipewire] Screencast session created"), None); + // OBS cancelling its own pending request at teardown is not a refusal. + assert_eq!(state("[portals] Request cancelled"), None); } #[test] diff --git a/crates/clipbin-obs/src/lib.rs b/crates/clipbin-obs/src/lib.rs index e65dde0..4e62b9a 100644 --- a/crates/clipbin-obs/src/lib.rs +++ b/crates/clipbin-obs/src/lib.rs @@ -12,7 +12,7 @@ pub mod runtime; mod thread; pub use recorder::{ - Engine, EngineError, Health, RecorderEvent, Recording, RecordingConfig, SEGMENT_EXTENSION, - StatusTracker, StopReason, VideoFormat, + Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig, + SEGMENT_EXTENSION, StatusTracker, StopReason, VideoFormat, }; pub use runtime::{RuntimeError, RuntimeLayout}; diff --git a/crates/clipbin-obs/src/recorder.rs b/crates/clipbin-obs/src/recorder.rs index 0f39d9a..1505f64 100644 --- a/crates/clipbin-obs/src/recorder.rs +++ b/crates/clipbin-obs/src/recorder.rs @@ -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, + /// 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, pub output_active: bool, pub total_frames: u64, pub dropped_frames: u64, @@ -284,6 +284,15 @@ pub struct Health { pub disk_available_bytes: Option, } +#[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); diff --git a/crates/clipbin-probe/src/main.rs b/crates/clipbin-probe/src/main.rs index 77aa9fb..66c0a3e 100644 --- a/crates/clipbin-probe/src/main.rs +++ b/crates/clipbin-probe/src/main.rs @@ -9,7 +9,7 @@ //! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS] //! [--width W --height H --fps F] [--no-audio] [--mic] //! [--min-free-mb MB] [--out DIR] -//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--out DIR] +//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--expect-motion] [--out DIR] //! //! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg //! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`). @@ -63,6 +63,7 @@ fn usage(error: &str) -> ExitCode { struct Options { verify: bool, + expect_motion: bool, target: String, window: String, restore_token: Option, @@ -83,6 +84,7 @@ impl Options { fn parse(args: &[String]) -> Result { let mut options = Self { verify: false, + expect_motion: false, target: "monitor".into(), window: String::new(), restore_token: None, @@ -112,6 +114,7 @@ impl Options { }; match flag.as_str() { "--verify" => options.verify = true, + "--expect-motion" => options.expect_motion = true, "--target" => options.target = value()?, "--window" => options.window = value()?, "--restore-token" => options.restore_token = Some(value()?), @@ -337,11 +340,11 @@ fn record(options: &Options) -> Result> { next_sample += Duration::from_secs(1); let health = recording.health(); println!( - "t={} health source={}x{} streaming={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}", + "t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}", elapsed(), health.source_width, health.source_height, - health.source_streaming, + health.pipewire, health.output_active, health.total_frames, health.dropped_frames, @@ -466,6 +469,27 @@ fn check(options: &Options) -> Result> { ); } + // A black or frozen capture still produces valid segments. When the + // captured content was moving, the middle segment must show it. + if options.expect_motion { + let middle = &segments[segments.len() / 2]; + let distinct = Command::new("ffmpeg") + .args(["-v", "error", "-i"]) + .arg(middle) + .args(["-map", "0:v:0", "-vf", "mpdecimate=max=0", "-f", "null", "-", "-stats_period", "100"]) + .args(["-progress", "pipe:1"]) + .output()?; + let frames = String::from_utf8_lossy(&distinct.stdout) + .lines() + .filter_map(|line| line.strip_prefix("frame=")) + .filter_map(|frames| frames.parse::().ok()) + .last() + .unwrap_or(0); + let moving = frames > 1; + ok &= moving; + println!("motion ok={moving} distinct_frames={frames} segment={}", middle.display()); + } + // A clip spanning the boundaries between the first three segments. let list = options.out.join("concat.txt"); let entries: String = segments[..3] diff --git a/docs/validation.md b/docs/validation.md index e9156b5..de1a5bc 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -9,7 +9,7 @@ pass/fail results. | Where | Script | Status | | --- | --- | --- | | Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 9 scenarios pass on devbox (Intel UHD 770, VA-API) | -| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Not run yet | +| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | First run 2026-10-08: H.264, HEVC and AV1 all usable; restore token still to confirm | | Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested | ## Probe commands @@ -45,7 +45,11 @@ The suite covers: 3. X11 window capture (Xcomposite). 4. Failures that must end in `status=failed`: PipeWire stream lost, captured X11 window closed, segment directory unwritable, free space below the margin. -5. A portal that grants no usable source never reports `Recording`. +5. A portal that refuses the request fails with an actionable reason and + never reports `Recording`. + +Segment checks run with `--expect-motion`, so black or frozen footage (which +still makes valid segments) fails. ## Linux hardware sign-off @@ -55,6 +59,24 @@ shows the portal picker; later runs pass the restore token and ask whether the picker stayed away), records a window, and asks you to close a window mid-recording. Send back `report.txt` from the output directory. +### First hardware run (2026-10-08) + +CachyOS 7.2.9, Hyprland, RX 9070 XT, 1440p60, OBS 32.0.4: + +- H.264, HEVC and AV1 all encode through VA-API (`*_ffmpeg_vaapi_tex`). + Monitor and window capture recorded keyframe-aligned 2 s segments that + remuxed across boundaries with no decode errors. +- The restore token came back on one of three runs, probably because the + "Allow a restore token" box in the picker was ticked only once. The script + now has a dedicated restore step. +- One run never got a portal selection: the picker either did not appear or + was not answered, and the probe stayed in `Waiting for capture` for 20 s + while writing black segments that `check` passed. `check --expect-motion` + now catches black footage; a refused request now fails at once. +- Closing the captured window (Super+W) is not visible to capture: xdph keeps + the PipeWire stream `streaming` and OBS repeats the window's last frame. + See findings. + ## Windows Run `scripts/validate/windows.ps1 -GameWindow ":<class>:<exe>"` with a @@ -102,6 +124,15 @@ Windows status: its last frame after the stream stops, and only logs the stream state. The engine reads that log line (stable for the pinned OBS version). A more robust follow-up is watching the PipeWire node state directly. +- **Closed windows on Wayland.** With Hyprland's portal, closing a captured + window leaves the stream running on its last frame, with no state change or + error. Capture cannot tell this apart from a static window, so sessions must + end from game process exit (SPEC: "Stop/finalise the session when its game + exits"), which the Steam/process detection milestone provides. +- **Writing while waiting for capture.** The output starts before the source + has a picture, so a source that never arrives produces black segments. The + session timeline should show that time as missing footage, or the output + should start only once capture is usable. - **Audio capture failures are not detected yet.** When the PulseAudio server was unreachable, recording continued with a silent track. - **Linux runtime dependencies.** The Linux OBS build links the system's diff --git a/scripts/validate/linux-hardware.sh b/scripts/validate/linux-hardware.sh index 3d05207..d6c2a95 100755 --- a/scripts/validate/linux-hardware.sh +++ b/scripts/validate/linux-hardware.sh @@ -25,6 +25,23 @@ ask() { log "answer: $1 -> ${answer}" } +# record <name> <codec> [probe args...]: records 20 s, then checks the +# segments only if the probe reached Recording. +record() { + local name="$1" codec="$2" dir="${out}/$1" + shift 2 + "${probe}" record --codec "${codec}" "$@" --seconds 20 "${format[@]}" --out "${dir}" \ + > "${dir}.log" 2> "${dir}.obs.log" + local record_exit=$? + if grep -q '^recorded=true' "${dir}.log"; then + "${probe}" check --codec "${codec}" --expect-motion --out "${dir}" >> "${dir}.log" 2>&1 + log "${name}: record_exit=${record_exit} check_exit=$? $(grep -E '^(encoder|check|motion)' "${dir}.log" | tr '\n' ' ')" + else + log "${name}: FAIL never reached Recording (record_exit=${record_exit})" + fi + log " $(grep -E 'status=' "${dir}.log" | tr '\n' ' ')" +} + log "Clipbin hardware validation $(date -Is)" log "host: $(uname -r) session=${XDG_SESSION_TYPE:-?} desktop=${XDG_CURRENT_DESKTOP:-?}" log "gpu: $(lspci 2>/dev/null | grep -iE 'vga|display' | cut -d: -f3- | xargs)" @@ -32,52 +49,44 @@ log "gpu: $(lspci 2>/dev/null | grep -iE 'vga|display' | cut -d: -f3- | xargs)" "${probe}" encoders --verify "${format[@]}" --out "${out}/encoders" > "${out}/encoders.log" 2> "${out}/encoders.obs.log" log "$(grep -E '^(obs_version|usable\.|verify\.)' "${out}/encoders.log")" -token="" +echo +echo "Each recording opens the screen-share picker: pick a monitor with motion on" +echo "it (a video or game). Tick \"Allow a restore token\" (Hyprland) or" +echo "\"Remember this selection\" when the picker offers it." for codec in h264 hevc av1; do if grep -q "^usable\.${codec}=none" "${out}/encoders.log"; then log "SKIP ${codec}: no usable hardware encoder" continue fi - if [[ -z "${token}" ]]; then - echo - echo "Recording a monitor for 20 seconds with ${codec}. The portal will ask" - echo "you to pick a monitor: pick one and keep some motion on screen." - fi - dir="${out}/monitor-${codec}" - restore=() - [[ -n "${token}" ]] && restore=(--restore-token "${token}") - "${probe}" record --target monitor --codec "${codec}" "${restore[@]}" --seconds 20 "${format[@]}" \ - --out "${dir}" > "${dir}.log" 2> "${dir}.obs.log" - record_exit=$? - "${probe}" check --codec "${codec}" --out "${dir}" >> "${dir}.log" 2>&1 - check_exit=$? - log "monitor-${codec}: record_exit=${record_exit} check_exit=${check_exit} $(grep -E '^(encoder|recorded|check)=' "${dir}.log" | tr '\n' ' ')" - log "$(grep -E 'status=' "${dir}.log" | tr '\n' ' ')" - if [[ -z "${token}" ]]; then - token="$(sed -n 's/^restore_token=//p' "${dir}.log")" - log "restore token received: $([[ -n "${token}" ]] && echo yes || echo no)" - else - ask "Did ${codec} start WITHOUT showing the portal picker again?" - fi + record "monitor-${codec}" "${codec}" --target monitor done +# Restoring the last selection must not show the picker again. +token="$(sed -n 's/^restore_token=//p' "${out}"/monitor-*.log | tail -1)" +if [[ -n "${token}" ]]; then + echo + echo "Recording again with the saved selection. The picker should NOT appear." + record monitor-restored h264 --target monitor --restore-token "${token}" + ask "Did the recording start WITHOUT showing the picker?" +else + log "restore: FAIL no restore token was returned (was \"Allow a restore token\" ticked?)" +fi + echo echo "Now recording a window for 20 seconds. Start a game (or any moving" -echo "window) first, then pick it in the portal." +echo "window) first, then pick it in the picker." read -r -p "Press Enter when ready. " _ -dir="${out}/window-h264" -"${probe}" record --target window --seconds 20 "${format[@]}" --out "${dir}" > "${dir}.log" 2> "${dir}.obs.log" -record_exit=$? -"${probe}" check --out "${dir}" >> "${dir}.log" 2>&1 -log "window-h264: record_exit=${record_exit} check_exit=$? $(grep -E '^(recorded|check)=' "${dir}.log" | tr '\n' ' ')" +record window-h264 h264 --target window +# On Wayland the portal may keep streaming a closed window's last frame, so +# this is informational: Clipbin ends sessions when the game process exits. echo -echo "Last: source loss. Record a window again, then close that window" -echo "about 5 seconds in. The probe should report a failure." +echo "Last: record a window again and close it (quit the app) about 5 seconds in." read -r -p "Press Enter when ready. " _ dir="${out}/window-closed" "${probe}" record --target window --seconds 20 "${format[@]}" --out "${dir}" > "${dir}.log" 2> "${dir}.obs.log" -log "window-closed: exit=$? $(grep -E 'status=failed' "${dir}.log" | head -1)" +log "window-closed (informational): exit=$? $(grep -E 'status=' "${dir}.log" | tail -1)" +ask "Did you quit the captured app about 5 seconds in?" echo echo "Report: ${report}" diff --git a/scripts/validate/linux-rig.sh b/scripts/validate/linux-rig.sh index 70c3a73..a528ff8 100755 --- a/scripts/validate/linux-rig.sh +++ b/scripts/validate/linux-rig.sh @@ -42,7 +42,7 @@ for codec in h264 hevc; do name="wayland-${codec}" dir="${out}/${name}" "${rig}" "${probe}" record --codec "${codec}" --seconds 9 --out "${dir}" > "${out}/${name}.log" 2>&1 \ - && "${probe}" check --codec "${codec}" --out "${dir}" >> "${out}/${name}.log" 2>&1 + && "${probe}" check --codec "${codec}" --expect-motion --out "${dir}" >> "${out}/${name}.log" 2>&1 ok=$? volume="$(ffmpeg -v info -i "${dir}/session/segment-000001.mkv" -map 0:a -af volumedetect -f null - 2>&1 \ | awk '/max_volume/ { print $5 }')" @@ -54,7 +54,7 @@ done name=x11-window "${rig}" --x11 bash -c "'${probe}' record --target window --window \"\${CLIPBIN_RIG_GAME_WINDOW}\" --seconds 9 --out '${out}/${name}'" \ > "${out}/${name}.log" 2>&1 \ - && "${probe}" check --out "${out}/${name}" >> "${out}/${name}.log" 2>&1 + && "${probe}" check --expect-motion --out "${out}/${name}" >> "${out}/${name}.log" 2>&1 result "${name}" "$?" # expect_failure <name> <reason pattern>: recording started, then failed honestly. @@ -101,13 +101,14 @@ else result "${name}" 1 fi -# 8. The portal grants nothing usable: recording never starts or claims to. -name=portal-no-source +# 8. The portal refuses the request: an actionable failure, never Recording. +name=portal-refused CLIPBIN_RIG_PORTAL_OUTPUT=NO-SUCH-OUTPUT "${rig}" "${probe}" record --seconds 8 --out "${out}/${name}" \ > "${out}/${name}.log" 2>&1 probe_exit=$? -if [[ "${probe_exit}" != 0 ]] && ! has "${out}/${name}.log" "status=Recording"; then - result "${name}" 0 "$(grep 'status=' "${out}/${name}.log" | tail -1)" +if [[ "${probe_exit}" != 0 ]] && ! has "${out}/${name}.log" "status=Recording" \ + && has "${out}/${name}.log" "status=failed reason=screen capture was not allowed"; then + result "${name}" 0 "$(grep -m1 -o 'status=failed reason=.*' "${out}/${name}.log")" else result "${name}" 1 fi