refactor: replace detectClipboard with try-fallback-cached clipboard strategy

- Remove detectClipboard Process and clipboardCmd property
- Add try-fallback-cached copy logic: attempt cached tool first,
  then cycle through hardcoded safe tools (dms, wl-copy, clipmanctl)
- Add stderr StdioCollector to all Process components for error details
- Update lib.js: replace parseClipboardDetection with validateClipboardTool,
  buildCopyCommand, nextClipboardTool, allClipboardTools
- Update tests to match new lib.js API

Benefits:
- No startup delay from clipboard detection process
- Robust fallback if clipboard tool becomes unavailable
- Better error messages with stderr details from failed commands
This commit is contained in:
Vybe (Coding Agent) 2026-05-21 06:37:14 +00:00
parent cac0ae6bde
commit 701daa9a39
3 changed files with 99 additions and 75 deletions

View file

@ -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 {

View file

@ -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 }
}

View file

@ -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 ---