From d6b704b8847fcc4341589f8123517575b8c06b67 Mon Sep 17 00:00:00 2001 From: vybe Date: Fri, 22 May 2026 10:19:25 +0000 Subject: [PATCH 1/6] refactor: introduce formatError centralizer + remove first batch of historical comments (TDD step 1 of first-principles plan) --- tailscalectl/lib.js | 25 ++++++++++++++++++------- test/lib.test.js | 22 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index e367975..4dfaba1 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -33,9 +33,9 @@ function findActiveExitNode(peerMap) { var safeClipboardTools = ["dms", "wl-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 = { + // Direct argv form — no sh -c, no escaping, no future injection surface. + // clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland). + var clipboardTools = { "dms": { argv: ["dms", "cl", "copy"] }, "wl-copy": { argv: ["wl-copy"] } } @@ -76,8 +76,8 @@ function getStrings() { } } -// Light UI predicates extracted from QML (advances #43 — keeps view thin) -function shouldShowClearExitNode(currentExitNode) { + // Light UI predicates — keep the view thin. + function shouldShowClearExitNode(currentExitNode) { return currentExitNode !== "" } @@ -111,11 +111,22 @@ function errorMessage(cmd) { "disconnect": "Failed to disconnect from Tailscale", "set": "Failed to set exit node", "status": "Failed to read Tailscale status" + }; + 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 messages[cmd] || "Tailscale command failed" + return base; } // 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, getStrings, shouldShowClearExitNode, isActiveExitNode } + module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } } diff --git a/test/lib.test.js b/test/lib.test.js index cb64f07..1ca6db6 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -1,7 +1,7 @@ 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, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib +const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib /* * Test coverage note for #48: @@ -240,3 +240,23 @@ test("parseStatusResult sets isConnected false for non-Running BackendState", () const state = parseStatusResult(json) assert.strictEqual(state.isConnected, false) }) + +// --- formatError (central error + detail formatting per first-principles plan) --- + +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") +}) From 25e56c33eaccc55d8fe0eecbe38e3d60b84a6c6d Mon Sep 17 00:00:00 2001 From: vybe Date: Fri, 22 May 2026 10:19:39 +0000 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20add=20getStatusCommand=20pure?= =?UTF-8?q?=20helper=20+=20test=20(TDD=20step=202=20=E2=80=94=20prepares?= =?UTF-8?q?=20low-duty-cycle=20status=20calls)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tailscalectl/lib.js | 7 ++++++- test/lib.test.js | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index 4dfaba1..b675532 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -103,6 +103,11 @@ 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"] +} + function errorMessage(cmd) { var messages = { "up": "Failed to connect to Tailscale", @@ -128,5 +133,5 @@ function formatError(action, detail) { // CommonJS export for Node.js tests (ignored by QML) if (typeof module !== "undefined" && module.exports) { - module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } + module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } } diff --git a/test/lib.test.js b/test/lib.test.js index 1ca6db6..22ad0e6 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -1,7 +1,7 @@ import { test } from "node:test" import assert from "node:assert" import lib from "../tailscalectl/lib.js" -const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib +const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib /* * Test coverage note for #48: @@ -260,3 +260,10 @@ 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") }) + +// --- getStatusCommand (new pure helper for low-duty-cycle architecture) --- + +test("getStatusCommand returns the canonical tailscale status --json argv", () => { + const cmd = getStatusCommand() + assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]) +}) From 4db80f7f1e9121e370a1560e29b0f49749ce2f0a Mon Sep 17 00:00:00 2001 From: vybe Date: Fri, 22 May 2026 10:19:48 +0000 Subject: [PATCH 3/6] chore: remove historical issue-chasing comments from QML and lib.js (per first-principles plan) --- tailscalectl/TailscaleWidget.qml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index a4fe36f..864b9a6 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -18,7 +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 + property bool _pendingToggle: false // transient one-shot for post-action verification (to be removed in first-principles refactor) layerNamespacePlugin: "tailscalectl" popoutWidth: 360 @@ -113,9 +113,7 @@ PluginComponent { 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. + // Post-action verification: if a toggle was requested, use the fresh state we just received. if (root._pendingToggle) { root._pendingToggle = false toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected) @@ -125,8 +123,7 @@ PluginComponent { } function toggleTailscale() { - // #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. + // Post-action verification: force a fresh status check, then act on the real ground truth. root._pendingToggle = true statusCheck.running = true } From aa3ade1777c5b0c7e2670fbbf3fed011abf07cd9 Mon Sep 17 00:00:00 2001 From: vybe Date: Fri, 22 May 2026 10:19:56 +0000 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20remove=205-second=20refreshTimer=20?= =?UTF-8?q?=E2=80=94=20first=20major=20step=20toward=20low-duty-cycle=20ar?= =?UTF-8?q?chitecture=20(on-demand=20+=20post-action=20verification=20only?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tailscalectl/TailscaleWidget.qml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index 864b9a6..fd77bb4 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -24,19 +24,8 @@ PluginComponent { popoutWidth: 360 popoutHeight: 400 - Timer { - id: refreshTimer - interval: 5000 - running: true - repeat: true - onTriggered: { - if (!statusCheck.running) { - statusCheck.running = true - } - } - } - Component.onCompleted: { + // Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification). statusCheck.running = true } From 2b3b45ccfa7d6d28237d405857a7953e900584e5 Mon Sep 17 00:00:00 2001 From: vybe Date: Fri, 22 May 2026 10:20:27 +0000 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20simplify=20copy=20path=20?= =?UTF-8?q?=E2=80=94=20drop=20caching=20and=20multi-tool=20retry=20state?= =?UTF-8?q?=20machine,=20keep=20simple=20dms=E2=86=92wl-copy=20fallback=20?= =?UTF-8?q?(first-principles=20plan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tailscalectl/TailscaleWidget.qml | 29 ++++++++++------------------- tailscalectl/lib.js | 3 ++- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index fd77bb4..04b3a7e 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -14,10 +14,8 @@ PluginComponent { property string tailscaleIP: "" property string currentExitNode: "" property var peers: [] - property string cachedClipboardTool: "" property string _copyText: "" property string _copyCurrentTool: "" - property int _copyAttempted: 0 property bool _pendingToggle: false // transient one-shot for post-action verification (to be removed in first-principles refactor) layerNamespacePlugin: "tailscalectl" @@ -51,17 +49,14 @@ PluginComponent { onExited: (code, status) => { if (code === 0) { - root.cachedClipboardTool = root._copyCurrentTool - ToastService.showInfo("Copied " + root._copyText + " to clipboard") + ToastService.showInfo(TailscaleLib.getStrings().copied(root._copyText)) + } else if (root._copyCurrentTool === "dms") { + // One simple fallback attempt: try wl-copy. + root._copyCurrentTool = "wl-copy" + root._executeCopy() } else { - 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 : "")) - } + var detail = copyProcess.stderr.text.trim() + ToastService.showError("tailscalectl", TailscaleLib.formatError("clipboard", detail)) } } } @@ -124,19 +119,15 @@ PluginComponent { function copyToClipboard(text) { root._copyText = text - root._copyAttempted = 0 - if (root.cachedClipboardTool) { - root._copyCurrentTool = root.cachedClipboardTool - } else { - root._copyCurrentTool = TailscaleLib.allClipboardTools()[0] - } + // Simple two-tool fallback per first-principles plan: dms first, then wl-copy. + root._copyCurrentTool = "dms" root._executeCopy() } function _executeCopy() { var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool) if (!cmd) { - ToastService.showError("tailscalectl", "Invalid clipboard command") + ToastService.showError("tailscalectl", TailscaleLib.formatError("clipboard")) return } copyProcess.command = cmd diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index b675532..86efe02 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -115,7 +115,8 @@ 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" + "status": "Failed to read Tailscale status", + "clipboard": "Error copying to clipboard" }; return messages[cmd] || "Tailscale command failed"; } From 740bc81cea59de115a51d3847422c98e6d7b3b83 Mon Sep 17 00:00:00 2001 From: vybe Date: Fri, 22 May 2026 10:20:47 +0000 Subject: [PATCH 6/6] refactor: centralize remaining error paths and popout strings via formatError + getStrings (final polish pass) --- tailscalectl/TailscaleWidget.qml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index 04b3a7e..9b478a4 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -35,8 +35,8 @@ PluginComponent { 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 : "")) + var detail = toggleProcess.stderr.text.trim() + ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail)) } statusCheck.running = true } @@ -68,8 +68,8 @@ PluginComponent { onExited: (code, status) => { if (code !== 0) { - var detail = exitNodeProcess.stderr.text.trim().slice(0, 120) - ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set") + (detail ? " — " + detail : "")) + var detail = exitNodeProcess.stderr.text.trim() + ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail)) } statusCheck.running = true } @@ -94,7 +94,7 @@ PluginComponent { root.tailscaleIP = "" root.currentExitNode = "" root.peers = [] - ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status")) + ToastService.showError("tailscalectl", TailscaleLib.formatError("status")) } // Post-action verification: if a toggle was requested, use the fresh state we just received. @@ -137,7 +137,7 @@ PluginComponent { popoutContent: Component { PopoutComponent { headerText: "Tailscale" - detailsText: root.isConnected ? "Connected" : "Disconnected" + detailsText: root.isConnected ? TailscaleLib.getStrings().connected : TailscaleLib.getStrings().disconnected showCloseButton: true Item { @@ -184,7 +184,7 @@ PluginComponent { Item { width: 1; height: 1; Layout.fillWidth: true } StyledText { - text: "Exit node: " + (root.currentExitNode || "None") + text: TailscaleLib.getStrings().exitNodePrefix + (root.currentExitNode || TailscaleLib.getStrings().none) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter