diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 136c93404..cb4aca46b 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -31,6 +31,8 @@ Item { id: root readonly property var log: Log.scoped("DMSShell") readonly property var _sessionsServiceRef: SessionsService + // Keep the optional integration service alive + readonly property var _dankConnectServiceRef: DankConnectService property var core: null diff --git a/quickshell/Modules/DankDash/Overview/DankConnectOverviewCard.qml b/quickshell/Modules/DankDash/Overview/DankConnectOverviewCard.qml new file mode 100644 index 000000000..450a90535 --- /dev/null +++ b/quickshell/Modules/DankDash/Overview/DankConnectOverviewCard.qml @@ -0,0 +1,209 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Card { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + readonly property var device: DankConnectService.primaryDevice + readonly property var pairingRequest: DankConnectService.pairingRequest + readonly property bool hasBattery: !!device && typeof device.batteryCharge === "number" && device.batteryCharge >= 0 + + function statusText() { + if (!DankConnectService.connected) + return I18n.tr("Disconnected"); + if (!device) + return I18n.tr("No devices"); + const connection = device.isReachable ? I18n.tr("Connected") : I18n.tr("Disconnected"); + const pairing = device.isPaired ? I18n.tr("Paired") : I18n.tr("Not paired"); + return connection + " • " + pairing; + } + + function batteryText() { + if (!hasBattery) + return ""; + const charge = Math.round(device.batteryCharge) + "%"; + return device.batteryCharging ? charge + " • " + I18n.tr("Charging") : charge; + } + + function reportError(title, response) { + if (response?.error) + ToastService.showError(title, response.error); + } + + Row { + id: contentRow + + anchors.fill: parent + spacing: Theme.spacingL + + Item { + width: Math.max(180, contentRow.width - actionRow.width - trailingPanel.width - contentRow.spacing * 2) + height: parent.height + + Row { + anchors.fill: parent + spacing: Theme.spacingM + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "smartphone" + size: 36 + color: root.device?.isReachable ? Theme.primary : Theme.surfaceTextSecondary + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 36 - parent.spacing + spacing: Theme.spacingXS + + StyledText { + width: parent.width + text: root.device?.name || "DankConnect" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + width: parent.width + text: root.statusText() + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + width: parent.width + visible: root.hasBattery + text: root.batteryText() + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + elide: Text.ElideRight + maximumLineCount: 1 + } + } + } + } + + Row { + id: actionRow + + anchors.verticalCenter: parent.verticalCenter + width: implicitWidth + spacing: Theme.spacingXS + + DankActionButton { + buttonSize: 34 + iconSize: 17 + iconName: "ring_volume" + iconColor: Theme.primary + tooltipText: I18n.tr("Ring") + enabled: DankConnectService.canRing(root.device) + onClicked: DankConnectService.ringDevice(root.device.id, response => root.reportError(I18n.tr("Ring"), response)) + } + + DankActionButton { + buttonSize: 34 + iconSize: 17 + iconName: "network_ping" + iconColor: Theme.primary + tooltipText: I18n.tr("Ping") + enabled: DankConnectService.canPing(root.device) + onClicked: DankConnectService.pingDevice(root.device.id, response => root.reportError(I18n.tr("Ping"), response)) + } + + DankActionButton { + buttonSize: 34 + iconSize: 17 + iconName: "content_paste_go" + iconColor: Theme.primary + tooltipText: I18n.tr("Send Clipboard") + enabled: DankConnectService.canSendClipboard(root.device) + onClicked: DankConnectService.sendClipboard(root.device.id, response => root.reportError(I18n.tr("Send Clipboard"), response)) + } + + DankActionButton { + buttonSize: 34 + iconSize: 17 + iconName: "open_in_new" + iconColor: Theme.primary + tooltipText: I18n.tr("Open App") + onClicked: DankConnectService.openApp() + } + } + + Item { + id: trailingPanel + + width: root.pairingRequest ? 270 : 52 + height: parent.height + + DankIcon { + anchors.centerIn: parent + visible: !root.pairingRequest && root.hasBattery + name: root.hasBattery ? Theme.getBatteryIcon(root.device.batteryCharge, root.device.batteryCharging, true) : "battery_std" + size: 28 + color: Theme.primary + } + + Column { + anchors.centerIn: parent + width: parent.width + spacing: Theme.spacingXS + visible: root.pairingRequest !== null + + StyledText { + width: parent.width + text: I18n.tr("Pairing request from %1").arg(root.pairingRequest?.name || I18n.tr("Device")) + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + width: parent.width + text: root.pairingRequest?.verificationKey || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Bold + color: Theme.primary + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + maximumLineCount: 1 + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingS + + DankButton { + text: I18n.tr("Reject") + buttonHeight: 30 + horizontalPadding: Theme.spacingM + onClicked: DankConnectService.rejectPairing(root.pairingRequest.deviceId, response => root.reportError(I18n.tr("Failed to reject pairing"), response)) + } + + DankButton { + text: I18n.tr("Accept") + buttonHeight: 30 + horizontalPadding: Theme.spacingM + backgroundColor: Theme.primary + textColor: Theme.primaryText + onClicked: DankConnectService.acceptPairing(root.pairingRequest.deviceId, response => root.reportError(I18n.tr("Failed to accept pairing"), response)) + } + } + } + } + } +} diff --git a/quickshell/Modules/DankDash/OverviewTab.qml b/quickshell/Modules/DankDash/OverviewTab.qml index 2d545f283..d1f6fde6c 100644 --- a/quickshell/Modules/DankDash/OverviewTab.qml +++ b/quickshell/Modules/DankDash/OverviewTab.qml @@ -9,8 +9,11 @@ Item { LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true + readonly property int overviewGridHeight: 410 + readonly property int dankConnectCardHeight: 124 + implicitWidth: SettingsData.showWeekNumber ? 736 : 700 - implicitHeight: 410 + implicitHeight: overviewGridHeight + Theme.spacingM + dankConnectCardHeight signal switchToWeatherTab signal switchToMediaTab @@ -89,5 +92,12 @@ Item { onClicked: root.switchToMediaTab() } + + DankConnectOverviewCard { + x: 0 + y: root.overviewGridHeight + Theme.spacingM + width: parent.width + height: root.dankConnectCardHeight + } } } diff --git a/quickshell/Services/DankConnectBackend.qml b/quickshell/Services/DankConnectBackend.qml new file mode 100644 index 000000000..7a181c650 --- /dev/null +++ b/quickshell/Services/DankConnectBackend.qml @@ -0,0 +1,284 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common + +// Canonical DankConnect IPC client. Targets daemon apiVersion 1. +Item { + id: root + + // dankgo omits zero-valued response IDs, so the subscription handshake + // must use a non-zero ID that can be matched in the response. + readonly property int subscriptionRequestId: 1 + + property string socketPath: { + const override = Quickshell.env("DANKCONNECT_SOCKET"); + if (override && override.length > 0) + return override; + const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR"); + return runtimeDir ? runtimeDir + "/dankconnect.sock" : ""; + } + + readonly property bool connected: requestSocket.linkUp && ready + property bool ready: false + property bool initialized: false + property bool subscribed: false + property int apiVersion: 0 + property var capabilities: [] + property var subscribedTopics: [] + property var devices: [] + property var devicesById: ({}) + property string lastError: "" + property bool launchPending: false + + property int _requestId: 0 + property var _pendingRequests: ({}) + + signal eventReceived(string topic, var data) + + function _nextId() { + _requestId = _requestId >= 2147483647 ? 1 : _requestId + 1; + return _requestId; + } + + function _parse(line) { + if (!line || line.length === 0) + return null; + try { + return JSON.parse(line); + } catch (error) { + return null; + } + } + + function _isHello(message) { + return message && message.id === undefined && message.apiVersion !== undefined && Array.isArray(message.capabilities); + } + + function _acceptHello(message) { + apiVersion = Number(message.apiVersion) || 0; + capabilities = message.capabilities.slice(); + ready = apiVersion === 1; + if (!ready) { + lastError = "Unsupported DankConnect API version " + apiVersion; + return; + } + lastError = ""; + launchDelayTimer.stop(); + launchPending = false; + refreshDevices(); + } + + function _flushPending(message) { + const callbacks = _pendingRequests; + _pendingRequests = {}; + for (const id in callbacks) { + const callback = callbacks[id]; + if (!callback) + continue; + callback({ + "id": Number(id), + "error": message + }); + } + } + + function _applyDevices(snapshots) { + const nextDevices = []; + const nextById = {}; + for (const device of (snapshots || [])) { + if (!device?.id) + continue; + nextDevices.push(device); + nextById[device.id] = device; + } + devicesById = nextById; + devices = nextDevices; + initialized = true; + } + + function _resolveDevice(deviceOrId) { + if (typeof deviceOrId === "string") + return devicesById[deviceOrId] || null; + return deviceOrId || null; + } + + function getDevice(deviceId) { + return devicesById[deviceId] || null; + } + + // supportedPlugins is the peer's incoming view: actions this client sends. + function supports(deviceOrId, packetType) { + const device = _resolveDevice(deviceOrId); + return !!device && (device.supportedPlugins || []).includes(packetType); + } + + // outgoingCapabilities is the peer's producing view: data this client consumes. + function produces(deviceOrId, packetType) { + const device = _resolveDevice(deviceOrId); + return !!device && (device.outgoingCapabilities || []).includes(packetType); + } + + function hasCapability(family) { + return capabilities.includes(family); + } + + function request(method, params, callback) { + if (!connected) { + callback?.({ + "error": "daemon not connected" + }); + return -1; + } + const family = method.split(".")[0]; + if (!hasCapability(family)) { + callback?.({ + "error": "daemon does not support " + family + }); + return -1; + } + + const id = _nextId(); + const message = { + "id": id, + "method": method, + "params": params || {} + }; + if (callback) + _pendingRequests[id] = callback; + requestSocket.send(message); + return id; + } + + function refreshDevices(callback) { + return request("device.list", {}, response => { + if (!response.error) + _applyDevices(response.result || []); + callback?.(response); + }); + } + + function launch() { + if (connected || requestSocket.linkUp || launchPending) + return; + launchPending = true; + launchDelayTimer.restart(); + } + + function _handleRequestLine(line) { + const message = _parse(line); + if (!message) + return; + if (_isHello(message)) { + _acceptHello(message); + return; + } + if (message.id === undefined) + return; + + const callback = _pendingRequests[message.id]; + if (!callback) + return; + delete _pendingRequests[message.id]; + callback(message); + } + + function _handleSubscriptionLine(line) { + const message = _parse(line); + if (!message) + return; + if (_isHello(message)) { + if (Number(message.apiVersion) !== 1) { + lastError = "Unsupported DankConnect subscription API version " + message.apiVersion; + return; + } + subscriptionSocket.send({ + "id": root.subscriptionRequestId, + "method": "subscribe", + "params": {} + }); + return; + } + if (message.id === root.subscriptionRequestId) { + subscribed = !message.error; + subscribedTopics = message.result?.topics || []; + if (message.error) + lastError = message.error; + return; + } + const topic = message.event || message.topic || ""; + if (!topic) + return; + if (topic === "devices") + _applyDevices(message.data || []); + eventReceived(topic, message.data); + } + + DankSocket { + id: requestSocket + + path: root.socketPath + connected: root.enabled && root.socketPath.length > 0 + reconnectBaseMs: 3000 + reconnectMaxMs: 3000 + + onConnectionStateChanged: { + if (linkUp) + return; + root.ready = false; + root.initialized = false; + root.subscribed = false; + root.subscribedTopics = []; + root.apiVersion = 0; + root.capabilities = []; + root._flushPending("daemon disconnected"); + } + + parser: SplitParser { + onRead: line => root._handleRequestLine(line) + } + } + + DankSocket { + id: subscriptionSocket + + path: root.socketPath + connected: root.enabled && root.ready + reconnectBaseMs: 3000 + reconnectMaxMs: 3000 + + onConnectionStateChanged: { + root.subscribed = false; + root.subscribedTopics = []; + } + + parser: SplitParser { + onRead: line => root._handleSubscriptionLine(line) + } + } + + Timer { + id: launchDelayTimer + + interval: 1000 + repeat: false + onTriggered: { + if (root.connected || requestSocket.linkUp) { + root.launchPending = false; + return; + } + Quickshell.execDetached(["sh", "-c", "if command -v dankconnect >/dev/null 2>&1; then exec dankconnect daemon; elif command -v flatpak >/dev/null 2>&1 && flatpak info com.danklinux.dankconnect >/dev/null 2>&1; then exec flatpak run --command=dankconnect com.danklinux.dankconnect daemon; fi"]); + launchResetTimer.restart(); + } + } + + Timer { + id: launchResetTimer + + interval: 5000 + repeat: false + onTriggered: root.launchPending = false + } +} diff --git a/quickshell/Services/DankConnectService.qml b/quickshell/Services/DankConnectService.qml new file mode 100644 index 000000000..918d2aadd --- /dev/null +++ b/quickshell/Services/DankConnectService.qml @@ -0,0 +1,172 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Singleton { + id: root + + readonly property bool connected: backend.connected && backend.subscribed + readonly property bool initialized: backend.initialized + readonly property int apiVersion: backend.apiVersion + readonly property var capabilities: backend.capabilities + readonly property var devices: backend.devices + readonly property var devicesById: backend.devicesById + readonly property string lastError: backend.lastError + readonly property var primaryDevice: _selectPrimaryDevice() + property var pairingRequest: null + property bool _openAppPending: false + + Component.onCompleted: backend.launch() + + onConnectedChanged: { + if (!connected) { + pairingRequest = null; + return; + } + if (_openAppPending) { + _openAppPending = false; + _launchAttachedApp(); + } + } + + function _selectPrimaryDevice() { + let selected = null; + let selectedScore = -1; + for (const device of devices) { + const score = (device.isPaired ? 2 : 0) + (device.isReachable ? 1 : 0); + if (score <= selectedScore) + continue; + selected = device; + selectedScore = score; + } + return selected; + } + + function _syncPairingRequest() { + for (const device of devices) { + if (!device.isPairRequestedByPeer) + continue; + pairingRequest = { + "deviceId": device.id, + "name": device.name || "", + "verificationKey": device.verificationKey || "" + }; + return; + } + if (initialized) + pairingRequest = null; + } + + function _handleEvent(topic, data) { + if (topic !== "pairing" || !data?.deviceId) + return; + pairingRequest = { + "deviceId": data.deviceId, + "name": data.name || "", + "verificationKey": data.verificationKey || "" + }; + } + + function supports(deviceOrId, packetType) { + return backend.supports(deviceOrId, packetType); + } + + function canRing(deviceOrId) { + const device = typeof deviceOrId === "string" ? backend.getDevice(deviceOrId) : deviceOrId; + return connected && !!device?.isPaired && !!device?.isReachable && backend.hasCapability("device") && supports(device, "kdeconnect.findmyphone.request"); + } + + function canPing(deviceOrId) { + const device = typeof deviceOrId === "string" ? backend.getDevice(deviceOrId) : deviceOrId; + return connected && !!device?.isPaired && !!device?.isReachable && backend.hasCapability("ping") && supports(device, "kdeconnect.ping"); + } + + function canSendClipboard(deviceOrId) { + const device = typeof deviceOrId === "string" ? backend.getDevice(deviceOrId) : deviceOrId; + return connected && !!device?.isPaired && !!device?.isReachable && backend.hasCapability("clipboard") && supports(device, "kdeconnect.clipboard"); + } + + function ringDevice(deviceId, callback) { + if (!canRing(deviceId)) { + callback?.({ + "error": "ring unavailable" + }); + return -1; + } + return backend.request("device.ring", { + "deviceId": deviceId + }, callback); + } + + function pingDevice(deviceId, callback) { + if (!canPing(deviceId)) { + callback?.({ + "error": "ping unavailable" + }); + return -1; + } + return backend.request("ping.send", { + "deviceId": deviceId + }, callback); + } + + function sendClipboard(deviceId, callback) { + if (!canSendClipboard(deviceId)) { + callback?.({ + "error": "clipboard unavailable" + }); + return -1; + } + return backend.request("clipboard.send", { + "deviceId": deviceId, + "content": "" + }, callback); + } + + function acceptPairing(deviceId, callback) { + return _respondToPairing("pairing.accept", deviceId, callback); + } + + function rejectPairing(deviceId, callback) { + return _respondToPairing("pairing.reject", deviceId, callback); + } + + function openApp() { + if (!connected) { + _openAppPending = true; + backend.launch(); + return; + } + _launchAttachedApp(); + } + + function _launchAttachedApp() { + Quickshell.execDetached(["sh", "-c", "if command -v dankconnect >/dev/null 2>&1; then exec dankconnect app; elif command -v flatpak >/dev/null 2>&1 && flatpak info com.danklinux.dankconnect >/dev/null 2>&1; then exec flatpak run --command=dankconnect com.danklinux.dankconnect app; fi"]); + } + + function _respondToPairing(method, deviceId, callback) { + if (!connected || !backend.hasCapability("pairing") || !deviceId) { + callback?.({ + "error": "pairing unavailable" + }); + return -1; + } + return backend.request(method, { + "deviceId": deviceId + }, response => { + if (!response.error && pairingRequest?.deviceId === deviceId) + pairingRequest = null; + callback?.(response); + }); + } + + DankConnectBackend { + id: backend + + enabled: true + onDevicesChanged: root._syncPairingRequest() + onEventReceived: (topic, data) => root._handleEvent(topic, data) + } +}