Compare commits

..

10 commits

Author SHA1 Message Date
0283ce6136 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:42:11 +00:00
5219271be3 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:42:11 +00:00
d6938c72ea 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:42:11 +00:00
40a408e029 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:42:11 +00:00
06925ed3fc 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:42:11 +00:00
5bf8e00cac chore: remaining issues batch progress (#23-#34) 2026-05-22 06:42:11 +00:00
c99dc29aae chore: JS quality batch (#20 for-in guard, #21 add edge tests, #22 dedup errorMessage keys) 2026-05-22 06:42:11 +00:00
bbaf195f5d chore: batch minor layout fixes (#18, #19, #27) - Row anchors, boundsBehavior 2026-05-22 06:42:10 +00:00
a095abb2b4 chore: verify copyProcess already has proper onExited exit-status check + success toast
Toast moved out of copyToClipboard() into onExited (code===0 path).
Fallback clipboard rotation also present.
Issue #17 already addressed by existing implementation.
Fixes #17
2026-05-22 06:42:10 +00:00
202462bf53 chore: confirm single authoritative error path for statusCheck
parseStatusResult safely catches JSON errors (lib.js:68).
onStreamFinished performs no toasts.
All failure toasts centralized in statusCheck.onExited (TailscaleWidget.qml:103).
Eliminates historical double-toast spam described in #13.
Fixes #13
2026-05-22 06:42:10 +00:00
4 changed files with 101 additions and 24 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
- `tailscale` CLI available on `PATH`
- A clipboard tool (`dms`, `wl-copy`, or `clipmanctl`)
- A clipboard tool (`dms` or `wl-copy`)
## Installation

View file

@ -18,6 +18,7 @@ PluginComponent {
property string _copyText: ""
property string _copyCurrentTool: ""
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"
popoutWidth: 360
@ -111,12 +112,23 @@ PluginComponent {
root.peers = []
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() {
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
toggleProcess.running = true
// #42: poll-then-act never decide up/down from potentially stale root.isConnected.
// Force a fresh statusCheck; the decision happens in its onExited using the just-received value.
root._pendingToggle = true
statusCheck.running = true
}
function setExitNode(hostname) {
@ -202,7 +214,7 @@ PluginComponent {
}
MouseArea {
visible: root.currentExitNode !== ""
visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode)
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter
@ -292,7 +304,7 @@ PluginComponent {
id: exitNodeButton
text: "↗"
font.pixelSize: Theme.fontSizeSmall
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
color: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
}
}
}

View file

@ -31,12 +31,13 @@ function findActiveExitNode(peerMap) {
return ""
}
var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"]
var safeClipboardTools = ["dms", "wl-copy"]
var clipboardCmdMap = {
"dms": "dms cl copy",
"wl-copy": "wl-copy",
"clipmanctl": "clipmanctl copy"
// Direct argv form — no sh -c, no escaping, no future injection surface (#49)
// clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland)
var clipboardTools = {
"dms": { argv: ["dms", "cl", "copy"] },
"wl-copy": { argv: ["wl-copy"] }
}
function validateClipboardTool(tool) {
@ -44,11 +45,10 @@ function validateClipboardTool(tool) {
}
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]
const entry = clipboardTools[tool]
if (!entry) return null
// Pure argv — the Process will pass the string verbatim. No shell involved.
return [...entry.argv, text]
}
function nextClipboardTool(currentTool) {
@ -61,6 +61,30 @@ function allClipboardTools() {
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) {
try {
const data = JSON.parse(jsonText)
@ -93,5 +117,5 @@ function errorMessage(cmd) {
// CommonJS export for Node.js tests (ignored by QML)
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

@ -1,7 +1,20 @@
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
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", () => {
const peerMap = {
@ -80,7 +93,7 @@ test("errorMessage returns generic message for unknown command", () => {
test("validateClipboardTool accepts whitelisted tool names", () => {
assert.strictEqual(validateClipboardTool("dms"), true)
assert.strictEqual(validateClipboardTool("wl-copy"), true)
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
// clipmanctl intentionally dropped (see #49)
})
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", () => {
const cmd = buildCopyCommand("hello", "wl-copy")
assert.ok(Array.isArray(cmd))
assert.strictEqual(cmd[0], "sh")
assert.strictEqual(cmd[1], "-c")
assert.deepStrictEqual(cmd, ["wl-copy", "hello"]) // direct argv, no sh -c (see #49)
})
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", () => {
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
assert.strictEqual(nextClipboardTool("wl-copy"), "dms") // clipmanctl dropped (#49)
})
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", () => {
const tools = allClipboardTools()
assert.ok(Array.isArray(tools))
assert.strictEqual(tools.length, 3)
assert.strictEqual(tools.length, 2)
assert.ok(tools.includes("dms"))
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 ---