1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 11:38:30 -04:00

Compare commits

...

1 Commits

Author SHA1 Message Date
purian23 52068fb61b feat(dankdash): add native DankConnect integration 2026-07-28 18:20:23 -04:00
5 changed files with 678 additions and 1 deletions
+2
View File
@@ -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
@@ -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))
}
}
}
}
}
}
+11 -1
View File
@@ -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
}
}
}
+284
View File
@@ -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
}
}
+172
View File
@@ -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)
}
}