Compare commits

..

No commits in common. "59ed4ebf44874c607f43508c9360c1f8197179fb" and "e395e26aea44d8bf9d16a87df6a3ecaceb9b23d1" have entirely different histories.

5 changed files with 46 additions and 121 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` or `wl-copy`)
- A clipboard tool (`dms`, `wl-copy`, or `clipmanctl`)
## Installation

View file

@ -12,28 +12,23 @@ PluginComponent {
property bool isConnected: false
property string tailscaleIP: ""
property string _pendingToggleAction: ""
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 poll-then-act (#42) reset immediately after use, no "desired state" cache
layerNamespacePlugin: "tailscalectl"
popoutWidth: 360
popoutHeight: 400
Timer {
id: refreshTimer
interval: 5000
running: true
repeat: true
onTriggered: {
if (!statusCheck.running) {
statusCheck.running = true
}
}
onTriggered: statusCheck.running = true
}
Component.onCompleted: {
@ -47,10 +42,11 @@ PluginComponent {
onExited: (code, status) => {
if (code !== 0) {
var action = root.isConnected ? "disconnect" : "connect"
var action = root._pendingToggleAction || (root.isConnected ? "disconnect" : "connect")
var detail = toggleProcess.stderr.text.trim().slice(0, 120)
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : ""))
}
root._pendingToggleAction = ""
statusCheck.running = true
}
}
@ -96,42 +92,36 @@ PluginComponent {
command: ["tailscale", "status", "--json"]
stdout: StdioCollector {}
onExited: (code, status) => {
if (code === 0) {
const state = TailscaleLib.parseStatusResult(statusCheck.stdout.text)
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
} else {
}
}
onExited: (code, status) => {
if (code !== 0) {
root.isConnected = false
root.tailscaleIP = ""
root.currentExitNode = ""
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() {
// #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
if (toggleProcess.running) return
root._pendingToggleAction = root.isConnected ? "disconnect" : "connect"
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
toggleProcess.running = true
}
function setExitNode(hostname) {
if (exitNodeProcess.running) return
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
exitNodeProcess.running = true
}
@ -214,7 +204,7 @@ PluginComponent {
}
MouseArea {
visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode)
visible: root.currentExitNode !== ""
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter
@ -304,7 +294,7 @@ PluginComponent {
id: exitNodeButton
text: "↗"
font.pixelSize: Theme.fontSizeSmall
color: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
}
}
}
@ -314,9 +304,14 @@ PluginComponent {
}
}
// propagateComposedEvents: true so that right-clicks both trigger our context menu
// *and* bubble to any parent MouseArea (e.g. for shell-level drag handling).
// Documented because the default (false) is far more common and this choice
// frequently surprises future maintainers.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
propagateComposedEvents: true
onClicked: {
root.toggleTailscale()
}

View file

@ -1,7 +1,6 @@
function parsePeers(peerMap) {
if (!peerMap) return []
return Object.keys(peerMap).map(function (key) {
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) return null
var p = peerMap[key]
return {
hostname: p.HostName || key,
@ -9,20 +8,16 @@ function parsePeers(peerMap) {
online: p.Online || false,
exitNode: p.ExitNodeOption || false
}
}).filter(function (peer) { return peer !== null })
})
}
function makeExitNodeCommand(hostname) {
if (hostname === "") {
return ["tailscale", "set", "--exit-node="]
}
return ["tailscale", "set", "--exit-node=" + hostname]
}
function findActiveExitNode(peerMap) {
if (!peerMap) return ""
for (const key in peerMap) {
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) continue
const p = peerMap[key]
if (p.ExitNode) {
return p.HostName || key
@ -31,13 +26,12 @@ function findActiveExitNode(peerMap) {
return ""
}
var safeClipboardTools = ["dms", "wl-copy"]
var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"]
// 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"] }
var clipboardCmdMap = {
"dms": "dms cl copy",
"wl-copy": "wl-copy",
"clipmanctl": "clipmanctl copy"
}
function validateClipboardTool(tool) {
@ -45,10 +39,11 @@ function validateClipboardTool(tool) {
}
function buildCopyCommand(text, tool) {
const entry = clipboardTools[tool]
if (!entry) return null
// Pure argv — the Process will pass the string verbatim. No shell involved.
return [...entry.argv, text]
if (!validateClipboardTool(tool)) return null
var cmd = clipboardCmdMap[tool]
if (!cmd) return null
var escaped = text.replace(/'/g, "'\\''")
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd]
}
function nextClipboardTool(currentTool) {
@ -61,30 +56,6 @@ 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)
@ -117,5 +88,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, getStrings, shouldShowClearExitNode, isActiveExitNode }
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult }
}

View file

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

View file

@ -1,20 +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
/*
* 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).
*/
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib
test("parsePeers extracts exitNode from ExitNodeOption", () => {
const peerMap = {
@ -93,7 +80,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)
// clipmanctl intentionally dropped (see #49)
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
})
test("validateClipboardTool rejects malicious inputs", () => {
@ -114,7 +101,8 @@ 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.deepStrictEqual(cmd, ["wl-copy", "hello"]) // direct argv, no sh -c (see #49)
assert.strictEqual(cmd[0], "sh")
assert.strictEqual(cmd[1], "-c")
})
test("buildCopyCommand returns null for invalid clipboard tool", () => {
@ -132,7 +120,8 @@ test("buildCopyCommand safely handles text with special characters", () => {
test("nextClipboardTool cycles through tools", () => {
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
assert.strictEqual(nextClipboardTool("wl-copy"), "dms") // clipmanctl dropped (#49)
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
})
test("nextClipboardTool falls back to first tool for unknown input", () => {
@ -145,40 +134,10 @@ 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, 2)
assert.strictEqual(tools.length, 3)
assert.ok(tools.includes("dms"))
assert.ok(tools.includes("wl-copy"))
// 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)
assert.ok(tools.includes("clipmanctl"))
})
// --- buildToggleCommand ---