Use aperture

This commit is contained in:
Maximiliano Sandoval R 2023-09-24 22:52:02 +02:00
parent ff82f1a938
commit 04a1c45fd5
Failed to generate hash of commit
13 changed files with 475 additions and 890 deletions

765
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@ rust-version = "1.70"
adw = {package = "libadwaita", version = "0.5", features = ["v1_4"]}
aes-gcm = "0.10"
anyhow = "1.0"
ashpd = {version = "0.6", default-features = false, features = ["pipewire", "gtk4", "tokio", "tracing"]}
aperture = "0.3.2"
ashpd = {version = "0.6", default-features = false, features = ["gtk4", "tokio", "tracing"]}
data-encoding = "2.3"
diesel = {version = "2.0", features = ["sqlite", "r2d2"]}
diesel_migrations = {version = "2.0", features = ["sqlite"]}
@ -17,9 +18,6 @@ futures-channel = "0.3"
futures-executor = "0.3"
futures-util = "0.3"
gettext-rs = {version = "0.7", features = ["gettext-system"]}
gst = {package = "gstreamer", version = "0.21"}
gst_video = { package = "gstreamer-video", version = "0.21"}
gst4gtk = {package = "gst-plugin-gtk4", version = "0.11.0-alpha.1", features = ["wayland", "x11egl", "x11glx"]}
gtk = {package = "gtk4", version = "0.7", features = ["v4_10"]}
hex = {version = "0.4.3", features = ["serde"]}
image = {version = "0.24", default-features = false, features = ["png"]}

View file

@ -15,7 +15,7 @@
"--socket=wayland",
"--device=dri",
"--talk-name=org.freedesktop.secrets",
"--env=RUST_LOG=authenticator=debug,ashpd=debug,oo7=debug",
"--env=RUST_LOG=authenticator=debug,ashpd=debug,oo7=debug,aperture=debug",
"--env=G_MESSAGES_DEBUG=none",
"--talk-name=org.gtk.vfs.*",
"--filesystem=xdg-run/gvfs",

View file

@ -233,7 +233,6 @@
<object class="AdwNavigationPage">
<property name="title" translatable="yes">Camera</property>
<property name="tag">camera</property>
<signal name="hidden" handler="camera_page_hidden" swapped="true" />
<property name="child">
<object class="Camera" id="camera">
<signal name="close" handler="camera_closed" swapped="true" />

View file

@ -57,8 +57,8 @@
<property name="child">
<object class="GtkOverlay">
<child>
<object class="GtkPicture" id="picture">
<property name="keep-aspect-ratio">False</property>
<object class="ApertureViewfinder" id="viewfinder">
<property name="detect-codes">True</property>
</object>
</child>
<child type="overlay">

View file

@ -27,7 +27,6 @@ src/models/algorithm.rs
src/widgets/accounts/add.rs
src/widgets/accounts/details.rs
src/widgets/accounts/row.rs
src/widgets/camera.rs
src/widgets/preferences/password_page.rs
src/widgets/preferences/window.rs
src/widgets/providers/dialog.rs

View file

@ -27,8 +27,7 @@ fn init_i18n() -> anyhow::Result<()> {
fn main() -> glib::ExitCode {
tracing_subscriber::fmt::init();
gtk::init().expect("failed to init gtk");
gst::init().expect("failed to init gstreamer");
gst4gtk::plugin_register_static().expect("Failed to register gstgtk4 plugin");
aperture::init(config::APP_ID);
if let Err(err) = init_i18n() {
tracing::error!("Failed to initialize i18n {}", err);

View file

@ -198,11 +198,6 @@ impl AccountAddDialog {
name_entry.set_position(entry.cursor_position());
}
#[template_callback]
fn camera_page_hidden(&self, _page: &adw::NavigationPage) {
self.imp().camera.stop();
}
#[template_callback]
fn camera_closed(&self, _camera: Camera) {
self.activate_action("add.previous", None).unwrap();

View file

@ -1,22 +1,20 @@
use std::cell::RefCell;
use std::cell::OnceCell;
use std::os::fd::RawFd;
use std::sync::Once;
use adw::subclass::prelude::*;
use anyhow::Result;
use ashpd::desktop::screenshot::ScreenshotRequest;
use gettextrs::gettext;
use glib::once_cell::sync::Lazy;
use gst::prelude::*;
use gtk::{
gio,
glib::{self, clone, Receiver},
glib::{self, clone},
prelude::*,
};
use image::GenericImageView;
use super::{CameraItem, CameraRow};
use crate::{utils::spawn_tokio, widgets::CameraPaintable};
static CAMERA_LOCATION: &str = "api.libcamera.location";
use super::CameraRow;
use crate::utils::spawn_tokio;
pub mod screenshot {
use super::*;
@ -62,30 +60,18 @@ pub mod screenshot {
}
}
pub enum CameraEvent {
CodeDetected(String),
StreamStarted,
}
pub enum CameraState {
NotFound,
Ready,
}
mod imp {
use glib::subclass::{InitializingObject, Signal};
use super::*;
#[derive(gtk::CompositeTemplate)]
#[derive(gtk::CompositeTemplate, Default)]
#[template(resource = "/com/belmoussaoui/Authenticator/camera.ui")]
pub struct Camera {
pub paintable: CameraPaintable,
pub receiver: RefCell<Option<Receiver<CameraEvent>>>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub picture: TemplateChild<gtk::Picture>,
pub viewfinder: TemplateChild<aperture::Viewfinder>,
#[template_child]
pub spinner: TemplateChild<gtk::Spinner>,
#[template_child]
@ -94,8 +80,8 @@ mod imp {
pub camera_selection_button: TemplateChild<gtk::MenuButton>,
#[template_child]
pub toolbar_view: TemplateChild<adw::ToolbarView>,
pub stream_list: gio::ListStore,
pub selection: gtk::SingleSelection,
pub provider: OnceCell<aperture::DeviceProvider>,
}
#[glib::object_subclass]
@ -113,24 +99,6 @@ mod imp {
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
fn new() -> Self {
let (sender, r) = glib::MainContext::channel(glib::Priority::default());
let receiver = RefCell::new(Some(r));
Self {
paintable: CameraPaintable::new(sender),
receiver,
camera_selection_button: TemplateChild::default(),
spinner: TemplateChild::default(),
stack: TemplateChild::default(),
picture: TemplateChild::default(),
screenshot: TemplateChild::default(),
toolbar_view: TemplateChild::default(),
stream_list: gio::ListStore::new::<glib::BoxedAnyObject>(),
selection: Default::default(),
}
}
}
impl ObjectImpl for Camera {
@ -150,14 +118,67 @@ mod imp {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.setup_receiver();
obj.setup_widget();
obj.set_state(CameraState::NotFound);
self.picture.set_paintable(Some(&self.paintable));
}
fn dispose(&self) {
self.paintable.close_pipeline();
let provider = aperture::DeviceProvider::instance();
self.provider.set(provider.clone()).unwrap();
self.viewfinder
.connect_state_notify(glib::clone!(@weak obj => move |_| {
obj.update_viewfinder_state();
}));
obj.update_viewfinder_state();
self.viewfinder.connect_code_detected(
glib::clone!(@weak obj => move|_, code_type, code| {
if matches!(code_type, aperture::CodeType::Qr) {
obj.emit_by_name::<()>("code-detected", &[&code]);
}
}),
);
let popover = gtk::Popover::new();
popover.add_css_class("menu");
self.selection.set_model(Some(provider));
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let camera_row = CameraRow::default();
item.downcast_ref::<gtk::ListItem>()
.unwrap()
.set_child(Some(&camera_row));
});
let selection = &self.selection;
factory.connect_bind(glib::clone!(@weak selection => move |_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let child = item.child().unwrap();
let row = child.downcast_ref::<CameraRow>().unwrap();
let item = item.item().and_downcast::<aperture::Camera>().unwrap();
row.set_label(&item.display_name());
selection.connect_selected_item_notify(glib::clone!(@weak row, @weak item => move |selection| {
if let Some(selected_item) = selection.selected_item() {
row.set_selected(selected_item == item);
} else {
row.set_selected(false);
}
}));
}));
let list_view = gtk::ListView::new(Some(self.selection.clone()), Some(factory));
popover.set_child(Some(&list_view));
self.selection.connect_selected_item_notify(
glib::clone!(@weak obj, @weak popover => move |selection| {
if let Some(selected_item) = selection.selected_item() {
let camera = selected_item.downcast_ref::<aperture::Camera>();
obj.imp().viewfinder.set_camera(camera);
}
popover.popdown();
}),
);
self.camera_selection_button.set_popover(Some(&popover));
}
}
@ -172,19 +193,6 @@ glib::wrapper! {
#[gtk::template_callbacks]
impl Camera {
pub fn start(&self) {
let imp = self.imp();
imp.paintable.start();
self.set_state(CameraState::Ready);
}
pub fn stop(&self) {
let imp = self.imp();
imp.paintable.stop();
imp.stream_list.remove_all();
imp.selection.set_selected(gtk::INVALID_LIST_POSITION);
}
pub fn connect_close<F>(&self, callback: F) -> glib::SignalHandlerId
where
F: Fn(&Self) + 'static,
@ -214,46 +222,27 @@ impl Camera {
)
}
fn set_streams(&self, streams: Vec<ashpd::desktop::camera::Stream>) {
let imp = self.imp();
let mut selected_stream = 0;
for (id, stream) in streams.into_iter().enumerate() {
let default = gettext("Unknown Device");
let nick = stream
.properties()
.get("node.nick")
.unwrap_or(&default)
.to_string();
pub async fn scan_from_camera(&self) {
static INIT: Once = Once::new();
if INIT.is_completed() {
return;
}
if let Some(location) = stream.properties().get(CAMERA_LOCATION) {
if location == "front" {
selected_stream = id;
let provider = self.imp().provider.get().unwrap();
match spawn_tokio(stream()).await {
Ok(fd) => {
if let Err(err) = provider.set_fd(fd) {
tracing::error!("Could not use the camera portal: {err}");
} else {
if let Err(err) = provider.start() {
tracing::error!("Could not start the device provider: {err}");
} else {
tracing::debug!("Device provider started");
INIT.call_once(|| ());
};
}
}
let item = CameraItem {
nick,
node_id: stream.node_id(),
};
imp.stream_list.append(&glib::BoxedAnyObject::new(item));
}
imp.selection.set_selected(selected_stream as u32);
}
pub async fn scan_from_camera(&self) {
match spawn_tokio(ashpd::desktop::camera::request()).await {
Ok(Some((stream_fd, nodes_id))) => {
match self.imp().paintable.set_pipewire_fd(stream_fd) {
Ok(_) => {
self.set_streams(nodes_id);
}
Err(err) => tracing::error!("Failed to start the camera stream {err}"),
};
}
Ok(None) => {
self.set_state(CameraState::NotFound);
}
Err(e) => tracing::error!("Failed to stream {}", e),
Err(err) => tracing::error!("Failed to start the camera portal: {err}"),
}
}
@ -273,93 +262,38 @@ impl Camera {
Ok(())
}
fn set_state(&self, state: CameraState) {
fn update_viewfinder_state(&self) {
let imp = self.imp();
let state = imp.viewfinder.state();
match state {
CameraState::NotFound => {
tracing::info!("The camera state changed: Not Found");
imp.stack.set_visible_child_name("not-found");
imp.toolbar_view.set_extend_content_to_top_edge(false);
imp.toolbar_view.remove_css_class("extended");
aperture::ViewfinderState::Loading => {
imp.stack.set_visible_child_name("loading");
}
CameraState::Ready => {
tracing::info!("The camera state changed: Ready");
aperture::ViewfinderState::Error => {
imp.stack.set_visible_child_name("not-found");
}
aperture::ViewfinderState::NoCameras => {
imp.stack.set_visible_child_name("not-found");
}
aperture::ViewfinderState::Ready => {
imp.stack.set_visible_child_name("stream");
imp.toolbar_view.set_extend_content_to_top_edge(true);
imp.toolbar_view.add_css_class("extended");
imp.spinner.stop();
}
}
}
tracing::info!("The camera state changed: {state:?}");
fn setup_receiver(&self) {
self.imp().receiver.borrow_mut().take().unwrap().attach(
None,
glib::clone!(@weak self as camera => @default-return glib::ControlFlow::Break, move |event| {
match event {
CameraEvent::CodeDetected(code) => {
camera.emit_by_name::<()>("code-detected", &[&code]);
}
CameraEvent::StreamStarted => {
camera.set_state(CameraState::Ready);
}
}
glib::ControlFlow::Continue
}),
);
}
let is_ready = matches!(state, aperture::ViewfinderState::Ready);
imp.toolbar_view.set_extend_content_to_top_edge(is_ready);
if is_ready {
imp.toolbar_view.add_css_class("extended");
} else {
imp.toolbar_view.remove_css_class("extended");
}
fn setup_widget(&self) {
let imp = self.imp();
let popover = gtk::Popover::new();
popover.add_css_class("menu");
imp.selection.set_model(Some(&imp.stream_list));
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let camera_row = CameraRow::default();
item.downcast_ref::<gtk::ListItem>()
.unwrap()
.set_child(Some(&camera_row));
});
let selection = &imp.selection;
factory.connect_bind(glib::clone!(@weak selection => move |_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let child = item.child().unwrap();
let row = child.downcast_ref::<CameraRow>().unwrap();
let item = item.item().and_downcast::<glib::BoxedAnyObject>().unwrap();
let camera_item = item.borrow::<CameraItem>();
row.set_label(&camera_item.nick);
selection.connect_selected_item_notify(glib::clone!(@weak row, @weak item => move |selection| {
if let Some(selected_item) = selection.selected_item() {
row.set_selected(selected_item == item);
} else {
row.set_selected(false);
}
}));
}));
let list_view = gtk::ListView::new(Some(imp.selection.clone()), Some(factory));
popover.set_child(Some(&list_view));
imp.selection.connect_selected_item_notify(glib::clone!(@weak self as obj, @weak popover => move |selection| {
if let Some(selected_item) = selection.selected_item() {
let node_id = selected_item.downcast_ref::<glib::BoxedAnyObject>().unwrap().borrow::<CameraItem>().node_id;
match obj.imp().paintable.set_pipewire_node_id(node_id) {
Ok(_) => {
obj.start();
},
Err(err) => {
tracing::error!("Failed to start a camera stream {err}");
}
}
}
popover.popdown();
}));
imp.camera_selection_button.set_popover(Some(&popover));
if matches!(state, aperture::ViewfinderState::Loading) {
imp.spinner.start();
} else {
imp.spinner.stop();
}
}
#[template_callback]
@ -375,3 +309,10 @@ impl Default for Camera {
glib::Object::new()
}
}
async fn stream() -> ashpd::Result<RawFd> {
let proxy = ashpd::desktop::camera::Camera::new().await?;
proxy.request_access().await?;
proxy.open_pipe_wire_remote().await
}

View file

@ -1,265 +0,0 @@
use std::os::unix::io::AsRawFd;
use glib::once_cell::sync::Lazy;
use gst::prelude::*;
use gtk::{
gdk,
glib::{self, clone, Sender},
graphene,
prelude::*,
subclass::prelude::*,
};
use crate::widgets::camera::CameraEvent;
static PIPELINE_NAME: Lazy<glib::GString> = Lazy::new(|| glib::GString::from("camera"));
/// Fancy Camera with QR code detection using ZBar
///
/// Pipeline:
/// queue -- videoconvert -- zbar -- fakesink
/// /
/// pipewiresrc -- tee
/// \
/// queue -- videoflip - glsinkbin
mod imp {
use std::cell::RefCell;
use super::*;
#[derive(Default)]
pub struct CameraPaintable {
pub sender: RefCell<Option<Sender<CameraEvent>>>,
pub pipeline: RefCell<Option<gst::Pipeline>>,
pub pipewire_element: RefCell<Option<gst::Element>>,
pub sink_paintable: RefCell<Option<gdk::Paintable>>,
pub guard: RefCell<Option<gst::bus::BusWatchGuard>>,
}
#[glib::object_subclass]
impl ObjectSubclass for CameraPaintable {
const NAME: &'static str = "CameraPaintable";
type Type = super::CameraPaintable;
type Interfaces = (gdk::Paintable,);
}
impl ObjectImpl for CameraPaintable {
fn dispose(&self) {
self.obj().close_pipeline();
}
}
impl PaintableImpl for CameraPaintable {
fn intrinsic_height(&self) -> i32 {
if let Some(ref paintable) = *self.sink_paintable.borrow() {
paintable.intrinsic_height()
} else {
0
}
}
fn intrinsic_width(&self) -> i32 {
if let Some(ref paintable) = *self.sink_paintable.borrow() {
paintable.intrinsic_width()
} else {
0
}
}
fn snapshot(&self, snapshot: &gdk::Snapshot, width: f64, height: f64) {
if let Some(ref image) = *self.sink_paintable.borrow() {
// Transformation to avoid stretching the camera. We translate and scale the
// image.
let aspect = width / height.max(std::f64::EPSILON); // Do not divide by zero.
let image_aspect = image.intrinsic_aspect_ratio();
if image_aspect == 0.0 {
image.snapshot(snapshot, width, height);
return;
};
let (new_width, new_height) = match aspect <= image_aspect {
true => (height * image_aspect, height), // Mobile view
false => (width, width / image_aspect), // Landscape
};
let p = graphene::Point::new(
((width - new_width) / 2.0) as f32,
((height - new_height) / 2.0) as f32,
);
snapshot.translate(&p);
image.snapshot(snapshot, new_width, new_height);
} else {
snapshot.append_color(
&gdk::RGBA::BLACK,
&graphene::Rect::new(0f32, 0f32, width as f32, height as f32),
);
}
}
}
}
glib::wrapper! {
pub struct CameraPaintable(ObjectSubclass<imp::CameraPaintable>)
@implements gdk::Paintable;
}
impl CameraPaintable {
pub fn new(sender: Sender<CameraEvent>) -> Self {
let paintable = glib::Object::new::<Self>();
paintable.imp().sender.replace(Some(sender));
paintable
}
pub fn set_pipewire_node_id(&self, node_id: u32) -> anyhow::Result<()> {
let pipewire_element = self.imp().pipewire_element.borrow().clone().unwrap();
pipewire_element.set_property("path", node_id.to_string());
tracing::debug!("Loading PipeWire Node ID: {node_id}");
self.close_pipeline();
self.init_pipeline(&pipewire_element)?;
Ok(())
}
pub fn set_pipewire_fd<F: AsRawFd>(&self, fd: F) -> anyhow::Result<()> {
let raw_fd = fd.as_raw_fd();
let pipewire_element = gst::ElementFactory::make_with_name("pipewiresrc", None)?;
pipewire_element.set_property("fd", raw_fd);
tracing::debug!("Loading PipeWire with FD: {}", raw_fd);
self.imp().pipewire_element.replace(Some(pipewire_element));
Ok(())
}
fn init_pipeline(&self, pipewire_src: &gst::Element) -> anyhow::Result<()> {
tracing::debug!("Init pipeline");
let imp = self.imp();
let pipeline = gst::Pipeline::new();
let sink = gst::ElementFactory::make_with_name("gtk4paintablesink", None)?;
let paintable = sink.property::<gdk::Paintable>("paintable");
paintable.connect_invalidate_contents(clone!(@weak self as pt => move |_| {
pt.invalidate_contents();
}));
paintable.connect_invalidate_size(clone!(@weak self as pt => move |_| {
pt.invalidate_size();
}));
let tee = gst::ElementFactory::make_with_name("tee", None)?;
let videoconvert = gst::ElementFactory::make_with_name("videoconvert", None)?;
let queue1 = gst::ElementFactory::make_with_name("queue", None)?;
let queue2 = gst::ElementFactory::make_with_name("queue", None)?;
let zbar = gst::ElementFactory::make_with_name("zbar", None)?;
let fakesink = gst::ElementFactory::make_with_name("fakesink", None)?;
let videoflip = gst::ElementFactory::make("videoflip")
.property("video-direction", gst_video::VideoOrientationMethod::Auto)
.build()?;
let sink = if paintable
.property::<Option<gdk::GLContext>>("gl-context")
.is_some()
{
gst::ElementFactory::make("glsinkbin")
.property("sink", &sink)
.build()?
} else {
let bin = gst::Bin::default();
let convert = gst::ElementFactory::make_with_name("videoconvert", None)?;
bin.add(&convert)?;
bin.add(&sink)?;
convert.link(&sink)?;
bin.add_pad(
&gst::GhostPad::builder_with_target(&convert.static_pad("sink").unwrap())?
.name("sink")
.build(),
)?;
bin.upcast()
};
imp.sink_paintable.replace(Some(paintable));
pipeline.add_many([
pipewire_src,
&tee,
&queue1,
&videoconvert,
&zbar,
&fakesink,
&queue2,
&videoflip,
&sink,
])?;
gst::Element::link_many([pipewire_src, &tee, &queue1, &videoconvert, &zbar, &fakesink])?;
tee.link_pads(None, &queue2, None)?;
gst::Element::link_many([&queue2, &videoflip, &sink])?;
let bus = pipeline.bus().unwrap();
let guard = bus.add_watch_local(
clone!(@weak self as paintable => @default-return glib::ControlFlow::Break, move |_, msg| {
use gst::MessageView;
let sender = paintable.imp().sender.borrow().as_ref().unwrap().clone();
match msg.view() {
MessageView::Error(err) => {
tracing::error!(
"Error from {:?}: {} ({:?})",
err.src().map(|s| s.path_string()),
err.error(),
err.debug()
);
}
MessageView::StateChanged(state) => {
if Some(&*PIPELINE_NAME) == state.src().map(|s| s.name()).as_ref() {
let structure = state.structure().unwrap();
let new_state = structure.get::<gst::State>("new-state")
.unwrap();
if new_state == gst::State::Playing {
sender.send(CameraEvent::StreamStarted).unwrap();
}
}
}
MessageView::Element(e) => {
if let Some(s) = e.structure() {
if let Ok(symbol) = s.get::<String>("symbol") {
sender.send(CameraEvent::CodeDetected(symbol)).unwrap();
}
}
}
_ => (),
}
glib::ControlFlow::Continue
}),
)?;
imp.guard.replace(Some(guard));
imp.pipeline.replace(Some(pipeline));
Ok(())
}
pub fn close_pipeline(&self) {
tracing::debug!("Closing pipeline");
if let Some(pipeline) = self.imp().pipeline.take() {
if let Err(err) = pipeline.set_state(gst::State::Null) {
tracing::error!("Failed to close the pipeline: {err}");
}
}
let _ = self.imp().guard.take();
}
pub fn start(&self) {
if let Some(pipeline) = &*self.imp().pipeline.borrow() {
if let Err(err) = pipeline.set_state(gst::State::Playing) {
tracing::error!("Failed to start the camera stream: {err}");
}
}
}
pub fn stop(&self) {
if let Some(pipeline) = &*self.imp().pipeline.borrow() {
if let Err(err) = pipeline.set_state(gst::State::Null) {
tracing::error!("Failed to stop the camera stream: {err}");
}
}
}
}

View file

@ -1,12 +1,5 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
#[derive(Clone, glib::Boxed)]
#[boxed_type(name = "CameraItem")]
pub struct CameraItem {
pub nick: String,
pub node_id: u32,
}
mod imp {
use super::*;
@ -59,8 +52,4 @@ impl CameraRow {
pub fn set_selected(&self, selected: bool) {
self.imp().checkmark.set_visible(selected);
}
pub fn set_item(&self, item: &CameraItem) {
self.imp().label.set_label(&item.nick);
}
}

View file

@ -1,6 +1,5 @@
mod accounts;
mod camera;
mod camera_paintable;
mod camera_row;
mod error_revealer;
mod keyring_error_dialog;
@ -12,9 +11,8 @@ mod window;
pub use self::{
accounts::{AccountAddDialog, QRCodeData},
camera::{screenshot, Camera, CameraEvent},
camera_paintable::CameraPaintable,
camera_row::{CameraItem, CameraRow},
camera::{screenshot, Camera},
camera_row::CameraRow,
error_revealer::ErrorRevealer,
keyring_error_dialog::KeyringErrorDialog,
preferences::PreferencesWindow,

View file

@ -531,7 +531,6 @@ impl PreferencesWindow {
let close_page = gio::ActionEntry::builder("close_page")
.activate(clone!(@weak self as win => move |_, _, _| {
win.pop_subpage();
win.imp().camera_page.imp().camera.stop();
}))
.build();