- lib.js: single clipboardTools array + getClipboardCommands(text) returning argv lists - Delete validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, safeClipboardTools, old clipboardTools object - Remove noClipboardTool / invalidClipboardCommand from getStrings (dead) - TailscaleWidget.qml: remove _copyCurrentTool + _executeCopy; add _copyIndex + _runNextCopy - copyProcess.onExited and copyToClipboard now purely index-driven, no tool names - Update tests: remove 4 old clipboard test blocks + dead string assertions; add focused getClipboardCommands tests - All 34 tests pass This is the minimal honest design given Process is a QML type.
131 lines
4.4 KiB
JavaScript
131 lines
4.4 KiB
JavaScript
function parsePeers(peerMap) {
|
||
if (!peerMap) return []
|
||
return Object.keys(peerMap).map(function (key) {
|
||
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) return null
|
||
var p = peerMap[key]
|
||
return {
|
||
hostname: p.HostName || key,
|
||
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
|
||
online: p.Online || false,
|
||
exitNode: p.ExitNodeOption || false
|
||
}
|
||
}).filter(function (peer) { return peer !== null })
|
||
}
|
||
|
||
function makeExitNodeCommand(hostname) {
|
||
if (!isValidExitNodeHostname(hostname)) {
|
||
return null;
|
||
}
|
||
if (hostname === "") {
|
||
return ["tailscale", "set", "--exit-node="];
|
||
}
|
||
return ["tailscale", "set", "--exit-node=" + hostname];
|
||
}
|
||
|
||
function findActiveExitNode(peerMap) {
|
||
if (!peerMap) return ""
|
||
for (const key in peerMap) {
|
||
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) continue
|
||
const p = peerMap[key]
|
||
if (p.ExitNode) {
|
||
return p.HostName || key
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
const clipboardTools = [
|
||
{ argv: ["dms", "cl", "copy"] },
|
||
{ argv: ["wl-copy"] }
|
||
];
|
||
|
||
function getClipboardCommands(text) {
|
||
return clipboardTools.map(function (tool) {
|
||
return tool.argv.concat([text]);
|
||
});
|
||
}
|
||
|
||
function getStrings() {
|
||
return {
|
||
header: "Tailscale",
|
||
connected: "Connected",
|
||
disconnected: "Disconnected",
|
||
exitNodePrefix: "Exit node: ",
|
||
none: "None",
|
||
copied: function (text) { return "Copied " + text + " to clipboard" },
|
||
clearExitNode: "×",
|
||
setExitNode: "↗",
|
||
invalidExitNodeHostname: "Invalid exit node hostname"
|
||
}
|
||
}
|
||
|
||
// Light UI predicates — keep the view thin.
|
||
function shouldShowClearExitNode(currentExitNode) {
|
||
return currentExitNode !== ""
|
||
}
|
||
|
||
function isActiveExitNode(currentExitNode, hostname) {
|
||
return currentExitNode === hostname
|
||
}
|
||
|
||
// Security: validate hostnames coming from tailscale status JSON.
|
||
// Fail closed on obviously malicious input.
|
||
function isValidExitNodeHostname(hostname) {
|
||
if (typeof hostname !== "string") return false;
|
||
if (hostname === "") return true; // clear command
|
||
// Permissive enough for real Tailscale hostnames while rejecting
|
||
// classic shell metacharacters and control characters.
|
||
return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname);
|
||
}
|
||
|
||
function parseStatusResult(jsonText) {
|
||
try {
|
||
const data = JSON.parse(jsonText)
|
||
return {
|
||
isConnected: data.BackendState === "Running",
|
||
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
||
currentExitNode: findActiveExitNode(data.Peer || {}),
|
||
peers: parsePeers(data.Peer || {})
|
||
}
|
||
} catch (e) {
|
||
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
|
||
}
|
||
}
|
||
|
||
function buildToggleCommand(isConnected) {
|
||
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
|
||
}
|
||
|
||
// Single source of truth for the status command used for on-demand and post-action verification.
|
||
function getStatusCommand() {
|
||
return ["tailscale", "status", "--json"]
|
||
}
|
||
|
||
function errorMessage(cmd) {
|
||
var messages = {
|
||
"up": "Failed to connect to Tailscale",
|
||
"connect": "Failed to connect to Tailscale",
|
||
"down": "Failed to disconnect from Tailscale",
|
||
"disconnect": "Failed to disconnect from Tailscale",
|
||
"set": "Failed to set exit node",
|
||
"status": "Failed to read Tailscale status",
|
||
"clipboard": "Error copying to clipboard"
|
||
};
|
||
return messages[cmd] || "Tailscale command failed";
|
||
}
|
||
|
||
// Central error formatting for the widget. Used by both success and error paths.
|
||
// detail is optional truncated stderr or extra context.
|
||
function formatError(action, detail) {
|
||
var base = errorMessage(action);
|
||
if (detail && detail.length > 0) {
|
||
var truncated = detail.length > 120 ? detail.slice(0, 120) : detail;
|
||
return base + " — " + truncated;
|
||
}
|
||
return base;
|
||
}
|
||
|
||
// CommonJS export for Node.js tests (ignored by QML)
|
||
if (typeof module !== "undefined" && module.exports) {
|
||
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
|
||
}
|