Compare commits

...

10 commits

Author SHA1 Message Date
59ed4ebf44 chore: evaluate structured logging per AGENTS.md rule 1 — no addition justified (#47)
- Existing toasts + truncated stderr provide the actionable information for users and errors
- Adding persistent debug logs would introduce state, rotation, and maintenance concerns
- with no demonstrable improvement to UX, decisions, or security for normal operation
- Explicit no-op commit closes the planning issue

Written by AI agent working for @jtmorris. Model: Grok build-0.1.
2026-05-22 06:34:14 +00:00
1cf2fc9472 fix: eliminate toggle decision staleness via poll-then-act (no cached desired state) (#42)
- toggleTailscale() now forces a fresh statusCheck instead of reading possibly-stale root.isConnected
- The actual up/down decision happens in statusCheck.onExited using the just-received value
- _pendingToggle is a transient one-shot flag (reset immediately after use) — not a persistent 'desired state' cache
- Complies with AGENTS.md rule 1 / #11

Written by AI agent working for @jtmorris. Model: Grok build-0.1.
2026-05-22 06:34:08 +00:00
270feba351 test: coverage for newly extracted pure helpers; document Process/Widget manual verification boundary (#48)
- The new helpers from #43 are now under test
- Added explicit comment explaining that full lifecycle + timing + QML behavior remains manual verification only (no QML test runner exists)

Written by AI agent working for @jtmorris. Model: Grok build-0.1.
2026-05-22 06:33:40 +00:00
01db16cfd9 refactor: extract shouldShowClearExitNode and isActiveExitNode to lib.js (TDD) — light progress on #43
- Two small pure UI predicates moved out of QML delegate
- QML now calls TailscaleLib.* (thinner glue)
- New tests + all existing tests pass
- No new state, no architecture change

Written by AI agent working for @jtmorris. Model: Grok build-0.1.
2026-05-22 06:33:33 +00:00
0b80f5cdc8 feat: extract UI strings to lib.js; harden clipboard to direct argv, drop clipmanctl (#34, #49)
- safeClipboardTools now only dms + wl-copy (clipmanctl removed as niche optional history manager)
- buildCopyCommand now returns pure argv arrays (no sh -c, no escaping, eliminates future injection risk)
- Added getStrings() for centralised user-facing strings (prep for real i18n)
- Updated all tests (TDD) and README
- All 29 tests pass

Written by AI agent working for @jtmorris. Model: Grok build-0.1.
2026-05-22 06:33:08 +00:00
e2a3315b08 fix: remove binaryAvailable, fix statusCheck parser location, add timer guard
- Revert binaryAvailable property: reintroduced stale-state anti-pattern
  already rejected in #11. All error paths already handled by Process
  onExited handlers.
- Move statusCheck parsing from onStreamFinished to onExited (#38):
  parser now only runs after exit code validation, preventing stale
  data from being applied on failure.
- Add refreshTimer guard (#44): timer no longer spawns new statusCheck
  while one is already running.

Closes #38, #44. Reverts #52 partial fix.
2026-05-22 06:07:03 +00:00
a789ab615f fix: remove propagateComposedEvents and fix plugin author (#50, #51)
- TailscaleWidget.qml: remove propagateComposedEvents from right-click MouseArea
- plugin.json: clean up author string
2026-05-22 06:07:03 +00:00
76c680cdc9 fix: lib.js security and correctness fixes (#40, #41)
- parsePeers: add hasOwnProperty guard and filter null entries
- findActiveExitNode: add hasOwnProperty guard to for..in loop
- makeExitNodeCommand: handle empty hostname correctly
2026-05-22 06:07:03 +00:00
c6e064f9b6 chore: remaining issues batch progress (#23-#34) 2026-05-22 06:07:03 +00:00
ca92974b49 chore: JS quality batch (#20 for-in guard, #21 add edge tests, #22 dedup errorMessage keys) 2026-05-22 06:07:03 +00:00
5 changed files with 121 additions and 46 deletions

View file

@ -21,7 +21,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
- 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`, `wl-copy`, or `clipmanctl`) - A clipboard tool (`dms` or `wl-copy`)
## Installation ## Installation

View file

@ -12,23 +12,28 @@ PluginComponent {
property bool isConnected: false property bool isConnected: false
property string tailscaleIP: "" property string tailscaleIP: ""
property string _pendingToggleAction: ""
property string currentExitNode: "" property string currentExitNode: ""
property var peers: [] property var peers: []
property string cachedClipboardTool: "" property string cachedClipboardTool: ""
property string _copyText: "" property string _copyText: ""
property string _copyCurrentTool: "" property string _copyCurrentTool: ""
property int _copyAttempted: 0 property int _copyAttempted: 0
property bool _pendingToggle: false // transient one-shot for poll-then-act (#42) reset immediately after use, no "desired state" cache
layerNamespacePlugin: "tailscalectl" layerNamespacePlugin: "tailscalectl"
popoutWidth: 360 popoutWidth: 360
popoutHeight: 400 popoutHeight: 400
Timer { Timer {
id: refreshTimer
interval: 5000 interval: 5000
running: true running: true
repeat: true repeat: true
onTriggered: statusCheck.running = true onTriggered: {
if (!statusCheck.running) {
statusCheck.running = true
}
}
} }
Component.onCompleted: { Component.onCompleted: {
@ -42,11 +47,10 @@ PluginComponent {
onExited: (code, status) => { onExited: (code, status) => {
if (code !== 0) { if (code !== 0) {
var action = root._pendingToggleAction || (root.isConnected ? "disconnect" : "connect") var action = root.isConnected ? "disconnect" : "connect"
var detail = toggleProcess.stderr.text.trim().slice(0, 120) var detail = toggleProcess.stderr.text.trim().slice(0, 120)
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : "")) ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : ""))
} }
root._pendingToggleAction = ""
statusCheck.running = true statusCheck.running = true
} }
} }
@ -92,36 +96,42 @@ PluginComponent {
command: ["tailscale", "status", "--json"] command: ["tailscale", "status", "--json"]
stdout: StdioCollector { stdout: StdioCollector {}
onStreamFinished: {
const state = TailscaleLib.parseStatusResult(this.text) onExited: (code, status) => {
if (code === 0) {
const state = TailscaleLib.parseStatusResult(statusCheck.stdout.text)
root.isConnected = state.isConnected root.isConnected = state.isConnected
root.tailscaleIP = state.tailscaleIP root.tailscaleIP = state.tailscaleIP
root.currentExitNode = state.currentExitNode root.currentExitNode = state.currentExitNode
root.peers = state.peers root.peers = state.peers
} } else {
}
onExited: (code, status) => {
if (code !== 0) {
root.isConnected = false root.isConnected = false
root.tailscaleIP = "" root.tailscaleIP = ""
root.currentExitNode = "" root.currentExitNode = ""
root.peers = [] root.peers = []
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status")) ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status"))
} }
// #42 poll-then-act: if a toggle was requested, use the *fresh* state we just received
// #47: structured logging evaluated and rejected toasts + 120-char stderr already give
// actionable feedback; persistent logs would add state/rotation with no net UX benefit.
if (root._pendingToggle) {
root._pendingToggle = false
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
toggleProcess.running = true
}
} }
} }
function toggleTailscale() { function toggleTailscale() {
if (toggleProcess.running) return // #42: poll-then-act never decide up/down from potentially stale root.isConnected.
root._pendingToggleAction = root.isConnected ? "disconnect" : "connect" // Force a fresh statusCheck; the decision happens in its onExited using the just-received value.
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected) root._pendingToggle = true
toggleProcess.running = true statusCheck.running = true
} }
function setExitNode(hostname) { function setExitNode(hostname) {
if (exitNodeProcess.running) return
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname) exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
exitNodeProcess.running = true exitNodeProcess.running = true
} }
@ -204,7 +214,7 @@ PluginComponent {
} }
MouseArea { MouseArea {
visible: root.currentExitNode !== "" visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode)
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@ -294,7 +304,7 @@ PluginComponent {
id: exitNodeButton id: exitNodeButton
text: "↗" text: "↗"
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText color: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
} }
} }
} }
@ -304,14 +314,9 @@ 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,6 +1,7 @@
function parsePeers(peerMap) { function parsePeers(peerMap) {
if (!peerMap) return [] if (!peerMap) return []
return Object.keys(peerMap).map(function (key) { 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 { return {
hostname: p.HostName || key, hostname: p.HostName || key,
@ -8,16 +9,20 @@ function parsePeers(peerMap) {
online: p.Online || false, online: p.Online || false,
exitNode: p.ExitNodeOption || false exitNode: p.ExitNodeOption || false
} }
}) }).filter(function (peer) { return peer !== null })
} }
function makeExitNodeCommand(hostname) { function makeExitNodeCommand(hostname) {
if (hostname === "") {
return ["tailscale", "set", "--exit-node="]
}
return ["tailscale", "set", "--exit-node=" + hostname] return ["tailscale", "set", "--exit-node=" + hostname]
} }
function findActiveExitNode(peerMap) { function findActiveExitNode(peerMap) {
if (!peerMap) return "" if (!peerMap) return ""
for (const key in peerMap) { for (const key in peerMap) {
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) continue
const p = peerMap[key] const p = peerMap[key]
if (p.ExitNode) { if (p.ExitNode) {
return p.HostName || key return p.HostName || key
@ -26,12 +31,13 @@ function findActiveExitNode(peerMap) {
return "" return ""
} }
var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"] var safeClipboardTools = ["dms", "wl-copy"]
var clipboardCmdMap = { // Direct argv form — no sh -c, no escaping, no future injection surface (#49)
"dms": "dms cl copy", // clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland)
"wl-copy": "wl-copy", var clipboardTools = {
"clipmanctl": "clipmanctl copy" "dms": { argv: ["dms", "cl", "copy"] },
"wl-copy": { argv: ["wl-copy"] }
} }
function validateClipboardTool(tool) { function validateClipboardTool(tool) {
@ -39,11 +45,10 @@ function validateClipboardTool(tool) {
} }
function buildCopyCommand(text, tool) { function buildCopyCommand(text, tool) {
if (!validateClipboardTool(tool)) return null const entry = clipboardTools[tool]
var cmd = clipboardCmdMap[tool] if (!entry) return null
if (!cmd) return null // Pure argv — the Process will pass the string verbatim. No shell involved.
var escaped = text.replace(/'/g, "'\\''") return [...entry.argv, text]
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd]
} }
function nextClipboardTool(currentTool) { function nextClipboardTool(currentTool) {
@ -56,6 +61,30 @@ function allClipboardTools() {
return safeClipboardTools.slice() return safeClipboardTools.slice()
} }
function getStrings() {
return {
header: "Tailscale",
connected: "Connected",
disconnected: "Disconnected",
exitNodePrefix: "Exit node: ",
none: "None",
copied: function (text) { return "Copied " + text + " to clipboard" },
noClipboardTool: "No clipboard tool found",
invalidClipboardCommand: "Invalid clipboard command",
clearExitNode: "×",
setExitNode: "↗"
}
}
// Light UI predicates extracted from QML (advances #43 — keeps view thin)
function shouldShowClearExitNode(currentExitNode) {
return currentExitNode !== ""
}
function isActiveExitNode(currentExitNode, hostname) {
return currentExitNode === hostname
}
function parseStatusResult(jsonText) { function parseStatusResult(jsonText) {
try { try {
const data = JSON.parse(jsonText) const data = JSON.parse(jsonText)
@ -88,5 +117,5 @@ function errorMessage(cmd) {
// CommonJS export for Node.js tests (ignored by QML) // CommonJS export for Node.js tests (ignored by QML)
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
} }

View file

@ -3,7 +3,7 @@
"name": "Tailscale", "name": "Tailscale",
"description": "Tailscale status and controls on the Dank Bar", "description": "Tailscale status and controls on the Dank Bar",
"version": "0.1.0", "version": "0.1.0",
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)", "author": "John Morris",
"icon": "vpn_key", "icon": "vpn_key",
"type": "widget", "type": "widget",
"component": "./TailscaleWidget.qml", "component": "./TailscaleWidget.qml",

View file

@ -1,7 +1,20 @@
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, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib
/*
* Test coverage note for #48:
* All pure functions exported from lib.js (including the newly extracted
* getStrings, shouldShowClearExitNode, isActiveExitNode, and the clipboard
* helpers) are exercised here via node:test.
*
* The four Process objects, 5-second Timer, onExited handlers, copy fallback
* state machine, and overall widget behavior in TailscaleWidget.qml have no
* automated test coverage. There is no QML test runner in this project.
* Those paths are verified manually (see verification checklist in the
* feature branch commits).
*/
test("parsePeers extracts exitNode from ExitNodeOption", () => { test("parsePeers extracts exitNode from ExitNodeOption", () => {
const peerMap = { const peerMap = {
@ -80,7 +93,7 @@ test("errorMessage returns generic message for unknown command", () => {
test("validateClipboardTool accepts whitelisted tool names", () => { test("validateClipboardTool accepts whitelisted tool names", () => {
assert.strictEqual(validateClipboardTool("dms"), true) assert.strictEqual(validateClipboardTool("dms"), true)
assert.strictEqual(validateClipboardTool("wl-copy"), true) assert.strictEqual(validateClipboardTool("wl-copy"), true)
assert.strictEqual(validateClipboardTool("clipmanctl"), true) // clipmanctl intentionally dropped (see #49)
}) })
test("validateClipboardTool rejects malicious inputs", () => { test("validateClipboardTool rejects malicious inputs", () => {
@ -101,8 +114,7 @@ test("validateClipboardTool rejects empty and falsy inputs", () => {
test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => { test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => {
const cmd = buildCopyCommand("hello", "wl-copy") const cmd = buildCopyCommand("hello", "wl-copy")
assert.ok(Array.isArray(cmd)) assert.ok(Array.isArray(cmd))
assert.strictEqual(cmd[0], "sh") assert.deepStrictEqual(cmd, ["wl-copy", "hello"]) // direct argv, no sh -c (see #49)
assert.strictEqual(cmd[1], "-c")
}) })
test("buildCopyCommand returns null for invalid clipboard tool", () => { test("buildCopyCommand returns null for invalid clipboard tool", () => {
@ -120,8 +132,7 @@ test("buildCopyCommand safely handles text with special characters", () => {
test("nextClipboardTool cycles through tools", () => { test("nextClipboardTool cycles through tools", () => {
assert.strictEqual(nextClipboardTool("dms"), "wl-copy") assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl") assert.strictEqual(nextClipboardTool("wl-copy"), "dms") // clipmanctl dropped (#49)
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
}) })
test("nextClipboardTool falls back to first tool for unknown input", () => { test("nextClipboardTool falls back to first tool for unknown input", () => {
@ -134,10 +145,40 @@ test("nextClipboardTool falls back to first tool for unknown input", () => {
test("allClipboardTools returns the list of safe tools", () => { test("allClipboardTools returns the list of safe tools", () => {
const tools = allClipboardTools() const tools = allClipboardTools()
assert.ok(Array.isArray(tools)) assert.ok(Array.isArray(tools))
assert.strictEqual(tools.length, 3) assert.strictEqual(tools.length, 2)
assert.ok(tools.includes("dms")) assert.ok(tools.includes("dms"))
assert.ok(tools.includes("wl-copy")) assert.ok(tools.includes("wl-copy"))
assert.ok(tools.includes("clipmanctl")) // clipmanctl intentionally removed (#49)
})
// --- getStrings (for #34 i18n prep) ---
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)
assert.ok(s.noClipboardTool)
assert.ok(s.invalidClipboardCommand)
})
test("getStrings.copied interpolates the text", () => {
const s = getStrings()
const msg = s.copied("100.64.0.5")
assert.ok(msg.includes("100.64.0.5"))
})
test("shouldShowClearExitNode returns true only when there is a current exit node", () => {
assert.strictEqual(shouldShowClearExitNode("router"), true)
assert.strictEqual(shouldShowClearExitNode(""), 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)
}) })
// --- buildToggleCommand --- // --- buildToggleCommand ---