Compare commits
No commits in common. "00a40a9aa29f3034114dea0957cb8d22e7bc9264" and "6385f6b19ce749cf04a7e88ac47cbebcced81bde" have entirely different histories.
00a40a9aa2
...
6385f6b19c
8 changed files with 274 additions and 478 deletions
99
AGENTS.md
99
AGENTS.md
|
|
@ -22,7 +22,6 @@ You **MUST** read each of these documents before contributing to this repository
|
|||
---
|
||||
|
||||
## Critical Design Rules
|
||||
|
||||
### 1. Only Store or Cache State When It Serves a Purpose
|
||||
|
||||
**Never store or cache state unless doing so meets at least one of:**
|
||||
|
|
@ -41,101 +40,3 @@ Proposal: run `which tailscale` at startup, set a `binaryAvailable` boolean, and
|
|||
2. Creates a new class of edge cases that must be tested: the binary existed when the flag was set but later disappears. The code must now defend against both “flag is false” and “flag is wrong.”
|
||||
3. Unreliable guard for a failure the code must handle anyway. A missing binary produces a clear exit-code failure on the real command. Checking the flag *and* handling the failure duplicates work.
|
||||
Reference: #11.
|
||||
|
||||
|
||||
## Code Style Guidance
|
||||
|
||||
### 1. **NEVER** Write Conditional and Loop Blocks Without Curly Braces
|
||||
|
||||
**ALWAYS** use curly braces, `{}`, for if, else, while, and for blocks, even when they contain only a single statement. This ensures that any future additions to the block remain within the intended control flow. Reference: `CVE-2014-1266`.
|
||||
|
||||
**GOOD** Examples:
|
||||
```javascript
|
||||
if (myvar) {
|
||||
do_action();
|
||||
}
|
||||
```
|
||||
```javascript
|
||||
if (myvar) { do_action(); }
|
||||
```
|
||||
```javascript
|
||||
while (myvar) {
|
||||
do_action();
|
||||
}
|
||||
```
|
||||
```javascript
|
||||
while (myvar) { do_action(); }
|
||||
```
|
||||
|
||||
**BAD** Examples:
|
||||
```javascript
|
||||
if (myvar)
|
||||
do_action();
|
||||
```
|
||||
```javascript
|
||||
if (myvar) do_action();
|
||||
```
|
||||
```javascript
|
||||
while (myvar)
|
||||
do_action();
|
||||
```
|
||||
```javascript
|
||||
while (myvar) do_action();
|
||||
```
|
||||
|
||||
### 2. ALWAYS Terminate JavaScript Statements with Semicolons
|
||||
|
||||
ALWAYS end every JavaScript statement with a semicolon (;).
|
||||
|
||||
Rationale: Semicolons make intent explicit, eliminate ASI surprises, improve readability, and produce clearer diffs and tooling output.
|
||||
|
||||
Scope:
|
||||
- Applies to all JavaScript statements — inside signal handlers (onClicked: { ... }), custom methods (function foo() { ... }), arrow function bodies, standalone .js files, and test files.
|
||||
- Does NOT apply to QML declarative property bindings (width: 100, text: "foo", anchors.centerIn: parent, model: myModel, etc.). These are not statements; adding ; after them is either a syntax error or non-idiomatic in QML.
|
||||
- When multiple QML bindings are written on a single line for compactness, ; is used only as a separator between bindings (Qt/QML convention), not as a statement terminator.
|
||||
|
||||
GOOD (JavaScript statements):
|
||||
```javascript
|
||||
// Inside a QML signal handler or method
|
||||
onClicked: {
|
||||
root.toggleTailscale();
|
||||
ToastService.showInfo("Toggled");
|
||||
}
|
||||
function buildCommand(hostname) {
|
||||
if (hostname === "") {
|
||||
return ["tailscale", "set", "--exit-node="];
|
||||
}
|
||||
return ["tailscale", "set", "--exit-node=" + hostname];
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// In a .js file
|
||||
if (!peerMap) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(peerMap).map(...);
|
||||
```
|
||||
|
||||
BAD (missing semicolons on statements):
|
||||
```javascript
|
||||
if (condition) {
|
||||
doAction()
|
||||
}
|
||||
return value
|
||||
```
|
||||
|
||||
BAD (incorrect semicolon on QML binding — never do this):
|
||||
```javascript
|
||||
width: 360; // OK (separator on same line)
|
||||
height: 400; // WRONG — this is a binding, not a statement
|
||||
text: "Tailscale"; // WRONG
|
||||
```
|
||||
|
||||
GOOD (correct QML binding style):
|
||||
```javascript
|
||||
width: 360
|
||||
height: 400
|
||||
text: "Tailscale"
|
||||
Item { width: 1; height: 1; Layout.fillWidth: true } // ; only as separator
|
||||
```
|
||||
15
README.md
15
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
A lightweight widget plugin that shows Tailscale connectivity status on the Dank Bar with quick controls for toggling connection, switching exit nodes, and copying peer addresses.
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -14,14 +14,14 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
|||
- Peer list with hostnames and IPs
|
||||
- **Click-to-copy** any hostname or IP to clipboard
|
||||
- **Exit node selection** — click `↗` on any exit-node-capable peer to route through it
|
||||
- **On-demand status** — polls Tailscale for ground truth on load, explicit actions, and post-mutation verification (defensive poll-act-poll for toggles; no always-on timer)
|
||||
- **Auto-refresh** — status polls every 5 seconds
|
||||
- **Toast notifications** for all errors
|
||||
|
||||
## Requirements
|
||||
|
||||
- Dank Material Shell installed and running
|
||||
- `tailscale` CLI available on `PATH`
|
||||
- A clipboard tool (`dms` or `wl-copy`)
|
||||
- A clipboard tool (`dms`, `wl-copy`, or `clipmanctl`)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
|||
"id": "tailscalectl",
|
||||
"name": "Tailscale",
|
||||
"description": "Tailscale status and controls on the Dank Bar",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)",
|
||||
"icon": "vpn_key",
|
||||
"type": "widget",
|
||||
|
|
@ -79,13 +79,6 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
|||
}
|
||||
```
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Uses `Proc` singleton (from `qs.Common`) for all external `tailscale` commands (one-shot stdout capture + auto cleanup).
|
||||
- Fully I18n-ready via `I18n.tr(...)` (source keys in American English only today; see `tailscalectl/i18n/` for scaffolding).
|
||||
- Follows current `dms-plugin-dev` + DMS 1.4 plugin best practices (capabilities, requires, no raw Process for one-shots, etc.).
|
||||
- Toggle uses intentional defensive poll-act-poll (see code comments).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -4,116 +4,162 @@ import qs.Common
|
|||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
import Quickshell.Io
|
||||
import "./lib.js" as TailscaleLib
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
|
||||
property bool isConnected: false
|
||||
property bool binaryAvailable: true
|
||||
property string tailscaleIP: ""
|
||||
property string currentExitNode: ""
|
||||
property var peers: []
|
||||
property string cachedClipboardTool: ""
|
||||
property string _copyText: ""
|
||||
property int _copyIndex: 0
|
||||
property string _copyCurrentTool: ""
|
||||
property int _copyAttempted: 0
|
||||
|
||||
layerNamespacePlugin: "tailscalectl"
|
||||
popoutWidth: 360
|
||||
popoutHeight: 400
|
||||
|
||||
// Transient coordination for the defensive poll-act-poll toggle (exact behavior preserved).
|
||||
// We poll for on-the-ground truth (so we choose the correct "up"/"down" and don't lie to the user),
|
||||
// act, then poll again for verification. This is *not* long-term cached state.
|
||||
// The _pendingAction is short-lived per user action only.
|
||||
property string _pendingAction: ""
|
||||
Timer {
|
||||
interval: 5000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: statusCheck.running = true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
// Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification).
|
||||
root._runStatusCheck();
|
||||
statusCheck.running = true
|
||||
}
|
||||
|
||||
function _runStatusCheck() {
|
||||
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
||||
Process {
|
||||
id: toggleProcess
|
||||
|
||||
stderr: StdioCollector {}
|
||||
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
var action = root.isConnected ? "disconnect" : "connect"
|
||||
var detail = toggleProcess.stderr.text.trim().slice(0, 120)
|
||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : ""))
|
||||
}
|
||||
statusCheck.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: copyProcess
|
||||
|
||||
stderr: StdioCollector {}
|
||||
|
||||
onExited: (code, status) => {
|
||||
if (code === 0) {
|
||||
const state = TailscaleLib.parseStatusResult(stdout);
|
||||
root.isConnected = state.isConnected;
|
||||
root.tailscaleIP = state.tailscaleIP;
|
||||
root.currentExitNode = state.currentExitNode;
|
||||
root.peers = state.peers;
|
||||
root.cachedClipboardTool = root._copyCurrentTool
|
||||
ToastService.showInfo("Copied " + root._copyText + " to clipboard")
|
||||
} else {
|
||||
root.isConnected = false;
|
||||
root.tailscaleIP = "";
|
||||
root.currentExitNode = "";
|
||||
root.peers = [];
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
||||
root._copyAttempted += 1
|
||||
if (root._copyAttempted < TailscaleLib.allClipboardTools().length) {
|
||||
root._copyCurrentTool = TailscaleLib.nextClipboardTool(root._copyCurrentTool)
|
||||
root._executeCopy()
|
||||
} else {
|
||||
var detail = copyProcess.stderr.text.trim().slice(0, 120)
|
||||
ToastService.showError("tailscalectl", "No clipboard tool found" + (detail ? " — " + detail : ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected);
|
||||
if (cmd) {
|
||||
// If toggle action on deck, then we just retrieved on-the-ground truth and can now act to toggle.
|
||||
Proc.runCommand("tailscale-toggle", cmd, (out, c) => {
|
||||
if (c !== 0) {
|
||||
const action = root.isConnected ? "disconnect" : "connect";
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action)));
|
||||
Process {
|
||||
id: exitNodeProcess
|
||||
|
||||
stderr: StdioCollector {}
|
||||
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
var detail = exitNodeProcess.stderr.text.trim().slice(0, 120)
|
||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set") + (detail ? " — " + detail : ""))
|
||||
}
|
||||
root._pendingAction = "";
|
||||
root._runStatusCheck(); // post-action verification poll (exact behavior)
|
||||
});
|
||||
statusCheck.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusCheck
|
||||
|
||||
command: ["tailscale", "status", "--json"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const state = TailscaleLib.parseStatusResult(this.text)
|
||||
root.isConnected = state.isConnected
|
||||
root.tailscaleIP = state.tailscaleIP
|
||||
root.currentExitNode = state.currentExitNode
|
||||
root.peers = state.peers
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.isConnected = false
|
||||
root.tailscaleIP = ""
|
||||
root.currentExitNode = ""
|
||||
root.peers = []
|
||||
if (code === 127) {
|
||||
root.binaryAvailable = false
|
||||
ToastService.showError("tailscalectl", "Tailscale binary not found")
|
||||
} else {
|
||||
root._pendingAction = "";
|
||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status"))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTailscale() {
|
||||
root._pendingAction = "toggle";
|
||||
root._runStatusCheck();
|
||||
if (!root.binaryAvailable) {
|
||||
ToastService.showError("tailscalectl", "Tailscale not available")
|
||||
return
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
root._runStatusCheck();
|
||||
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
|
||||
toggleProcess.running = true
|
||||
}
|
||||
|
||||
function setExitNode(hostname) {
|
||||
const cmd = TailscaleLib.makeExitNodeCommand(hostname);
|
||||
if (!cmd) {
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname));
|
||||
return;
|
||||
if (!root.binaryAvailable) {
|
||||
ToastService.showError("tailscalectl", "Tailscale not available")
|
||||
return
|
||||
}
|
||||
Proc.runCommand("tailscale-exit", cmd, (stdout, code) => {
|
||||
if (code !== 0) {
|
||||
// Note: no stderr detail available from Proc (accepted per plan).
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set")));
|
||||
}
|
||||
root._runStatusCheck();
|
||||
});
|
||||
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
|
||||
exitNodeProcess.running = true
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
root._copyText = text;
|
||||
root._copyIndex = 0;
|
||||
root._runNextCopy();
|
||||
root._copyText = text
|
||||
root._copyAttempted = 0
|
||||
if (root.cachedClipboardTool) {
|
||||
root._copyCurrentTool = root.cachedClipboardTool
|
||||
} else {
|
||||
root._copyCurrentTool = TailscaleLib.allClipboardTools()[0]
|
||||
}
|
||||
root._executeCopy()
|
||||
}
|
||||
|
||||
function _runNextCopy() {
|
||||
const cmds = TailscaleLib.getClipboardCommands(root._copyText);
|
||||
if (root._copyIndex >= cmds.length) {
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard")));
|
||||
return;
|
||||
function _executeCopy() {
|
||||
var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool)
|
||||
if (!cmd) {
|
||||
ToastService.showError("tailscalectl", "Invalid clipboard command")
|
||||
return
|
||||
}
|
||||
Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => {
|
||||
if (code === 0) {
|
||||
ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText));
|
||||
} else {
|
||||
root._copyIndex += 1;
|
||||
root._runNextCopy();
|
||||
}
|
||||
});
|
||||
copyProcess.command = cmd
|
||||
copyProcess.running = true
|
||||
}
|
||||
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: I18n.tr(TailscaleLib.getStrings().header)
|
||||
detailsText: root.isConnected ? I18n.tr(TailscaleLib.getStrings().connected) : I18n.tr(TailscaleLib.getStrings().disconnected)
|
||||
headerText: "Tailscale"
|
||||
detailsText: root.isConnected ? "Connected" : "Disconnected"
|
||||
showCloseButton: true
|
||||
|
||||
Item {
|
||||
|
|
@ -121,6 +167,14 @@ PluginComponent {
|
|||
width: parent.width
|
||||
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
visible: !root.binaryAvailable
|
||||
text: "Tailscale not available"
|
||||
anchors.centerIn: parent
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
Row {
|
||||
id: statusRow
|
||||
y: Theme.spacingM
|
||||
|
|
@ -160,14 +214,14 @@ PluginComponent {
|
|||
Item { width: 1; height: 1; Layout.fillWidth: true }
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none))
|
||||
text: "Exit node: " + (root.currentExitNode || "None")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode)
|
||||
visible: root.currentExitNode !== ""
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
|
@ -257,7 +311,7 @@ PluginComponent {
|
|||
id: exitNodeButton
|
||||
text: "↗"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
|
||||
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -267,9 +321,14 @@ PluginComponent {
|
|||
}
|
||||
}
|
||||
|
||||
// propagateComposedEvents: true so that right-clicks both trigger our context menu
|
||||
// *and* bubble to any parent MouseArea (e.g. for shell-level drag handling).
|
||||
// Documented because the default (false) is far more common and this choice
|
||||
// frequently surprises future maintainers.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
propagateComposedEvents: true
|
||||
onClicked: {
|
||||
root.toggleTailscale()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
# I18n for tailscalectl
|
||||
|
||||
This plugin is fully instrumented with `I18n.tr(...)` (from `qs.Common`) for all user-facing strings.
|
||||
|
||||
- Source keys are the American English strings defined in `lib.js` (returned by `getStrings()`, plus bases from `errorMessage()` / `formatError()`).
|
||||
- Call sites in `TailscaleWidget.qml` wrap them: `I18n.tr(TailscaleLib.getStrings().foo)` or `I18n.tr(TailscaleLib.getStrings().copied).arg(text)`.
|
||||
- Today: falls back to the key (perfect en-US).
|
||||
- Future: Drop additional `xx.json` here (or contribute keys to DMS core translations) when a loader or extraction process supports per-plugin locales.
|
||||
|
||||
## Current keys (source of truth)
|
||||
|
||||
See `getStrings()` and `errorMessage()` in `lib.js` for the canonical list.
|
||||
|
||||
Example `en.json` (for documentation / future tools):
|
||||
|
||||
```json
|
||||
{
|
||||
"Tailscale": "Tailscale",
|
||||
"Connected": "Connected",
|
||||
"Disconnected": "Disconnected",
|
||||
"Exit node: ": "Exit node: ",
|
||||
"None": "None",
|
||||
"Copied %1 to clipboard": "Copied %1 to clipboard",
|
||||
"Invalid exit node hostname": "Invalid exit node hostname",
|
||||
"Failed to connect to Tailscale": "Failed to connect to Tailscale",
|
||||
"Failed to disconnect from Tailscale": "Failed to disconnect from Tailscale",
|
||||
"Failed to set exit node": "Failed to set exit node",
|
||||
"Failed to read Tailscale status": "Failed to read Tailscale status",
|
||||
"Error copying to clipboard": "Error copying to clipboard",
|
||||
"Tailscale command failed": "Tailscale command failed"
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Symbols/glyphs ("×", "↗", "—") are intentionally left as literals in the UI (not run through I18n as they are not linguistic content).
|
||||
- Plugin name/description in `plugin.json` and technical IDs ("tailscalectl") remain English.
|
||||
- This follows DMS `dms-plugin-dev` best practice for future-proofing even when only en is shipped.
|
||||
|
||||
Written by AI agent working for @jtmorris. Model: grok-build-0.1.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"Tailscale": "Tailscale",
|
||||
"Connected": "Connected",
|
||||
"Disconnected": "Disconnected",
|
||||
"Exit node: ": "Exit node: ",
|
||||
"None": "None",
|
||||
"Copied %1 to clipboard": "Copied %1 to clipboard",
|
||||
"Invalid exit node hostname": "Invalid exit node hostname",
|
||||
"Failed to connect to Tailscale": "Failed to connect to Tailscale",
|
||||
"Failed to disconnect from Tailscale": "Failed to disconnect from Tailscale",
|
||||
"Failed to set exit node": "Failed to set exit node",
|
||||
"Failed to read Tailscale status": "Failed to read Tailscale status",
|
||||
"Error copying to clipboard": "Error copying to clipboard",
|
||||
"Tailscale command failed": "Tailscale command failed"
|
||||
}
|
||||
|
|
@ -1,99 +1,77 @@
|
|||
function parsePeers(peerMap) {
|
||||
if (!peerMap) { return []; }
|
||||
if (!peerMap) return []
|
||||
return Object.keys(peerMap).map(function (key) {
|
||||
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { return null; }
|
||||
var p = peerMap[key];
|
||||
var p = peerMap[key]
|
||||
return {
|
||||
hostname: p.HostName || key,
|
||||
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
|
||||
online: p.Online || false,
|
||||
exitNode: p.ExitNodeOption || false
|
||||
};
|
||||
}).filter(function (peer) { return peer !== null; });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeExitNodeCommand(hostname) {
|
||||
if (!isValidExitNodeHostname(hostname)) {
|
||||
return null;
|
||||
}
|
||||
if (hostname === "") {
|
||||
return ["tailscale", "set", "--exit-node="];
|
||||
}
|
||||
return ["tailscale", "set", "--exit-node=" + hostname];
|
||||
return ["tailscale", "set", "--exit-node=" + hostname]
|
||||
}
|
||||
|
||||
function findActiveExitNode(peerMap) {
|
||||
if (!peerMap) { return ""; }
|
||||
for (const key of Object.keys(peerMap)) {
|
||||
const p = peerMap[key];
|
||||
if (!peerMap) return ""
|
||||
for (const key in peerMap) {
|
||||
const p = peerMap[key]
|
||||
if (p.ExitNode) {
|
||||
return p.HostName || key;
|
||||
return p.HostName || key
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return ""
|
||||
}
|
||||
|
||||
const clipboardTools = [
|
||||
{ argv: ["dms", "cl", "copy"] },
|
||||
{ argv: ["wl-copy"] }
|
||||
];
|
||||
var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"]
|
||||
|
||||
function getClipboardCommands(text) {
|
||||
return clipboardTools.map(function (tool) {
|
||||
return tool.argv.concat([text]);
|
||||
});
|
||||
var clipboardCmdMap = {
|
||||
"dms": "dms cl copy",
|
||||
"wl-copy": "wl-copy",
|
||||
"clipmanctl": "clipmanctl copy"
|
||||
}
|
||||
|
||||
function getStrings() {
|
||||
return {
|
||||
header: "Tailscale",
|
||||
connected: "Connected",
|
||||
disconnected: "Disconnected",
|
||||
exitNodePrefix: "Exit node: ",
|
||||
none: "None",
|
||||
copied: "Copied %1 to clipboard",
|
||||
invalidExitNodeHostname: "Invalid exit node hostname"
|
||||
};
|
||||
function validateClipboardTool(tool) {
|
||||
return typeof tool === "string" && safeClipboardTools.includes(tool)
|
||||
}
|
||||
|
||||
// Light UI predicates — keep the view thin.
|
||||
function shouldShowClearExitNode(currentExitNode) {
|
||||
return currentExitNode !== "";
|
||||
function buildCopyCommand(text, tool) {
|
||||
if (!validateClipboardTool(tool)) return null
|
||||
var cmd = clipboardCmdMap[tool]
|
||||
if (!cmd) return null
|
||||
var escaped = text.replace(/'/g, "'\\''")
|
||||
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd]
|
||||
}
|
||||
|
||||
function isActiveExitNode(currentExitNode, hostname) {
|
||||
return currentExitNode === hostname;
|
||||
function nextClipboardTool(currentTool) {
|
||||
var idx = safeClipboardTools.indexOf(currentTool)
|
||||
if (idx < 0) return safeClipboardTools[0]
|
||||
return safeClipboardTools[(idx + 1) % safeClipboardTools.length]
|
||||
}
|
||||
|
||||
// Security: validate hostnames coming from tailscale status JSON.
|
||||
// Fail closed on obviously malicious input.
|
||||
function isValidExitNodeHostname(hostname) {
|
||||
if (typeof hostname !== "string") { return false; }
|
||||
if (hostname === "") { return true; }
|
||||
return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname);
|
||||
function allClipboardTools() {
|
||||
return safeClipboardTools.slice()
|
||||
}
|
||||
|
||||
function parseStatusResult(jsonText) {
|
||||
try {
|
||||
const data = JSON.parse(jsonText);
|
||||
const data = JSON.parse(jsonText)
|
||||
return {
|
||||
isConnected: data.BackendState === "Running",
|
||||
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
||||
currentExitNode: findActiveExitNode(data.Peer || {}),
|
||||
peers: parsePeers(data.Peer || {})
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] };
|
||||
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function buildToggleCommand(isConnected) {
|
||||
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"];
|
||||
}
|
||||
|
||||
// Single source of truth for the status command used for on-demand and post-action verification.
|
||||
function getStatusCommand() {
|
||||
return ["tailscale", "status", "--json"];
|
||||
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
|
||||
}
|
||||
|
||||
function errorMessage(cmd) {
|
||||
|
|
@ -103,34 +81,12 @@ function errorMessage(cmd) {
|
|||
"down": "Failed to disconnect from Tailscale",
|
||||
"disconnect": "Failed to disconnect from Tailscale",
|
||||
"set": "Failed to set exit node",
|
||||
"status": "Failed to read Tailscale status",
|
||||
"clipboard": "Error copying to clipboard"
|
||||
};
|
||||
return messages[cmd] || "Tailscale command failed";
|
||||
}
|
||||
|
||||
// Central error formatting for the widget. Used by both success and error paths.
|
||||
// detail is optional truncated stderr or extra context.
|
||||
function formatError(action, detail) {
|
||||
var base = errorMessage(action);
|
||||
if (detail && detail.length > 0) {
|
||||
var truncated = detail.length > 120 ? detail.slice(0, 120) : detail;
|
||||
return base + " — " + truncated;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const PendingAction = Object.freeze({
|
||||
TOGGLE: "toggle"
|
||||
});
|
||||
|
||||
function commandForPendingAction(pending, freshIsConnected) {
|
||||
if (pending === PendingAction.TOGGLE) {
|
||||
return buildToggleCommand(freshIsConnected);
|
||||
}
|
||||
return null;
|
||||
"status": "Failed to read Tailscale status"
|
||||
}
|
||||
return messages[cmd] || "Tailscale command failed"
|
||||
}
|
||||
|
||||
// CommonJS export for Node.js tests (ignored by QML)
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction };
|
||||
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
"id": "tailscalectl",
|
||||
"name": "Tailscale",
|
||||
"description": "Tailscale status and controls on the Dank Bar",
|
||||
"version": "0.1.0",
|
||||
"author": "John Morris",
|
||||
"icon": "vpn_key",
|
||||
"type": "widget",
|
||||
"capabilities": ["dankbar-widget"],
|
||||
"component": "./TailscaleWidget.qml",
|
||||
"permissions": ["process"],
|
||||
"requires": ["tailscale"],
|
||||
"version": "0.2.0"
|
||||
"permissions": ["settings_read", "settings_write", "process"]
|
||||
}
|
||||
|
|
|
|||
244
test/lib.test.js
244
test/lib.test.js
|
|
@ -1,17 +1,7 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import lib from "../tailscalectl/lib.js";
|
||||
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction } = lib;
|
||||
|
||||
/*
|
||||
* Unit tests for the pure functions exported from lib.js.
|
||||
*
|
||||
* All functions in lib.js are exercised via Node's built-in test runner.
|
||||
*
|
||||
* TailscaleWidget.qml has no automated test coverage. The Proc.runCommand
|
||||
* calls, callback-based coordination (exact poll-act-poll preserved), and all
|
||||
* widget UI behavior must be verified manually in a running DMS instance.
|
||||
*/
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import lib from "../tailscalectl/lib.js"
|
||||
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib
|
||||
|
||||
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||
const peerMap = {
|
||||
|
|
@ -27,108 +17,127 @@ test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
|||
Online: true,
|
||||
ExitNodeOption: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const peers = parsePeers(peerMap);
|
||||
const peers = parsePeers(peerMap)
|
||||
|
||||
assert.strictEqual(peers[0].exitNode, true);
|
||||
assert.strictEqual(peers[1].exitNode, false);
|
||||
});
|
||||
assert.strictEqual(peers[0].exitNode, true)
|
||||
assert.strictEqual(peers[1].exitNode, false)
|
||||
})
|
||||
|
||||
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
|
||||
const cmd = makeExitNodeCommand("router");
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]);
|
||||
});
|
||||
const cmd = makeExitNodeCommand("router")
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"])
|
||||
})
|
||||
|
||||
test("makeExitNodeCommand with empty string clears exit node", () => {
|
||||
const cmd = makeExitNodeCommand("");
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]);
|
||||
});
|
||||
const cmd = makeExitNodeCommand("")
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="])
|
||||
})
|
||||
|
||||
test("findActiveExitNode returns hostname of peer with ExitNode=true", () => {
|
||||
const peerMap = {
|
||||
"peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true },
|
||||
"peer-2": { HostName: "gluetun-den", ExitNode: false, ExitNodeOption: true }
|
||||
};
|
||||
assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc");
|
||||
});
|
||||
}
|
||||
assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc")
|
||||
})
|
||||
|
||||
test("findActiveExitNode returns empty string when no exit node", () => {
|
||||
const peerMap = {
|
||||
"peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false }
|
||||
};
|
||||
assert.strictEqual(findActiveExitNode(peerMap), "");
|
||||
});
|
||||
}
|
||||
assert.strictEqual(findActiveExitNode(peerMap), "")
|
||||
})
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale up failure", () => {
|
||||
const msg = errorMessage("up", 1);
|
||||
assert.strictEqual(msg, "Failed to connect to Tailscale");
|
||||
const msg = errorMessage("up", 1)
|
||||
assert.strictEqual(msg, "Failed to connect to Tailscale")
|
||||
})
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale down failure", () => {
|
||||
const msg = errorMessage("down", 1);
|
||||
assert.strictEqual(msg, "Failed to disconnect from Tailscale");
|
||||
});
|
||||
const msg = errorMessage("down", 1)
|
||||
assert.strictEqual(msg, "Failed to disconnect from Tailscale")
|
||||
})
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale set failure", () => {
|
||||
const msg = errorMessage("set", 1);
|
||||
assert.strictEqual(msg, "Failed to set exit node");
|
||||
});
|
||||
const msg = errorMessage("set", 1)
|
||||
assert.strictEqual(msg, "Failed to set exit node")
|
||||
})
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale status failure", () => {
|
||||
const msg = errorMessage("status", 1);
|
||||
assert.strictEqual(msg, "Failed to read Tailscale status");
|
||||
});
|
||||
const msg = errorMessage("status", 1)
|
||||
assert.strictEqual(msg, "Failed to read Tailscale status")
|
||||
})
|
||||
|
||||
test("errorMessage returns generic message for unknown command", () => {
|
||||
const msg = errorMessage("unknown", 1);
|
||||
assert.strictEqual(msg, "Tailscale command failed");
|
||||
});
|
||||
|
||||
test("getClipboardCommands returns ordered argv arrays with text appended", () => {
|
||||
const cmds = getClipboardCommands("1.2.3.4");
|
||||
assert.ok(Array.isArray(cmds));
|
||||
assert.strictEqual(cmds.length, 2);
|
||||
assert.deepStrictEqual(cmds[0], ["dms", "cl", "copy", "1.2.3.4"]);
|
||||
assert.deepStrictEqual(cmds[1], ["wl-copy", "1.2.3.4"]);
|
||||
});
|
||||
|
||||
test("getClipboardCommands handles text with special characters safely (direct argv)", () => {
|
||||
const cmds = getClipboardCommands("it's a 'test' with \"quotes\" and\nnewlines");
|
||||
assert.ok(Array.isArray(cmds));
|
||||
assert.strictEqual(cmds.length, 2);
|
||||
assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines"));
|
||||
});
|
||||
|
||||
test("getClipboardCommands is deterministic and open for future tools", () => {
|
||||
const cmds = getClipboardCommands("foo");
|
||||
assert.ok(Array.isArray(cmds[0]));
|
||||
assert.ok(Array.isArray(cmds[1]));
|
||||
});
|
||||
|
||||
test("getStrings returns canonical UI strings for the widget", () => {
|
||||
const s = getStrings();
|
||||
assert.ok(s.header)
|
||||
assert.ok(s.connected)
|
||||
assert.ok(s.disconnected)
|
||||
assert.ok(s.exitNodePrefix)
|
||||
assert.ok(s.copied)
|
||||
const msg = errorMessage("unknown", 1)
|
||||
assert.strictEqual(msg, "Tailscale command failed")
|
||||
})
|
||||
|
||||
test("getStrings.copied is the I18n template key (interpolation happens at call site via .arg)", () => {
|
||||
const s = getStrings();
|
||||
assert.strictEqual(s.copied, "Copied %1 to clipboard");
|
||||
// --- validateClipboardTool ---
|
||||
|
||||
test("validateClipboardTool accepts whitelisted tool names", () => {
|
||||
assert.strictEqual(validateClipboardTool("dms"), true)
|
||||
assert.strictEqual(validateClipboardTool("wl-copy"), true)
|
||||
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
|
||||
})
|
||||
|
||||
test("shouldShowClearExitNode returns true only when there is a current exit node", () => {
|
||||
assert.strictEqual(shouldShowClearExitNode("router"), true)
|
||||
assert.strictEqual(shouldShowClearExitNode(""), false)
|
||||
test("validateClipboardTool rejects malicious inputs", () => {
|
||||
assert.strictEqual(validateClipboardTool("rm -rf /"), false)
|
||||
assert.strictEqual(validateClipboardTool("echo hi; malicious"), false)
|
||||
assert.strictEqual(validateClipboardTool("$(whoami)"), false)
|
||||
assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false)
|
||||
})
|
||||
|
||||
test("isActiveExitNode correctly identifies the active exit node button", () => {
|
||||
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-sjc"), true)
|
||||
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-den"), false)
|
||||
assert.strictEqual(isActiveExitNode("", "router"), false)
|
||||
test("validateClipboardTool rejects empty and falsy inputs", () => {
|
||||
assert.strictEqual(validateClipboardTool(""), false)
|
||||
assert.strictEqual(validateClipboardTool(null), false)
|
||||
assert.strictEqual(validateClipboardTool(undefined), false)
|
||||
})
|
||||
|
||||
// --- buildCopyCommand ---
|
||||
|
||||
test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => {
|
||||
const cmd = buildCopyCommand("hello", "wl-copy")
|
||||
assert.ok(Array.isArray(cmd))
|
||||
assert.strictEqual(cmd[0], "sh")
|
||||
assert.strictEqual(cmd[1], "-c")
|
||||
})
|
||||
|
||||
test("buildCopyCommand returns null for invalid clipboard tool", () => {
|
||||
assert.strictEqual(buildCopyCommand("hello", "rm -rf /"), null)
|
||||
assert.strictEqual(buildCopyCommand("hello", ""), null)
|
||||
assert.strictEqual(buildCopyCommand("hello", null), null)
|
||||
})
|
||||
|
||||
test("buildCopyCommand safely handles text with special characters", () => {
|
||||
const cmd = buildCopyCommand("it's a 'test' with \"quotes\" and\nnewlines", "wl-copy")
|
||||
assert.ok(Array.isArray(cmd))
|
||||
})
|
||||
|
||||
// --- nextClipboardTool ---
|
||||
|
||||
test("nextClipboardTool cycles through tools", () => {
|
||||
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
|
||||
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
|
||||
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
|
||||
})
|
||||
|
||||
test("nextClipboardTool falls back to first tool for unknown input", () => {
|
||||
assert.strictEqual(nextClipboardTool(""), "dms")
|
||||
assert.strictEqual(nextClipboardTool("unknown"), "dms")
|
||||
})
|
||||
|
||||
// --- allClipboardTools ---
|
||||
|
||||
test("allClipboardTools returns the list of safe tools", () => {
|
||||
const tools = allClipboardTools()
|
||||
assert.ok(Array.isArray(tools))
|
||||
assert.strictEqual(tools.length, 3)
|
||||
assert.ok(tools.includes("dms"))
|
||||
assert.ok(tools.includes("wl-copy"))
|
||||
assert.ok(tools.includes("clipmanctl"))
|
||||
})
|
||||
|
||||
// --- buildToggleCommand ---
|
||||
|
|
@ -146,17 +155,7 @@ test("buildToggleCommand treats null and undefined as disconnected", () => {
|
|||
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
|
||||
})
|
||||
|
||||
test("commandForPendingAction returns toggle command when pending is TOGGLE and passes through buildToggleCommand logic", () => {
|
||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true), ["tailscale", "down"]);
|
||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false), ["tailscale", "up"]);
|
||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null), ["tailscale", "up"]);
|
||||
});
|
||||
|
||||
test("commandForPendingAction returns null for no pending action or unknown pending value", () => {
|
||||
assert.strictEqual(commandForPendingAction(null, true), null);
|
||||
assert.strictEqual(commandForPendingAction(undefined, false), null);
|
||||
assert.strictEqual(commandForPendingAction("something-else", true), null);
|
||||
});
|
||||
// --- parseStatusResult ---
|
||||
|
||||
test("parseStatusResult produces correct state from valid JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
|
|
@ -200,58 +199,3 @@ test("parseStatusResult sets isConnected false for non-Running BackendState", ()
|
|||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.isConnected, false)
|
||||
})
|
||||
|
||||
// --- formatError (central error + detail formatting) ---
|
||||
|
||||
test("formatError returns base message without detail", () => {
|
||||
assert.strictEqual(formatError("status"), "Failed to read Tailscale status")
|
||||
assert.strictEqual(formatError("set"), "Failed to set exit node")
|
||||
})
|
||||
|
||||
test("formatError appends and truncates detail", () => {
|
||||
const longDetail = "x".repeat(200)
|
||||
const msg = formatError("up", longDetail)
|
||||
assert.ok(msg.includes("Failed to connect to Tailscale"))
|
||||
assert.ok(msg.endsWith("x".repeat(120)))
|
||||
assert.ok(msg.length < 200)
|
||||
})
|
||||
|
||||
test("formatError handles empty or falsy detail gracefully", () => {
|
||||
assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale")
|
||||
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale")
|
||||
})
|
||||
|
||||
// --- isValidExitNodeHostname + makeExitNodeCommand safety ---
|
||||
|
||||
test("isValidExitNodeHostname accepts empty string (clear)", () => {
|
||||
assert.strictEqual(isValidExitNodeHostname(""), true)
|
||||
})
|
||||
|
||||
test("isValidExitNodeHostname accepts realistic Tailscale hostnames", () => {
|
||||
["router", "gluetun-sjc", "my-exit-node-01", "peer_with_underscore", "a.b.c"].forEach(h =>
|
||||
assert.strictEqual(isValidExitNodeHostname(h), true, h)
|
||||
)
|
||||
})
|
||||
|
||||
test("isValidExitNodeHostname rejects obvious injection attempts", () => {
|
||||
["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)"].forEach(h =>
|
||||
assert.strictEqual(isValidExitNodeHostname(h), false, h)
|
||||
)
|
||||
})
|
||||
|
||||
test("makeExitNodeCommand returns null for invalid hostname", () => {
|
||||
assert.strictEqual(makeExitNodeCommand("; rm"), null)
|
||||
assert.strictEqual(makeExitNodeCommand("$(whoami)"), null)
|
||||
})
|
||||
|
||||
test("makeExitNodeCommand still produces correct argv for valid input", () => {
|
||||
assert.deepStrictEqual(makeExitNodeCommand(""), ["tailscale", "set", "--exit-node="])
|
||||
assert.deepStrictEqual(makeExitNodeCommand("gluetun-sjc"), ["tailscale", "set", "--exit-node=gluetun-sjc"])
|
||||
})
|
||||
|
||||
// --- getStatusCommand ---
|
||||
|
||||
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
||||
const cmd = getStatusCommand()
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"])
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue