Compare commits
No commits in common. "59ed4ebf44874c607f43508c9360c1f8197179fb" and "e395e26aea44d8bf9d16a87df6a3ecaceb9b23d1" have entirely different histories.
59ed4ebf44
...
e395e26aea
5 changed files with 46 additions and 121 deletions
|
|
@ -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` or `wl-copy`)
|
- A clipboard tool (`dms`, `wl-copy`, or `clipmanctl`)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,28 +12,23 @@ 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: {
|
onTriggered: statusCheck.running = true
|
||||||
if (!statusCheck.running) {
|
|
||||||
statusCheck.running = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
|
|
@ -47,10 +42,11 @@ PluginComponent {
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
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)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,42 +92,36 @@ PluginComponent {
|
||||||
|
|
||||||
command: ["tailscale", "status", "--json"]
|
command: ["tailscale", "status", "--json"]
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
onExited: (code, status) => {
|
const state = TailscaleLib.parseStatusResult(this.text)
|
||||||
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() {
|
||||||
// #42: poll-then-act — never decide up/down from potentially stale root.isConnected.
|
if (toggleProcess.running) return
|
||||||
// Force a fresh statusCheck; the decision happens in its onExited using the just-received value.
|
root._pendingToggleAction = root.isConnected ? "disconnect" : "connect"
|
||||||
root._pendingToggle = true
|
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
|
||||||
statusCheck.running = true
|
toggleProcess.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
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +204,7 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode)
|
visible: root.currentExitNode !== ""
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
@ -304,7 +294,7 @@ PluginComponent {
|
||||||
id: exitNodeButton
|
id: exitNodeButton
|
||||||
text: "↗"
|
text: "↗"
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
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 {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.RightButton
|
acceptedButtons: Qt.RightButton
|
||||||
|
propagateComposedEvents: true
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.toggleTailscale()
|
root.toggleTailscale()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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,
|
||||||
|
|
@ -9,20 +8,16 @@ 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
|
||||||
|
|
@ -31,13 +26,12 @@ function findActiveExitNode(peerMap) {
|
||||||
return ""
|
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)
|
var clipboardCmdMap = {
|
||||||
// clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland)
|
"dms": "dms cl copy",
|
||||||
var clipboardTools = {
|
"wl-copy": "wl-copy",
|
||||||
"dms": { argv: ["dms", "cl", "copy"] },
|
"clipmanctl": "clipmanctl copy"
|
||||||
"wl-copy": { argv: ["wl-copy"] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateClipboardTool(tool) {
|
function validateClipboardTool(tool) {
|
||||||
|
|
@ -45,10 +39,11 @@ function validateClipboardTool(tool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCopyCommand(text, tool) {
|
function buildCopyCommand(text, tool) {
|
||||||
const entry = clipboardTools[tool]
|
if (!validateClipboardTool(tool)) return null
|
||||||
if (!entry) return null
|
var cmd = clipboardCmdMap[tool]
|
||||||
// Pure argv — the Process will pass the string verbatim. No shell involved.
|
if (!cmd) return null
|
||||||
return [...entry.argv, text]
|
var escaped = text.replace(/'/g, "'\\''")
|
||||||
|
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd]
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextClipboardTool(currentTool) {
|
function nextClipboardTool(currentTool) {
|
||||||
|
|
@ -61,30 +56,6 @@ 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)
|
||||||
|
|
@ -117,5 +88,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, getStrings, shouldShowClearExitNode, isActiveExitNode }
|
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)",
|
||||||
"icon": "vpn_key",
|
"icon": "vpn_key",
|
||||||
"type": "widget",
|
"type": "widget",
|
||||||
"component": "./TailscaleWidget.qml",
|
"component": "./TailscaleWidget.qml",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +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, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib
|
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = 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 = {
|
||||||
|
|
@ -93,7 +80,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)
|
||||||
// clipmanctl intentionally dropped (see #49)
|
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("validateClipboardTool rejects malicious inputs", () => {
|
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", () => {
|
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.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", () => {
|
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", () => {
|
test("nextClipboardTool cycles through tools", () => {
|
||||||
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
|
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", () => {
|
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", () => {
|
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, 2)
|
assert.strictEqual(tools.length, 3)
|
||||||
assert.ok(tools.includes("dms"))
|
assert.ok(tools.includes("dms"))
|
||||||
assert.ok(tools.includes("wl-copy"))
|
assert.ok(tools.includes("wl-copy"))
|
||||||
// clipmanctl intentionally removed (#49)
|
assert.ok(tools.includes("clipmanctl"))
|
||||||
})
|
|
||||||
|
|
||||||
// --- 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 ---
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue