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
-
+## License
-## Settings
-
+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