diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index d1849de..cc58653 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -15,7 +15,10 @@ PluginComponent { property string tailscaleIP: "" property string currentExitNode: "" property var peers: [] - property string clipboardCmd: "" + property string cachedClipboardTool: "" + property string _copyText: "" + property string _copyCurrentTool: "" + property int _copyAttempted: 0 layerNamespacePlugin: "tailscalectl" popoutWidth: 360 @@ -29,16 +32,19 @@ PluginComponent { } Component.onCompleted: { - detectClipboard.running = true + statusCheck.running = true } Process { id: toggleProcess + stderr: StdioCollector {} + onExited: (code, status) => { if (code !== 0) { var action = root.isConnected ? "disconnect" : "connect" - ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action)) + var detail = toggleProcess.stderr.text.trim().slice(0, 120) + ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : "")) } statusCheck.running = true } @@ -46,35 +52,40 @@ PluginComponent { Process { id: copyProcess + + stderr: StdioCollector {} + + onExited: (code, status) => { + if (code === 0) { + root.cachedClipboardTool = root._copyCurrentTool + ToastService.showInfo("Copied " + root._copyText + " to clipboard") + } 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 : "")) + } + } + } } Process { id: exitNodeProcess + stderr: StdioCollector {} + onExited: (code, status) => { if (code !== 0) { - ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set")) + var detail = exitNodeProcess.stderr.text.trim().slice(0, 120) + ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set") + (detail ? " — " + detail : "")) } statusCheck.running = true } } - Process { - id: detectClipboard - - command: ["sh", "-c", "if which dms >/dev/null 2>&1; then echo 'dms cl copy'; elif which wl-copy >/dev/null 2>&1; then echo 'wl-copy'; elif which clipmanctl >/dev/null 2>&1; then echo 'clipmanctl copy'; elif which xclip >/dev/null 2>&1; then echo 'xclip -selection clipboard'; elif which xsel >/dev/null 2>&1; then echo 'xsel --clipboard --input'; else echo none; fi"] - - stdout: StdioCollector { - onStreamFinished: { - root.clipboardCmd = TailscaleLib.parseClipboardDetection(this.text) - } - } - - onExited: (code, status) => { - statusCheck.running = true - } - } - Process { id: statusCheck @@ -125,18 +136,24 @@ PluginComponent { } function copyToClipboard(text) { - if (!root.clipboardCmd || root.clipboardCmd === "none") { - ToastService.showError("tailscalectl", "No clipboard tool found") - return + root._copyText = text + root._copyAttempted = 0 + if (root.cachedClipboardTool) { + root._copyCurrentTool = root.cachedClipboardTool + } else { + root._copyCurrentTool = TailscaleLib.allClipboardTools()[0] } - var cmd = TailscaleLib.buildCopyCommand(text, root.clipboardCmd) + root._executeCopy() + } + + function _executeCopy() { + var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool) if (!cmd) { ToastService.showError("tailscalectl", "Invalid clipboard command") return } copyProcess.command = cmd copyProcess.running = true - ToastService.showInfo("Copied " + text + " to clipboard") } popoutContent: Component { diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index dff4060..e24d8ba 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -26,16 +26,34 @@ function findActiveExitNode(peerMap) { return "" } -var safeClipboardCmds = ["dms cl copy", "wl-copy", "clipmanctl copy", "xclip -selection clipboard", "xsel --clipboard --input", "none"] +var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"] -function validateClipboardCmd(cmd) { - return typeof cmd === "string" && safeClipboardCmds.includes(cmd) +var clipboardCmdMap = { + "dms": "dms cl copy", + "wl-copy": "wl-copy", + "clipmanctl": "clipmanctl copy" } -function buildCopyCommand(text, clipboardCmd) { - if (!validateClipboardCmd(clipboardCmd)) return null +function validateClipboardTool(tool) { + return typeof tool === "string" && safeClipboardTools.includes(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 + "' | " + clipboardCmd] + return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd] +} + +function nextClipboardTool(currentTool) { + var idx = safeClipboardTools.indexOf(currentTool) + if (idx < 0) return safeClipboardTools[0] + return safeClipboardTools[(idx + 1) % safeClipboardTools.length] +} + +function allClipboardTools() { + return safeClipboardTools.slice() } function parseStatusResult(jsonText) { @@ -56,11 +74,6 @@ function buildToggleCommand(isConnected) { return isConnected ? ["tailscale", "down"] : ["tailscale", "up"] } -function parseClipboardDetection(stdout) { - var trimmed = typeof stdout === "string" ? stdout.trim() : "" - return validateClipboardCmd(trimmed) ? trimmed : "none" -} - function errorMessage(cmd) { var messages = { "up": "Failed to connect to Tailscale", @@ -75,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, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult } + module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } } diff --git a/test/lib.test.js b/test/lib.test.js index e433e96..0530478 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, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult } = lib +const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib test("parsePeers extracts exitNode from ExitNodeOption", () => { const peerMap = { @@ -75,28 +75,25 @@ test("errorMessage returns generic message for unknown command", () => { assert.strictEqual(msg, "Tailscale command failed") }) -// --- validateClipboardCmd --- +// --- validateClipboardTool --- -test("validateClipboardCmd accepts whitelisted values", () => { - assert.strictEqual(validateClipboardCmd("dms cl copy"), true) - assert.strictEqual(validateClipboardCmd("wl-copy"), true) - assert.strictEqual(validateClipboardCmd("clipmanctl copy"), true) - assert.strictEqual(validateClipboardCmd("xclip -selection clipboard"), true) - assert.strictEqual(validateClipboardCmd("xsel --clipboard --input"), true) - assert.strictEqual(validateClipboardCmd("none"), true) +test("validateClipboardTool accepts whitelisted tool names", () => { + assert.strictEqual(validateClipboardTool("dms"), true) + assert.strictEqual(validateClipboardTool("wl-copy"), true) + assert.strictEqual(validateClipboardTool("clipmanctl"), true) }) -test("validateClipboardCmd rejects malicious inputs", () => { - assert.strictEqual(validateClipboardCmd("rm -rf /"), false) - assert.strictEqual(validateClipboardCmd("echo hi; malicious"), false) - assert.strictEqual(validateClipboardCmd("$(whoami)"), false) - assert.strictEqual(validateClipboardCmd("wl-copy || rm -rf /"), false) +test("validateClipboardTool rejects malicious inputs", () => { + assert.strictEqual(validateClipboardTool("rm -rf /"), false) + assert.strictEqual(validateClipboardTool("echo hi; malicious"), false) + assert.strictEqual(validateClipboardTool("$(whoami)"), false) + assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false) }) -test("validateClipboardCmd rejects empty and falsy inputs", () => { - assert.strictEqual(validateClipboardCmd(""), false) - assert.strictEqual(validateClipboardCmd(null), false) - assert.strictEqual(validateClipboardCmd(undefined), false) +test("validateClipboardTool rejects empty and falsy inputs", () => { + assert.strictEqual(validateClipboardTool(""), false) + assert.strictEqual(validateClipboardTool(null), false) + assert.strictEqual(validateClipboardTool(undefined), false) }) // --- buildCopyCommand --- @@ -108,7 +105,7 @@ test("buildCopyCommand returns safe command for whitelisted clipboard tool", () assert.strictEqual(cmd[1], "-c") }) -test("buildCopyCommand returns null for invalid clipboard command", () => { +test("buildCopyCommand returns null for invalid clipboard tool", () => { assert.strictEqual(buildCopyCommand("hello", "rm -rf /"), null) assert.strictEqual(buildCopyCommand("hello", ""), null) assert.strictEqual(buildCopyCommand("hello", null), null) @@ -119,31 +116,28 @@ test("buildCopyCommand safely handles text with special characters", () => { assert.ok(Array.isArray(cmd)) }) -// --- parseClipboardDetection --- +// --- nextClipboardTool --- -test("parseClipboardDetection passes through expected output strings", () => { - assert.strictEqual(parseClipboardDetection("dms cl copy"), "dms cl copy") - assert.strictEqual(parseClipboardDetection("wl-copy"), "wl-copy") - assert.strictEqual(parseClipboardDetection("clipmanctl copy"), "clipmanctl copy") - assert.strictEqual(parseClipboardDetection("none"), "none") +test("nextClipboardTool cycles through tools", () => { + assert.strictEqual(nextClipboardTool("dms"), "wl-copy") + assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl") + assert.strictEqual(nextClipboardTool("clipmanctl"), "dms") }) -test("parseClipboardDetection trims extra whitespace", () => { - assert.strictEqual(parseClipboardDetection(" wl-copy \n"), "wl-copy") +test("nextClipboardTool falls back to first tool for unknown input", () => { + assert.strictEqual(nextClipboardTool(""), "dms") + assert.strictEqual(nextClipboardTool("unknown"), "dms") }) -test("parseClipboardDetection falls back to none for multi-line output", () => { - assert.strictEqual(parseClipboardDetection("wl-copy\nrm -rf /"), "none") -}) +// --- allClipboardTools --- -test("parseClipboardDetection falls back to empty output", () => { - assert.strictEqual(parseClipboardDetection(""), "none") - assert.strictEqual(parseClipboardDetection(" "), "none") -}) - -test("parseClipboardDetection falls back to none for unexpected strings", () => { - assert.strictEqual(parseClipboardDetection("pbpaste"), "none") - assert.strictEqual(parseClipboardDetection("custom-tool"), "none") +test("allClipboardTools returns the list of safe tools", () => { + const tools = allClipboardTools() + assert.ok(Array.isArray(tools)) + assert.strictEqual(tools.length, 3) + assert.ok(tools.includes("dms")) + assert.ok(tools.includes("wl-copy")) + assert.ok(tools.includes("clipmanctl")) }) // --- buildToggleCommand ---