diff --git a/Common/SettingsData.qml b/Common/SettingsData.qml index 00adac95..1fb62907 100644 --- a/Common/SettingsData.qml +++ b/Common/SettingsData.qml @@ -1050,6 +1050,7 @@ Singleton { function setCornerRadius(radius) { cornerRadius = radius saveSettings() + NiriService.generateNiriLayoutConfig() } function setClockFormat(use24Hour) { diff --git a/Modals/BluetoothPairingModal.qml b/Modals/BluetoothPairingModal.qml new file mode 100644 index 00000000..f89b19b0 --- /dev/null +++ b/Modals/BluetoothPairingModal.qml @@ -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 = "" + } +} diff --git a/Modules/ControlCenter/Details/BluetoothDetail.qml b/Modules/ControlCenter/Details/BluetoothDetail.qml index 403a79bd..d783eed1 100644 --- a/Modules/ControlCenter/Details/BluetoothDetail.qml +++ b/Modules/ControlCenter/Details/BluetoothDetail.qml @@ -5,6 +5,7 @@ import Quickshell.Bluetooth import qs.Common import qs.Services import qs.Widgets +import qs.Modals Rectangle { implicitHeight: BluetoothService.adapter && BluetoothService.adapter.enabled ? headerRow.height + bluetoothContent.height + Theme.spacingM : headerRow.height @@ -14,9 +15,14 @@ Rectangle { border.width: 0 property var bluetoothCodecModalRef: null + property var devicesBeingPaired: new Set() signal showCodecSelector(var device) + function isDeviceBeingPaired(deviceAddress) { + return devicesBeingPaired.has(deviceAddress) + } + function updateDeviceCodecDisplay(deviceAddress, codecName) { for (let i = 0; i < pairedRepeater.count; i++) { let item = pairedRepeater.itemAt(i) @@ -327,7 +333,7 @@ Rectangle { required property int index property bool canConnect: BluetoothService.canConnect(modelData) - property bool isBusy: BluetoothService.isDeviceBusy(modelData) + property bool isBusy: BluetoothService.isDeviceBusy(modelData) || isDeviceBeingPaired(modelData.address) width: parent.width 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 border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) border.width: 0 - opacity: canConnect ? 1 : 0.6 + opacity: (canConnect && !isBusy) ? 1 : 0.6 Row { anchors.left: parent.left @@ -367,7 +373,7 @@ Rectangle { StyledText { text: { - if (modelData.pairing) return "Pairing..." + if (modelData.pairing || isBusy) return "Pairing..." if (modelData.blocked) return "Blocked" return BluetoothService.getSignalStrength(modelData) } @@ -390,12 +396,12 @@ Rectangle { anchors.rightMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter text: { - if (modelData.pairing) return "Pairing..." + if (isBusy) return "Pairing..." if (!canConnect) return "Cannot pair" return "Pair" } font.pixelSize: Theme.fontSizeSmall - color: canConnect ? Theme.primary : Theme.surfaceVariantText + color: (canConnect && !isBusy) ? Theme.primary : Theme.surfaceVariantText font.weight: Font.Medium } @@ -407,7 +413,20 @@ Rectangle { enabled: canConnect && !isBusy onClicked: { 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) + } + } } \ No newline at end of file diff --git a/Services/BluetoothService.qml b/Services/BluetoothService.qml index 50ca5015..0a8ad1b3 100644 --- a/Services/BluetoothService.qml +++ b/Services/BluetoothService.qml @@ -6,6 +6,7 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Bluetooth +import qs.Services Singleton { id: root @@ -15,6 +16,7 @@ Singleton { readonly property bool enabled: (adapter && adapter.enabled) ?? false readonly property bool discovering: (adapter && adapter.discovering) ?? false 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: { if (!adapter || !adapter.devices) { return false @@ -173,6 +175,25 @@ Singleton { 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) { if (!device) { return "" @@ -180,6 +201,14 @@ Singleton { 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) { if (!device) { return false diff --git a/Services/DMSService.qml b/Services/DMSService.qml index 6e805445..eb995b11 100644 --- a/Services/DMSService.qml +++ b/Services/DMSService.qml @@ -42,6 +42,7 @@ Singleton { signal loginctlEvent(var event) signal capabilitiesReceived() signal credentialsRequest(var data) + signal bluetoothPairingRequest(var data) Component.onCompleted: { if (socketPath && socketPath.length > 0) { @@ -217,7 +218,10 @@ Singleton { function sendSubscribeRequest() { const request = { - "method": "subscribe" + "method": "subscribe", + "params": { + "services": ["bluetooth", "bluetooth.pairing"] + } } if (verboseLogs) { @@ -270,6 +274,8 @@ Singleton { } else { loginctlStateUpdate(data) } + } else if (service === "bluetooth.pairing") { + bluetoothPairingRequest(data) } } @@ -408,4 +414,48 @@ Singleton { function unlockSession(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) + } } diff --git a/Services/NiriService.qml b/Services/NiriService.qml index ce84b04f..851762fe 100644 --- a/Services/NiriService.qml +++ b/Services/NiriService.qml @@ -1,6 +1,6 @@ pragma Singleton -pragma ComponentBehavior: Bound +pragma ComponentBehavior import QtCore import QtQuick @@ -640,7 +640,7 @@ Singleton { const enrichedToplevel = { "appId": bestMatch.appId, "title": bestMatch.title, - "activated": bestMatch.activated, + "activated": niriWindow.is_focused ?? false, "niriWindowId": niriWindow.id, "niriWorkspaceId": niriWindow.workspace_id, "activate": function () { @@ -726,7 +726,7 @@ Singleton { const enrichedToplevel = { "appId": bestMatch.appId, "title": bestMatch.title, - "activated": bestMatch.activated, + "activated": niriWindow.is_focused ?? false, "niriWindowId": niriWindow.id, "niriWorkspaceId": niriWindow.workspace_id, "activate": function () { @@ -753,12 +753,10 @@ Singleton { } function generateNiriLayoutConfig() { - const niriSocket = Quickshell.env("NIRI_SOCKET") - if (!niriSocket || niriSocket.length === 0) - return - if (configGenerationPending) + if (!CompositorService.isNiri || configGenerationPending) return + suppressNextToast() configGenerationPending = true configGenerationDebounce.restart() }