diff --git a/po/POTFILES b/po/POTFILES index de41976..ae7be24 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -3,7 +3,9 @@ src/Dialog/BtReceiver.vala src/Dialog/BtScan.vala src/Dialog/SenderDialog.vala src/Dialog/DeviceRow.vala +src/Dialog/Pair.vala src/Services/Adapter.vala +src/Services/Agent.vala src/Services/Device.vala src/Services/Manager.vala src/Services/ObexAgent.vala diff --git a/src/Dialog/Pair.vala b/src/Dialog/Pair.vala new file mode 100644 index 0000000..7119a4d --- /dev/null +++ b/src/Dialog/Pair.vala @@ -0,0 +1,114 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2018-2025 elementary, Inc. (https://elementary.io) + */ + +public class PairDialog : Granite.MessageDialog { + public enum AuthType { + REQUEST_CONFIRMATION, + REQUEST_AUTHORIZATION, + DISPLAY_PASSKEY, + DISPLAY_PIN_CODE + } + + public ObjectPath object_path { get; construct; } + public AuthType auth_type { get; construct; } + public string passkey { get; construct; } + public bool cancelled { get; set; } + + // Un-used default constructor + private PairDialog () { + Object ( + buttons: Gtk.ButtonsType.CANCEL + ); + } + + public PairDialog.request_authorization (ObjectPath object_path) { + Object ( + auth_type: AuthType.REQUEST_AUTHORIZATION, + buttons: Gtk.ButtonsType.CANCEL, + object_path: object_path, + primary_text: _("Confirm Bluetooth Pairing") + ); + } + + public PairDialog.display_passkey (ObjectPath object_path, uint32 passkey, uint16 entered) { + Object ( + auth_type: AuthType.DISPLAY_PASSKEY, + buttons: Gtk.ButtonsType.CANCEL, + object_path: object_path, + passkey: "%u".printf (passkey), + primary_text: _("Confirm Bluetooth Passkey") + ); + } + + public PairDialog.request_confirmation (ObjectPath object_path, uint32 passkey) { + Object ( + auth_type: AuthType.REQUEST_CONFIRMATION, + buttons: Gtk.ButtonsType.CANCEL, + object_path: object_path, + passkey: "%u".printf (passkey), + primary_text: _("Confirm Bluetooth Passkey") + ); + } + + public PairDialog.display_pin_code (ObjectPath object_path, string pincode) { + Object ( + auth_type: AuthType.DISPLAY_PIN_CODE, + buttons: Gtk.ButtonsType.CANCEL, + object_path: object_path, + passkey: pincode, + primary_text: _("Enter Bluetooth PIN") + ); + } + + construct { + Bluetooth.Device device; + string device_name = _("Unknown Bluetooth Device"); + try { + device = Bus.get_proxy_sync (BusType.SYSTEM, "org.bluez", object_path, DBusProxyFlags.GET_INVALIDATED_PROPERTIES); + image_icon = new ThemedIcon (device.icon ?? "bluetooth"); + device_name = device.name ?? device.address; + } catch (IOError e) { + image_icon = new ThemedIcon ("bluetooth"); + critical (e.message); + } + + switch (auth_type) { + case AuthType.REQUEST_CONFIRMATION: + badge_icon = new ThemedIcon ("dialog-password"); + secondary_text = _("Make sure the code displayed on “%s” matches the one below.").printf (device_name); + + var confirm_button = add_button (_("Pair"), Gtk.ResponseType.ACCEPT); + confirm_button.get_style_context ().add_class (Gtk.STYLE_CLASS_SUGGESTED_ACTION); + break; + case AuthType.DISPLAY_PASSKEY: + badge_icon = new ThemedIcon ("dialog-password"); + secondary_text = _("“%s” would like to pair with this device. Make sure the code displayed on “%s” matches the one below.").printf (device_name, device_name); + + var confirm_button = add_button (_("Pair"), Gtk.ResponseType.ACCEPT); + confirm_button.get_style_context ().add_class (Gtk.STYLE_CLASS_SUGGESTED_ACTION); + break; + case AuthType.DISPLAY_PIN_CODE: + badge_icon = new ThemedIcon ("dialog-password"); + secondary_text = _("Type the code displayed below on “%s”, followed by Enter.").printf (device_name); + break; + case AuthType.REQUEST_AUTHORIZATION: + badge_icon = new ThemedIcon ("dialog-question"); + secondary_text = _("“%s” would like to pair with this device.").printf (device_name); + + var confirm_button = add_button (_("Pair"), Gtk.ResponseType.ACCEPT); + confirm_button.get_style_context ().add_class (Gtk.STYLE_CLASS_SUGGESTED_ACTION); + break; + } + + if (passkey != null && passkey != "") { + var passkey_label = new Gtk.Label (passkey); + passkey_label.get_style_context ().add_class (Granite.STYLE_CLASS_H1_LABEL); + + custom_bin.add (passkey_label); + } + + modal = true; + } +} diff --git a/src/Services/Agent.vala b/src/Services/Agent.vala new file mode 100644 index 0000000..e0c9a55 --- /dev/null +++ b/src/Services/Agent.vala @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2018-2025 elementary, Inc. (https://elementary.io) + * + * Authored by: Corentin Noël + */ + +[DBus (name = "org.bluez.Error")] +public errordomain BluezError { + REJECTED, + CANCELED +} + +[DBus (name = "org.bluez.Agent1")] +public class Bluetooth.Agent : Object { + private const string PATH = "/org/bluez/agent/elementary"; + + private PairDialog pair_dialog; + + [DBus (visible=false)] + public Agent () { + Bus.own_name (BusType.SYSTEM, "org.bluez.AgentManager1", BusNameOwnerFlags.NONE, + (connection, name) => { + try { + connection.register_object (PATH, this); + ready = true; + } catch (Error e) { + critical (e.message); + } + }, + (connection, name) => {}, + (connection, name) => {} + ); + } + + [DBus (visible=false)] + public bool ready { get; private set; } + + [DBus (visible=false)] + public signal void unregistered (); + + [DBus (visible=false)] + public GLib.ObjectPath get_path () { + return new GLib.ObjectPath (PATH); + } + + public void release () throws Error { + unregistered (); + } + + public async string request_pin_code (ObjectPath device) throws Error, BluezError { + throw new BluezError.REJECTED ("Pairing method not supported"); + } + + // Called to display a pin code on-screen that needs to be entered on the other device. Can return + // instantly + public async void display_pin_code (ObjectPath device, string pincode) throws Error, BluezError { + pair_dialog = new PairDialog.display_pin_code (device, pincode); + pair_dialog.present (); + } + + public async uint32 request_passkey (ObjectPath device) throws Error, BluezError { + throw new BluezError.REJECTED ("Pairing method not supported"); + } + + // Called to display a passkey on-screen that needs to be entered on the other device. Can return + // instantly + public async void display_passkey (ObjectPath device, uint32 passkey, uint16 entered) throws Error { + pair_dialog = new PairDialog.display_passkey (device, passkey, entered); + pair_dialog.present (); + } + + // Called to request confirmation from the user that they want to pair with the given device and that + // the passkey matches. **MUST** throw BluezError if pairing is to be rejected. This is handled in + // `check_pairing_response`. If the method returns without an error, pairing is authorized + public async void request_confirmation (ObjectPath device, uint32 passkey) throws Error, BluezError { + pair_dialog = new PairDialog.request_confirmation (device, passkey); + yield check_pairing_response (pair_dialog); + } + + // Called to request confirmation from the user that they want to pair with the given device + // **MUST** throw BluezError if pairing is to be rejected. This is handled in `check_pairing_response` + // If the method returns without an error, pairing is authorized + public async void request_authorization (ObjectPath device) throws Error, BluezError { + pair_dialog = new PairDialog.request_authorization (device); + yield check_pairing_response (pair_dialog); + } + + // Called to authorize the use of a specific service (Audio/HID/etc), so we restrict this to paired + // devices only + public void authorize_service (ObjectPath device_path, string uuid) throws Error, BluezError { + var device = get_device_from_object_path (device_path); + + bool paired = device.paired; + bool trusted = device.trusted; + + // Shouldn't really happen as trusted devices should be automatically authorized, but lets handle it anyway + if (paired && trusted) { + // allow + return; + } + + // A device that has been paired, but not yet trusted, trust it and allow it to access + // services + if (paired && !trusted) { + device.trusted = true; + // allow + return; + } + + // Reject everything else + throw new BluezError.REJECTED ("Rejecting service auth, not paired or trusted"); + } + + public void cancel () throws Error { + if (pair_dialog != null) { + pair_dialog.cancelled = true; + pair_dialog.destroy (); + } + } + + private async void check_pairing_response (PairDialog dialog) throws BluezError { + SourceFunc callback = check_pairing_response.callback; + BluezError? error = null; + + dialog.response.connect ((response) => { + if (response != Gtk.ResponseType.ACCEPT || dialog.cancelled) { + if (dialog.cancelled) { + error = new BluezError.CANCELED ("Pairing cancelled"); + } else { + error = new BluezError.REJECTED ("Pairing rejected"); + } + } + + Idle.add ((owned)callback); + dialog.destroy (); + }); + + dialog.present (); + + yield; + + if (error != null) { + throw error; + } + } + + private Device get_device_from_object_path (ObjectPath object_path) throws GLib.Error { + return Bus.get_proxy_sync (BusType.SYSTEM, "org.bluez", object_path, DBusProxyFlags.GET_INVALIDATED_PROPERTIES); + } +} diff --git a/src/Services/Manager.vala b/src/Services/Manager.vala index 5725a34..333dab6 100644 --- a/src/Services/Manager.vala +++ b/src/Services/Manager.vala @@ -15,6 +15,13 @@ * along with this program. If not, see . */ +[DBus (name = "org.bluez.AgentManager1")] +public interface Bluetooth.AgentManager : Object { + public abstract void register_agent (ObjectPath agent, string capability) throws Error; + public abstract void request_default_agent (ObjectPath agent) throws Error; + public abstract void unregister_agent (ObjectPath agent) throws Error; +} + public class Bluetooth.ObjectManager : Object { public signal void device_added (Bluetooth.Device device); public signal void device_removed (Bluetooth.Device device); @@ -22,8 +29,12 @@ public class Bluetooth.ObjectManager : Object { public bool has_adapter { get; private set; default = false; } - private Settings settings; + private bool is_registered = false; + + private AgentManager agent_manager; + private Bluetooth.Agent agent; private GLib.DBusObjectManagerClient object_manager; + private Settings settings; construct { settings = new Settings ("io.elementary.desktop.bluetooth"); @@ -32,13 +43,13 @@ public class Bluetooth.ObjectManager : Object { settings.changed ["sharing"].connect (() => { if (settings.get_boolean ("sharing")) { - register_agent (); + register_agent.begin (); } else { - unregister_agent (); + unregister_agent.begin (); } }); - register_agent (); + register_agent.begin (); } public async void create_manager () { @@ -74,6 +85,9 @@ public class Bluetooth.ObjectManager : Object { [CCode (cname="bluetooth_adapter_proxy_get_type")] extern static GLib.Type get_adapter_proxy_type (); + [CCode (cname="bluetooth_agent_manager_proxy_get_type")] + extern static GLib.Type get_agent_manager_proxy_type (); + private GLib.Type object_manager_proxy_get_type (DBusObjectManagerClient manager, string object_path, string? interface_name) { if (interface_name == null) { return typeof (GLib.DBusObjectProxy); @@ -84,44 +98,58 @@ public class Bluetooth.ObjectManager : Object { return get_device_proxy_type (); case "org.bluez.Adapter1": return get_adapter_proxy_type (); + case "org.bluez.AgentManager1": + return get_agent_manager_proxy_type (); default: return typeof (GLib.DBusProxy); } } - private void register_agent () { - try { - var connection = GLib.Bus.get_sync (BusType.SESSION); - connection.call.begin ( - "org.bluez.obex", - "/org/bluez/obex", - "org.bluez.obex.AgentManager1", - "RegisterAgent", - new Variant ("(o)", Obex.Agent.AGENT_OBJECT_PATH), - null, - GLib.DBusCallFlags.NONE, - -1 - ); - } catch (Error e) { - critical (e.message); + private async void create_agent () { + if (object_manager == null) { + return; + } + GLib.DBusObject? bluez_object = object_manager.get_object ("/org/bluez"); + if (bluez_object != null) { + agent_manager = (AgentManager) bluez_object.get_interface ("org.bluez.AgentManager1"); } + + agent = new Bluetooth.Agent (); + agent.notify["ready"].connect (() => { + if (is_registered) { + register_agent.begin (); + } + }); + + agent.unregistered.connect (() => { + is_registered = false; + }); } - private void unregister_agent () { - try { - var connection = GLib.Bus.get_sync (BusType.SESSION); - connection.call.begin ( - "org.bluez.obex", - "/org/bluez/obex", - "org.bluez.obex.AgentManager1", - "UnregisterAgent", - new Variant ("(o)", Obex.Agent.AGENT_OBJECT_PATH), - null, - GLib.DBusCallFlags.NONE, - -1 - ); - } catch (Error e) { - critical (e.message); + public async void register_agent () { + is_registered = true; + if (agent_manager == null) { + yield create_agent (); + } + + if (agent_manager != null && agent.ready) { + try { + agent_manager.register_agent (agent.get_path (), "DisplayYesNo"); + agent_manager.request_default_agent (agent.get_path ()); + } catch (Error e) { + critical (e.message); + } + } + } + + public async void unregister_agent () { + is_registered = false; + if (agent_manager != null && agent.ready) { + try { + agent_manager.unregister_agent (agent.get_path ()); + } catch (Error e) { + critical (e.message); + } } } diff --git a/src/meson.build b/src/meson.build index 552b9ef..6eebf3f 100644 --- a/src/meson.build +++ b/src/meson.build @@ -2,6 +2,7 @@ executable( meson.project_name(), 'Application.vala', 'Services' / 'Adapter.vala', + 'Services' / 'Agent.vala', 'Services' / 'Device.vala', 'Services' / 'Manager.vala', 'Services' / 'ObexAgent.vala', @@ -11,6 +12,7 @@ executable( 'Dialog' / 'SenderDialog.vala', 'Dialog' / 'BtScan.vala', 'Dialog' / 'DeviceRow.vala', + 'Dialog' / 'Pair.vala', dependencies : [ granite_dep, gtk_dep,