diff --git a/README.md b/README.md index f5ff7ed..9182a18 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,141 @@ -# Zerotier KDE Widget -## This Widget Shows Zerotier Network Members -#### I had to stop using Linux due to school and work. yes dual boot etc. I could continue to use Linux, but I don't have time to deal with it and using Linux is time-consuming and I don't have the time to spare for it right now. That's why I can't continue developing it, I want to write it from scratch in the future. because this code is really shitty. The more I look at the code, the more I want to delete it, so I can't continue developing it right now. -#### 28/02/2024 -## Features (V1.1) -- Show online member count on icon -- Click to copy zerotier ip -- Updates members status on intervals -- Multiple network selection window -- Only show online members option - -## TODO (V2.0) -- [ ] Sorting system [WIP] -- [x] Select all networks -- [x] Context menu on icon -- [ ] Content auto windows height -- [ ] Change emojis to png (emoji system on kde is suck) -- [ ] Network selector theme fix -- [ ] Update interval 30s,1min+++ -- [ ] Sometimes after reboot have duplicate list (idk why but it gets fixed when updates the list) (BUG) - -## TODO (v3.0) -- [ ] Managing (create-update network, member operations,etc,all available API features) -- [ ] Network specific icon color -- [ ] Notification (online-offline,copy) +# Zerotier KDE Widget + +A KDE Plasma 6 widget that shows the Zerotier networks this machine joined and +the peers it is connected to. + +## How it gets its data + +The widget talks to the **local `zerotier-one` service** on +`http://localhost:9993`, not to Zerotier Central. Central's API needs an account +token that is not available on the free plan, while the local service is part of +the daemon you already run. + +That trade is worth knowing about: + +| | Zerotier Central API | Local service API (used here) | +|---|---|---| +| Cost | Paid plan | Free, no account involved | +| Scope | Every member of a network, from anywhere | What this machine sees | +| Member names | Yes, from Central | No -- node ids, plus your own labels | +| Member addresses | Yes, from the controller | Recovered from the neighbour table | + +So the widget lists your joined networks and the peers currently reachable, not +the full membership roster of a network. + +## Setup + +The daemon's auth token is only readable by root. Give your user account a copy: + +```bash +sudo install -m 600 -o $USER \ + /var/lib/zerotier-one/authtoken.secret ~/.zerotierOneAuthToken +``` + +The widget finds that file on its own -- there is nothing to paste into the +settings. If you keep the token somewhere else, paste it into the Auth token +field in the widget's settings instead. + +## Features + +- Connected peer count on the panel icon, as a theme-coloured symbolic icon +- Joined networks with their status and your assigned address +- Peers with their address on the virtual network, latency, and whether the + link is direct or relayed +- Click any row to copy the address you would actually connect to +- Your own labels for peers, since the local service only knows node ids +- Filter down to specific networks, or hide peers that are not reachable +- Refresh interval from 2 seconds upwards, plus a Refresh action in the + context menu + +### How a peer's address is worked out + +The address to connect to is the one on the virtual network -- `10.x.y.z`, not +the peer's public IP, which is only how the packets happen to travel today. + +The local service does not know it: which address a network handed to which +member is the controller's business. The kernel does know, once a peer has been +talked to, and Zerotier derives a member's MAC deterministically from the +network id and the node id: + +``` +MAC[0] = (last byte of nwid & 0xfe) | 0x02 +MAC[i] = node[i-1] XOR nwid[7-i] for i = 1..5 +``` + +So the widget computes the MAC each peer must have, then looks it up in the +neighbour table of the network's interface. A peer that has never exchanged IP +traffic has no entry yet and shows up without an address until something -- a +ping is enough -- puts it there. ## Installation -There are three ways to install this widget in your KDE Plasma. -1. Head over to the Plasma Add-On installer by going to: `Right click on Desktop,Dock or Panel -> Add Widgets -> Get New Widgets -> Search "Zerotier KDE Widget" and Install`. -2. Download the `zerotier.plasmoid` file shared in this repo's [release section](https://github.com/Duoslow/zerotierIndicator/releases/latest) or from the widget's KDE Store [link](https://store.kde.org/p/1666827). After this, you can just do this: `Right Click on Desktop -> Add Widgets -> Install from local file -> Point to the downloaded zerotier.plasmoid file`. -3. Download the `zerotier.plasmoid` file shared in this repo's [release section](https://github.com/Duoslow/zerotierIndicator/releases/latest) and run this ` -kpackagetool5 -t Plasma/Applet --install zerotier.plasmoid ` +1. From the KDE Store: `Right click on the desktop, dock or panel -> Add + Widgets -> Get New Widgets -> search for "Zerotier Indicator"`. +2. From the `zerotier.plasmoid` file in this repo's + [releases](https://github.com/Hafikan/zerotierIndicator/releases/latest): + `Right click on the desktop -> Add Widgets -> Install from local file`. +3. From a checkout: + +```bash +./build.sh +kpackagetool6 -t Plasma/Applet --install zerotier.plasmoid # or --upgrade +``` + +## Development + +### Layout + +| File | Role | +|---|---| +| `ui/main.qml` | The applet: panel icon, popup, refresh timers | +| `ui/zerotier.js` | Service API calls, online rules, MAC derivation | +| `ui/AuthToken.qml` | Finds the auth token | +| `ui/ShellCommand.qml` | Runs a command and returns its stdout | +| `ui/ConfigGeneral.qml` | Settings: interval, networks, token | +| `ui/ConfigPeers.qml` | Settings: peer labels | + +### Two things that will bite you + +**Qt 6 refuses to read local files over `XMLHttpRequest`** unless the process +was started with `QML_XHR_ALLOW_FILE_READ=1`, and it fails *silently* -- no +error, no `onerror`, the request simply never completes. That is why the auth +token is read through the executable data engine instead. The same trap applies +to anything else you might want to read off disk from QML. + +**`console.log` output can be swallowed** depending on how the applet is +launched, which makes the above even harder to spot. Exercising a code path +under `qml6` and signalling results through `Qt.exit(code)` is a reliable way to +debug it: + +```bash +QT_QPA_PLATFORM=offscreen qml6 test.qml; echo "exit=$?" +``` + +### Building + +`./build.sh` packs `package/` into `zerotier.plasmoid`. To try a change without +touching the panel: + +```bash +plasmawindowed org.github.hafikan.zerotierIndicator +``` + +Publishing an update: bump `Version` in `package/metadata.json`, run +`./build.sh`, and upload the new `zerotier.plasmoid` to the store listing. The +store compares that version string to decide who gets an update notification. + +## Version history + +- **v2.0** -- Ported to Plasma 6, rebuilt on the local service API, new flat UI +- **v1.1** -- Zerotier Central API, Plasma 5 + +## Credits + +Originally written by [Duoslow](https://github.com/Duoslow/zerotierIndicator) +(Uğur Yavaş) for Plasma 5 and the Zerotier Central API. This fork carries the +Plasma 6 port and the move to the local service API, and is published under a +separate plugin id so both can be installed side by side. -## Widget GUI and Indicator -![ex](https://i.imgur.com/MYQDika.png)![a](https://i.imgur.com/y92VmYu.png) +## License -## Settings -![settings](https://i.imgur.com/Owxf7E2.png) +MIT -- see [LICENSE](LICENSE). The original copyright notice is retained. diff --git a/build.sh b/build.sh index 14e36cd..17fb050 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,24 @@ #!/bin/bash -cd package -zip -r ../zerotier.plasmoid * -cd .. \ No newline at end of file +set -e +cd "$(dirname "$0")" + +# Rebuilt from scratch: zip -r would otherwise merge into the existing archive +# and keep files that no longer exist in package/. +rm -f zerotier.plasmoid + +if command -v zip >/dev/null 2>&1; then + (cd package && zip -qr ../zerotier.plasmoid .) +else + # zip is not installed everywhere; python ships a zip writer. + python3 -c ' +import os, zipfile +with zipfile.ZipFile("zerotier.plasmoid", "w", zipfile.ZIP_DEFLATED) as archive: + for folder, _, files in os.walk("package"): + for name in files: + path = os.path.join(folder, name) + archive.write(path, os.path.relpath(path, "package")) +' +fi + +echo "Built zerotier.plasmoid" +echo "Install with: kpackagetool6 -t Plasma/Applet --install zerotier.plasmoid" diff --git a/package/contents/config/config.qml b/package/contents/config/config.qml index 774960b..fbaedea 100644 --- a/package/contents/config/config.qml +++ b/package/contents/config/config.qml @@ -1,10 +1,15 @@ -import QtQuick 2.0 -import org.kde.plasma.configuration 2.0 +import QtQuick +import org.kde.plasma.configuration ConfigModel { - ConfigCategory { - name: "General" - icon: "configure" - source: "ConfigGeneral.qml" - } -} \ No newline at end of file + ConfigCategory { + name: "General" + icon: "configure" + source: "ConfigGeneral.qml" + } + ConfigCategory { + name: "Peer Names" + icon: "tag" + source: "ConfigPeers.qml" + } +} diff --git a/package/contents/config/main.xml b/package/contents/config/main.xml index 0078cdb..ad8b927 100644 --- a/package/contents/config/main.xml +++ b/package/contents/config/main.xml @@ -2,19 +2,27 @@ - - 1 - 1 - 999 + + + 10 + 2 + 3600 - + + + - + false + + + {} + diff --git a/package/contents/images/zerotier-symbolic.svg b/package/contents/images/zerotier-symbolic.svg new file mode 100644 index 0000000..4cbc15d --- /dev/null +++ b/package/contents/images/zerotier-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/package/contents/ui/AuthToken.qml b/package/contents/ui/AuthToken.qml new file mode 100644 index 0000000..5056a5b --- /dev/null +++ b/package/contents/ui/AuthToken.qml @@ -0,0 +1,66 @@ +import QtQuick +import org.kde.plasma.plasma5support as P5Support + +/** + * Finds the zerotier-one auth token. + * + * Qt 6 refuses to read local files over XMLHttpRequest unless the process was + * started with QML_XHR_ALLOW_FILE_READ=1 -- and it fails silently, the request + * simply never completes. We cannot set that for plasmashell, so the file is + * read through the executable data engine instead. + */ +Item { + id: reader + + /** An explicitly configured token wins over anything on disk. */ + property string configuredToken: "" + + readonly property alias token: internal.token + readonly property alias source: internal.source + readonly property alias resolved: internal.resolved + + // The daemon's own file is root only, so the per-user copy is normally the + // one that works. Echoing the path first tells the UI where it came from. + readonly property string command: + 'for f in "$HOME/.zerotierOneAuthToken" /var/lib/zerotier-one/authtoken.secret; do' + + ' [ -r "$f" ] && { echo "$f"; cat "$f"; break; }; done' + + function resolve() { + if (configuredToken.length > 0) { + internal.token = configuredToken; + internal.source = "widget settings"; + internal.resolved = true; + return; + } + internal.resolved = false; + executable.connectSource(command); + } + + QtObject { + id: internal + property string token: "" + property string source: "" + property bool resolved: false + } + + P5Support.DataSource { + id: executable + engine: "executable" + connectedSources: [] + + onNewData: function (sourceName, data) { + disconnectSource(sourceName); + + const lines = String(data["stdout"] || "").split("\n").filter(function (line) { + return line.trim().length > 0; + }); + + internal.source = lines.length > 0 ? lines[0].trim() : ""; + internal.token = lines.length > 1 ? lines[1].trim() : ""; + internal.resolved = true; + } + } + + Component.onCompleted: resolve() + onConfiguredTokenChanged: resolve() +} diff --git a/package/contents/ui/ConfigGeneral.qml b/package/contents/ui/ConfigGeneral.qml index 6fafbb0..7eac7ce 100644 --- a/package/contents/ui/ConfigGeneral.qml +++ b/package/contents/ui/ConfigGeneral.qml @@ -1,131 +1,259 @@ -import QtQuick 2.0 -import QtQuick.Window 2.2 -import QtQuick.Controls 1.4 -import QtQuick.Layouts 1.0 -import org.kde.plasma.core 2.0 as PlasmaCore -import org.kde.plasma.components 2.0 as PlasmaComponents -import org.kde.plasma.extras 2.0 as PlasmaExtras - -Item { - id: generalSettings - property alias cfg_updateInterval: updateTime.value - property alias cfg_zerotierToken: ztoken.text - property string cfg_zerotierToken: "" - property string selectednetwork: plasmoid.configuration.selectednetwork - +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.kirigami as Kirigami +import org.kde.kcmutils as KCM +import "zerotier.js" as ZeroTier + +KCM.SimpleKCM { + id: page + + property alias cfg_refreshSeconds: refreshInterval.value + property alias cfg_showOnlyConnected: onlyConnected.checked + property alias cfg_authToken: tokenField.text + // Not an alias: the checkboxes below edit it, and it is saved with the + // rest of the page when the user hits Apply. + property string cfg_selectednetwork: "" + + readonly property string authToken: tokenReader.token + readonly property string authTokenSource: tokenReader.source + readonly property bool authTokenResolved: tokenReader.resolved + property string networkError: "" + property bool networksLoading: false + + AuthToken { + id: tokenReader + configuredToken: tokenField.text + onResolvedChanged: if (resolved) page.loadNetworks() + onTokenChanged: page.loadNetworks() + } + + function resolveToken() { + tokenReader.resolve(); + } + + function loadNetworks() { + if (authToken.length === 0) { + networkModel.clear(); + return; + } + networksLoading = true; + networkError = ""; + ZeroTier.request(authToken, "/network", function (networks) { + // Rebuilt from scratch so a second refresh cannot duplicate rows. + networkModel.clear(); + for (const network of networks) { + networkModel.append({ + networkId: network.id, + networkName: ZeroTier.networkName(network), + address: ZeroTier.networkAddress(network), + statusText: ZeroTier.networkStatusText(network) + }); + } + networksLoading = false; + }, function (error) { + networkModel.clear(); + networkError = error.message; + networksLoading = false; + }); + } + + function isSelected(networkId) { + return ZeroTier.parseNetworkList(cfg_selectednetwork).indexOf(networkId) >= 0; + } + + function setSelected(networkId, selected) { + const list = ZeroTier.parseNetworkList(cfg_selectednetwork).filter(function (id) { + return id !== networkId; + }); + if (selected) { + list.push(networkId); + } + cfg_selectednetwork = list.join(","); + } + + ListModel { + id: networkModel + } ColumnLayout { - anchors.right: parent.right - anchors.left: parent.left + spacing: Kirigami.Units.largeSpacing - RowLayout { - Label { - text: "Zerotier Access Token" + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: page.authTokenResolved && page.authToken.length === 0 + type: Kirigami.MessageType.Warning + text: "Cannot read the zerotier-one auth token. Give your user account a copy:\n" + + "sudo install -m 600 -o $USER /var/lib/zerotier-one/authtoken.secret ~/.zerotierOneAuthToken" + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: page.authToken.length > 0 + type: Kirigami.MessageType.Positive + text: "Reading the local zerotier-one service, token from " + page.authTokenSource + "." + } + + Kirigami.FormLayout { + Layout.fillWidth: true + + QQC2.SpinBox { + id: refreshInterval + Kirigami.FormData.label: "Refresh every:" + from: 2 + to: 3600 + stepSize: 5 + textFromValue: function (value) { return value + " s"; } + valueFromText: function (text) { return parseInt(text, 10); } } - TextField { - id: ztoken - placeholderText: "Enter Token Here" - focus:true - Layout.preferredWidth: 250 - onTextChanged:{ - netbutton.enabled = true - plasmoid.configuration.zerotierToken = ztoken.text - } + + QQC2.CheckBox { + id: onlyConnected + Kirigami.FormData.label: "Peers:" + text: "Show connected peers only" } - Button{ - text:"Get Here!" - onClicked:{ - Qt.openUrlExternally("https://my.zerotier.com/account") + + RowLayout { + Kirigami.FormData.label: "Auth token:" + spacing: Kirigami.Units.smallSpacing + + QQC2.TextField { + id: tokenField + Layout.preferredWidth: Kirigami.Units.gridUnit * 16 + placeholderText: "Detected automatically" + echoMode: TextInput.Password + } + + QQC2.Button { + text: "Reload" + icon.name: "view-refresh" + onClicked: page.resolveToken() } } - } - RowLayout{ - Label{ - text:"Current Networks " + + QQC2.Label { + text: "Only needed when the token file cannot be read." + font: Kirigami.Theme.smallFont + opacity: 0.7 + } + + Item { + Kirigami.FormData.isSection: true } - TextField { - id: znid - enabled:false - placeholderText: "There is currently no network id" - textColor:"white" - Layout.preferredWidth: 350 - } } + RowLayout { - Label { - text: "Network Selection " - } - Button { - id:"netbutton" - text:"Select Network" - enabled:false - onClicked:{ - var component = Qt.createComponent("configChild.qml") - if( component.status != Component.Ready ) - { - if( component.status == Component.Error ) - console.debug("Error:"+ component.errorString() ); - return; // or maybe throw - } - var window = component.createObject(root) - window.show() - - } + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Kirigami.Heading { + level: 4 + text: "Networks" } - // Button { #TODO:select all function - // id:"selectall" - // text:"Select All" - // enabled:false - // onClicked:{ - // console.log("Select all") - // } - // } - - } - RowLayout{ - Label{ - text: "Show Only Online Members" + + QQC2.Label { + Layout.fillWidth: true + text: page.cfg_selectednetwork.length === 0 ? "showing all joined networks" : "" + font: Kirigami.Theme.smallFont + opacity: 0.7 + elide: Text.ElideRight + } + + QQC2.BusyIndicator { + visible: page.networksLoading + running: visible + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small } - CheckBox{ - id: showonly - checked: plasmoid.configuration.show - onCheckedChanged: plasmoid.configuration.show = checked + + QQC2.Button { + text: "Show all" + flat: true + enabled: page.cfg_selectednetwork.length > 0 + onClicked: page.cfg_selectednetwork = "" } - // Button{ //TEST BUTTON - // // property string myString: plasmoid.configuration.selectednetwork - // // property variant stringList: myString.split(',') - // text:"test" - // onClicked:{ - // console.log(plasmoid.configuration.show) - // // for(let data of stringList){ - // // console.log("hehe: ",data) - // // } - // // console.log(stringList[0]); - // } - // } } - - RowLayout { - Label { - text: "Update every" - } - SpinBox { - id: updateTime - minimumValue: 1 - stepSize: 1 - maximumValue: 60 - suffix: "min" - Layout.preferredWidth: 100 - } - } - - + QQC2.Frame { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 12 + + ListView { + id: networkList + anchors.fill: parent + model: networkModel + clip: true + currentIndex: -1 + + delegate: QQC2.CheckDelegate { + width: networkList.width + // With nothing selected every network is shown, so every + // box reads as ticked until the user narrows it down. + checked: page.cfg_selectednetwork.length === 0 || page.isSelected(model.networkId) + + onToggled: { + if (page.cfg_selectednetwork.length === 0) { + // First tick turns "all" into an explicit list of + // everything except the row just unticked. + const all = []; + for (let i = 0; i < networkModel.count; i++) { + const id = networkModel.get(i).networkId; + if (id !== model.networkId) { + all.push(id); + } + } + page.cfg_selectednetwork = all.join(","); + } else { + page.setSelected(model.networkId, checked); + } + // Clicking breaks the declarative binding above; put it + // back so the buttons keep updating this row. + checked = Qt.binding(function () { + return page.cfg_selectednetwork.length === 0 || page.isSelected(model.networkId); + }); + } + + contentItem: ColumnLayout { + spacing: 0 + + QQC2.Label { + Layout.fillWidth: true + text: model.networkName + elide: Text.ElideRight + textFormat: Text.PlainText + } + + QQC2.Label { + Layout.fillWidth: true + text: model.networkId + " · " + + (model.address !== "" ? model.address : model.statusText) + elide: Text.ElideRight + textFormat: Text.PlainText + font: Kirigami.Theme.smallFont + opacity: 0.7 + } + } + } + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 4 + visible: networkModel.count === 0 && !page.networksLoading + + icon.name: page.networkError !== "" ? "dialog-warning" : "network-server" + text: { + if (page.networkError !== "") { + return page.networkError; + } + if (page.authToken.length === 0) { + return "No auth token"; + } + return "No networks joined"; + } + explanation: page.authToken.length > 0 && page.networkError === "" + ? "zerotier-cli join " : "" + } + } + } } - onSelectednetworkChanged:{ - console.log("selected networks changed"); - znid.text = plasmoid.configuration.selectednetwork - } - - } diff --git a/package/contents/ui/ConfigPeers.qml b/package/contents/ui/ConfigPeers.qml new file mode 100644 index 0000000..f6149a1 --- /dev/null +++ b/package/contents/ui/ConfigPeers.qml @@ -0,0 +1,180 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.kirigami as Kirigami +import org.kde.kcmutils as KCM +import "zerotier.js" as ZeroTier + +// The local service only knows a peer's 10 digit node id -- member names live +// in Zerotier Central, which needs a paid API token. This page lets the user +// attach their own labels instead. +KCM.SimpleKCM { + id: page + + // Not an alias: edited row by row below, saved with the page on Apply. + property string cfg_peerNames: "{}" + + readonly property string authToken: tokenReader.token + property string peerError: "" + property bool peersLoading: false + + AuthToken { + id: tokenReader + configuredToken: Plasmoid.configuration.authToken + onResolvedChanged: { + if (!resolved) { + return; + } + if (token.length > 0) { + page.loadPeers(); + } else { + page.peerError = "Cannot read the zerotier-one auth token"; + } + } + } + + function labelFor(nodeId) { + const names = ZeroTier.parsePeerNames(cfg_peerNames); + return names[nodeId] !== undefined ? names[nodeId] : ""; + } + + function setLabel(nodeId, label) { + const names = ZeroTier.parsePeerNames(cfg_peerNames); + if (label.length > 0) { + names[nodeId] = label; + } else { + delete names[nodeId]; + } + cfg_peerNames = JSON.stringify(names); + } + + function loadPeers() { + peersLoading = true; + peerError = ""; + ZeroTier.request(authToken, "/peer", function (peers) { + // Rebuilt from scratch so a second refresh cannot duplicate rows. + peerModel.clear(); + const devices = peers.filter(ZeroTier.isDevicePeer); + devices.sort(function (a, b) { + return a.address.localeCompare(b.address); + }); + for (const peer of devices) { + peerModel.append({ + nodeId: peer.address, + connected: ZeroTier.peerIsConnected(peer), + details: ZeroTier.peerLatencyText(peer) + + (peer.version ? " · v" + peer.version : "") + }); + } + peersLoading = false; + }, function (error) { + peerModel.clear(); + peerError = error.message; + peersLoading = false; + }); + } + + ListModel { + id: peerModel + } + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + QQC2.Label { + Layout.fillWidth: true + text: "Give the peers on your networks a readable name." + wrapMode: Text.Wrap + } + + QQC2.BusyIndicator { + visible: page.peersLoading + running: visible + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + } + + QQC2.Button { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + text: "Refresh" + flat: true + enabled: page.authToken.length > 0 && !page.peersLoading + onClicked: page.loadPeers() + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + QQC2.Frame { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 18 + + ListView { + id: peerList + anchors.fill: parent + model: peerModel + clip: true + spacing: Kirigami.Units.smallSpacing + + delegate: RowLayout { + width: peerList.width + spacing: Kirigami.Units.largeSpacing + + Rectangle { + Layout.leftMargin: Kirigami.Units.smallSpacing + implicitWidth: Kirigami.Units.smallSpacing * 1.5 + implicitHeight: implicitWidth + radius: width / 2 + color: model.connected ? Kirigami.Theme.positiveTextColor + : Kirigami.Theme.disabledTextColor + } + + ColumnLayout { + spacing: 0 + + QQC2.Label { + text: model.nodeId + font.family: "monospace" + textFormat: Text.PlainText + } + + QQC2.Label { + text: model.details + font: Kirigami.Theme.smallFont + opacity: 0.7 + textFormat: Text.PlainText + } + } + + QQC2.TextField { + Layout.fillWidth: true + Layout.rightMargin: Kirigami.Units.smallSpacing + placeholderText: "Name for this peer" + text: page.labelFor(model.nodeId) + onEditingFinished: page.setLabel(model.nodeId, text) + } + } + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 4 + visible: peerModel.count === 0 && !page.peersLoading + + icon.name: page.peerError !== "" ? "dialog-warning" : "network-connect" + text: page.peerError !== "" ? page.peerError : "No peers seen yet" + explanation: page.peerError === "" + ? "Peers appear once another device on your networks comes online." + : "" + } + } + } + } +} diff --git a/package/contents/ui/ShellCommand.qml b/package/contents/ui/ShellCommand.qml new file mode 100644 index 0000000..544baac --- /dev/null +++ b/package/contents/ui/ShellCommand.qml @@ -0,0 +1,31 @@ +import QtQuick +import org.kde.plasma.plasma5support as P5Support + +/** Runs a shell command and hands its stdout to a callback. */ +Item { + id: runner + + property string command: "" + /** Set before calling run(); invoked with the command's stdout. */ + property var callback: null + + function run() { + if (command.length === 0) { + return; + } + executable.connectSource(command); + } + + P5Support.DataSource { + id: executable + engine: "executable" + connectedSources: [] + + onNewData: function (sourceName, data) { + disconnectSource(sourceName); + if (runner.callback) { + runner.callback(String(data["stdout"] || "")); + } + } + } +} diff --git a/package/contents/ui/configChild.qml b/package/contents/ui/configChild.qml deleted file mode 100644 index 7a58049..0000000 --- a/package/contents/ui/configChild.qml +++ /dev/null @@ -1,200 +0,0 @@ -import QtQuick 2.9 -import QtQuick.Window 2.2 -import QtQuick.Controls 2.2 -import QtQuick.Layouts 1.0 -import org.kde.plasma.core 2.0 as PlasmaCore -import org.kde.plasma.components 2.0 as PlasmaComponents -import org.kde.plasma.extras 2.0 as PlasmaExtras -ApplicationWindow{ - id:root - width:400;height:400 - title:"Network Selector" - property string zerotierToken: plasmoid.configuration.zerotierToken - property string selected_network: plasmoid.configuration.selectednetwork - RowLayout { - anchors.fill: parent - height: parent.height - width: 1500 - ListModel { - id: zmodel - function requestUrl(method, url, options, callback) { - let xhr = new XMLHttpRequest(); - xhr.open(method, url, true); - xhr.onload = function (e) { - console.log(xhr.status); - // console.log(xhr.responseText); - if (xhr.status == 200) { - let body = JSON.parse(xhr.responseText); - callback(body); - } - else { - console.log("Failed to execure the request: status code is not 200"); - } - } - xhr.onerror = function(e) { - console.log("Error executing the request: network error"); - retryConnection.restart(); - } - if (options.responseType) xhr.responseType = options.responseType; - if (options.headers) { - let headers = Object.keys(options.headers); - for (let i = 0; i < headers.length; i++) { - xhr.setRequestHeader(headers[i], options.headers[headers[i]]); - } - } - xhr.send(options.postData ? options.postData : undefined); - } - - function zeroRequest(endpoint, callback) { - if (!zerotierToken){ - console.log("no token provided") - return; - } - requestUrl("GET", "https://my.zerotier.com/api/v1/"+endpoint, { - responseType: "json", - headers: { - "Authorization": "Bearer "+zerotierToken - } - }, callback); - } - - function getnetworks(){ - zeroRequest("network", function(res) { - for (let dat of res) - { - zmodel.append({ - n_id:dat.id, - n_name:dat.config.name, - n_count:dat.totalMemberCount, - n_stat: (selected_network.split(',').indexOf(dat.id) >= 0) ? "✅" : "❌" - }) - } - // console.log(res[0].id) - }); - } - } - Timer { - id: retryConnection - interval: 30000 - repeat: false - running: false - onTriggered: zmodel.getnetworks() - } - - ListView { - - id: zlist - anchors.fill: parent - model: zmodel - - function data_adder(a1,a2){ - // console.log("Selected: "+a1) - // console.log("a2 data= ",a2) - if(!plasmoid.configuration.selectednetwork){ - // console.log("new data") - plasmoid.configuration.selectednetwork = a1 - zmodel.setProperty(a2,"n_stat","✅") - return "Added" - }else if(selected_network.split(',').indexOf(a1)== -1){ - // console.log("data not found") - plasmoid.configuration.selectednetwork = a1 + "," + plasmoid.configuration.selectednetwork - // console.log("new data = ",plasmoid.configuration.selectednetwork ) - zmodel.setProperty(a2,"n_stat","✅") - return "Added" - }else{ - // console.log("data found") - return "Already Have" - // console.log("data = ",plasmoid.configuration.selectednetwork ) - } - } - function removeValue(mlist, value) { - return mlist.replace(new RegExp(",?" + value + ",?"), function(match) { - var first_comma = match.charAt(0) === ',', - second_comma; - - if (first_comma && - (second_comma = match.charAt(match.length - 1) === ',')) { - return ','; - } - return ''; - }); - } - function data_remover(a1,a2){ - console.log("Removed",a1) - plasmoid.configuration.selectednetwork = removeValue(plasmoid.configuration.selectednetwork,a1) - zmodel.setProperty(a2,"n_stat","❌") - return "Removed" - } - - delegate: Component { - Item { - width: parent.width - height: 90 - Column { - padding: 8 - Text { text: 'Name: ' + n_name +"" } - Text { text: 'ID: ' + n_id } - Text { text: 'Member Count: ' + n_count } - Text { text: 'Selected: ' + n_stat } - - } - MouseArea { - anchors.fill: parent - onClicked: zlist.currentIndex = index - } - } - } - highlight: Rectangle { - color: 'grey' - // Text { - // anchors.centerIn: parent - // text: ' Selected ' //+ zmodel.get(zlist.currentIndex).name - // color: 'white' - // font.pointSize :10 - // } - } - focus: true - // onCurrentItemChanged: test(zmodel.get(zlist.currentIndex).n_id) - - } - Timer { - id: timer - function setTimeout(cb, delayTime) { - timer.interval = delayTime; - timer.repeat = false; - timer.triggered.connect(cb); - timer.triggered.connect(function release () { - timer.triggered.disconnect(cb); - timer.triggered.disconnect(release); - }); - timer.start(); - } - } - RowLayout{ - anchors.right: parent.right - anchors.bottom: parent.bottom - Button{ - id:"bt_add" - text:"Add" - onClicked:{ - bt_add.text = zlist.data_adder(zmodel.get(zlist.currentIndex).n_id , zlist.currentIndex); - timer.setTimeout(function(){ bt_add.text = "Add"; }, 1000); - } - } - Button{ - anchors.left:bt_add.right - id:"bt_remove" - text:"Remove" - onClicked:{ - bt_remove.text = zlist.data_remover(zmodel.get(zlist.currentIndex).n_id , zlist.currentIndex) - timer.setTimeout(function(){ bt_remove.text = "Remove"; }, 1000); - } - } - } - - } - Component.onCompleted: { - console.log(zerotierToken); - zmodel.getnetworks(); - } -} diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml index 662c45c..5b0cdad 100644 --- a/package/contents/ui/main.qml +++ b/package/contents/ui/main.qml @@ -1,317 +1,509 @@ -import QtQuick 2.0 -import QtQuick.Layouts 1.1 -import QtQuick.Window 2.1 -import QtGraphicalEffects 1.0 -import QtQuick.Controls 2.0 -import org.kde.plasma.plasmoid 2.0 -import org.kde.plasma.core 2.0 as PlasmaCore -import org.kde.plasma.components 2.0 as PlasmaComponents -import org.kde.plasma.components 3.0 as PlasmaComponents3 -import org.kde.plasma.extras 2.0 as PlasmaExtras - -Item { +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components as PlasmaComponents3 +import org.kde.plasma.extras as PlasmaExtras +import org.kde.kirigami as Kirigami +import "zerotier.js" as ZeroTier + +PlasmoidItem { id: root - Plasmoid.switchWidth: units.gridUnit * 10 - Plasmoid.switchHeight: units.gridUnit * 5 - property int updateInterval: plasmoid.configuration.updateInterval - property string zerotierToken: plasmoid.configuration.zerotierToken - property string selectednetwork : plasmoid.configuration.selectednetwork - property bool shownetworks : plasmoid.configuration.show - property string isonline: "" - property int onlinecount: 0 - function requestUrl(method, url, options, callback) { - let xhr = new XMLHttpRequest(); - xhr.open(method, url, true); - xhr.onload = function (e) { - console.log(xhr.status); - // console.log(xhr.responseText); - if (xhr.status == 200) { - let body = JSON.parse(xhr.responseText); - callback(body); - } - else { - console.log("Failed to execure the request: status code is not 200"); - } - } - xhr.onerror = function(e) { - console.log("Error executing the request: network error"); - retryConnection.restart(); - } - if (options.responseType) xhr.responseType = options.responseType; - if (options.headers) { - let headers = Object.keys(options.headers); - for (let i = 0; i < headers.length; i++) { - xhr.setRequestHeader(headers[i], options.headers[headers[i]]); - } - } - xhr.send(options.postData ? options.postData : undefined); - } - function zeroRequest(endpoint, callback) { - if (!zerotierToken){ - firstretry.restart(); - console.log("zerotierToken is null") - return; + readonly property string configuredToken: Plasmoid.configuration.authToken + readonly property string selectedNetworks: Plasmoid.configuration.selectednetwork + readonly property bool onlyConnected: Plasmoid.configuration.showOnlyConnected + readonly property int refreshSeconds: Plasmoid.configuration.refreshSeconds + readonly property string peerNames: Plasmoid.configuration.peerNames + + // Resolved from the token file at startup; empty means we cannot talk to + // the local service at all. + readonly property string authToken: tokenReader.token + readonly property string authTokenSource: tokenReader.source + readonly property bool authTokenResolved: tokenReader.resolved + + property int networkCount: 0 + property int peerCount: 0 + property int connectedPeerCount: 0 + property string lastError: "" + property bool loading: false + + switchWidth: Kirigami.Units.gridUnit * 12 + switchHeight: Kirigami.Units.gridUnit * 10 + preferredRepresentation: compactRepresentation + + toolTipMainText: "Zerotier" + toolTipSubText: { + if (lastError !== "") { + return lastError; } - if(!selectednetwork){ - firstretry.restart(); - console.log("selectednetwork is null") - return; + if (!authTokenResolved) { + return "Looking for the zerotier-one auth token..."; } - requestUrl("GET", "https://my.zerotier.com/api/v1/"+endpoint, { - responseType: "json", - headers: { - "Authorization": "Bearer "+zerotierToken - } - }, callback); + return connectedPeerCount + " of " + peerCount + " peers connected on " + + networkCount + (networkCount === 1 ? " network" : " networks"); + } + + Plasmoid.contextualActions: [ + PlasmaCore.Action { + text: "Refresh Now" + icon.name: "view-refresh" + onTriggered: zerotierModel.reload() + } + ] + + AuthToken { + id: tokenReader + configuredToken: root.configuredToken + // Both the token arriving and it turning out to be unreadable are + // reasons to rebuild the list. + onTokenChanged: zerotierModel.reload() + onResolvedChanged: if (resolved) zerotierModel.reload() + } + + function resolveToken() { + tokenReader.resolve(); + } + + // Tells us which managed address belongs to which peer; see zerotier.js. + ShellCommand { + id: neighbourQuery + command: "ip -4 neigh show" } ListModel { id: zerotierModel - property variant networklist: plasmoid.configuration.selectednetwork.split(',') - function updateData() { - // console.log("Starting update"); - zerotierModel.clear(); - onlinecount=0 - for(let netw of networklist){ - console.log("network id : ",netw) - zeroRequest("network/"+netw, function(resa) { - console.log("NETWORK NAME: ",resa.config.name) - console.log("NETWORK ONLINE: ",resa.onlineMemberCount) - onlinecount = onlinecount + resa.onlineMemberCount - console.log("NETWORK ONLINE COUNT:",onlinecount) - zeroRequest("network/"+netw+"/member", function(res) { - for (let dat of res) { - // Subtract timestamps to determine how long ago member was seen and convert to seconds - var lastseen = (dat.clock - dat.lastOnline)/1000 - // console.log("MEMBER LAST SEEN: ", lastseen + " seconds ago" ) - // If member has not been seen for more than 120 seconds, consider offline. - if(lastseen < 120){ - dat.online = true - }else{ - dat.online = false - } - if (plasmoid.configuration.show){ - if(!dat.online){ - continue;} - } - if(dat.online == true){ - isonline = "✅" - }else{ - isonline = "❌" - } - zerotierModel.append({ - id:dat.id, - name:dat.name, - online:isonline, - n_name:resa.config.name, - ipAssignments:dat.config.ipAssignments[0] - }) - - } - }); - - }); + // Bumped on every reload. Replies carrying an older generation belong + // to a run whose model was already cleared, so appending them would + // duplicate the list -- they are dropped instead. + property int generation: 0 + + function reload() { + generation++; + const gen = generation; + + if (root.authToken.length === 0) { + clear(); + root.loading = false; + root.networkCount = 0; + root.peerCount = 0; + root.connectedPeerCount = 0; + root.lastError = root.authTokenResolved ? "Cannot read the zerotier-one auth token" : ""; + return; } - console.log("COUNT",onlinecount) - // onlinecount = res[0].onlineMemberCount #TODO: member count all - // console.log(onlinecount) - // console.log(res[0].id) - // console.log(res[0].config.name) - - - - } - - } - - Plasmoid.compactRepresentation: MouseArea { - Layout.preferredWidth: intRow.implicitWidth - Layout.minimumWidth: intRow.implicitWidth - Layout.preferredHeight: 32 - onClicked: plasmoid.expanded = !plasmoid.expanded; - - Row { - id: intRow - anchors.fill: parent - spacing: 4 - anchors.margins: units.gridUnit*0.2 - - Image { - id: mainIcon - anchors.top: parent.top - anchors.bottom: parent.bottom - width: height - source: "../images/logo.png" - opacity: (onlinecount==0) ? 0.4 : 0.8 + + root.loading = true; + + // Both lists are fetched at once and the model is rebuilt only when + // both are in, so networks always sort above peers no matter which + // reply lands first. + let networks = null; + let peers = null; + let neighbours = null; + + function rebuildWhenReady() { + if (networks === null || peers === null || neighbours === null || gen !== generation) { + return; + } + rebuild(networks, peers, neighbours); + root.loading = false; } - PlasmaComponents.Label { - id: mainCounter - anchors.verticalCenter: parent.verticalCenter - height: parent.height - text: onlinecount - fontSizeMode: Text.VerticalFit - font.pixelSize: 300 - minimumPointSize: theme.smallestFont.pointSize - horizontalAlignment: Text.AlignHCenter - opacity: (onlinecount==0) ? 0.4 : 1 - width: contentWidth+(units.gridUnit*0.1) - smooth: true - wrapMode: Text.NoWrap - + + function fail(error) { + if (gen !== generation) { + return; + } + root.lastError = error.message; + root.loading = false; + if (!error.fatal) { + retryTimer.restart(); + } } + + ZeroTier.request(root.authToken, "/network", function (result) { + if (gen !== generation) { + return; + } + networks = result; + rebuildWhenReady(); + }, fail); + + ZeroTier.request(root.authToken, "/peer", function (result) { + if (gen !== generation) { + return; + } + peers = result; + rebuildWhenReady(); + }, fail); + + // A missing neighbour table only costs us the managed addresses, + // so it must never block the rest of the list. + neighbourQuery.callback = function (stdout) { + if (gen !== generation) { + return; + } + neighbours = ZeroTier.parseNeighbours(stdout); + rebuildWhenReady(); + }; + neighbourQuery.run(); } - } - - - Plasmoid.preferredRepresentation: Plasmoid.compactRepresentation - - Plasmoid.fullRepresentation: Item { - - Layout.preferredWidth: units.gridUnit * 20 - Layout.preferredHeight: Screen.height * 0.45 - - Component { - id: zeroDelegate - PlasmaComponents.ListItem { - id: zeroItem - height: units.gridUnit * 2.8 - width: parent.width - enabled: true - onContainsMouseChanged: { - zeroList.currentIndex = (containsMouse) ? index : -1; - } - onClicked: { - textEdit.text = model.ipAssignments - console.log("taphandler pressed?",model.ipAssignments); - textEdit.selectAll() - textEdit.copy() - } - TextEdit{ - id: textEdit - visible: false - } - - - Rectangle { - id: channelIcon - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.left: parent.left - radius: 90 - visible: false - } - - Item { - id: channelHeader - anchors.left: channelIcon.right - anchors.right: parent.right - anchors.top: parent.top - anchors.leftMargin: units.largeSpacing - height: parent.height/2 - - PlasmaComponents.Label { - id: viewersCount - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - width: implicitWidth - text: model.online - } - PlasmaComponents.Label { - id: channelName - text: model.name - elide: Text.ElideRight - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.right: viewersCount.left - } - } + function rebuild(networks, peers, neighbours) { + clear(); + root.lastError = ""; - PlasmaComponents.Label { - id: streamName - anchors.top: channelHeader.bottom - anchors.left: channelIcon.right - anchors.leftMargin: units.largeSpacing - anchors.bottom: parent.bottom - anchors.right: parent.right - text: model.ipAssignments - elide: Text.ElideRight - opacity: 0.6 - } - PlasmaComponents.Label { - id: viewersCountd - anchors.right: parent.right - anchors.leftMargin: units.largeSpacing - anchors.top: channelHeader.bottom - anchors.bottom: parent.bottom - width: implicitWidth - - text: model.n_name - } + const wanted = ZeroTier.parseNetworkList(root.selectedNetworks); + const shown = networks.filter(function (network) { + // An empty selection means "every network this machine joined". + return wanted.length === 0 || wanted.indexOf(network.id) >= 0; + }); + root.networkCount = shown.length; + + shown.sort(function (a, b) { + return ZeroTier.networkName(a).localeCompare(ZeroTier.networkName(b)); + }); + + for (const network of shown) { + append({ + section: "Networks", + itemId: network.id, + primary: ZeroTier.networkName(network), + secondary: ZeroTier.networkAddress(network) !== "" + ? ZeroTier.networkAddress(network) + : ZeroTier.networkStatusText(network), + trailing: network.portDeviceName ? network.portDeviceName : "", + ok: ZeroTier.networkIsUp(network), + copyText: ZeroTier.networkAddress(network).split("/")[0], + tooltip: network.id + " · " + ZeroTier.networkStatusText(network) + }); + } + + // Only LEAF peers are other machines; the rest are Zerotier's own + // root servers and would just be noise. + const devices = peers.filter(ZeroTier.isDevicePeer); + root.peerCount = devices.length; + + let connected = 0; + const names = ZeroTier.parsePeerNames(root.peerNames); + const rows = []; + + for (const peer of devices) { + const isConnected = ZeroTier.peerIsConnected(peer); + if (isConnected) { + connected++; } + if (root.onlyConnected && !isConnected) { + continue; + } + + const direct = ZeroTier.peerIsDirect(peer); + // The address on the virtual network is what you actually + // connect to, so it leads the line and is what gets copied. + const managed = ZeroTier.findPeerAddress(peer.address, shown, neighbours); + const link = ZeroTier.peerLatencyText(peer) + + (isConnected ? (direct ? " · direct" : " · relayed") : ""); + + rows.push({ + section: "Peers", + itemId: peer.address, + primary: ZeroTier.peerLabel(peer, names), + secondary: managed !== "" ? managed + " · " + link : link, + trailing: peer.version ? peer.version : "", + ok: isConnected, + copyText: managed !== "" ? managed : peer.address, + tooltip: managed !== "" + ? "Click to copy " + managed + " · node " + peer.address + : "No address seen yet · node " + peer.address + }); } + root.connectedPeerCount = connected; - PlasmaComponents3.ScrollView { - anchors.fill: parent + // Connected first, then by label. + rows.sort(function (a, b) { + if (a.ok !== b.ok) { + return a.ok ? -1 : 1; + } + return a.primary.localeCompare(b.primary); + }); - ListView { - id: zeroList - currentIndex: -1 - delegate: zeroDelegate - model: zerotierModel - anchors.fill: parent - highlight: PlasmaComponents.Highlight { } + for (const row of rows) { + append(row); } } - } + Timer { - interval: root.updateInterval*60000 + interval: root.refreshSeconds * 1000 repeat: true - running: true - onTriggered: { - zerotierModel.clear(); - zerotierModel.updateData(); - } + running: root.authToken.length > 0 + onTriggered: zerotierModel.reload() } Timer { - id: retryConnection + id: retryTimer interval: 30000 repeat: false - running: false - onTriggered: { - zerotierModel.clear(); - zerotierModel.updateData(); - } + onTriggered: zerotierModel.reload() } - Timer { - id: firstretry - interval: 5000 - repeat: false - running: false - onTriggered: { - zerotierModel.clear(); - zerotierModel.updateData(); + + onSelectedNetworksChanged: zerotierModel.reload() + onOnlyConnectedChanged: zerotierModel.reload() + onPeerNamesChanged: zerotierModel.reload() + + compactRepresentation: MouseArea { + id: compactRoot + + readonly property bool vertical: Plasmoid.formFactor === PlasmaCore.Types.Vertical + + Layout.minimumWidth: vertical ? 0 : compactRow.implicitWidth + Layout.minimumHeight: vertical ? compactRow.implicitHeight : 0 + Layout.preferredWidth: Layout.minimumWidth + Layout.preferredHeight: Layout.minimumHeight + + hoverEnabled: true + activeFocusOnTab: true + onClicked: root.expanded = !root.expanded + + GridLayout { + id: compactRow + anchors.centerIn: parent + rowSpacing: 0 + columnSpacing: Kirigami.Units.smallSpacing + flow: compactRoot.vertical ? GridLayout.TopToBottom : GridLayout.LeftToRight + + Kirigami.Icon { + source: Qt.resolvedUrl("../images/zerotier-symbolic.svg") + isMask: true + color: root.lastError !== "" ? Kirigami.Theme.neutralTextColor : Kirigami.Theme.textColor + // Dimmed when nothing is connected, so a glance is enough. + opacity: root.connectedPeerCount > 0 ? 1.0 : 0.55 + + readonly property int side: Math.max(Kirigami.Units.iconSizes.small, + Math.min(compactRoot.height, compactRoot.width, + Kirigami.Units.iconSizes.medium)) + Layout.preferredWidth: side + Layout.preferredHeight: side } + + PlasmaComponents3.Label { + text: root.connectedPeerCount + visible: root.authToken.length > 0 + opacity: root.connectedPeerCount > 0 ? 1.0 : 0.55 + font.pointSize: Kirigami.Theme.smallFont.pointSize + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } } - onShownetworksChanged:{ - console.log("ShowNetworks changed"); - zerotierModel.clear(); - zerotierModel.updateData(); - } - onSelectednetworkChanged:{ - console.log("selected network changed"); - zerotierModel.clear(); - zerotierModel.updateData(); - - } - Component.onCompleted: { - zerotierModel.clear(); - zerotierModel.updateData(); + + fullRepresentation: PlasmaExtras.Representation { + id: fullRep + + Layout.minimumWidth: Kirigami.Units.gridUnit * 16 + Layout.minimumHeight: Kirigami.Units.gridUnit * 14 + Layout.preferredWidth: Kirigami.Units.gridUnit * 22 + Layout.preferredHeight: Kirigami.Units.gridUnit * 24 + + // Which row just got copied, so we can flash a hint on it. + property string copiedId: "" + + Timer { + id: copyFeedback + interval: 1200 + onTriggered: fullRep.copiedId = "" + } + + // QML has no clipboard API, so copying goes through a hidden editor. + TextEdit { + id: clipboardHelper + visible: false + + function copyText(text) { + clipboardHelper.text = text; + clipboardHelper.selectAll(); + clipboardHelper.copy(); + clipboardHelper.deselect(); + } + } + + header: PlasmaExtras.PlasmoidHeading { + contentItem: RowLayout { + spacing: Kirigami.Units.smallSpacing + + PlasmaExtras.Heading { + Layout.fillWidth: true + level: 4 + elide: Text.ElideRight + text: root.authToken.length > 0 + ? root.connectedPeerCount + " peers connected" + : "Zerotier" + } + + PlasmaComponents3.BusyIndicator { + visible: root.loading + running: visible + Layout.preferredWidth: Kirigami.Units.iconSizes.small + Layout.preferredHeight: Kirigami.Units.iconSizes.small + } + + PlasmaComponents3.ToolButton { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + text: "Refresh" + enabled: !root.loading + onClicked: zerotierModel.reload() + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + PlasmaComponents3.ToolButton { + icon.name: "configure" + display: QQC2.AbstractButton.IconOnly + text: "Configure" + onClicked: Plasmoid.internalAction("configure").trigger() + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + } + + contentItem: Item { + + PlasmaComponents3.ScrollView { + anchors.fill: parent + visible: zerotierModel.count > 0 + contentWidth: availableWidth + + ListView { + id: entryList + model: zerotierModel + currentIndex: -1 + reuseItems: true + clip: true + + section.property: "section" + section.criteria: ViewSection.FullString + section.delegate: PlasmaExtras.ListSectionHeader { + width: entryList.width + text: section + } + + delegate: PlasmaComponents3.ItemDelegate { + id: entryDelegate + + width: entryList.width + hoverEnabled: true + enabled: model.copyText !== "" + + onClicked: { + clipboardHelper.copyText(model.copyText); + fullRep.copiedId = model.itemId; + copyFeedback.restart(); + } + + QQC2.ToolTip.text: model.tooltip + QQC2.ToolTip.visible: hovered && model.tooltip !== "" + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + + contentItem: RowLayout { + spacing: Kirigami.Units.largeSpacing + + Rectangle { + implicitWidth: Kirigami.Units.smallSpacing * 1.5 + implicitHeight: implicitWidth + radius: width / 2 + color: model.ok ? Kirigami.Theme.positiveTextColor + : Kirigami.Theme.disabledTextColor + opacity: model.ok ? 1.0 : 0.6 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + PlasmaComponents3.Label { + Layout.fillWidth: true + text: model.primary + elide: Text.ElideRight + textFormat: Text.PlainText + } + + PlasmaExtras.DescriptiveLabel { + Layout.fillWidth: true + text: model.secondary + elide: Text.ElideRight + textFormat: Text.PlainText + font: Kirigami.Theme.smallFont + } + } + + PlasmaComponents3.Label { + text: "Copied" + font: Kirigami.Theme.smallFont + color: Kirigami.Theme.positiveTextColor + opacity: fullRep.copiedId === model.itemId ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.shortDuration } + } + } + + Kirigami.Icon { + source: "edit-copy-symbolic" + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + opacity: entryDelegate.hovered && fullRep.copiedId !== model.itemId ? 0.7 : 0.0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.shortDuration } + } + } + } + } + } + } + + PlasmaExtras.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 4 + visible: zerotierModel.count === 0 + + iconName: root.lastError !== "" ? "dialog-warning" : "network-disconnect" + + text: { + if (root.lastError !== "") { + return root.lastError; + } + if (root.loading || !root.authTokenResolved) { + return "Loading..."; + } + return "No networks joined"; + } + + explanation: { + if (root.authToken.length === 0 && root.authTokenResolved) { + return "Give your user account a copy of the token:\n" + + "sudo install -m 600 -o $USER \\\n" + + " /var/lib/zerotier-one/authtoken.secret ~/.zerotierOneAuthToken"; + } + if (root.lastError === "" && !root.loading) { + return "Join a network with: zerotier-cli join "; + } + return ""; + } + + helpfulAction: Kirigami.Action { + visible: root.lastError !== "" + icon.name: "view-refresh" + text: "Try again" + onTriggered: root.resolveToken() + } + } + } } } diff --git a/package/contents/ui/zerotier.js b/package/contents/ui/zerotier.js new file mode 100644 index 0000000..5bf511c --- /dev/null +++ b/package/contents/ui/zerotier.js @@ -0,0 +1,259 @@ +.pragma library + +// Helpers for the local zerotier-one service API (http://localhost:9993). +// +// This is deliberately not the Zerotier Central API: Central requires an +// account API token that is not available on the free plan. The local daemon +// exposes what this machine itself knows -- the networks it joined and the +// peers it is talking to -- with no account or plan involved. + +var API = "http://localhost:9993"; + +// The auth token itself is read by AuthToken.qml: Qt 6 blocks local file reads +// over XMLHttpRequest, so that has to go through the executable data engine. + +/** + * GET an endpoint of the local service API. + * + * onError receives {message, status, fatal}. "fatal" marks errors that keep + * failing until the user changes something, so callers know not to retry. + */ +function request(authToken, endpoint, onSuccess, onError) { + if (!authToken) { + onError({ message: "No zerotier-one auth token", status: 0, fatal: true }); + return; + } + + var xhr = new XMLHttpRequest(); + xhr.open("GET", API + endpoint, true); + xhr.setRequestHeader("X-ZT1-Auth", authToken); + + xhr.onload = function () { + if (xhr.status === 200) { + try { + onSuccess(JSON.parse(xhr.responseText)); + } catch (e) { + onError({ message: "Malformed reply from zerotier-one", status: xhr.status }); + } + } else if (xhr.status === 401 || xhr.status === 403) { + onError({ message: "zerotier-one rejected the auth token", status: xhr.status, fatal: true }); + } else { + onError({ message: "zerotier-one error: HTTP " + xhr.status, status: xhr.status }); + } + }; + + xhr.onerror = function () { + onError({ message: "zerotier-one is not running", status: 0 }); + }; + + xhr.send(); +} + +// --- networks --------------------------------------------------------------- + +function networkAddress(network) { + var addresses = network.assignedAddresses; + return (addresses && addresses.length > 0) ? addresses[0] : ""; +} + +function networkName(network) { + return (network.name && network.name.length > 0) ? network.name : network.id; +} + +/** Anything other than OK means the interface is not usable right now. */ +function networkIsUp(network) { + return network.status === "OK"; +} + +function networkStatusText(network) { + switch (network.status) { + case "OK": + return "Connected"; + case "REQUESTING_CONFIGURATION": + return "Requesting configuration"; + case "ACCESS_DENIED": + return "Access denied - not authorized on this network"; + case "NOT_FOUND": + return "Network not found"; + case "PORT_ERROR": + return "Port error"; + case "CLIENT_TOO_OLD": + return "Client too old"; + default: + return network.status; + } +} + +// --- peers ------------------------------------------------------------------ + +/** + * PLANET and MOON peers are Zerotier's own root servers, not the user's + * devices; only LEAF peers are other machines on the networks. + */ +function isDevicePeer(peer) { + return peer.role === "LEAF"; +} + +function activePath(peer) { + var paths = peer.paths || []; + var fallback = null; + for (var i = 0; i < paths.length; i++) { + var path = paths[i]; + if (!path.active || path.expired) { + continue; + } + if (path.preferred) { + return path; + } + if (!fallback) { + fallback = path; + } + } + return fallback; +} + +/** A peer is reachable when at least one of its paths is still alive. */ +function peerIsConnected(peer) { + return activePath(peer) !== null; +} + +/** Relayed traffic goes through Zerotier's infrastructure instead of directly. */ +function peerIsDirect(peer) { + return peerIsConnected(peer) && !peer.tunneled; +} + +function peerLatencyText(peer) { + if (!peerIsConnected(peer)) { + return "unreachable"; + } + if (typeof peer.latency !== "number" || peer.latency < 0) { + return "latency unknown"; + } + return peer.latency + " ms"; +} + +/** The remote endpoint currently in use, without the port. */ +function peerEndpoint(peer) { + var path = activePath(peer); + if (!path || !path.address) { + return ""; + } + var separator = path.address.lastIndexOf("/"); + return separator > 0 ? path.address.substring(0, separator) : path.address; +} + +// --- virtual addresses ------------------------------------------------------ +// +// The local service knows a peer's node id and how packets reach it, but not +// which address the network assigned to it -- that lives on the controller. +// The kernel does know, though: once a peer has been talked to, its managed +// address sits in the neighbour table of the zerotier interface. Zerotier +// derives a member's MAC deterministically from the network id and the node +// id, which is enough to tell whose entry is whose. + +function byteAt(hex, index) { + return parseInt(hex.substr(index * 2, 2), 16); +} + +function macForNode(nodeId, networkId) { + var nwid = String(networkId).toLowerCase(); + var node = String(nodeId).toLowerCase(); + if (nwid.length !== 16 || node.length !== 10) { + return ""; + } + + // First octet comes from the network id, forced to a locally administered + // unicast address; 0x52 is remapped because it collides with a common + // hypervisor prefix. + var first = (byteAt(nwid, 7) & 0xfe) | 0x02; + if (first === 0x52) { + first = 0x32; + } + + var octets = [first]; + for (var i = 0; i < 5; i++) { + octets.push(byteAt(node, i) ^ byteAt(nwid, 6 - i)); + } + + return octets.map(function (octet) { + return (octet < 16 ? "0" : "") + octet.toString(16); + }).join(":"); +} + +/** Parses "ip -4 neigh show" output into {ip, dev, mac} entries. */ +function parseNeighbours(stdout) { + var entries = []; + var lines = String(stdout).split("\n"); + + for (var i = 0; i < lines.length; i++) { + var parts = lines[i].trim().split(/\s+/); + if (parts.length < 4) { + continue; + } + + var dev = ""; + var mac = ""; + for (var j = 1; j < parts.length - 1; j++) { + if (parts[j] === "dev") { + dev = parts[j + 1]; + } else if (parts[j] === "lladdr") { + mac = parts[j + 1].toLowerCase(); + } + } + + var state = parts[parts.length - 1]; + // Entries without a MAC never resolved, so they say nothing about who + // is on the other end. + if (dev === "" || mac === "" || state === "FAILED" || state === "INCOMPLETE") { + continue; + } + + entries.push({ ip: parts[0], dev: dev, mac: mac }); + } + + return entries; +} + +/** The managed address of a peer on one of the joined networks, if known. */ +function findPeerAddress(nodeId, networks, neighbours) { + for (var i = 0; i < networks.length; i++) { + var network = networks[i]; + var mac = macForNode(nodeId, network.id); + if (mac === "" || !network.portDeviceName) { + continue; + } + for (var j = 0; j < neighbours.length; j++) { + if (neighbours[j].dev === network.portDeviceName && neighbours[j].mac === mac) { + return neighbours[j].ip; + } + } + } + return ""; +} + +// --- configuration ---------------------------------------------------------- + +/** Node id -> user supplied label, stored in the config as a JSON object. */ +function parsePeerNames(stored) { + if (!stored || stored.length === 0) { + return {}; + } + try { + var names = JSON.parse(stored); + return (names && typeof names === "object") ? names : {}; + } catch (e) { + return {}; + } +} + +function peerLabel(peer, names) { + var label = names[peer.address]; + return (label && label.length > 0) ? label : peer.address; +} + +/** The selected network ids, stored in the config as a comma separated string. */ +function parseNetworkList(stored) { + return String(stored).split(",").filter(function (id) { + return id.length > 0; + }); +} diff --git a/package/metadata.desktop b/package/metadata.desktop deleted file mode 100644 index 07dc862..0000000 --- a/package/metadata.desktop +++ /dev/null @@ -1,15 +0,0 @@ -[Desktop Entry] -Comment=Displays Zerotier Network Members -Icon=preferences-system-network-server -Name=Zerotier Indicator -Type=Service -X-KDE-PluginInfo-Author=Duoslow -X-KDE-PluginInfo-Category=Online Services -X-KDE-PluginInfo-Email=heniugur@gmail.com -X-KDE-PluginInfo-License=MIT -X-KDE-PluginInfo-Name=org.github.duoslow.zerotierIndicator -X-KDE-PluginInfo-Version=1 -X-KDE-PluginInfo-Website=https://github.com/Duoslow/zerotierindicator -X-KDE-ServiceTypes=Plasma/Applet -X-Plasma-API=declarativeappletscript -X-Plasma-MainScript=ui/main.qml diff --git a/package/metadata.json b/package/metadata.json new file mode 100644 index 0000000..0f0bb16 --- /dev/null +++ b/package/metadata.json @@ -0,0 +1,24 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Authors": [ + { + "Email": "hafikanyesilyurt@gmail.com", + "Name": "Hafikan" + }, + { + "Email": "heniugur@gmail.com", + "Name": "Duoslow" + } + ], + "Category": "Online Services", + "Description": "Shows the Zerotier networks this machine joined and the peers it is connected to", + "Icon": "network-vpn", + "Id": "org.github.hafikan.zerotierIndicator", + "License": "MIT", + "Name": "Zerotier Indicator", + "Version": "2.0", + "Website": "https://github.com/Hafikan/zerotierIndicator" + }, + "X-Plasma-API-Minimum-Version": "6.0" +} diff --git a/zerotier.plasmoid b/zerotier.plasmoid index 48f0052..9b0d19d 100644 Binary files a/zerotier.plasmoid and b/zerotier.plasmoid differ