- plugin.json contained duplicate "version" keys ("0.1.0" then "0.2.0"); JSON parsers take the last value so it appeared to work, but this is invalid per spec and a maintenance hazard.
- findActiveExitNode used legacy for-in + hasOwnProperty guard; replaced with Object.keys() for modern, safe iteration (now consistent with parsePeers and the rest of lib.js).
- All 36 unit tests pass after the change.
Written by AI agent working for @jtmorris. Model: grok-build-0.1.
136 lines
4.3 KiB
JavaScript
136 lines
4.3 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 of Object.keys(peerMap)) {
|
|
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: "Copied %1 to clipboard",
|
|
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; }
|
|
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;
|
|
}
|
|
|
|
const PendingAction = Object.freeze({
|
|
TOGGLE: "toggle"
|
|
});
|
|
|
|
function commandForPendingAction(pending, freshIsConnected) {
|
|
if (pending === PendingAction.TOGGLE) {
|
|
return buildToggleCommand(freshIsConnected);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction };
|
|
}
|