Add native screen recording with themed top-bar controls

This commit is contained in:
Samuel Onoja committed 2026-09-28 08:30:59 +01:00
1 parent 0c7ff410a3
commit 6209d556ee
16 files changed
+1060 -29

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ jiff.workspace = true
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt", "sync"] }
wayland-client.workspace = true
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Stop recording">
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/>
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<rect x="22" y="18" width="16" height="16" rx="3" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Record screen">
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/>
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<circle cx="30" cy="26" r="9" fill="#3d7be6"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

+18
View File
@@ -4,6 +4,7 @@ mod motion;
mod note_store;
mod notification_ui;
mod notifications;
mod recording;
mod status;
mod status_ui;
@@ -205,6 +206,7 @@ struct FereseShell {
notification_surface: Option<notification_ui::NotificationSurface>,
status_service: status::Service,
status: status::Snapshot,
recorder: recording::Recorder,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
note_editor: Option<DesktopNoteEditor>,
@@ -264,6 +266,8 @@ enum Message {
ActivateWorkspace(u64),
ToggleOverview,
StatusUpdated(status::Update),
StartRecording,
StopRecording,
NotificationEvent(notifications::Event),
NotificationTick,
DismissNotification(u32),
@@ -311,6 +315,7 @@ impl cosmic::Application for FereseShell {
notification_surface: None,
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
status_error: None,
menu: None,
note_editor: None,
@@ -543,6 +548,18 @@ impl cosmic::Application for FereseShell {
}
Task::none()
}
Message::StartRecording => {
self.recorder.start();
if self.recorder.busy() {
self.close_menu()
} else {
Task::none()
}
}
Message::StopRecording => {
self.recorder.stop();
Task::none()
}
Message::StatusUpdated(update) => {
if update.generation >= self.status_service.generation {
self.status = update.snapshot;
@@ -659,6 +676,7 @@ impl cosmic::Application for FereseShell {
}
Message::Event(event, id) => self.handle_event(event, id),
Message::Tick => {
self.recorder.poll();
self.clock = current_time();
if self.config.desktop_widgets.clock.enabled {
self.desktop_clock = self
+141
View File
@@ -0,0 +1,141 @@
//! The bar only controls the recorder and consumes bounded status messages.
//! Encoding and portal negotiation run in the separate ferese-record process.
use serde::Deserialize;
use std::{
io::{BufRead, BufReader, Read},
path::PathBuf,
process::{ChildStdin, Command, Stdio},
sync::mpsc::{self, Receiver},
time::Instant,
};
#[derive(Debug, Default)]
pub enum State {
#[default]
Idle,
Selecting,
Recording(Instant),
Saving,
Saved(PathBuf),
Error(String),
}
#[derive(Deserialize)]
struct Update {
state: String,
message: Option<String>,
path: Option<PathBuf>,
}
enum Event {
Update(Update),
Exited,
}
#[derive(Default)]
pub struct Recorder {
pub state: State,
control: Option<ChildStdin>,
updates: Option<Receiver<Event>>,
}
impl Recorder {
pub fn busy(&self) -> bool {
self.updates.is_some()
}
pub fn elapsed(&self) -> Option<String> {
let State::Recording(start) = self.state else {
return None;
};
let seconds = start.elapsed().as_secs();
Some(format!("{:02}:{:02}", seconds / 60, seconds % 60))
}
pub fn start(&mut self) {
if self.busy() {
return;
}
let program = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("ferese-record")))
.filter(|p| p.is_file())
.unwrap_or_else(|| PathBuf::from("ferese-record"));
match Command::new(program)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
{
Ok(mut child) => {
self.control = child.stdin.take();
let output = child.stdout.take().unwrap();
let (send, receive) = mpsc::sync_channel(8);
self.updates = Some(receive);
self.state = State::Selecting;
std::thread::spawn(move || {
let mut output = BufReader::new(output);
loop {
let mut line = String::new();
match output.by_ref().take(65537).read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if line.len() > 65536 => break,
Ok(_) => {
if let Ok(update) = serde_json::from_str::<Update>(&line) {
if send.send(Event::Update(update)).is_err() {
break;
}
}
}
}
}
let _ = child.wait();
let _ = send.send(Event::Exited);
});
}
Err(error) => self.state = State::Error(format!("Cannot start recorder: {error}")),
}
}
pub fn stop(&mut self) {
if self.busy() {
// EOF requests EOS/finalization. Never terminate the encoder abruptly.
self.control.take();
self.state = State::Saving;
}
}
pub fn poll(&mut self) {
let mut done = false;
if let Some(updates) = &self.updates {
while let Ok(event) = updates.try_recv() {
match event {
Event::Update(update) => match update.state.as_str() {
"selecting" if self.control.is_some() => self.state = State::Selecting,
"recording" if self.control.is_some() => {
self.state = State::Recording(Instant::now())
}
"saving" => self.state = State::Saving,
"saved" => {
self.state = update.path.map(State::Saved).unwrap_or_else(|| {
State::Error("Recorder returned no saved file".into())
})
}
"cancelled" => self.state = State::Idle,
"error" => {
self.state = State::Error(
update.message.unwrap_or_else(|| "Recording failed".into()),
)
}
_ => (),
},
Event::Exited => {
if matches!(
self.state,
State::Selecting | State::Recording(_) | State::Saving
) {
self.state = State::Error("The recorder stopped unexpectedly".into());
}
done = true;
}
}
}
}
if done {
self.control.take();
self.updates.take();
}
}
}
+69 -4
View File
@@ -16,6 +16,7 @@ pub enum Menu {
Bluetooth,
Audio,
Battery,
Recording,
Notifications,
System,
}
@@ -41,6 +42,7 @@ impl Menu {
Self::Bluetooth => "Bluetooth",
Self::Audio => "Sound",
Self::Battery => "Battery",
Self::Recording => "Screen recording",
Self::Notifications => "Notifications",
}
}
@@ -52,7 +54,7 @@ impl Menu {
Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(),
Self::Notifications => status.notifications.is_some(),
Self::System => true,
Self::System | Self::Recording => true,
}
}
}
@@ -242,19 +244,27 @@ impl FereseShell {
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme.for_bar();
let metrics = BarMetrics::from(theme);
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center);
let mut controls = row::with_capacity(7).spacing(1).align_y(Alignment::Center);
for kind in [
Menu::System,
Menu::Network,
Menu::Bluetooth,
Menu::Audio,
Menu::Recording,
Menu::Notifications,
Menu::Battery,
] {
if !kind.available(&self.status) {
continue;
}
let (source, enabled) = status_icon(kind, &self.status);
let (source, enabled) = if kind == Menu::Recording && self.recorder.busy() {
(
include_bytes!("../assets/icons/status/record-stop.svg").as_slice(),
true,
)
} else {
status_icon(kind, &self.status)
};
let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind);
let foreground = color(if selected {
theme.accent
@@ -271,6 +281,15 @@ impl FereseShell {
)]
.spacing(4)
.align_y(Alignment::Center);
if kind == Menu::Recording
&& let Some(elapsed) = self.recorder.elapsed()
{
content = content.push(
text(elapsed)
.size(metrics.text_size)
.class(theme::Text::Color(foreground)),
);
}
if kind == Menu::Battery && self.config.status.battery_percentage {
if let Some(battery) = &self.status.battery {
content = content.push(
@@ -304,14 +323,22 @@ impl FereseShell {
.align_x(alignment::Horizontal::Center)
.height(metrics.control_height)
.align_y(alignment::Vertical::Center);
let recording_busy = kind == Menu::Recording && self.recorder.busy();
let control = button::custom(content)
.name(status_label(kind, &self.status))
.name(if recording_busy {
"Stop screen recording".into()
} else {
status_label(kind, &self.status)
})
.padding([
0.0,
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
])
.height(metrics.control_height)
.on_press_with_rectangle(move |offset, bounds| {
if recording_busy {
return cosmic::Action::App(Message::StopRecording);
}
cosmic::Action::App(Message::OpenMenu(
kind,
Rectangle {
@@ -407,6 +434,42 @@ impl FereseShell {
]
.spacing(8),
);
} else if kind == Menu::Recording {
use recording::State;
let description = match &self.recorder.state {
State::Idle => {
"Record a display. Video is saved to Videos/Ferese as WebM. Audio is off."
.to_owned()
}
State::Selecting => "Choose a display in the sharing dialog.".to_owned(),
State::Recording(_) => format!(
"Recording {} · audio off",
self.recorder.elapsed().unwrap_or_default()
),
State::Saving => "Finishing your recording…".to_owned(),
State::Saved(path) => format!("Saved to {}", path.display()),
State::Error(message) => message.clone(),
};
rows = rows.push(text(description).size(13).class(theme::Text::Color(muted)));
if !self.recorder.busy() {
rows = rows.push(menu_button(
"Record display",
Message::StartRecording,
primary,
p,
));
} else if !matches!(self.recorder.state, State::Saving) {
rows = rows.push(menu_button(
if matches!(self.recorder.state, State::Selecting) {
"Cancel"
} else {
"Stop and save"
},
Message::StopRecording,
primary,
p,
));
}
} else {
let combined = menu.kind == Menu::System;
if combined {
@@ -1106,6 +1169,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
|| "Battery: unavailable".into(),
|b| format!("Battery: {}%, {}", b.percent, b.status),
),
Menu::Recording => "Screen recording".into(),
Menu::System => "Control Center".into(),
}
}
@@ -1193,6 +1257,7 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
)
}
}
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
Menu::System => (
include_bytes!("../assets/icons/status/control-center.svg"),
true,