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:
parent
cac0ae6bde
commit
701daa9a39
3 changed files with 99 additions and 75 deletions
|
|
@ -15,7 +15,10 @@ PluginComponent {
|
||||||
property string tailscaleIP: ""
|
property string tailscaleIP: ""
|
||||||
property string currentExitNode: ""
|
property string currentExitNode: ""
|
||||||
property var peers: []
|
property var peers: []
|
||||||
property string clipboardCmd: ""
|
property string cachedClipboardTool: ""
|
||||||
|
property string _copyText: ""
|
||||||
|
property string _copyCurrentTool: ""
|
||||||
|
property int _copyAttempted: 0
|
||||||
|
|
||||||
layerNamespacePlugin: "tailscalectl"
|
layerNamespacePlugin: "tailscalectl"
|
||||||
popoutWidth: 360
|
popoutWidth: 360
|
||||||
|
|
@ -29,16 +32,19 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
detectClipboard.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: toggleProcess
|
id: toggleProcess
|
||||||
|
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
var action = root.isConnected ? "disconnect" : "connect"
|
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
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
@ -46,35 +52,40 @@ PluginComponent {
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: copyProcess
|
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 {
|
Process {
|
||||||
id: exitNodeProcess
|
id: exitNodeProcess
|
||||||
|
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
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
|
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 {
|
Process {
|
||||||
id: statusCheck
|
id: statusCheck
|
||||||
|
|
||||||
|
|
@ -125,18 +136,24 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
if (!root.clipboardCmd || root.clipboardCmd === "none") {
|
root._copyText = text
|
||||||
ToastService.showError("tailscalectl", "No clipboard tool found")
|
root._copyAttempted = 0
|
||||||
return
|
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) {
|
if (!cmd) {
|
||||||
ToastService.showError("tailscalectl", "Invalid clipboard command")
|
ToastService.showError("tailscalectl", "Invalid clipboard command")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
copyProcess.command = cmd
|
copyProcess.command = cmd
|
||||||
copyProcess.running = true
|
copyProcess.running = true
|
||||||
ToastService.showInfo("Copied " + text + " to clipboard")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
popoutContent: Component {
|
popoutContent: Component {
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,34 @@ function findActiveExitNode(peerMap) {
|
||||||
return ""
|
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) {
|
var clipboardCmdMap = {
|
||||||
return typeof cmd === "string" && safeClipboardCmds.includes(cmd)
|
"dms": "dms cl copy",
|
||||||
|
"wl-copy": "wl-copy",
|
||||||
|
"clipmanctl": "clipmanctl copy"
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCopyCommand(text, clipboardCmd) {
|
function validateClipboardTool(tool) {
|
||||||
if (!validateClipboardCmd(clipboardCmd)) return null
|
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, "'\\''")
|
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) {
|
function parseStatusResult(jsonText) {
|
||||||
|
|
@ -56,11 +74,6 @@ function buildToggleCommand(isConnected) {
|
||||||
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
|
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseClipboardDetection(stdout) {
|
|
||||||
var trimmed = typeof stdout === "string" ? stdout.trim() : ""
|
|
||||||
return validateClipboardCmd(trimmed) ? trimmed : "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(cmd) {
|
function errorMessage(cmd) {
|
||||||
var messages = {
|
var messages = {
|
||||||
"up": "Failed to connect to Tailscale",
|
"up": "Failed to connect to Tailscale",
|
||||||
|
|
@ -75,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, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult }
|
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +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, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult } = lib
|
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib
|
||||||
|
|
||||||
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||||
const peerMap = {
|
const peerMap = {
|
||||||
|
|
@ -75,28 +75,25 @@ test("errorMessage returns generic message for unknown command", () => {
|
||||||
assert.strictEqual(msg, "Tailscale command failed")
|
assert.strictEqual(msg, "Tailscale command failed")
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- validateClipboardCmd ---
|
// --- validateClipboardTool ---
|
||||||
|
|
||||||
test("validateClipboardCmd accepts whitelisted values", () => {
|
test("validateClipboardTool accepts whitelisted tool names", () => {
|
||||||
assert.strictEqual(validateClipboardCmd("dms cl copy"), true)
|
assert.strictEqual(validateClipboardTool("dms"), true)
|
||||||
assert.strictEqual(validateClipboardCmd("wl-copy"), true)
|
assert.strictEqual(validateClipboardTool("wl-copy"), true)
|
||||||
assert.strictEqual(validateClipboardCmd("clipmanctl copy"), true)
|
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
|
||||||
assert.strictEqual(validateClipboardCmd("xclip -selection clipboard"), true)
|
|
||||||
assert.strictEqual(validateClipboardCmd("xsel --clipboard --input"), true)
|
|
||||||
assert.strictEqual(validateClipboardCmd("none"), true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("validateClipboardCmd rejects malicious inputs", () => {
|
test("validateClipboardTool rejects malicious inputs", () => {
|
||||||
assert.strictEqual(validateClipboardCmd("rm -rf /"), false)
|
assert.strictEqual(validateClipboardTool("rm -rf /"), false)
|
||||||
assert.strictEqual(validateClipboardCmd("echo hi; malicious"), false)
|
assert.strictEqual(validateClipboardTool("echo hi; malicious"), false)
|
||||||
assert.strictEqual(validateClipboardCmd("$(whoami)"), false)
|
assert.strictEqual(validateClipboardTool("$(whoami)"), false)
|
||||||
assert.strictEqual(validateClipboardCmd("wl-copy || rm -rf /"), false)
|
assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("validateClipboardCmd rejects empty and falsy inputs", () => {
|
test("validateClipboardTool rejects empty and falsy inputs", () => {
|
||||||
assert.strictEqual(validateClipboardCmd(""), false)
|
assert.strictEqual(validateClipboardTool(""), false)
|
||||||
assert.strictEqual(validateClipboardCmd(null), false)
|
assert.strictEqual(validateClipboardTool(null), false)
|
||||||
assert.strictEqual(validateClipboardCmd(undefined), false)
|
assert.strictEqual(validateClipboardTool(undefined), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- buildCopyCommand ---
|
// --- buildCopyCommand ---
|
||||||
|
|
@ -108,7 +105,7 @@ test("buildCopyCommand returns safe command for whitelisted clipboard tool", ()
|
||||||
assert.strictEqual(cmd[1], "-c")
|
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", "rm -rf /"), null)
|
||||||
assert.strictEqual(buildCopyCommand("hello", ""), null)
|
assert.strictEqual(buildCopyCommand("hello", ""), null)
|
||||||
assert.strictEqual(buildCopyCommand("hello", null), null)
|
assert.strictEqual(buildCopyCommand("hello", null), null)
|
||||||
|
|
@ -119,31 +116,28 @@ test("buildCopyCommand safely handles text with special characters", () => {
|
||||||
assert.ok(Array.isArray(cmd))
|
assert.ok(Array.isArray(cmd))
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- parseClipboardDetection ---
|
// --- nextClipboardTool ---
|
||||||
|
|
||||||
test("parseClipboardDetection passes through expected output strings", () => {
|
test("nextClipboardTool cycles through tools", () => {
|
||||||
assert.strictEqual(parseClipboardDetection("dms cl copy"), "dms cl copy")
|
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
|
||||||
assert.strictEqual(parseClipboardDetection("wl-copy"), "wl-copy")
|
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
|
||||||
assert.strictEqual(parseClipboardDetection("clipmanctl copy"), "clipmanctl copy")
|
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
|
||||||
assert.strictEqual(parseClipboardDetection("none"), "none")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("parseClipboardDetection trims extra whitespace", () => {
|
test("nextClipboardTool falls back to first tool for unknown input", () => {
|
||||||
assert.strictEqual(parseClipboardDetection(" wl-copy \n"), "wl-copy")
|
assert.strictEqual(nextClipboardTool(""), "dms")
|
||||||
|
assert.strictEqual(nextClipboardTool("unknown"), "dms")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("parseClipboardDetection falls back to none for multi-line output", () => {
|
// --- allClipboardTools ---
|
||||||
assert.strictEqual(parseClipboardDetection("wl-copy\nrm -rf /"), "none")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parseClipboardDetection falls back to empty output", () => {
|
test("allClipboardTools returns the list of safe tools", () => {
|
||||||
assert.strictEqual(parseClipboardDetection(""), "none")
|
const tools = allClipboardTools()
|
||||||
assert.strictEqual(parseClipboardDetection(" "), "none")
|
assert.ok(Array.isArray(tools))
|
||||||
})
|
assert.strictEqual(tools.length, 3)
|
||||||
|
assert.ok(tools.includes("dms"))
|
||||||
test("parseClipboardDetection falls back to none for unexpected strings", () => {
|
assert.ok(tools.includes("wl-copy"))
|
||||||
assert.strictEqual(parseClipboardDetection("pbpaste"), "none")
|
assert.ok(tools.includes("clipmanctl"))
|
||||||
assert.strictEqual(parseClipboardDetection("custom-tool"), "none")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- buildToggleCommand ---
|
// --- buildToggleCommand ---
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue