Compare commits

..

1 commit

Author SHA1 Message Date
7d0c02267b Added guard against useless state caching anti-pattern AI agents keep insisting on.
Multiple LLMs keep trying to revert issue #11 unless specifically argued against, and keep sneaking it into code. Other, similar examples of this pathological insistence on storing/caching state that offers no tangible benefit warranting the complexity and edge cases have been tried, but headed off by me earlier.
2026-05-21 09:23:13 +00:00
8 changed files with 389 additions and 815 deletions

View file

@ -22,7 +22,6 @@ You **MUST** read each of these documents before contributing to this repository
--- ---
## Critical Design Rules ## Critical Design Rules
### 1. Only Store or Cache State When It Serves a Purpose ### 1. Only Store or Cache State When It Serves a Purpose
**Never store or cache state unless doing so meets at least one of:** **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.” 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. 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. 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
```

View file

@ -2,28 +2,26 @@
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. 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.
![Tailscale Widget v0.2.1](resources/dms_tailscalectl_v0.1.0.png) ![Tailscale Widget v0.1.0](resources/dms_tailscalectl_v0.1.0.png)
## Features ## Features
- **Status icon** in the bar — `vpn_key` when connected, `vpn_key_off` when disconnected - **Status icon** in the bar — `vpn_key` when connected, `vpn_key_off` when disconnected
- **Right-click** to toggle Tailscale on/off - **Right-click** to toggle Tailscale on/off
- **Left-click** to open a popout showing: - **Left-click** to open a popout showing:
- Your current Tailscale IP (when connected) - Your current Tailscale IP
- Active exit node (with clear button) - Active exit node (with clear button)
- Peer list with hostnames and IPs (when connected) - Peer list with hostnames and IPs
- A clear "Not connected" empty state when disconnected (no stale peer list)
- **Click-to-copy** any hostname or IP to clipboard - **Click-to-copy** any hostname or IP to clipboard
- **Exit node selection** — click `↗` on any exit-node-capable peer to route through it - **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
- **Single-flight actions** — concurrent status/toggle/exit/copy chains are rejected while an operation is in flight
- **Toast notifications** for all errors - **Toast notifications** for all errors
## Requirements ## Requirements
- Dank Material Shell installed and running - Dank Material Shell installed and running
- `tailscale` CLI available on `PATH` - `tailscale` CLI available on `PATH`
- A clipboard tool (`dms` or `wl-copy`) - A clipboard tool (`dms`, `wl-copy`, or `clipmanctl`)
## Installation ## Installation
@ -44,8 +42,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
tailscalectl/ tailscalectl/
├── plugin.json ├── plugin.json
├── TailscaleWidget.qml ├── TailscaleWidget.qml
├── lib.js └── lib.js
└── i18n/
``` ```
3. Reload the plugin: 3. Reload the plugin:
@ -73,27 +70,15 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
"id": "tailscalectl", "id": "tailscalectl",
"name": "Tailscale", "name": "Tailscale",
"description": "Tailscale status and controls on the Dank Bar", "description": "Tailscale status and controls on the Dank Bar",
"author": "John Morris", "version": "0.1.0",
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)",
"icon": "vpn_key", "icon": "vpn_key",
"type": "widget", "type": "widget",
"capabilities": ["dankbar-widget"],
"component": "./TailscaleWidget.qml", "component": "./TailscaleWidget.qml",
"permissions": ["process"], "permissions": ["settings_read", "settings_write", "process"]
"requires": ["tailscale"],
"version": "0.2.1"
} }
``` ```
## 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; a failed status poll aborts the pending toggle (does not invent `up`/`down`).
- When `BackendState` is not `Running`, peer list / exit node / self IP are cleared so the UI never shows a stale connected-looking peer list.
- Status row uses `RowLayout` with a real `Layout.fillWidth` spacer (not a no-op on plain `Row`).
- Peer `ListView` uses `Flickable.StopAtBounds` (no desktop rubber-band overshoot).
## Testing ## Testing
```bash ```bash

View file

@ -4,170 +4,178 @@ import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modules.Plugins import qs.Modules.Plugins
import Quickshell.Io
import "./lib.js" as TailscaleLib import "./lib.js" as TailscaleLib
PluginComponent { PluginComponent {
id: root id: root
property bool isConnected: false property bool isConnected: false
property bool binaryAvailable: true
property string tailscaleIP: "" property string tailscaleIP: ""
property string currentExitNode: "" property string currentExitNode: ""
property var peers: [] property var peers: []
property string cachedClipboardTool: ""
property string _copyText: "" property string _copyText: ""
property int _copyIndex: 0 property string _copyCurrentTool: ""
property int _copyAttempted: 0
// Transient coordination for defensive poll-act-poll toggle (not long-term cache).
// Poll for ground truth act poll again for verification.
property string _pendingAction: ""
// Single-flight guard: prevent interleaved status/toggle/exit/copy chains (#15/#30 class).
property bool _busy: false
layerNamespacePlugin: "tailscalectl" layerNamespacePlugin: "tailscalectl"
popoutWidth: 360 popoutWidth: 360
popoutHeight: 400 popoutHeight: 400
Timer {
interval: 5000
running: true
repeat: true
onTriggered: statusCheck.running = true
}
Component.onCompleted: { Component.onCompleted: {
root._runStatusCheck(); statusCheck.running = true
} }
function _runStatusCheck() { Process {
if (root._busy && root._pendingAction === "") { id: toggleProcess
// A non-toggle status refresh while something is already in flight: skip.
// Toggle path sets _pendingAction first and is allowed to chain after actions clear busy carefully. stderr: StdioCollector {}
return;
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
} }
root._busy = true;
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
var statusOk = (code === 0);
if (statusOk) {
const state = TailscaleLib.parseStatusResult(stdout);
root.isConnected = state.isConnected;
root.tailscaleIP = state.tailscaleIP;
root.currentExitNode = state.currentExitNode;
root.peers = state.peers;
} else {
root.isConnected = false;
root.tailscaleIP = "";
root.currentExitNode = "";
root.peers = [];
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
}
const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk);
if (cmd) {
// Fresh poll succeeded; act, then verify with another status poll.
Proc.runCommand("tailscale-toggle", cmd, (out, c) => {
if (c !== 0) {
const action = root.isConnected ? "disconnect" : "connect";
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action)));
}
root._pendingAction = "";
// Keep busy through verification poll: call internal runner that assumes we own the lock.
root._runStatusCheckUnlocked();
});
} else {
// Includes failed status while a toggle was pending: abort rather than invent up/down.
root._pendingAction = "";
root._busy = false;
}
});
} }
// Used only as the continuation after toggle action; assumes _busy is already true. Process {
function _runStatusCheckUnlocked() { id: copyProcess
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
stderr: StdioCollector {}
onExited: (code, status) => {
if (code === 0) { if (code === 0) {
const state = TailscaleLib.parseStatusResult(stdout); root.cachedClipboardTool = root._copyCurrentTool
root.isConnected = state.isConnected; ToastService.showInfo("Copied " + root._copyText + " to clipboard")
root.tailscaleIP = state.tailscaleIP;
root.currentExitNode = state.currentExitNode;
root.peers = state.peers;
} else { } else {
root.isConnected = false; root._copyAttempted += 1
root.tailscaleIP = ""; if (root._copyAttempted < TailscaleLib.allClipboardTools().length) {
root.currentExitNode = ""; root._copyCurrentTool = TailscaleLib.nextClipboardTool(root._copyCurrentTool)
root.peers = []; root._executeCopy()
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); } else {
var detail = copyProcess.stderr.text.trim().slice(0, 120)
ToastService.showError("tailscalectl", "No clipboard tool found" + (detail ? " — " + detail : ""))
}
} }
root._busy = false; }
}); }
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 : ""))
}
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 {
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status"))
}
}
}
} }
function toggleTailscale() { function toggleTailscale() {
if (root._busy) { if (!root.binaryAvailable) {
return; ToastService.showError("tailscalectl", "Tailscale not available")
return
} }
root._pendingAction = TailscaleLib.PendingAction.TOGGLE; toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
root._runStatusCheck(); toggleProcess.running = true
}
function refreshStatus() {
if (root._busy) {
return;
}
root._runStatusCheck();
} }
function setExitNode(hostname) { function setExitNode(hostname) {
if (root._busy) { if (!root.binaryAvailable) {
return; ToastService.showError("tailscalectl", "Tailscale not available")
return
} }
const cmd = TailscaleLib.makeExitNodeCommand(hostname); exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
if (!cmd) { exitNodeProcess.running = true
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname));
return;
}
root._busy = true;
Proc.runCommand("tailscale-exit", cmd, (stdout, code) => {
if (code !== 0) {
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set")));
}
// Verify via unlocked status continuation (busy already held).
root._runStatusCheckUnlocked();
});
} }
function copyToClipboard(text) { function copyToClipboard(text) {
if (root._busy) { root._copyText = text
return; root._copyAttempted = 0
if (root.cachedClipboardTool) {
root._copyCurrentTool = root.cachedClipboardTool
} else {
root._copyCurrentTool = TailscaleLib.allClipboardTools()[0]
} }
root._copyText = text; root._executeCopy()
root._copyIndex = 0;
root._busy = true;
root._runNextCopy();
} }
function _runNextCopy() { function _executeCopy() {
const cmds = TailscaleLib.getClipboardCommands(root._copyText); var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool)
if (root._copyIndex >= cmds.length) { if (!cmd) {
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard"))); ToastService.showError("tailscalectl", "Invalid clipboard command")
root._busy = false; return
return;
} }
Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => { copyProcess.command = cmd
if (code === 0) { copyProcess.running = true
ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText));
root._busy = false;
} else {
root._copyIndex += 1;
root._runNextCopy();
}
});
} }
popoutContent: Component { popoutContent: Component {
PopoutComponent { PopoutComponent {
headerText: I18n.tr(TailscaleLib.getStrings().header) headerText: "Tailscale"
detailsText: root.isConnected ? I18n.tr(TailscaleLib.getStrings().connected) : I18n.tr(TailscaleLib.getStrings().disconnected) detailsText: root.isConnected ? "Connected" : "Disconnected"
showCloseButton: true showCloseButton: true
Item { Item {
id: contentItem id: contentItem
width: parent.width width: parent.width
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerArea.height + Theme.spacingM height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM
RowLayout { StyledText {
visible: !root.binaryAvailable
text: "Tailscale not available"
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
Row {
id: statusRow id: statusRow
y: Theme.spacingM y: Theme.spacingM
width: parent.width width: parent.width
@ -180,11 +188,11 @@ PluginComponent {
MouseArea { MouseArea {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
Layout.alignment: Qt.AlignVCenter anchors.verticalCenter: parent.verticalCenter
width: toggleIcon.implicitWidth width: toggleIcon.implicitWidth
height: toggleIcon.implicitHeight height: toggleIcon.implicitHeight
onClicked: { onClicked: {
root.toggleTailscale(); root.toggleTailscale()
} }
DankIcon { DankIcon {
@ -200,30 +208,27 @@ PluginComponent {
text: root.tailscaleIP || "—" text: root.tailscaleIP || "—"
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.primary color: Theme.primary
Layout.alignment: Qt.AlignVCenter anchors.verticalCenter: parent.verticalCenter
} }
Item { Item { width: 1; height: 1; Layout.fillWidth: true }
Layout.fillWidth: true
height: 1
}
StyledText { StyledText {
text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none)) text: "Exit node: " + (root.currentExitNode || "None")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter anchors.verticalCenter: parent.verticalCenter
} }
MouseArea { MouseArea {
visible: root.currentExitNode !== "" visible: root.currentExitNode !== ""
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
Layout.alignment: Qt.AlignVCenter anchors.verticalCenter: parent.verticalCenter
width: clearExitNodeText.implicitWidth width: clearExitNodeText.implicitWidth
height: clearExitNodeText.implicitHeight height: clearExitNodeText.implicitHeight
onClicked: { onClicked: {
root.setExitNode(""); root.setExitNode("")
} }
StyledText { StyledText {
@ -235,100 +240,78 @@ PluginComponent {
} }
} }
// Peer list when connected; empty-state hint when not (#55). ListView {
Item { id: peerList
id: peerArea
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM
width: parent.width - Theme.spacingM * 2 width: parent.width - Theme.spacingM * 2
height: root.isConnected height: Math.min(root.peers.length * (Theme.fontSizeSmall + Theme.spacingXS), 200)
? Math.min(Math.max(root.peers.length, 1) * (Theme.fontSizeSmall + Theme.spacingXS), 200)
: (Theme.fontSizeSmall + Theme.spacingXS)
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
model: root.peers
interactive: true
boundsBehavior: Flickable.DragAndOvershootBounds
StyledText { delegate: Item {
visible: !root.isConnected width: peerList.width
anchors.fill: parent height: Theme.fontSizeSmall + Theme.spacingXS
text: I18n.tr(TailscaleLib.getStrings().notConnectedHint)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
ListView { Row {
id: peerList anchors.fill: parent
visible: root.isConnected spacing: Theme.spacingS
anchors.fill: parent anchors.verticalCenter: parent.verticalCenter
model: root.peers
interactive: true
// Desktop popout: no rubber-band overshoot (#27).
boundsBehavior: Flickable.StopAtBounds
clip: true
delegate: Item { MouseArea {
width: peerList.width cursorShape: Qt.PointingHandCursor
height: Theme.fontSizeSmall + Theme.spacingXS hoverEnabled: true
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS width: peerHostnameText.implicitWidth
height: peerHostnameText.implicitHeight
MouseArea { onClicked: {
cursorShape: Qt.PointingHandCursor root.copyToClipboard(modelData.hostname)
hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter
width: peerHostnameText.implicitWidth
height: peerHostnameText.implicitHeight
onClicked: {
root.copyToClipboard(modelData.hostname);
}
StyledText {
id: peerHostnameText
text: modelData.hostname
font.pixelSize: Theme.fontSizeSmall
color: modelData.online ? Theme.primary : Theme.surfaceVariantText
}
} }
MouseArea { StyledText {
cursorShape: Qt.PointingHandCursor id: peerHostnameText
hoverEnabled: true text: modelData.hostname
anchors.verticalCenter: parent.verticalCenter font.pixelSize: Theme.fontSizeSmall
width: peerIpText.implicitWidth color: modelData.online ? Theme.primary : Theme.surfaceVariantText
height: peerIpText.implicitHeight }
onClicked: { }
root.copyToClipboard(modelData.ip);
}
StyledText { MouseArea {
id: peerIpText cursorShape: Qt.PointingHandCursor
text: modelData.ip hoverEnabled: true
font.pixelSize: Theme.fontSizeSmall anchors.verticalCenter: parent.verticalCenter
color: Theme.surfaceVariantText width: peerIpText.implicitWidth
} height: peerIpText.implicitHeight
onClicked: {
root.copyToClipboard(modelData.ip)
} }
MouseArea { StyledText {
visible: modelData.exitNode id: peerIpText
cursorShape: Qt.PointingHandCursor text: modelData.ip
hoverEnabled: true font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter color: Theme.surfaceVariantText
width: exitNodeButton.implicitWidth }
height: exitNodeButton.implicitHeight }
onClicked: {
root.setExitNode(modelData.hostname);
}
StyledText { MouseArea {
id: exitNodeButton visible: modelData.exitNode
text: "↗" cursorShape: Qt.PointingHandCursor
font.pixelSize: Theme.fontSizeSmall hoverEnabled: true
color: (root.currentExitNode === modelData.hostname) ? Theme.primary : Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter
} width: exitNodeButton.implicitWidth
height: exitNodeButton.implicitHeight
onClicked: {
root.setExitNode(modelData.hostname)
}
StyledText {
id: exitNodeButton
text: "↗"
font.pixelSize: Theme.fontSizeSmall
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
} }
} }
} }
@ -338,11 +321,16 @@ 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 { MouseArea {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
propagateComposedEvents: true
onClicked: { onClicked: {
root.toggleTailscale(); root.toggleTailscale()
} }
} }

View file

@ -1,22 +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) lives beside this README.
## 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 4.5.

View file

@ -1,16 +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",
"Not connected": "Not connected",
"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"
}

View file

@ -1,113 +1,77 @@
function parsePeers(peerMap) { function parsePeers(peerMap) {
if (!peerMap) { if (!peerMap) return []
return [];
}
return Object.keys(peerMap).map(function (key) { return Object.keys(peerMap).map(function (key) {
var p = peerMap[key]; var p = peerMap[key]
return { return {
hostname: p.HostName || key, hostname: p.HostName || key,
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "", ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
online: p.Online || false, online: p.Online || false,
exitNode: p.ExitNodeOption || false exitNode: p.ExitNodeOption || false
}; }
}); })
} }
function makeExitNodeCommand(hostname) { function makeExitNodeCommand(hostname) {
if (!isValidExitNodeHostname(hostname)) { return ["tailscale", "set", "--exit-node=" + hostname]
return null;
}
if (hostname === "") {
return ["tailscale", "set", "--exit-node="];
}
return ["tailscale", "set", "--exit-node=" + hostname];
} }
function findActiveExitNode(peerMap) { function findActiveExitNode(peerMap) {
if (!peerMap) { if (!peerMap) return ""
return ""; for (const key in peerMap) {
} const p = peerMap[key]
for (const key of Object.keys(peerMap)) {
const p = peerMap[key];
if (p.ExitNode) { if (p.ExitNode) {
return p.HostName || key; return p.HostName || key
} }
} }
return ""; return ""
} }
const clipboardTools = [ var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"]
{ argv: ["dms", "cl", "copy"] },
{ argv: ["wl-copy"] }
];
function getClipboardCommands(text) { var clipboardCmdMap = {
return clipboardTools.map(function (tool) { "dms": "dms cl copy",
return tool.argv.concat([text]); "wl-copy": "wl-copy",
}); "clipmanctl": "clipmanctl copy"
} }
function getStrings() { function validateClipboardTool(tool) {
return { return typeof tool === "string" && safeClipboardTools.includes(tool)
header: "Tailscale",
connected: "Connected",
disconnected: "Disconnected",
exitNodePrefix: "Exit node: ",
none: "None",
copied: "Copied %1 to clipboard",
invalidExitNodeHostname: "Invalid exit node hostname",
notConnectedHint: "Not connected"
};
} }
// Security: validate hostnames coming from tailscale status JSON. function buildCopyCommand(text, tool) {
// Fail closed on obviously malicious input. Allow multi-label MagicDNS names if (!validateClipboardTool(tool)) return null
// up to DNS FQDN length (253). var cmd = clipboardCmdMap[tool]
function isValidExitNodeHostname(hostname) { if (!cmd) return null
if (typeof hostname !== "string") { var escaped = text.replace(/'/g, "'\\''")
return false; return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd]
} }
if (hostname === "") {
return true; function nextClipboardTool(currentTool) {
} var idx = safeClipboardTools.indexOf(currentTool)
if (hostname.length > 253) { if (idx < 0) return safeClipboardTools[0]
return false; return safeClipboardTools[(idx + 1) % safeClipboardTools.length]
} }
// Each label: alnum start/end, alnum/hyphen/underscore inside; dots separate labels.
return /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?)(\.([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?))*$/.test(hostname); function allClipboardTools() {
return safeClipboardTools.slice()
} }
function parseStatusResult(jsonText) { function parseStatusResult(jsonText) {
try { try {
const data = JSON.parse(jsonText); const data = JSON.parse(jsonText)
const isConnected = data.BackendState === "Running";
if (!isConnected) {
// #55: when not Running, do not surface stale peer list / exit node / IP.
return {
isConnected: false,
tailscaleIP: "",
currentExitNode: "",
peers: []
};
}
return { return {
isConnected: true, isConnected: data.BackendState === "Running",
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "", tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
currentExitNode: findActiveExitNode(data.Peer || {}), currentExitNode: findActiveExitNode(data.Peer || {}),
peers: parsePeers(data.Peer || {}) peers: parsePeers(data.Peer || {})
}; }
} catch (e) { } catch (e) {
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }; return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
} }
} }
function buildToggleCommand(isConnected) { function buildToggleCommand(isConnected) {
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]; 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"];
} }
function errorMessage(cmd) { function errorMessage(cmd) {
@ -117,52 +81,12 @@ function errorMessage(cmd) {
"down": "Failed to disconnect from Tailscale", "down": "Failed to disconnect from Tailscale",
"disconnect": "Failed to disconnect from Tailscale", "disconnect": "Failed to disconnect from Tailscale",
"set": "Failed to set exit node", "set": "Failed to set exit node",
"status": "Failed to read Tailscale status", "status": "Failed to read Tailscale status"
"clipboard": "Error copying to clipboard" }
}; return messages[cmd] || "Tailscale command failed"
return messages[cmd] || "Tailscale command failed";
}
// Central error formatting for the widget. 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"
});
// statusOk must be true (successful status poll) before acting on pending toggle.
// Never invent up/down from a failed poll (would force "up" after clearing isConnected).
function commandForPendingAction(pending, freshIsConnected, statusOk) {
if (!statusOk) {
return null;
}
if (pending === PendingAction.TOGGLE) {
return buildToggleCommand(freshIsConnected);
}
return null;
} }
// CommonJS export for Node.js tests (ignored by QML)
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult }
parsePeers,
makeExitNodeCommand,
findActiveExitNode,
errorMessage,
formatError,
getStatusCommand,
isValidExitNodeHostname,
getClipboardCommands,
buildToggleCommand,
parseStatusResult,
getStrings,
PendingAction,
commandForPendingAction
};
} }

View file

@ -2,12 +2,10 @@
"id": "tailscalectl", "id": "tailscalectl",
"name": "Tailscale", "name": "Tailscale",
"description": "Tailscale status and controls on the Dank Bar", "description": "Tailscale status and controls on the Dank Bar",
"author": "John Morris", "version": "0.1.0",
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)",
"icon": "vpn_key", "icon": "vpn_key",
"type": "widget", "type": "widget",
"capabilities": ["dankbar-widget"],
"component": "./TailscaleWidget.qml", "component": "./TailscaleWidget.qml",
"permissions": ["process"], "permissions": ["settings_read", "settings_write", "process"]
"requires": ["tailscale"],
"version": "0.2.1"
} }

View file

@ -1,32 +1,7 @@
import { test } from "node:test"; import { test } from "node:test"
import assert from "node:assert"; import assert from "node:assert"
import lib from "../tailscalectl/lib.js"; import lib from "../tailscalectl/lib.js"
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib
const {
parsePeers,
makeExitNodeCommand,
findActiveExitNode,
errorMessage,
formatError,
getStatusCommand,
isValidExitNodeHostname,
getClipboardCommands,
buildToggleCommand,
parseStatusResult,
getStrings,
PendingAction,
commandForPendingAction
} = lib;
/*
* Unit tests for pure functions in lib.js.
*
* TailscaleWidget.qml has no automated test coverage. Proc.runCommand
* coordination, busy-mutex behavior, and widget UI must be verified
* manually in a running DMS instance.
*/
// --- parsePeers ---
test("parsePeers extracts exitNode from ExitNodeOption", () => { test("parsePeers extracts exitNode from ExitNodeOption", () => {
const peerMap = { const peerMap = {
@ -42,344 +17,185 @@ test("parsePeers extracts exitNode from ExitNodeOption", () => {
Online: true, Online: true,
ExitNodeOption: false ExitNodeOption: false
} }
}; }
const peers = parsePeers(peerMap); const peers = parsePeers(peerMap)
assert.strictEqual(peers.length, 2); assert.strictEqual(peers[0].exitNode, true)
assert.strictEqual(peers[0].exitNode, true); assert.strictEqual(peers[1].exitNode, false)
assert.strictEqual(peers[1].exitNode, false); })
assert.strictEqual(peers[0].hostname, "router");
assert.strictEqual(peers[0].ip, "100.64.0.1");
assert.strictEqual(peers[0].online, true);
});
test("parsePeers returns empty array for null/undefined peerMap", () => {
assert.deepStrictEqual(parsePeers(null), []);
assert.deepStrictEqual(parsePeers(undefined), []);
});
test("parsePeers returns empty array for empty peerMap", () => {
assert.deepStrictEqual(parsePeers({}), []);
});
test("parsePeers falls back to key when HostName missing", () => {
const peers = parsePeers({
"node-key-abc": { TailscaleIPs: ["100.64.0.9"], Online: false }
});
assert.strictEqual(peers[0].hostname, "node-key-abc");
assert.strictEqual(peers[0].ip, "100.64.0.9");
assert.strictEqual(peers[0].online, false);
assert.strictEqual(peers[0].exitNode, false);
});
test("parsePeers uses empty ip when TailscaleIPs missing or empty", () => {
const peers = parsePeers({
a: { HostName: "a" },
b: { HostName: "b", TailscaleIPs: [] }
});
assert.strictEqual(peers[0].ip, "");
assert.strictEqual(peers[1].ip, "");
});
// --- makeExitNodeCommand / hostname validation ---
test("makeExitNodeCommand returns tailscale set command for hostname", () => { test("makeExitNodeCommand returns tailscale set command for hostname", () => {
const cmd = makeExitNodeCommand("router"); const cmd = makeExitNodeCommand("router")
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"])
}); })
test("makeExitNodeCommand with empty string clears exit node", () => { test("makeExitNodeCommand with empty string clears exit node", () => {
const cmd = makeExitNodeCommand(""); const cmd = makeExitNodeCommand("")
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="])
}); })
test("makeExitNodeCommand returns null for invalid hostname", () => {
assert.strictEqual(makeExitNodeCommand("; rm"), null);
assert.strictEqual(makeExitNodeCommand("$(whoami)"), null);
assert.strictEqual(makeExitNodeCommand(null), null);
assert.strictEqual(makeExitNodeCommand(42), 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"]);
});
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 accepts MagicDNS-style FQDNs under 253 chars", () => {
assert.strictEqual(isValidExitNodeHostname("my-node.tail1234.ts.net"), true);
const longButValid = "a".repeat(60) + "." + "b".repeat(60) + "." + "c".repeat(60);
assert.ok(longButValid.length < 253);
assert.strictEqual(isValidExitNodeHostname(longButValid), true);
});
test("isValidExitNodeHostname rejects injection attempts and garbage", () => {
["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)", " spacy ", "-leading", "trailing-"].forEach((h) => {
assert.strictEqual(isValidExitNodeHostname(h), false, h);
});
});
test("isValidExitNodeHostname rejects non-strings and oversized names", () => {
assert.strictEqual(isValidExitNodeHostname(undefined), false);
assert.strictEqual(isValidExitNodeHostname({}), false);
assert.strictEqual(isValidExitNodeHostname("x".repeat(254)), false);
});
// --- findActiveExitNode ---
test("findActiveExitNode returns hostname of peer with ExitNode=true", () => { test("findActiveExitNode returns hostname of peer with ExitNode=true", () => {
const peerMap = { const peerMap = {
"peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true }, "peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true },
"peer-2": { HostName: "gluetun-den", ExitNode: false, 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", () => { test("findActiveExitNode returns empty string when no exit node", () => {
const peerMap = { const peerMap = {
"peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false } "peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false }
}; }
assert.strictEqual(findActiveExitNode(peerMap), ""); assert.strictEqual(findActiveExitNode(peerMap), "")
}); })
test("findActiveExitNode returns empty string for null peerMap", () => { test("errorMessage returns user-friendly message for tailscale up failure", () => {
assert.strictEqual(findActiveExitNode(null), ""); const msg = errorMessage("up", 1)
}); assert.strictEqual(msg, "Failed to connect to Tailscale")
})
test("findActiveExitNode falls back to map key when HostName missing", () => { test("errorMessage returns user-friendly message for tailscale down failure", () => {
const peerMap = { const msg = errorMessage("down", 1)
"key-only": { ExitNode: true } assert.strictEqual(msg, "Failed to disconnect from Tailscale")
}; })
assert.strictEqual(findActiveExitNode(peerMap), "key-only");
});
// --- errorMessage / formatError --- test("errorMessage returns user-friendly message for tailscale set failure", () => {
const msg = errorMessage("set", 1)
assert.strictEqual(msg, "Failed to set exit node")
})
test("errorMessage returns user-friendly messages for known actions", () => { test("errorMessage returns user-friendly message for tailscale status failure", () => {
assert.strictEqual(errorMessage("up"), "Failed to connect to Tailscale"); const msg = errorMessage("status", 1)
assert.strictEqual(errorMessage("connect"), "Failed to connect to Tailscale"); assert.strictEqual(msg, "Failed to read Tailscale status")
assert.strictEqual(errorMessage("down"), "Failed to disconnect from Tailscale"); })
assert.strictEqual(errorMessage("disconnect"), "Failed to disconnect from Tailscale");
assert.strictEqual(errorMessage("set"), "Failed to set exit node");
assert.strictEqual(errorMessage("status"), "Failed to read Tailscale status");
assert.strictEqual(errorMessage("clipboard"), "Error copying to clipboard");
});
test("errorMessage returns generic message for unknown command", () => { test("errorMessage returns generic message for unknown command", () => {
assert.strictEqual(errorMessage("unknown"), "Tailscale command failed"); const msg = errorMessage("unknown", 1)
}); assert.strictEqual(msg, "Tailscale command failed")
})
test("formatError returns base message without detail", () => { // --- validateClipboardTool ---
assert.strictEqual(formatError("status"), "Failed to read Tailscale status");
assert.strictEqual(formatError("set"), "Failed to set exit node");
});
test("formatError appends and truncates detail to 120 chars", () => { test("validateClipboardTool accepts whitelisted tool names", () => {
const longDetail = "x".repeat(200); assert.strictEqual(validateClipboardTool("dms"), true)
const msg = formatError("up", longDetail); assert.strictEqual(validateClipboardTool("wl-copy"), true)
assert.ok(msg.includes("Failed to connect to Tailscale")); assert.strictEqual(validateClipboardTool("clipmanctl"), true)
assert.ok(msg.endsWith("x".repeat(120))); })
assert.ok(msg.length < 200);
});
test("formatError handles empty or falsy detail gracefully", () => { test("validateClipboardTool rejects malicious inputs", () => {
assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale"); assert.strictEqual(validateClipboardTool("rm -rf /"), false)
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale"); assert.strictEqual(validateClipboardTool("echo hi; malicious"), false)
}); assert.strictEqual(validateClipboardTool("$(whoami)"), false)
assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false)
})
// --- clipboard --- test("validateClipboardTool rejects empty and falsy inputs", () => {
assert.strictEqual(validateClipboardTool(""), false)
assert.strictEqual(validateClipboardTool(null), false)
assert.strictEqual(validateClipboardTool(undefined), false)
})
test("getClipboardCommands returns ordered argv arrays with text appended", () => { // --- buildCopyCommand ---
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)", () => { test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => {
const cmds = getClipboardCommands("it's a 'test' with \"quotes\" and\nnewlines"); const cmd = buildCopyCommand("hello", "wl-copy")
assert.ok(Array.isArray(cmds)); assert.ok(Array.isArray(cmd))
assert.strictEqual(cmds.length, 2); assert.strictEqual(cmd[0], "sh")
assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines")); assert.strictEqual(cmd[1], "-c")
}); })
// --- strings --- 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("getStrings returns canonical UI strings for the widget", () => { test("buildCopyCommand safely handles text with special characters", () => {
const s = getStrings(); const cmd = buildCopyCommand("it's a 'test' with \"quotes\" and\nnewlines", "wl-copy")
assert.ok(s.header); assert.ok(Array.isArray(cmd))
assert.ok(s.connected); })
assert.ok(s.disconnected);
assert.ok(s.exitNodePrefix);
assert.ok(s.none);
assert.ok(s.copied);
assert.ok(s.invalidExitNodeHostname);
assert.ok(s.notConnectedHint);
});
test("getStrings.copied is the I18n template key (interpolation via .arg at call site)", () => { // --- nextClipboardTool ---
const s = getStrings();
assert.strictEqual(s.copied, "Copied %1 to clipboard");
});
// --- toggle helpers --- test("nextClipboardTool cycles through tools", () => {
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
})
test("buildToggleCommand returns down when connected", () => { test("nextClipboardTool falls back to first tool for unknown input", () => {
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]); assert.strictEqual(nextClipboardTool(""), "dms")
}); assert.strictEqual(nextClipboardTool("unknown"), "dms")
})
test("buildToggleCommand returns up when disconnected", () => { // --- allClipboardTools ---
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"]);
}); 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 ---
test("buildToggleCommand returns down command when connected", () => {
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"])
})
test("buildToggleCommand returns up command when disconnected", () => {
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"])
})
test("buildToggleCommand treats null and undefined as disconnected", () => { test("buildToggleCommand treats null and undefined as disconnected", () => {
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"]); assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"])
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]); assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
}); })
test("commandForPendingAction returns toggle command when pending is TOGGLE and statusOk", () => { // --- parseStatusResult ---
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true, true), ["tailscale", "down"]);
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false, true), ["tailscale", "up"]);
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null, true), ["tailscale", "up"]);
});
test("commandForPendingAction returns null when status poll failed (do not invent up/down)", () => { test("parseStatusResult produces correct state from valid JSON", () => {
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, false), null);
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, true, false), null);
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, undefined), null);
});
test("commandForPendingAction returns null for no pending action or unknown pending value", () => {
assert.strictEqual(commandForPendingAction(null, true, true), null);
assert.strictEqual(commandForPendingAction(undefined, false, true), null);
assert.strictEqual(commandForPendingAction("something-else", true, true), null);
assert.strictEqual(commandForPendingAction("", true, true), null);
});
// --- parseStatusResult (#55 and ground-truth parsing) ---
test("parseStatusResult produces correct state from valid Running JSON", () => {
const json = JSON.stringify({ const json = JSON.stringify({
BackendState: "Running", BackendState: "Running",
Self: { TailscaleIPs: ["100.64.0.5"] }, Self: { TailscaleIPs: ["100.64.0.5"] },
Peer: { Peer: {
"key-1": { "key-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNode: true, ExitNodeOption: true }
HostName: "router",
TailscaleIPs: ["100.64.0.1"],
Online: true,
ExitNode: true,
ExitNodeOption: true
}
} }
}); })
const state = parseStatusResult(json); const state = parseStatusResult(json)
assert.strictEqual(state.isConnected, true); assert.strictEqual(state.isConnected, true)
assert.strictEqual(state.tailscaleIP, "100.64.0.5"); assert.strictEqual(state.tailscaleIP, "100.64.0.5")
assert.strictEqual(state.currentExitNode, "router"); assert.strictEqual(state.currentExitNode, "router")
assert.strictEqual(state.peers.length, 1); assert.strictEqual(state.peers.length, 1)
}); })
test("parseStatusResult returns safe defaults for invalid JSON", () => { test("parseStatusResult returns safe defaults for invalid JSON", () => {
const state = parseStatusResult("not json at all"); const state = parseStatusResult("not json at all")
assert.strictEqual(state.isConnected, false); assert.strictEqual(state.isConnected, false)
assert.strictEqual(state.tailscaleIP, ""); assert.strictEqual(state.tailscaleIP, "")
assert.strictEqual(state.currentExitNode, ""); assert.strictEqual(state.currentExitNode, "")
assert.deepStrictEqual(state.peers, []); assert.strictEqual(state.peers.length, 0)
}); })
test("parseStatusResult handles missing Self gracefully when Running", () => { test("parseStatusResult handles missing Self gracefully", () => {
const json = JSON.stringify({ BackendState: "Running", Peer: {} }); const json = JSON.stringify({ BackendState: "Running", Peer: {} })
const state = parseStatusResult(json); const state = parseStatusResult(json)
assert.strictEqual(state.isConnected, true); assert.strictEqual(state.isConnected, true)
assert.strictEqual(state.tailscaleIP, ""); assert.strictEqual(state.tailscaleIP, "")
}); })
test("parseStatusResult handles missing and empty Peer gracefully when Running", () => { test("parseStatusResult handles missing and empty Peer gracefully", () => {
const json = JSON.stringify({ const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } })
BackendState: "Running", const state = parseStatusResult(json)
Self: { TailscaleIPs: ["100.64.0.5"] } assert.strictEqual(state.peers.length, 0)
}); assert.strictEqual(state.currentExitNode, "")
const state = parseStatusResult(json); })
assert.strictEqual(state.peers.length, 0);
assert.strictEqual(state.currentExitNode, "");
});
test("parseStatusResult sets isConnected false for non-Running BackendState", () => { test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
const json = JSON.stringify({ const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} })
BackendState: "NeedsLogin", const state = parseStatusResult(json)
Self: { TailscaleIPs: ["100.64.0.5"] }, assert.strictEqual(state.isConnected, false)
Peer: {} })
});
const state = parseStatusResult(json);
assert.strictEqual(state.isConnected, false);
});
// #55: disconnected must not surface peer list / exit node from leftover JSON
test("parseStatusResult clears peers and exit node when BackendState is not Running (#55)", () => {
const json = JSON.stringify({
BackendState: "Stopped",
Self: { TailscaleIPs: ["100.64.0.5"] },
Peer: {
"k1": {
HostName: "router",
TailscaleIPs: ["100.64.0.1"],
Online: false,
ExitNode: true,
ExitNodeOption: true
}
}
});
const state = parseStatusResult(json);
assert.strictEqual(state.isConnected, false);
assert.deepStrictEqual(state.peers, []);
assert.strictEqual(state.currentExitNode, "");
// Self IP may still appear in raw JSON; we clear display IP when disconnected
// so the popout does not look "half connected".
assert.strictEqual(state.tailscaleIP, "");
});
test("parseStatusResult clears peers for NeedsLogin even if Peer map is populated (#55)", () => {
const json = JSON.stringify({
BackendState: "NeedsLogin",
Self: { TailscaleIPs: ["100.64.0.5"] },
Peer: {
"k1": { HostName: "ghost", TailscaleIPs: ["100.64.0.2"], Online: false }
}
});
const state = parseStatusResult(json);
assert.strictEqual(state.isConnected, false);
assert.deepStrictEqual(state.peers, []);
assert.strictEqual(state.currentExitNode, "");
assert.strictEqual(state.tailscaleIP, "");
});
// --- getStatusCommand ---
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
const cmd = getStatusCommand();
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]);
});
// --- PendingAction constant ---
test("PendingAction.TOGGLE is the stable string used by the widget", () => {
assert.strictEqual(PendingAction.TOGGLE, "toggle");
});
// --- export surface: no over-abstracted UI predicates ---
test("lib does not export trivial UI predicates shouldShowClearExitNode / isActiveExitNode", () => {
assert.strictEqual(lib.shouldShowClearExitNode, undefined);
assert.strictEqual(lib.isActiveExitNode, undefined);
});