Compare commits

..

No commits in common. "2227cb60fe9907cc949accd9b7b5f62172c352ee" and "cac0ae6bde7b54d2a556977a8087585ccf1e9e74" have entirely different histories.

3 changed files with 75 additions and 99 deletions

View file

@ -15,10 +15,7 @@ PluginComponent {
property string tailscaleIP: "" property string tailscaleIP: ""
property string currentExitNode: "" property string currentExitNode: ""
property var peers: [] property var peers: []
property string cachedClipboardTool: "" property string clipboardCmd: ""
property string _copyText: ""
property string _copyCurrentTool: ""
property int _copyAttempted: 0
layerNamespacePlugin: "tailscalectl" layerNamespacePlugin: "tailscalectl"
popoutWidth: 360 popoutWidth: 360
@ -32,19 +29,16 @@ PluginComponent {
} }
Component.onCompleted: { Component.onCompleted: {
statusCheck.running = true detectClipboard.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"
var detail = toggleProcess.stderr.text.trim().slice(0, 120) ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action))
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : ""))
} }
statusCheck.running = true statusCheck.running = true
} }
@ -52,40 +46,35 @@ 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) {
var detail = exitNodeProcess.stderr.text.trim().slice(0, 120) ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set"))
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
@ -136,24 +125,18 @@ PluginComponent {
} }
function copyToClipboard(text) { function copyToClipboard(text) {
root._copyText = text if (!root.clipboardCmd || root.clipboardCmd === "none") {
root._copyAttempted = 0 ToastService.showError("tailscalectl", "No clipboard tool found")
if (root.cachedClipboardTool) { return
root._copyCurrentTool = root.cachedClipboardTool
} else {
root._copyCurrentTool = TailscaleLib.allClipboardTools()[0]
} }
root._executeCopy() var cmd = TailscaleLib.buildCopyCommand(text, root.clipboardCmd)
}
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 {

View file

@ -26,34 +26,16 @@ function findActiveExitNode(peerMap) {
return "" return ""
} }
var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"] var safeClipboardCmds = ["dms cl copy", "wl-copy", "clipmanctl copy", "xclip -selection clipboard", "xsel --clipboard --input", "none"]
var clipboardCmdMap = { function validateClipboardCmd(cmd) {
"dms": "dms cl copy", return typeof cmd === "string" && safeClipboardCmds.includes(cmd)
"wl-copy": "wl-copy",
"clipmanctl": "clipmanctl copy"
} }
function validateClipboardTool(tool) { function buildCopyCommand(text, clipboardCmd) {
return typeof tool === "string" && safeClipboardTools.includes(tool) if (!validateClipboardCmd(clipboardCmd)) return null
}
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 + "' | " + cmd] return ["sh", "-c", "printf '%s' '" + escaped + "' | " + clipboardCmd]
}
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) {
@ -74,6 +56,11 @@ 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",
@ -88,5 +75,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 } module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult }
} }

View file

@ -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, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult } = lib
test("parsePeers extracts exitNode from ExitNodeOption", () => { test("parsePeers extracts exitNode from ExitNodeOption", () => {
const peerMap = { const peerMap = {
@ -75,25 +75,28 @@ test("errorMessage returns generic message for unknown command", () => {
assert.strictEqual(msg, "Tailscale command failed") assert.strictEqual(msg, "Tailscale command failed")
}) })
// --- validateClipboardTool --- // --- validateClipboardCmd ---
test("validateClipboardTool accepts whitelisted tool names", () => { test("validateClipboardCmd accepts whitelisted values", () => {
assert.strictEqual(validateClipboardTool("dms"), true) assert.strictEqual(validateClipboardCmd("dms cl copy"), true)
assert.strictEqual(validateClipboardTool("wl-copy"), true) assert.strictEqual(validateClipboardCmd("wl-copy"), true)
assert.strictEqual(validateClipboardTool("clipmanctl"), 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 rejects malicious inputs", () => { test("validateClipboardCmd rejects malicious inputs", () => {
assert.strictEqual(validateClipboardTool("rm -rf /"), false) assert.strictEqual(validateClipboardCmd("rm -rf /"), false)
assert.strictEqual(validateClipboardTool("echo hi; malicious"), false) assert.strictEqual(validateClipboardCmd("echo hi; malicious"), false)
assert.strictEqual(validateClipboardTool("$(whoami)"), false) assert.strictEqual(validateClipboardCmd("$(whoami)"), false)
assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false) assert.strictEqual(validateClipboardCmd("wl-copy || rm -rf /"), false)
}) })
test("validateClipboardTool rejects empty and falsy inputs", () => { test("validateClipboardCmd rejects empty and falsy inputs", () => {
assert.strictEqual(validateClipboardTool(""), false) assert.strictEqual(validateClipboardCmd(""), false)
assert.strictEqual(validateClipboardTool(null), false) assert.strictEqual(validateClipboardCmd(null), false)
assert.strictEqual(validateClipboardTool(undefined), false) assert.strictEqual(validateClipboardCmd(undefined), false)
}) })
// --- buildCopyCommand --- // --- buildCopyCommand ---
@ -105,7 +108,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 tool", () => { test("buildCopyCommand returns null for invalid clipboard command", () => {
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)
@ -116,28 +119,31 @@ test("buildCopyCommand safely handles text with special characters", () => {
assert.ok(Array.isArray(cmd)) assert.ok(Array.isArray(cmd))
}) })
// --- nextClipboardTool --- // --- parseClipboardDetection ---
test("nextClipboardTool cycles through tools", () => { test("parseClipboardDetection passes through expected output strings", () => {
assert.strictEqual(nextClipboardTool("dms"), "wl-copy") assert.strictEqual(parseClipboardDetection("dms cl copy"), "dms cl copy")
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl") assert.strictEqual(parseClipboardDetection("wl-copy"), "wl-copy")
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms") assert.strictEqual(parseClipboardDetection("clipmanctl copy"), "clipmanctl copy")
assert.strictEqual(parseClipboardDetection("none"), "none")
}) })
test("nextClipboardTool falls back to first tool for unknown input", () => { test("parseClipboardDetection trims extra whitespace", () => {
assert.strictEqual(nextClipboardTool(""), "dms") assert.strictEqual(parseClipboardDetection(" wl-copy \n"), "wl-copy")
assert.strictEqual(nextClipboardTool("unknown"), "dms")
}) })
// --- allClipboardTools --- test("parseClipboardDetection falls back to none for multi-line output", () => {
assert.strictEqual(parseClipboardDetection("wl-copy\nrm -rf /"), "none")
})
test("allClipboardTools returns the list of safe tools", () => { test("parseClipboardDetection falls back to empty output", () => {
const tools = allClipboardTools() assert.strictEqual(parseClipboardDetection(""), "none")
assert.ok(Array.isArray(tools)) assert.strictEqual(parseClipboardDetection(" "), "none")
assert.strictEqual(tools.length, 3) })
assert.ok(tools.includes("dms"))
assert.ok(tools.includes("wl-copy")) test("parseClipboardDetection falls back to none for unexpected strings", () => {
assert.ok(tools.includes("clipmanctl")) assert.strictEqual(parseClipboardDetection("pbpaste"), "none")
assert.strictEqual(parseClipboardDetection("custom-tool"), "none")
}) })
// --- buildToggleCommand --- // --- buildToggleCommand ---