1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-06 05:25:41 -05:00

bluetooth: integrate with DMS API v9 - Supports proper pairing with an agent & pin, passcode, etc.

This commit is contained in:
bbedward
2025-10-23 11:54:22 -04:00
parent 61d68b1f76
commit 1311da7258
6 changed files with 482 additions and 14 deletions

View File

@@ -1050,6 +1050,7 @@ Singleton {
function setCornerRadius(radius) { function setCornerRadius(radius) {
cornerRadius = radius cornerRadius = radius
saveSettings() saveSettings()
NiriService.generateNiriLayoutConfig()
} }
function setClockFormat(use24Hour) { function setClockFormat(use24Hour) {

View File

@@ -0,0 +1,360 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
property string deviceName: ""
property string deviceAddress: ""
property string requestType: ""
property string token: ""
property int passkey: 0
property string pinInput: ""
property string passkeyInput: ""
function show(pairingData) {
token = pairingData.token || ""
deviceName = pairingData.deviceName || ""
deviceAddress = pairingData.deviceAddr || ""
requestType = pairingData.requestType || ""
passkey = pairingData.passkey || 0
pinInput = ""
passkeyInput = ""
open()
Qt.callLater(() => {
if (contentLoader.item) {
if (requestType === "pin" && contentLoader.item.pinInputField) {
contentLoader.item.pinInputField.forceActiveFocus()
} else if (requestType === "passkey" && contentLoader.item.passkeyInputField) {
contentLoader.item.passkeyInputField.forceActiveFocus()
}
}
})
}
shouldBeVisible: false
width: 420
height: {
if (requestType === "confirm" || requestType === "authorize" || requestType.startsWith("authorize-service"))
return 200
return 230
}
onShouldBeVisibleChanged: () => {
if (!shouldBeVisible) {
pinInput = ""
passkeyInput = ""
}
}
onOpened: {
Qt.callLater(() => {
if (contentLoader.item) {
if (requestType === "pin" && contentLoader.item.pinInputField) {
contentLoader.item.pinInputField.forceActiveFocus()
} else if (requestType === "passkey" && contentLoader.item.passkeyInputField) {
contentLoader.item.passkeyInputField.forceActiveFocus()
}
}
})
}
onBackgroundClicked: () => {
DMSService.bluetoothCancelPairing(token)
close()
pinInput = ""
passkeyInput = ""
}
content: Component {
FocusScope {
id: pairingContent
property alias pinInputField: pinInputField
property alias passkeyInputField: passkeyInputField
anchors.fill: parent
focus: true
Keys.onEscapePressed: event => {
DMSService.bluetoothCancelPairing(token)
close()
pinInput = ""
passkeyInput = ""
event.accepted = true
}
Column {
anchors.centerIn: parent
width: parent.width - Theme.spacingM * 2
spacing: Theme.spacingM
Row {
width: parent.width
Column {
width: parent.width - 40
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Pair Bluetooth Device")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: {
if (requestType === "confirm")
return I18n.tr("Confirm passkey for ") + deviceName
if (requestType === "authorize")
return I18n.tr("Authorize pairing with ") + deviceName
if (requestType.startsWith("authorize-service"))
return I18n.tr("Authorize service for ") + deviceName
if (requestType === "pin")
return I18n.tr("Enter PIN for ") + deviceName
if (requestType === "passkey")
return I18n.tr("Enter passkey for ") + deviceName
return deviceName
}
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
width: parent.width
elide: Text.ElideRight
}
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: () => {
DMSService.bluetoothCancelPairing(token)
close()
pinInput = ""
passkeyInput = ""
}
}
}
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: pinInputField.activeFocus ? Theme.primary : Theme.outlineStrong
border.width: pinInputField.activeFocus ? 2 : 1
visible: requestType === "pin"
MouseArea {
anchors.fill: parent
onClicked: () => {
pinInputField.forceActiveFocus()
}
}
DankTextField {
id: pinInputField
anchors.fill: parent
font.pixelSize: Theme.fontSizeMedium
textColor: Theme.surfaceText
text: pinInput
placeholderText: I18n.tr("Enter PIN")
backgroundColor: "transparent"
enabled: root.shouldBeVisible
onTextEdited: () => {
pinInput = text
}
onAccepted: () => {
submitPairing()
}
}
}
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: passkeyInputField.activeFocus ? Theme.primary : Theme.outlineStrong
border.width: passkeyInputField.activeFocus ? 2 : 1
visible: requestType === "passkey"
MouseArea {
anchors.fill: parent
onClicked: () => {
passkeyInputField.forceActiveFocus()
}
}
DankTextField {
id: passkeyInputField
anchors.fill: parent
font.pixelSize: Theme.fontSizeMedium
textColor: Theme.surfaceText
text: passkeyInput
placeholderText: I18n.tr("Enter 6-digit passkey")
backgroundColor: "transparent"
enabled: root.shouldBeVisible
onTextEdited: () => {
passkeyInput = text
}
onAccepted: () => {
submitPairing()
}
}
}
Rectangle {
width: parent.width
height: 60
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
visible: requestType === "confirm"
Column {
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Passkey:")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: String(passkey).padStart(6, "0")
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
font.weight: Font.Bold
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
Item {
width: parent.width
height: 40
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
Rectangle {
width: Math.max(70, cancelText.contentWidth + Theme.spacingM * 2)
height: 36
radius: Theme.cornerRadius
color: cancelArea.containsMouse ? Theme.surfaceTextHover : "transparent"
border.color: Theme.surfaceVariantAlpha
border.width: 1
StyledText {
id: cancelText
anchors.centerIn: parent
text: I18n.tr("Cancel")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
DMSService.bluetoothCancelPairing(token)
close()
pinInput = ""
passkeyInput = ""
}
}
}
Rectangle {
width: Math.max(80, pairText.contentWidth + Theme.spacingM * 2)
height: 36
radius: Theme.cornerRadius
color: pairArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
enabled: {
if (requestType === "pin")
return pinInput.length > 0
if (requestType === "passkey")
return passkeyInput.length === 6
return true
}
opacity: enabled ? 1 : 0.5
StyledText {
id: pairText
anchors.centerIn: parent
text: {
if (requestType === "confirm")
return I18n.tr("Confirm")
if (requestType === "authorize" || requestType.startsWith("authorize-service"))
return I18n.tr("Authorize")
return I18n.tr("Pair")
}
font.pixelSize: Theme.fontSizeMedium
color: Theme.background
font.weight: Font.Medium
}
MouseArea {
id: pairArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: parent.enabled
onClicked: () => {
submitPairing()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
}
}
function submitPairing() {
const secrets = {}
if (requestType === "pin") {
secrets["pin"] = pinInput
} else if (requestType === "passkey") {
secrets["passkey"] = passkeyInput
} else if (requestType === "confirm" || requestType === "authorize" || requestType.startsWith("authorize-service")) {
secrets["decision"] = "yes"
}
DMSService.bluetoothSubmitPairing(token, secrets, true, response => {
if (response.error) {
ToastService.showError(I18n.tr("Pairing failed"), response.error)
}
})
close()
pinInput = ""
passkeyInput = ""
}
}

View File

@@ -5,6 +5,7 @@ import Quickshell.Bluetooth
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modals
Rectangle { Rectangle {
implicitHeight: BluetoothService.adapter && BluetoothService.adapter.enabled ? headerRow.height + bluetoothContent.height + Theme.spacingM : headerRow.height implicitHeight: BluetoothService.adapter && BluetoothService.adapter.enabled ? headerRow.height + bluetoothContent.height + Theme.spacingM : headerRow.height
@@ -14,9 +15,14 @@ Rectangle {
border.width: 0 border.width: 0
property var bluetoothCodecModalRef: null property var bluetoothCodecModalRef: null
property var devicesBeingPaired: new Set()
signal showCodecSelector(var device) signal showCodecSelector(var device)
function isDeviceBeingPaired(deviceAddress) {
return devicesBeingPaired.has(deviceAddress)
}
function updateDeviceCodecDisplay(deviceAddress, codecName) { function updateDeviceCodecDisplay(deviceAddress, codecName) {
for (let i = 0; i < pairedRepeater.count; i++) { for (let i = 0; i < pairedRepeater.count; i++) {
let item = pairedRepeater.itemAt(i) let item = pairedRepeater.itemAt(i)
@@ -327,7 +333,7 @@ Rectangle {
required property int index required property int index
property bool canConnect: BluetoothService.canConnect(modelData) property bool canConnect: BluetoothService.canConnect(modelData)
property bool isBusy: BluetoothService.isDeviceBusy(modelData) property bool isBusy: BluetoothService.isDeviceBusy(modelData) || isDeviceBeingPaired(modelData.address)
width: parent.width width: parent.width
height: 50 height: 50
@@ -335,7 +341,7 @@ Rectangle {
color: availableMouseArea.containsMouse && !isBusy ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.surfaceContainerHighest color: availableMouseArea.containsMouse && !isBusy ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.surfaceContainerHighest
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: 0 border.width: 0
opacity: canConnect ? 1 : 0.6 opacity: (canConnect && !isBusy) ? 1 : 0.6
Row { Row {
anchors.left: parent.left anchors.left: parent.left
@@ -367,7 +373,7 @@ Rectangle {
StyledText { StyledText {
text: { text: {
if (modelData.pairing) return "Pairing..." if (modelData.pairing || isBusy) return "Pairing..."
if (modelData.blocked) return "Blocked" if (modelData.blocked) return "Blocked"
return BluetoothService.getSignalStrength(modelData) return BluetoothService.getSignalStrength(modelData)
} }
@@ -390,12 +396,12 @@ Rectangle {
anchors.rightMargin: Theme.spacingM anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
if (modelData.pairing) return "Pairing..." if (isBusy) return "Pairing..."
if (!canConnect) return "Cannot pair" if (!canConnect) return "Cannot pair"
return "Pair" return "Pair"
} }
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: canConnect ? Theme.primary : Theme.surfaceVariantText color: (canConnect && !isBusy) ? Theme.primary : Theme.surfaceVariantText
font.weight: Font.Medium font.weight: Font.Medium
} }
@@ -407,7 +413,20 @@ Rectangle {
enabled: canConnect && !isBusy enabled: canConnect && !isBusy
onClicked: { onClicked: {
if (modelData) { if (modelData) {
BluetoothService.connectDeviceWithTrust(modelData) const deviceAddr = modelData.address
devicesBeingPaired.add(deviceAddr)
devicesBeingPairedChanged()
BluetoothService.pairDevice(modelData, response => {
devicesBeingPaired.delete(deviceAddr)
devicesBeingPairedChanged()
if (response.error) {
ToastService.showError(I18n.tr("Pairing failed"), response.error)
} else if (!BluetoothService.enhancedPairingAvailable) {
ToastService.showSuccess(I18n.tr("Device paired"))
}
})
} }
} }
} }
@@ -522,4 +541,15 @@ Rectangle {
} }
} }
BluetoothPairingModal {
id: bluetoothPairingModal
}
Connections {
target: DMSService
function onBluetoothPairingRequest(data) {
bluetoothPairingModal.show(data)
}
}
} }

View File

@@ -6,6 +6,7 @@ import QtQuick
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.Bluetooth import Quickshell.Bluetooth
import qs.Services
Singleton { Singleton {
id: root id: root
@@ -15,6 +16,7 @@ Singleton {
readonly property bool enabled: (adapter && adapter.enabled) ?? false readonly property bool enabled: (adapter && adapter.enabled) ?? false
readonly property bool discovering: (adapter && adapter.discovering) ?? false readonly property bool discovering: (adapter && adapter.discovering) ?? false
readonly property var devices: adapter ? adapter.devices : null readonly property var devices: adapter ? adapter.devices : null
readonly property bool enhancedPairingAvailable: DMSService.dmsAvailable && DMSService.apiVersion >= 9 && DMSService.capabilities.includes("bluetooth")
readonly property bool connected: { readonly property bool connected: {
if (!adapter || !adapter.devices) { if (!adapter || !adapter.devices) {
return false return false
@@ -173,6 +175,25 @@ Singleton {
device.connect() device.connect()
} }
function pairDevice(device, callback) {
if (!device) {
if (callback) callback({error: "Invalid device"})
return
}
// The DMS backend actually implements a bluez agent, so we can pair anything
if (enhancedPairingAvailable) {
const devicePath = getDevicePath(device)
DMSService.bluetoothPair(devicePath, callback)
return
}
// Quickshell does not implement a bluez agent, so we can try to pair but only with devices that don't require a passcode
device.trusted = true
device.connect()
if (callback) callback({success: true})
}
function getCardName(device) { function getCardName(device) {
if (!device) { if (!device) {
return "" return ""
@@ -180,6 +201,14 @@ Singleton {
return `bluez_card.${device.address.replace(/:/g, "_")}` return `bluez_card.${device.address.replace(/:/g, "_")}`
} }
function getDevicePath(device) {
if (!device || !device.address) {
return ""
}
const adapterPath = adapter ? "/org/bluez/hci0" : "/org/bluez/hci0"
return `${adapterPath}/dev_${device.address.replace(/:/g, "_")}`
}
function isAudioDevice(device) { function isAudioDevice(device) {
if (!device) { if (!device) {
return false return false

View File

@@ -42,6 +42,7 @@ Singleton {
signal loginctlEvent(var event) signal loginctlEvent(var event)
signal capabilitiesReceived() signal capabilitiesReceived()
signal credentialsRequest(var data) signal credentialsRequest(var data)
signal bluetoothPairingRequest(var data)
Component.onCompleted: { Component.onCompleted: {
if (socketPath && socketPath.length > 0) { if (socketPath && socketPath.length > 0) {
@@ -217,7 +218,10 @@ Singleton {
function sendSubscribeRequest() { function sendSubscribeRequest() {
const request = { const request = {
"method": "subscribe" "method": "subscribe",
"params": {
"services": ["bluetooth", "bluetooth.pairing"]
}
} }
if (verboseLogs) { if (verboseLogs) {
@@ -270,6 +274,8 @@ Singleton {
} else { } else {
loginctlStateUpdate(data) loginctlStateUpdate(data)
} }
} else if (service === "bluetooth.pairing") {
bluetoothPairingRequest(data)
} }
} }
@@ -408,4 +414,48 @@ Singleton {
function unlockSession(callback) { function unlockSession(callback) {
sendRequest("loginctl.unlock", null, callback) sendRequest("loginctl.unlock", null, callback)
} }
function bluetoothPair(devicePath, callback) {
sendRequest("bluetooth.pair", {
"device": devicePath
}, callback)
}
function bluetoothConnect(devicePath, callback) {
sendRequest("bluetooth.connect", {
"device": devicePath
}, callback)
}
function bluetoothDisconnect(devicePath, callback) {
sendRequest("bluetooth.disconnect", {
"device": devicePath
}, callback)
}
function bluetoothRemove(devicePath, callback) {
sendRequest("bluetooth.remove", {
"device": devicePath
}, callback)
}
function bluetoothTrust(devicePath, callback) {
sendRequest("bluetooth.trust", {
"device": devicePath
}, callback)
}
function bluetoothSubmitPairing(token, secrets, accept, callback) {
sendRequest("bluetooth.pairing.submit", {
"token": token,
"secrets": secrets,
"accept": accept
}, callback)
}
function bluetoothCancelPairing(token, callback) {
sendRequest("bluetooth.pairing.cancel", {
"token": token
}, callback)
}
} }

View File

@@ -1,6 +1,6 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound pragma ComponentBehavior
import QtCore import QtCore
import QtQuick import QtQuick
@@ -640,7 +640,7 @@ Singleton {
const enrichedToplevel = { const enrichedToplevel = {
"appId": bestMatch.appId, "appId": bestMatch.appId,
"title": bestMatch.title, "title": bestMatch.title,
"activated": bestMatch.activated, "activated": niriWindow.is_focused ?? false,
"niriWindowId": niriWindow.id, "niriWindowId": niriWindow.id,
"niriWorkspaceId": niriWindow.workspace_id, "niriWorkspaceId": niriWindow.workspace_id,
"activate": function () { "activate": function () {
@@ -726,7 +726,7 @@ Singleton {
const enrichedToplevel = { const enrichedToplevel = {
"appId": bestMatch.appId, "appId": bestMatch.appId,
"title": bestMatch.title, "title": bestMatch.title,
"activated": bestMatch.activated, "activated": niriWindow.is_focused ?? false,
"niriWindowId": niriWindow.id, "niriWindowId": niriWindow.id,
"niriWorkspaceId": niriWindow.workspace_id, "niriWorkspaceId": niriWindow.workspace_id,
"activate": function () { "activate": function () {
@@ -753,12 +753,10 @@ Singleton {
} }
function generateNiriLayoutConfig() { function generateNiriLayoutConfig() {
const niriSocket = Quickshell.env("NIRI_SOCKET") if (!CompositorService.isNiri || configGenerationPending)
if (!niriSocket || niriSocket.length === 0)
return
if (configGenerationPending)
return return
suppressNextToast()
configGenerationPending = true configGenerationPending = true
configGenerationDebounce.restart() configGenerationDebounce.restart()
} }