Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use portal to request access to camera #238

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
12 changes: 11 additions & 1 deletion io.elementary.camera.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ finish-args:
- '--socket=fallback-x11'
- '--socket=wayland'
- '--socket=pulseaudio'
- '--device=all'

- '--metadata=X-DConf=migrate-path=/io/elementary/camera/'
cleanup:
Expand All @@ -30,6 +29,17 @@ modules:
url: http://git.0pointer.net/clone/libcanberra.git
disable-shallow-clone: true

- name: portal
buildsystem: meson
config-opts:
- '-Ddocs=false'
- '-Dtests=false'
- '-Dbackends=gtk3'
sources:
- type: git
url: https://github.com/flatpak/libportal.git
tag: 0.6

- name: camera
buildsystem: meson
sources:
Expand Down
1 change: 1 addition & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ executable(
dependency('gtk+-3.0'),
dependency('libcanberra'),
dependency('libhandy-1', version: '>=0.90.0'),
dependency('libportal'),
meson.get_compiler('vala').find_library('posix')
],
install : true
Expand Down
150 changes: 132 additions & 18 deletions src/Widgets/CameraView.vala
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@
* Corentin Noël <corentin@elementary.io>
*/


errordomain Camera.PermissionError {
ACCESS_DENIED
}

public class Camera.Widgets.CameraView : Gtk.Box {
private const string VIDEO_SRC_NAME = "v4l2src";
public signal void recording_finished (string file_path);

private Gtk.Stack main_widget;
private Gtk.Box status_box;
private Granite.Widgets.AlertView no_device_view;
private Granite.Widgets.AlertView device_error_view;
private Gtk.Label status_label;
Gtk.Widget gst_video_widget;

Expand All @@ -36,7 +41,7 @@ public class Camera.Widgets.CameraView : Gtk.Box {
private Gst.Video.Direction? hflip;
private Gst.Bin? record_bin;
private Gst.Device? current_device = null;
private uint init_device_timeout_id = 0;
private uint device_init_timeout_id = 0;

public uint n_cameras {
get {
Expand Down Expand Up @@ -89,42 +94,81 @@ public class Camera.Widgets.CameraView : Gtk.Box {
status_box.pack_start (spinner);
status_box.pack_start (status_label);

no_device_view = new Granite.Widgets.AlertView (
_("No Supported Camera Found"),
_("Connect a webcam or other supported video device to take photos and video."),
""
);
device_error_view = new Granite.Widgets.AlertView ("", "","");

main_widget.add (status_box); // must be add_child for GTK4
main_widget.add (no_device_view); // must be add_child for GTK4
main_widget.add (device_error_view); // must be add_child for GTK4
monitor.get_bus ().add_watch (GLib.Priority.DEFAULT, on_bus_message);

var caps = new Gst.Caps.empty_simple ("video/x-raw");
caps.append (new Gst.Caps.empty_simple ("image/jpeg"));
monitor.add_filter ("Video/Source", caps);

init_device_timeout_id = Timeout.add_seconds (2, () => {
if (!Xdp.Portal.running_under_sandbox ()) {
start_device_init_timeout ();

} else {
realize.connect (() => {
var portal = new Xdp.Portal ();
portal.access_camera.begin (null, Xdp.CameraFlags.NONE, null, (obj, res) => {
try {
var access_granted = portal.access_camera.end (res);
debug ("access_granted: %s", access_granted.to_string ());

if (!access_granted) {
throw new Camera.PermissionError.ACCESS_DENIED ("Access to camera denied");

} else {
var camera_fd = portal.open_pipewire_remote_for_camera ();
debug ("camera_fd: %i", camera_fd);
create_pipeline_fd (camera_fd);
}

} catch (Error e) {
warning ("Camera Access Denied: %s", e.message);

device_error_view.title = _("Camera Access Denied");
device_error_view.description = _("Allow access to your camera from the privacy settings.");
device_error_view.show ();
main_widget.visible_child = device_error_view;
}
});
});
}
}

private void show_no_device_error () {
device_error_view.title = _("No Supported Camera Found");
device_error_view.description = _("Connect a webcam or other supported video device to take photos and video.");
device_error_view.show ();
main_widget.visible_child = device_error_view;
}

private void start_device_init_timeout () {
device_init_timeout_id = Timeout.add_seconds (2, () => {
if (n_cameras == 0) {
no_device_view.show ();
main_widget.visible_child = no_device_view;
show_no_device_error ();
}
return Source.REMOVE;
});
}

private void on_camera_added (Gst.Device device) {
if (init_device_timeout_id > 0) {
Source.remove (init_device_timeout_id);
init_device_timeout_id = 0;
private void stop_device_init_timeout () {
if (device_init_timeout_id > 0) {
Source.remove (device_init_timeout_id);
device_init_timeout_id = 0;
}
}

private void on_camera_added (Gst.Device device) {
stop_device_init_timeout ();
camera_added (device);
change_camera (device);
}
private void on_camera_removed (Gst.Device device) {
camera_removed (device);
if (n_cameras == 0) {
no_device_view.show ();
main_widget.visible_child = no_device_view;
show_no_device_error ();
} else {
change_camera (monitor.get_devices ().nth_data (0));
}
Expand All @@ -150,6 +194,20 @@ public class Camera.Widgets.CameraView : Gtk.Box {
message.parse_device_removed (out device);
on_camera_removed (device);

break;
case ERROR:
Error error;
string debug_info;
message.parse_error (out error, out debug_info);
warning ("Unexpected error from pipeline: %s", error.message);

break;
case EOS:
// free resources:
if (pipeline != null) {
pipeline.set_state (Gst.State.NULL);
}

break;
default:
break;
Expand Down Expand Up @@ -190,6 +248,62 @@ public class Camera.Widgets.CameraView : Gtk.Box {
current_device = camera;
}

private void create_pipeline_fd (int camera_fd) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi there. I've been looking into starting a new application involving webcam recording with GStreamer so I was taking a look through this source code and found this PR.

This function is largely duplicate code with the create_pipeline function which uses the v4l2src GStreamer element. The common code could be factored out to another function. However, I'm not sure it's really necessary to keep the V4L2 code path at all. From https://blogs.gnome.org/uraeus/2021/10/01/pipewire-and-fixing-the-linux-video-capture-stack/ :

we do not recommend your GStreamer application using the v4l2 or libcamera plugins, instead we recommend that you use the PipeWire plugins

In the future you may be able to remove the explicit call to the XDG Portal too:

There are a few major items we are still trying to decide upon in terms of the interaction between PipeWire and the Camera portal API. It would be tempting to see if we can hide the Camera portal API behind the PipeWire API, or failing that at least hide it for people using the GStreamer plugin. That way all applications get the portal support for free when porting to GStreamer instead of requiring using the Camera portal API as a second step. On the other side you need to set up the screen sharing portal yourself, so it would probably make things more consistent if we left it to application developers to do for camera access too.

However, taking a look at the source code for the pipewiresrc GStreamer element, I don't see any usage of the XDG Portal. I'm unclear if that's still on the roadmap to be implemented or if that idea was dismissed. I asked in the Pipewire Matrix room earlier today but haven't gotten a response yet.

Copy link

@Be-ing Be-ing Jun 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cheese and Kamoso use the camerabin GStreamer element.

Pipewire registers pipewiresrc with GST_RANK_PRIMARY + 1 so camerabin automatically uses pipewiresrc if it is available. Running gst-launch-1.0 -v camerabin confirms this:

/GstCameraBin:camerabin0/GstWrapperCameraBinSrc:camerasrc/GstAutoVideoSrc:camerasrc-real-src/GstPipeWireSrc:camerasrc-real-src-actual-src-pipewir.GstPad:src: caps = video/x-raw, format=(string)YUY2, width=(int)1280, height=(int)720, framerate=(fraction)10/1

Without Pipewire's GStreamer plugin, camerabin uses V4L2, running GST_PLUGIN_FEATURE_RANK=pipewiresrc:NONE gst-launch-1.0 -v camerabin:

/GstCameraBin:camerabin0/GstWrapperCameraBinSrc:camerasrc/GstAutoVideoSrc:camerasrc-real-src/GstV4l2Src:camerasrc-real-src-actual-src-v4l.GstPad:src: caps = video/x-raw, width=(int)1280, height=(int)720, framerate=(fraction)10/1, format=(string)YUY2, pixel-aspect-ratio=(fraction)1/1, interlace-mode=(string)progressive, colorimetry=(string)2:4:5:1

So camerabin would support both the cases of Pipewire being present or not. However, I don't know if there's a way to pass the file descriptor from the portal to camerabin.

try {
pipeline = (Gst.Pipeline) Gst.parse_launch (
"pipewiresrc fd=%i ! videoconvert ! ".printf (camera_fd) +
"decodebin name=decodebin ! " +
"videoflip method=horizontal-flip name=hflip ! " +
"videobalance name=balance ! " +
"tee name=tee ! " +
"videorate name=videorate ! " +
"queue leaky=downstream max-size-buffers=10 ! " +
"videoscale name=videoscale"
);

tee = pipeline.get_by_name ("tee");
hflip = (pipeline.get_by_name ("hflip") as Gst.Video.Direction);
color_balance = (pipeline.get_by_name ("balance") as Gst.Video.ColorBalance);

if (gst_video_widget != null) {
main_widget.remove (gst_video_widget);
}

dynamic Gst.Element videorate = pipeline.get_by_name ("videorate");
videorate.max_rate = 30;
videorate.drop_only = true;

dynamic Gst.Element gtksink = Gst.ElementFactory.make ("gtkglsink", null);
if (gtksink != null) {
dynamic Gst.Element glsinkbin = Gst.ElementFactory.make ("glsinkbin", null);
glsinkbin.sink = gtksink;
pipeline.add (glsinkbin);
pipeline.get_by_name ("videoscale").link (glsinkbin);
} else {
gtksink = Gst.ElementFactory.make ("gtksink", null);
pipeline.add (gtksink);
pipeline.get_by_name ("videoscale").link (gtksink);
}

gst_video_widget = gtksink.widget;

main_widget.add (gst_video_widget); // must be add_child for GTK4
gst_video_widget.show ();

main_widget.visible_child = gst_video_widget;
pipeline.set_state (Gst.State.PLAYING);

pipeline.get_bus ().add_watch (GLib.Priority.DEFAULT, on_bus_message);

} catch (Error e) {
// It is possible that there is another camera present that could selected so do not show
// no_device_error
var dialog = new Granite.MessageDialog.with_image_from_icon_name (_("Unable To View Camera"), e.message, "dialog-error");
dialog.run ();
dialog.destroy ();
}
}

private void create_pipeline (Gst.Device camera) {
try {
var caps = camera.get_caps ();
Expand Down Expand Up @@ -258,7 +372,7 @@ public class Camera.Widgets.CameraView : Gtk.Box {
pipeline.set_state (Gst.State.PLAYING);
} catch (Error e) {
// It is possible that there is another camera present that could selected so do not show
// no_device_view
// no_device_error
var dialog = new Granite.MessageDialog.with_image_from_icon_name (_("Unable To View Camera"), e.message, "dialog-error");
dialog.run ();
dialog.destroy ();
Expand Down