From 3157622c8b68c00f6a404a47ebcc64b65c282f0e Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 22 Jul 2025 19:20:10 -0400 Subject: [PATCH] bluetooth: pairing dialog - Allows connecting pin/passkey/confirmation dialogs - Fixes inability to connect to many devices - Dependent on un-merged quickshell PR: https://github.com/quickshell-mirror/quickshell/pull/138 --- Modules/BluetoothPairingDialog.qml | 372 +++++++++++++++++++++++++ Modules/ControlCenter/BluetoothTab.qml | 49 ++-- Services/BluetoothService.qml | 157 ++++++++++- shell.qml | 6 + 4 files changed, 555 insertions(+), 29 deletions(-) create mode 100644 Modules/BluetoothPairingDialog.qml diff --git a/Modules/BluetoothPairingDialog.qml b/Modules/BluetoothPairingDialog.qml new file mode 100644 index 00000000..9b25c8a7 --- /dev/null +++ b/Modules/BluetoothPairingDialog.qml @@ -0,0 +1,372 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import Quickshell.Bluetooth +import qs.Common +import qs.Services +import qs.Widgets + +PanelWindow { + id: root + + property bool bluetoothPairingDialogVisible: BluetoothService.pairingDialogVisible + property int pairingType: BluetoothService.pairingType + property int passkey: BluetoothService.pendingPasskey + property string deviceAddress: BluetoothService.pendingDeviceAddress + property alias inputText: pairingInput.text + + visible: bluetoothPairingDialogVisible + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: bluetoothPairingDialogVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + color: "transparent" + onVisibleChanged: { + if (visible) { + console.log("BluetoothPairingDialog: Showing dialog for device:", deviceAddress, "name:", BluetoothService.pendingDeviceName, "type:", pairingType); + pairingInput.enabled = true; + BluetoothService.inputText = ""; + Qt.callLater(function() { + if (pairingType === BluetoothPairingRequestType.PinCode || pairingType === BluetoothPairingRequestType.Passkey) + pairingInput.forceActiveFocus(); + + }); + } else { + pairingInput.enabled = false; + } + } + + anchors { + top: true + left: true + right: true + bottom: true + } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.5) + opacity: bluetoothPairingDialogVisible ? 1 : 0 + + MouseArea { + anchors.fill: parent + onClicked: { + pairingInput.enabled = false; + BluetoothService.rejectPairing(); + } + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + + } + + } + + Rectangle { + width: Math.min(400, parent.width - Theme.spacingL * 2) + height: Math.min(contentColumn.implicitHeight + Theme.spacingL * 2, parent.height - Theme.spacingL * 2) + anchors.centerIn: parent + color: Theme.surfaceContainer + radius: Theme.cornerRadiusLarge + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: 1 + opacity: bluetoothPairingDialogVisible ? 1 : 0 + scale: bluetoothPairingDialogVisible ? 1 : 0.9 + + MouseArea { + // Prevent propagation to background + + anchors.fill: parent + onClicked: { + } + } + + Column { + id: contentColumn + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + // Header + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "bluetooth" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - 40 - Theme.spacingM - Theme.iconSize + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + Text { + text: "Bluetooth Pairing" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + } + + Text { + text: BluetoothService.pendingDeviceName || deviceAddress || "Unknown Device" + font.pixelSize: Theme.fontSizeMedium + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + width: parent.width + elide: Text.ElideRight + } + + Text { + text: deviceAddress || "" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) + font.family: "monospace" + visible: deviceAddress && deviceAddress !== BluetoothService.pendingDeviceName + } + + } + + DankActionButton { + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + hoverColor: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) + onClicked: { + pairingInput.enabled = false; + BluetoothService.rejectPairing(); + } + } + + } + + // Dynamic content based on pairing type + Column { + width: parent.width + spacing: Theme.spacingM + + // Authorization + Text { + text: "Allow pairing with this device?" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.horizontalCenter: parent.horizontalCenter + visible: pairingType === BluetoothPairingRequestType.Authorization + } + + // Service Authorization + Text { + text: "Allow service connection from this device?" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.horizontalCenter: parent.horizontalCenter + visible: pairingType === BluetoothPairingRequestType.ServiceAuthorization + } + + // Confirmation + Rectangle { + width: parent.width + height: 80 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) + border.color: Theme.primary + border.width: 1 + visible: pairingType === BluetoothPairingRequestType.Confirmation + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + + Text { + text: "Confirm this passkey matches on both devices:" + font.pixelSize: Theme.fontSizeMedium + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + text: passkey.toString().padStart(6, '0') + font.pixelSize: Theme.fontSizeXXLarge + color: Theme.primary + font.weight: Font.Bold + font.family: "monospace" + anchors.horizontalCenter: parent.horizontalCenter + } + + } + + } + + // PIN Code or Passkey Input + Column { + width: parent.width + spacing: Theme.spacingS + visible: pairingType === BluetoothPairingRequestType.PinCode || pairingType === BluetoothPairingRequestType.Passkey + + Text { + text: pairingType === BluetoothPairingRequestType.PinCode ? "Enter PIN code for this device:" : "Enter 6-digit passkey shown on other device:" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.horizontalCenter: parent.horizontalCenter + } + + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + border.color: pairingInput.activeFocus ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: pairingInput.activeFocus ? 2 : 1 + + DankTextField { + id: pairingInput + + anchors.fill: parent + font.pixelSize: Theme.fontSizeLarge + textColor: Theme.surfaceText + text: BluetoothService.inputText + enabled: bluetoothPairingDialogVisible + placeholderText: pairingType === BluetoothPairingRequestType.PinCode ? "e.g., 0000 or 1234" : "123456" + backgroundColor: "transparent" + normalBorderColor: "transparent" + focusedBorderColor: "transparent" + inputMethodHints: pairingType === BluetoothPairingRequestType.Passkey ? Qt.ImhDigitsOnly : Qt.ImhNone + onTextEdited: { + // For passkey, limit to 6 digits only + if (pairingType === BluetoothPairingRequestType.Passkey) { + var filtered = text.replace(/[^0-9]/g, '').substring(0, 6); + if (text !== filtered) { + text = filtered; + return ; + } + } + BluetoothService.inputText = text; + } + onAccepted: { + if (text.length > 0) + BluetoothService.acceptPairing(); + + } + } + + } + + } + + } + + // Buttons + Row { + width: parent.width + spacing: Theme.spacingM + + Rectangle { + width: (parent.width - Theme.spacingM) / 2 + height: 40 + radius: Theme.cornerRadius + color: rejectArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + border.color: Theme.error + border.width: 1 + + Text { + anchors.centerIn: parent + text: "Cancel" + font.pixelSize: Theme.fontSizeMedium + color: Theme.error + font.weight: Font.Medium + } + + MouseArea { + id: rejectArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: BluetoothService.rejectPairing() + } + + } + + Rectangle { + width: (parent.width - Theme.spacingM) / 2 + height: 40 + radius: Theme.cornerRadius + color: acceptArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) + border.color: Theme.primary + border.width: 1 + opacity: { + // Authorization/Confirmation/ServiceAuthorization always enabled + if (pairingType <= BluetoothPairingRequestType.Confirmation || pairingType === BluetoothPairingRequestType.ServiceAuthorization) + return 1; + + // PIN/Passkey need input + return BluetoothService.inputText.length > 0 ? 1 : 0.5; + } + + Text { + anchors.centerIn: parent + text: { + switch (pairingType) { + case BluetoothPairingRequestType.Authorization: + return "Accept"; + case BluetoothPairingRequestType.Confirmation: + return "Confirm"; + case BluetoothPairingRequestType.ServiceAuthorization: + return "Allow"; + case BluetoothPairingRequestType.PinCode: + return "Pair"; + case BluetoothPairingRequestType.Passkey: + return "Enter"; + default: + return "OK"; + } + } + font.pixelSize: Theme.fontSizeMedium + color: Theme.primary + font.weight: Font.Medium + } + + MouseArea { + id: acceptArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: pairingType <= BluetoothPairingRequestType.Confirmation || pairingType === BluetoothPairingRequestType.ServiceAuthorization || BluetoothService.inputText.length > 0 + onClicked: BluetoothService.acceptPairing() + } + + } + + } + + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + + } + + Behavior on scale { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + + } + + } + +} diff --git a/Modules/ControlCenter/BluetoothTab.qml b/Modules/ControlCenter/BluetoothTab.qml index 0f014a45..f644480d 100644 --- a/Modules/ControlCenter/BluetoothTab.qml +++ b/Modules/ControlCenter/BluetoothTab.qml @@ -25,8 +25,8 @@ Item { width: parent.width height: 60 radius: Theme.cornerRadius - color: bluetoothToggle.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : (BluetoothService.adapter && BluetoothService.adapter.enabled ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12)) - border.color: BluetoothService.adapter && BluetoothService.adapter.enabled ? Theme.primary : "transparent" + color: bluetoothToggle.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : (BluetoothService.enabled ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12)) + border.color: BluetoothService.enabled ? Theme.primary : "transparent" border.width: 2 Row { @@ -38,7 +38,7 @@ Item { DankIcon { name: "bluetooth" size: Theme.iconSizeLarge - color: BluetoothService.adapter && BluetoothService.adapter.enabled ? Theme.primary : Theme.surfaceText + color: BluetoothService.enabled ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } @@ -49,12 +49,12 @@ Item { Text { text: "Bluetooth" font.pixelSize: Theme.fontSizeLarge - color: BluetoothService.adapter && BluetoothService.adapter.enabled ? Theme.primary : Theme.surfaceText + color: BluetoothService.enabled ? Theme.primary : Theme.surfaceText font.weight: Font.Medium } Text { - text: BluetoothService.adapter && BluetoothService.adapter.enabled ? "Enabled" : "Disabled" + text: BluetoothService.enabled ? "Enabled" : "Disabled" font.pixelSize: Theme.fontSizeSmall color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) } @@ -68,11 +68,10 @@ Item { anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor + enabled: !BluetoothService.operationInProgress + cursorShape: enabled ? Qt.PointingHandCursor : Qt.BusyCursor onClicked: { - if (BluetoothService.adapter) { - BluetoothService.adapter.enabled = !BluetoothService.adapter.enabled; - } + BluetoothService.toggleAdapter(); } } @@ -81,7 +80,7 @@ Item { Column { width: parent.width spacing: Theme.spacingM - visible: BluetoothService.adapter && BluetoothService.adapter.enabled + visible: BluetoothService.enabled Text { text: "Paired Devices" @@ -91,7 +90,7 @@ Item { } Repeater { - model: BluetoothService.adapter && BluetoothService.adapter.devices ? BluetoothService.adapter.devices.values.filter((dev) => { + model: BluetoothService.devices ? BluetoothService.devices.values.filter((dev) => { return dev && (dev.paired || dev.trusted); }) : [] @@ -224,7 +223,7 @@ Item { Column { width: parent.width spacing: Theme.spacingM - visible: BluetoothService.adapter && BluetoothService.adapter.enabled + visible: BluetoothService.enabled Row { width: parent.width @@ -256,7 +255,7 @@ Item { spacing: Theme.spacingXS DankIcon { - name: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "bluetooth_searching" + name: BluetoothService.discovering ? "stop" : "bluetooth_searching" size: Theme.iconSize - 4 color: Theme.primary anchors.verticalCenter: parent.verticalCenter @@ -265,7 +264,7 @@ Item { Text { id: scanText - text: BluetoothService.adapter && BluetoothService.adapter.discovering ? "Stop Scanning" : "Start Scanning" + text: BluetoothService.discovering ? "Stop Scanning" : "Start Scanning" font.pixelSize: Theme.fontSizeMedium color: Theme.primary font.weight: Font.Medium @@ -279,11 +278,10 @@ Item { anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor + enabled: !BluetoothService.operationInProgress + cursorShape: enabled ? Qt.PointingHandCursor : Qt.BusyCursor onClicked: { - if (BluetoothService.adapter) { - BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering; - } + BluetoothService.toggleDiscovery(); } } @@ -293,10 +291,10 @@ Item { Repeater { model: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + if (!BluetoothService.discovering || !BluetoothService.devices) return []; - var filtered = Bluetooth.devices.values.filter((dev) => { + var filtered = BluetoothService.devices.values.filter((dev) => { return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); }); return BluetoothService.sortDevices(filtered); @@ -496,10 +494,10 @@ Item { width: parent.width spacing: Theme.spacingM visible: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + if (!BluetoothService.discovering || !BluetoothService.devices) return false; - var availableCount = Bluetooth.devices.values.filter((dev) => { + var availableCount = BluetoothService.devices.values.filter((dev) => { return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); }).length; @@ -550,20 +548,21 @@ Item { font.pixelSize: Theme.fontSizeMedium color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) visible: { - if (!BluetoothService.adapter || !Bluetooth.devices) + if (!BluetoothService.devices) return true; - var availableCount = Bluetooth.devices.values.filter((dev) => { + var availableCount = BluetoothService.devices.values.filter((dev) => { return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); }).length; - return availableCount === 0 && !BluetoothService.adapter.discovering; + return availableCount === 0 && !BluetoothService.discovering; } wrapMode: Text.WordWrap width: parent.width horizontalAlignment: Text.AlignHCenter } + } } diff --git a/Services/BluetoothService.qml b/Services/BluetoothService.qml index 04b15eab..585751d4 100644 --- a/Services/BluetoothService.qml +++ b/Services/BluetoothService.qml @@ -10,8 +10,10 @@ Singleton { readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter readonly property bool available: adapter !== null - readonly property bool enabled: (adapter && adapter.enabled) ?? false - readonly property bool discovering: (adapter && adapter.discovering) ?? false + readonly property bool enabled: available ? adapter.enabled ?? false : false + readonly property bool discovering: available ? adapter.discovering ?? false : false + + property bool operationInProgress: false readonly property var devices: adapter ? adapter.devices : null readonly property var pairedDevices: { if (!adapter || !adapter.devices) @@ -30,6 +32,15 @@ Singleton { }); } + // Pairing dialog properties + property bool pairingDialogVisible: false + property int pairingType: BluetoothPairingRequestType.Authorization + property string pendingDeviceAddress: "" + property string pendingDeviceName: "" + property int pendingPasskey: 0 + property var pendingToken: null + property string inputText: "" + function sortDevices(devices) { return devices.sort((a, b) => { var aName = a.name || a.deviceName || ""; @@ -167,9 +178,147 @@ Singleton { function connectDeviceWithTrust(device) { if (!device) return; - device.trusted = true; - device.connect(); + device.connect() + } + + function toggleAdapter() { + if (!available || operationInProgress) { + console.warn("BluetoothService: Cannot toggle adapter - not available or operation in progress"); + return false; + } + + operationInProgress = true; + var targetState = !adapter.enabled; + + try { + adapter.enabled = targetState; + return true; + } catch (error) { + console.error("BluetoothService: Failed to toggle adapter:", error); + operationInProgress = false; + return false; + } + } + + function toggleDiscovery() { + if (!available || !adapter.enabled || operationInProgress) { + console.warn("BluetoothService: Cannot toggle discovery - adapter not ready or operation in progress"); + return false; + } + + operationInProgress = true; + var targetState = !adapter.discovering; + + try { + adapter.discovering = targetState; + return true; + } catch (error) { + console.error("BluetoothService: Failed to toggle discovery:", error); + operationInProgress = false; + return false; + } + } + + // Monitor adapter state changes to clear operation flags + Connections { + target: adapter + ignoreUnknownSignals: true + + function onEnabledChanged() { + operationInProgress = false; + } + + function onDiscoveringChanged() { + operationInProgress = false; + } + } + + // Pairing agent signal handler + Connections { + target: Bluetooth.agent + ignoreUnknownSignals: true + + function onPairingRequested(deviceAddress, type, passkey, token) { + console.log("BluetoothService: Pairing requested for", deviceAddress, "type:", type, "passkey:", passkey, "token:", token); + root.pairingType = type; + root.pendingDeviceAddress = deviceAddress; + root.pendingPasskey = passkey; + root.pendingToken = token; + root.inputText = ""; + + // Try to find and store the device name using MAC address + var device = root.getDeviceFromAddress(deviceAddress); + root.pendingDeviceName = device ? (device.name || device.deviceName || deviceAddress) : deviceAddress; + console.log("BluetoothService: Device name:", root.pendingDeviceName, "for address:", deviceAddress, "token:", token); + + root.pairingDialogVisible = true; + } + } + + function acceptPairing() { + console.log("BluetoothService: Accepting pairing for", root.pendingDeviceAddress, "type:", root.pairingType, "token:", root.pendingToken); + if (!Bluetooth.agent || root.pendingToken === null) return; + + switch (root.pairingType) { + case BluetoothPairingRequestType.Authorization: + case BluetoothPairingRequestType.Confirmation: + case BluetoothPairingRequestType.ServiceAuthorization: + Bluetooth.agent.respondToRequest(root.pendingToken, true); + break; + + case BluetoothPairingRequestType.PinCode: + if (root.inputText.length > 0) { + Bluetooth.agent.respondWithPinCode(root.pendingToken, root.inputText); + } else { + console.warn("BluetoothService: No PIN code entered"); + return; + } + break; + + case BluetoothPairingRequestType.Passkey: + var passkey = parseInt(root.inputText); + if (passkey >= 0 && passkey <= 999999) { + Bluetooth.agent.respondWithPasskey(root.pendingToken, passkey); + } else { + console.warn("BluetoothService: Invalid passkey:", root.inputText); + return; + } + break; + } + + closePairingDialog(); + } + + function rejectPairing() { + console.log("BluetoothService: Rejecting pairing for", root.pendingDeviceAddress, "token:", root.pendingToken); + if (Bluetooth.agent && root.pendingToken !== null) { + Bluetooth.agent.respondToRequest(root.pendingToken, false); + } + closePairingDialog(); + } + + function closePairingDialog() { + root.pairingDialogVisible = false; + root.pendingDeviceAddress = ""; + root.pendingDeviceName = ""; + root.pendingPasskey = 0; + root.pendingToken = null; + root.inputText = ""; + root.pairingType = BluetoothPairingRequestType.Authorization; + } + + function getDeviceFromPath(devicePath) { + if (!adapter || !adapter.devices || !devicePath) + return null; + return adapter.devices.values.find(d => d && d.path === devicePath) || null; + } + + function getDeviceFromAddress(deviceAddress) { + if (!adapter || !adapter.devices || !deviceAddress) + return null; + return adapter.devices.values.find(d => d && d.address === deviceAddress) || null; } + } diff --git a/shell.qml b/shell.qml index e167ce09..46276335 100644 --- a/shell.qml +++ b/shell.qml @@ -16,6 +16,7 @@ ShellRoot { delegate: TopBar { modelData: item } + } // Global popup windows @@ -43,6 +44,10 @@ ShellRoot { id: wifiPasswordDialog } + BluetoothPairingDialog { + id: bluetoothPairingDialog + } + NetworkInfoDialog { id: networkInfoDialog } @@ -95,4 +100,5 @@ ShellRoot { Toast { id: toastWidget } + }