Compare commits
No commits in common. "8ef466a92874f99bf4dd257d5ad4daff89f2b85b" and "0283ce6136d2db1eef1b36a7caf543609f16275f" have entirely different histories.
8ef466a928
...
0283ce6136
3 changed files with 53 additions and 74 deletions
|
|
@ -14,16 +14,29 @@ PluginComponent {
|
||||||
property string tailscaleIP: ""
|
property string tailscaleIP: ""
|
||||||
property string currentExitNode: ""
|
property string currentExitNode: ""
|
||||||
property var peers: []
|
property var peers: []
|
||||||
|
property string cachedClipboardTool: ""
|
||||||
property string _copyText: ""
|
property string _copyText: ""
|
||||||
property string _copyCurrentTool: ""
|
property string _copyCurrentTool: ""
|
||||||
property bool _pendingToggle: false // transient one-shot for post-action verification (to be removed in first-principles refactor)
|
property int _copyAttempted: 0
|
||||||
|
property bool _pendingToggle: false // transient one-shot for poll-then-act (#42) — reset immediately after use, no "desired state" cache
|
||||||
|
|
||||||
layerNamespacePlugin: "tailscalectl"
|
layerNamespacePlugin: "tailscalectl"
|
||||||
popoutWidth: 360
|
popoutWidth: 360
|
||||||
popoutHeight: 400
|
popoutHeight: 400
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshTimer
|
||||||
|
interval: 5000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
if (!statusCheck.running) {
|
||||||
|
statusCheck.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
// Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification).
|
|
||||||
statusCheck.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,8 +48,8 @@ PluginComponent {
|
||||||
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()
|
var detail = toggleProcess.stderr.text.trim().slice(0, 120)
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail))
|
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : ""))
|
||||||
}
|
}
|
||||||
statusCheck.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
@ -49,14 +62,17 @@ PluginComponent {
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
ToastService.showInfo(TailscaleLib.getStrings().copied(root._copyText))
|
root.cachedClipboardTool = root._copyCurrentTool
|
||||||
} else if (root._copyCurrentTool === "dms") {
|
ToastService.showInfo("Copied " + root._copyText + " to clipboard")
|
||||||
// One simple fallback attempt: try wl-copy.
|
} else {
|
||||||
root._copyCurrentTool = "wl-copy"
|
root._copyAttempted += 1
|
||||||
|
if (root._copyAttempted < TailscaleLib.allClipboardTools().length) {
|
||||||
|
root._copyCurrentTool = TailscaleLib.nextClipboardTool(root._copyCurrentTool)
|
||||||
root._executeCopy()
|
root._executeCopy()
|
||||||
} else {
|
} else {
|
||||||
var detail = copyProcess.stderr.text.trim()
|
var detail = copyProcess.stderr.text.trim().slice(0, 120)
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.formatError("clipboard", detail))
|
ToastService.showError("tailscalectl", "No clipboard tool found" + (detail ? " — " + detail : ""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,8 +84,8 @@ PluginComponent {
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
var detail = exitNodeProcess.stderr.text.trim()
|
var detail = exitNodeProcess.stderr.text.trim().slice(0, 120)
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail))
|
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set") + (detail ? " — " + detail : ""))
|
||||||
}
|
}
|
||||||
statusCheck.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
@ -94,10 +110,12 @@ PluginComponent {
|
||||||
root.tailscaleIP = ""
|
root.tailscaleIP = ""
|
||||||
root.currentExitNode = ""
|
root.currentExitNode = ""
|
||||||
root.peers = []
|
root.peers = []
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.formatError("status"))
|
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-action verification: if a toggle was requested, use the fresh state we just received.
|
// #42 poll-then-act: if a toggle was requested, use the *fresh* state we just received
|
||||||
|
// #47: structured logging evaluated and rejected — toasts + 120-char stderr already give
|
||||||
|
// actionable feedback; persistent logs would add state/rotation with no net UX benefit.
|
||||||
if (root._pendingToggle) {
|
if (root._pendingToggle) {
|
||||||
root._pendingToggle = false
|
root._pendingToggle = false
|
||||||
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
|
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
|
||||||
|
|
@ -107,7 +125,8 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTailscale() {
|
function toggleTailscale() {
|
||||||
// Post-action verification: force a fresh status check, then act on the real ground truth.
|
// #42: poll-then-act — never decide up/down from potentially stale root.isConnected.
|
||||||
|
// Force a fresh statusCheck; the decision happens in its onExited using the just-received value.
|
||||||
root._pendingToggle = true
|
root._pendingToggle = true
|
||||||
statusCheck.running = true
|
statusCheck.running = true
|
||||||
}
|
}
|
||||||
|
|
@ -119,15 +138,19 @@ PluginComponent {
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
root._copyText = text
|
root._copyText = text
|
||||||
// Simple two-tool fallback per first-principles plan: dms first, then wl-copy.
|
root._copyAttempted = 0
|
||||||
root._copyCurrentTool = "dms"
|
if (root.cachedClipboardTool) {
|
||||||
|
root._copyCurrentTool = root.cachedClipboardTool
|
||||||
|
} else {
|
||||||
|
root._copyCurrentTool = TailscaleLib.allClipboardTools()[0]
|
||||||
|
}
|
||||||
root._executeCopy()
|
root._executeCopy()
|
||||||
}
|
}
|
||||||
|
|
||||||
function _executeCopy() {
|
function _executeCopy() {
|
||||||
var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool)
|
var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool)
|
||||||
if (!cmd) {
|
if (!cmd) {
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.formatError("clipboard"))
|
ToastService.showError("tailscalectl", "Invalid clipboard command")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
copyProcess.command = cmd
|
copyProcess.command = cmd
|
||||||
|
|
@ -137,7 +160,7 @@ PluginComponent {
|
||||||
popoutContent: Component {
|
popoutContent: Component {
|
||||||
PopoutComponent {
|
PopoutComponent {
|
||||||
headerText: "Tailscale"
|
headerText: "Tailscale"
|
||||||
detailsText: root.isConnected ? TailscaleLib.getStrings().connected : TailscaleLib.getStrings().disconnected
|
detailsText: root.isConnected ? "Connected" : "Disconnected"
|
||||||
showCloseButton: true
|
showCloseButton: true
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
|
|
@ -184,7 +207,7 @@ PluginComponent {
|
||||||
Item { width: 1; height: 1; Layout.fillWidth: true }
|
Item { width: 1; height: 1; Layout.fillWidth: true }
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: TailscaleLib.getStrings().exitNodePrefix + (root.currentExitNode || TailscaleLib.getStrings().none)
|
text: "Exit node: " + (root.currentExitNode || "None")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ function findActiveExitNode(peerMap) {
|
||||||
|
|
||||||
var safeClipboardTools = ["dms", "wl-copy"]
|
var safeClipboardTools = ["dms", "wl-copy"]
|
||||||
|
|
||||||
// Direct argv form — no sh -c, no escaping, no future injection surface.
|
// Direct argv form — no sh -c, no escaping, no future injection surface (#49)
|
||||||
// clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland).
|
// clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland)
|
||||||
var clipboardTools = {
|
var clipboardTools = {
|
||||||
"dms": { argv: ["dms", "cl", "copy"] },
|
"dms": { argv: ["dms", "cl", "copy"] },
|
||||||
"wl-copy": { argv: ["wl-copy"] }
|
"wl-copy": { argv: ["wl-copy"] }
|
||||||
|
|
@ -76,7 +76,7 @@ function getStrings() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Light UI predicates — keep the view thin.
|
// Light UI predicates extracted from QML (advances #43 — keeps view thin)
|
||||||
function shouldShowClearExitNode(currentExitNode) {
|
function shouldShowClearExitNode(currentExitNode) {
|
||||||
return currentExitNode !== ""
|
return currentExitNode !== ""
|
||||||
}
|
}
|
||||||
|
|
@ -103,11 +103,6 @@ function buildToggleCommand(isConnected) {
|
||||||
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
|
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) {
|
function errorMessage(cmd) {
|
||||||
var messages = {
|
var messages = {
|
||||||
"up": "Failed to connect to Tailscale",
|
"up": "Failed to connect to Tailscale",
|
||||||
|
|
@ -115,24 +110,12 @@ function errorMessage(cmd) {
|
||||||
"down": "Failed to disconnect from Tailscale",
|
"down": "Failed to disconnect from Tailscale",
|
||||||
"disconnect": "Failed to disconnect from Tailscale",
|
"disconnect": "Failed to disconnect from Tailscale",
|
||||||
"set": "Failed to set exit node",
|
"set": "Failed to set exit node",
|
||||||
"status": "Failed to read Tailscale status",
|
"status": "Failed to read Tailscale status"
|
||||||
"clipboard": "Error copying to clipboard"
|
|
||||||
};
|
|
||||||
return messages[cmd] || "Tailscale command failed";
|
|
||||||
}
|
}
|
||||||
|
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)
|
// 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, formatError, getStatusCommand, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
|
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, formatError, getStatusCommand, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib
|
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Test coverage note for #48:
|
* Test coverage note for #48:
|
||||||
|
|
@ -240,30 +240,3 @@ test("parseStatusResult sets isConnected false for non-Running BackendState", ()
|
||||||
const state = parseStatusResult(json)
|
const state = parseStatusResult(json)
|
||||||
assert.strictEqual(state.isConnected, false)
|
assert.strictEqual(state.isConnected, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- formatError (central error + detail formatting per first-principles plan) ---
|
|
||||||
|
|
||||||
test("formatError returns base message without detail", () => {
|
|
||||||
assert.strictEqual(formatError("status"), "Failed to read Tailscale status")
|
|
||||||
assert.strictEqual(formatError("set"), "Failed to set exit node")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formatError appends and truncates detail", () => {
|
|
||||||
const longDetail = "x".repeat(200)
|
|
||||||
const msg = formatError("up", longDetail)
|
|
||||||
assert.ok(msg.includes("Failed to connect to Tailscale"))
|
|
||||||
assert.ok(msg.endsWith("x".repeat(120)))
|
|
||||||
assert.ok(msg.length < 200)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formatError handles empty or falsy detail gracefully", () => {
|
|
||||||
assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale")
|
|
||||||
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale")
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- getStatusCommand (new pure helper for low-duty-cycle architecture) ---
|
|
||||||
|
|
||||||
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
|
||||||
const cmd = getStatusCommand()
|
|
||||||
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"])
|
|
||||||
})
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue