quick-settings: add Bluetooth tile with bluez5 D-Bus integration

parent 54664737
......@@ -13,6 +13,7 @@ src/controlCenter/widgets/quickSettings/tiles/caffeineTile.vala
src/controlCenter/widgets/quickSettings/tiles/nightLightTile.vala
src/controlCenter/widgets/quickSettings/tiles/wifiTile.vala
src/controlCenter/widgets/quickSettings/tiles/vpnTile.vala
src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala
data/ui/widgets/notifications.blp
data/ui/widgets/title.blp
data/ui/widgets/inhibitors.blp
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: ximper-shell-notification-center 0.1.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-26 18:49+0300\n"
"POT-Creation-Date: 2026-03-26 22:05+0300\n"
"PO-Revision-Date: 2026-03-24 00:00+0300\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
......@@ -108,10 +108,12 @@ msgid "Wi-Fi"
msgstr "Wi-Fi"
#: src/controlCenter/widgets/quickSettings/tiles/wifiTile.vala:94
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:58
msgid "Disabled"
msgstr "Отключён"
#: src/controlCenter/widgets/quickSettings/tiles/wifiTile.vala:135
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:70
msgid "Not Connected"
msgstr "Не подключён"
......@@ -120,6 +122,7 @@ msgid "Password"
msgstr "Пароль"
#: src/controlCenter/widgets/quickSettings/tiles/wifiTile.vala:409
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:120
msgid "Connect"
msgstr "Подключить"
......@@ -143,6 +146,18 @@ msgstr "Отключён"
msgid "VPN Settings"
msgstr "Настройки VPN"
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:10
msgid "Bluetooth"
msgstr "Bluetooth"
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:119
msgid "Disconnect"
msgstr "Отключить"
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:162
msgid "Bluetooth Settings"
msgstr "Настройки Bluetooth"
#: data/ui/widgets/notifications.blp:57
msgid "No Notifications"
msgstr "Нет уведомлений"
......
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: ximper-shell-notification-center\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-26 18:49+0300\n"
"POT-Creation-Date: 2026-03-26 22:05+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -105,10 +105,12 @@ msgid "Wi-Fi"
msgstr ""
#: src/controlCenter/widgets/quickSettings/tiles/wifiTile.vala:94
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:58
msgid "Disabled"
msgstr ""
#: src/controlCenter/widgets/quickSettings/tiles/wifiTile.vala:135
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:70
msgid "Not Connected"
msgstr ""
......@@ -117,6 +119,7 @@ msgid "Password"
msgstr ""
#: src/controlCenter/widgets/quickSettings/tiles/wifiTile.vala:409
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:120
msgid "Connect"
msgstr ""
......@@ -140,6 +143,18 @@ msgstr ""
msgid "VPN Settings"
msgstr ""
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:10
msgid "Bluetooth"
msgstr ""
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:119
msgid "Disconnect"
msgstr ""
#: src/controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala:162
msgid "Bluetooth Settings"
msgstr ""
#: data/ui/widgets/notifications.blp:57
msgid "No Notifications"
msgstr ""
......
......@@ -39,6 +39,7 @@
"tiles": [
{ "type": "wifi" },
{ "type": "vpn" },
{ "type": "bluetooth" },
{ "type": "power-profiles" },
{ "type": "dnd" },
{ "type": "dark-mode" },
......
......@@ -459,7 +459,7 @@
"type": {
"type": "string",
"description": "The tile type",
"enum": ["dnd", "dark-mode", "power-profiles", "caffeine", "night-light", "command", "wifi", "vpn"]
"enum": ["dnd", "dark-mode", "power-profiles", "caffeine", "night-light", "command", "bluetooth", "wifi", "vpn"]
},
"label": {
"type": "string",
......
namespace XimperShellNotificationCenter.Widgets {
[DBus (name = "org.bluez.Adapter1")]
public interface BluezAdapterDBus : Object {
public abstract bool powered { get; set; }
public abstract bool discovering { get; }
public abstract void start_discovery () throws Error;
public abstract void stop_discovery () throws Error;
}
[DBus (name = "org.bluez.Device1")]
public interface BluezDeviceDBus : Object {
public abstract string address { owned get; }
public abstract string alias { owned get; }
public abstract bool connected { get; }
public abstract bool paired { get; }
public abstract string icon { owned get; }
public abstract bool trusted { get; set; }
public abstract async void connect () throws Error;
public abstract async void disconnect () throws Error;
}
public struct BluetoothDeviceInfo {
ObjectPath path;
string name;
string address;
string icon_name;
bool connected;
bool paired;
}
public class BluetoothProxy : Object {
public signal void state_changed ();
public signal void devices_changed ();
private BluezAdapterDBus ?adapter = null;
private DBusProxy ?adapter_raw = null;
private ObjectPath ?adapter_path = null;
private bool _available = false;
private Gee.ArrayList<BluetoothDeviceInfo?> _devices =
new Gee.ArrayList<BluetoothDeviceInfo?> ();
public ObjectPath ?connecting_device = null;
private DBusConnection ?connection = null;
private static BluetoothProxy ?_instance = null;
public static BluetoothProxy get_instance () {
if (_instance == null) {
_instance = new BluetoothProxy ();
}
return _instance;
}
public bool available {
get { return _available; }
}
public bool powered {
get {
if (adapter == null) return false;
return adapter.powered;
}
}
public Gee.ArrayList<BluetoothDeviceInfo?> devices {
get { return _devices; }
}
private BluetoothProxy () {
connect_dbus.begin ();
}
private async void connect_dbus () {
try {
connection = yield Bus.get (BusType.SYSTEM);
// Find adapter via ObjectManager
var reply = yield connection.call (
"org.bluez", "/",
"org.freedesktop.DBus.ObjectManager",
"GetManagedObjects",
null, null,
DBusCallFlags.NONE, -1, null);
var objects = reply.get_child_value (0);
find_adapter (objects);
if (adapter_path == null) return;
adapter = yield Bus.get_proxy (
BusType.SYSTEM, "org.bluez",
adapter_path);
adapter_raw = adapter as DBusProxy;
_available = true;
adapter_raw.g_properties_changed.connect (
on_adapter_props_changed);
// Watch InterfacesAdded/Removed
connection.signal_subscribe (
"org.bluez",
"org.freedesktop.DBus.ObjectManager",
"InterfacesAdded", "/", null,
DBusSignalFlags.NONE,
on_interfaces_changed);
connection.signal_subscribe (
"org.bluez",
"org.freedesktop.DBus.ObjectManager",
"InterfacesRemoved", "/", null,
DBusSignalFlags.NONE,
on_interfaces_changed);
// Watch PropertiesChanged on all bluez objects
connection.signal_subscribe (
"org.bluez",
"org.freedesktop.DBus.Properties",
"PropertiesChanged", null, null,
DBusSignalFlags.NONE,
on_device_props_changed);
yield load_devices ();
} catch (Error e) {
debug ("Bluetooth not available: %s",
e.message);
}
}
private void find_adapter (Variant objects) {
var iter = objects.iterator ();
string path;
Variant ifaces;
while (iter.next ("{o@a{sa{sv}}}", out path,
out ifaces)) {
var iface_iter = ifaces.iterator ();
string iface_name;
Variant props;
while (iface_iter.next ("{s@a{sv}}",
out iface_name,
out props)) {
if (iface_name == "org.bluez.Adapter1") {
adapter_path = new ObjectPath (path);
return;
}
}
}
}
private async void load_devices () {
var new_list =
new Gee.ArrayList<BluetoothDeviceInfo?> ();
try {
var reply = yield connection.call (
"org.bluez", "/",
"org.freedesktop.DBus.ObjectManager",
"GetManagedObjects",
null, null,
DBusCallFlags.NONE, -1, null);
var objects = reply.get_child_value (0);
string adapter_str = (string) adapter_path;
var iter = objects.iterator ();
string path;
Variant ifaces;
while (iter.next ("{o@a{sa{sv}}}",
out path, out ifaces)) {
if (!path.has_prefix (
adapter_str + "/dev_")) {
continue;
}
var iface_iter = ifaces.iterator ();
string iface_name;
Variant props;
while (iface_iter.next ("{s@a{sv}}",
out iface_name,
out props)) {
if (iface_name
!= "org.bluez.Device1") {
continue;
}
var info = parse_device (
path, props);
if (info.paired
|| info.name != info.address) {
new_list.add (info);
}
}
}
} catch (Error e) {
warning ("load_devices: %s", e.message);
}
_devices = new_list;
devices_changed ();
}
private BluetoothDeviceInfo parse_device (
string path, Variant props) {
var info = BluetoothDeviceInfo ();
info.path = new ObjectPath (path);
var prop_iter = props.iterator ();
string key;
Variant val;
while (prop_iter.next ("{sv}", out key, out val)) {
switch (key) {
case "Alias":
info.name = val.get_string ();
break;
case "Address":
info.address = val.get_string ();
break;
case "Connected":
info.connected = val.get_boolean ();
break;
case "Paired":
info.paired = val.get_boolean ();
break;
case "Icon":
info.icon_name = val.get_string ();
break;
}
}
if (info.name == null || info.name == "") {
info.name = info.address ?? "Unknown";
}
return info;
}
private void on_adapter_props_changed (
Variant changed, string[] invalidated) {
state_changed ();
}
private void on_interfaces_changed (
DBusConnection conn, string ?sender,
string path, string iface, string signal,
Variant params) {
load_devices.begin ();
}
private void on_device_props_changed (
DBusConnection conn, string ?sender,
string path, string iface, string signal,
Variant params) {
string iface_name;
params.get_child (0, "s", out iface_name);
if (iface_name == "org.bluez.Device1") {
load_devices.begin ();
}
}
public void set_powered (bool val) {
if (adapter == null) return;
adapter.powered = val;
}
public void start_discovery () {
if (adapter == null || !adapter.powered) return;
if (adapter.discovering) return;
try {
adapter.start_discovery ();
} catch (Error e) {
debug ("start_discovery: %s", e.message);
}
}
public void stop_discovery () {
if (adapter == null) return;
if (!adapter.discovering) return;
try {
adapter.stop_discovery ();
} catch (Error e) {
debug ("stop_discovery: %s", e.message);
}
}
public async void connect_device (
ObjectPath device_path) {
connecting_device = device_path;
devices_changed ();
try {
var dev = yield Bus.get_proxy<BluezDeviceDBus>
(BusType.SYSTEM, "org.bluez",
device_path);
var proxy = dev as DBusProxy;
if (proxy != null) {
proxy.set_default_timeout (120000);
}
if (!dev.trusted) {
dev.trusted = true;
}
yield dev.connect ();
} catch (Error e) {
warning ("connect_device: %s", e.message);
}
connecting_device = null;
devices_changed ();
}
public async void disconnect_device (
ObjectPath device_path) {
connecting_device = device_path;
devices_changed ();
try {
var dev = yield Bus.get_proxy<BluezDeviceDBus>
(BusType.SYSTEM, "org.bluez",
device_path);
yield dev.disconnect ();
} catch (Error e) {
warning ("disconnect_device: %s", e.message);
}
connecting_device = null;
devices_changed ();
}
public string ? get_connected_device_name () {
foreach (var dev in _devices) {
if (dev.connected) {
return dev.name;
}
}
return null;
}
public static string get_device_icon (
string ?icon_class) {
if (icon_class == null) {
return "bluetooth-symbolic";
}
switch (icon_class) {
case "audio-headset":
case "audio-headphones":
return "audio-headphones-symbolic";
case "phone":
return "phone-symbolic";
case "computer":
return "computer-symbolic";
case "input-keyboard":
return "input-keyboard-symbolic";
case "input-mouse":
return "input-mouse-symbolic";
case "input-gaming":
return "input-gaming-symbolic";
default:
return "bluetooth-symbolic";
}
}
}
}
......@@ -28,6 +28,9 @@ namespace XimperShellNotificationCenter.Widgets {
case "night-light":
tile = new NightLightTile (cfg);
break;
case "bluetooth":
tile = new BluetoothTile (cfg);
break;
case "wifi":
tile = new WifiTile (cfg);
break;
......
namespace XimperShellNotificationCenter.Widgets {
public class BluetoothTile : QuickSettingsTile {
private BluetoothProxy bt_proxy;
private uint sync_timeout = 0;
private static string ?settings_cmd = null;
private static bool settings_cmd_checked = false;
public BluetoothTile (Json.Object ?cfg) {
base ("bluetooth-active-symbolic",
_("Bluetooth"));
setup_arrow ();
bt_proxy = BluetoothProxy.get_instance ();
bt_proxy.state_changed.connect (schedule_sync);
bt_proxy.devices_changed.connect (() => {
sync_subtitle ();
submenu_update_requested ();
});
sync_subtitle ();
}
public override bool is_available () {
return bt_proxy.available;
}
public override void on_toggle () {
bt_proxy.set_powered (!bt_proxy.powered);
}
public override void on_cc_visibility_change (
bool visible) {
if (visible) {
sync_subtitle ();
} else {
bt_proxy.stop_discovery ();
}
}
private void schedule_sync () {
if (sync_timeout != 0) {
Source.remove (sync_timeout);
}
sync_timeout = Timeout.add (500, () => {
sync_timeout = 0;
sync_subtitle ();
submenu_update_requested ();
return Source.REMOVE;
});
}
private void sync_subtitle () {
active = bt_proxy.powered;
if (!bt_proxy.powered) {
icon.set_from_icon_name (
"bluetooth-disabled-symbolic");
set_subtitle (_("Disabled"));
return;
}
icon.set_from_icon_name (
"bluetooth-active-symbolic");
string ?connected_name =
bt_proxy.get_connected_device_name ();
if (connected_name != null) {
set_subtitle (connected_name);
} else {
set_subtitle (_("Not Connected"));
}
}
// Submenu
public override Gtk.Widget ?create_submenu () {
var box = new Gtk.Box (
Gtk.Orientation.VERTICAL, 0);
box.add_css_class ("quick-settings-submenu");
bt_proxy.start_discovery ();
build_device_list (box);
return box;
}
private void build_device_list (Gtk.Box box) {
foreach (var dev in bt_proxy.devices) {
var row = new Gtk.Box (
Gtk.Orientation.HORIZONTAL, 8);
row.add_css_class ("submenu-row");
var row_icon = new Gtk.Image.from_icon_name (
BluetoothProxy.get_device_icon (
dev.icon_name));
var row_label = new Gtk.Label (dev.name);
row_label.set_ellipsize (
Pango.EllipsizeMode.END);
row_label.set_hexpand (true);
row_label.set_halign (Gtk.Align.START);
row.append (row_icon);
row.append (row_label);
ObjectPath dev_path = dev.path;
bool is_connected = dev.connected;
bool is_busy =
bt_proxy.connecting_device != null
&& (string) bt_proxy.connecting_device
== (string) dev.path;
if (is_busy) {
var spinner = new Gtk.Spinner ();
spinner.start ();
row.append (spinner);
} else {
var action_label = new Gtk.Label (
is_connected
? _("Disconnect")
: _("Connect"));
action_label.add_css_class ("dim-label");
row.append (action_label);
}
var btn = new Gtk.Button ();
btn.set_child (row);
btn.add_css_class ("flat");
btn.set_sensitive (!is_busy);
btn.clicked.connect (() => {
if (is_connected) {
bt_proxy.disconnect_device.begin (
dev_path);
} else {
bt_proxy.connect_device.begin (
dev_path);
}
});
box.append (btn);
}
// Settings button
build_settings_button (box);
}
private void build_settings_button (Gtk.Box box) {
string ?cmd = get_settings_command ();
if (cmd == null) return;
if (bt_proxy.devices.size > 0) {
box.append (new Gtk.Separator (
Gtk.Orientation.HORIZONTAL));
}
var row = new Gtk.Box (
Gtk.Orientation.HORIZONTAL, 8);
row.add_css_class ("submenu-row");
row.append (new Gtk.Image.from_icon_name (
"emblem-system-symbolic"));
var label = new Gtk.Label (
_("Bluetooth Settings"));
label.set_hexpand (true);
label.set_halign (Gtk.Align.START);
row.append (label);
var btn = new Gtk.Button ();
btn.set_child (row);
btn.add_css_class ("flat");
btn.clicked.connect (() => {
try {
Process.spawn_command_line_async (cmd);
ximper_shell_notification_center_daemon
.set_visibility (false);
} catch (Error e) {
warning ("BT Settings: %s", e.message);
}
});
box.append (btn);
}
private static string ?get_settings_command () {
if (settings_cmd_checked) return settings_cmd;
settings_cmd_checked = true;
if (Environment.find_program_in_path (
"blueman-manager") != null) {
settings_cmd = "blueman-manager";
return settings_cmd;
}
return null;
}
}
}
......@@ -364,15 +364,10 @@ namespace XimperShellNotificationCenter.Widgets {
}
private static string ?get_settings_command () {
try {
string output;
Process.spawn_command_line_sync (
"which nm-connection-editor",
out output, null, null);
if (output.strip ().length > 0) {
return "nm-connection-editor";
}
} catch (SpawnError e) {}
if (Environment.find_program_in_path (
"nm-connection-editor") != null) {
return "nm-connection-editor";
}
return null;
}
}
......
......@@ -505,15 +505,10 @@ namespace XimperShellNotificationCenter.Widgets {
}
private static string ?get_settings_command () {
try {
string output;
Process.spawn_command_line_sync (
"which nm-connection-editor",
out output, null, null);
if (output.strip ().length > 0) {
return "nm-connection-editor";
}
} catch (SpawnError e) {}
if (Environment.find_program_in_path (
"nm-connection-editor") != null) {
return "nm-connection-editor";
}
return null;
}
......
......@@ -53,6 +53,8 @@ widget_sources = [
'controlCenter/widgets/quickSettings/tiles/caffeineTile.vala',
'controlCenter/widgets/quickSettings/tiles/nightLightTile.vala',
'controlCenter/widgets/quickSettings/networkManagerProxy.vala',
'controlCenter/widgets/quickSettings/bluetoothProxy.vala',
'controlCenter/widgets/quickSettings/tiles/bluetoothTile.vala',
'controlCenter/widgets/quickSettings/tiles/wifiTile.vala',
'controlCenter/widgets/quickSettings/tiles/vpnTile.vala',
]
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment