diff --git a/README.md b/README.md index 4cdd9e1..7b83e2c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank Bar with quick controls for toggling connection, switching exit nodes, and copying peer addresses. -![Tailscale Widget v0.1.0](resources/dms_tailscalectl_v0.1.0.png) +![Tailscale Widget v0.2.0](resources/dms_tailscalectl_v0.1.0.png) ## Features @@ -14,7 +14,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank - Peer list with hostnames and IPs - **Click-to-copy** any hostname or IP to clipboard - **Exit node selection** — click `↗` on any exit-node-capable peer to route through it -- **Auto-refresh** — status polls every 5 seconds +- **On-demand status** — polls Tailscale for ground truth on load, explicit actions, and post-mutation verification (defensive poll-act-poll for toggles; no always-on timer) - **Toast notifications** for all errors ## Requirements @@ -70,7 +70,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank "id": "tailscalectl", "name": "Tailscale", "description": "Tailscale status and controls on the Dank Bar", - "version": "0.1.0", + "version": "0.2.0", "author": "John Morris & Vybe (AI Slop... er... Coding Assistant)", "icon": "vpn_key", "type": "widget", @@ -79,6 +79,13 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank } ``` +## Implementation notes + +- Uses `Proc` singleton (from `qs.Common`) for all external `tailscale` commands (one-shot stdout capture + auto cleanup). +- Fully I18n-ready via `I18n.tr(...)` (source keys in American English only today; see `tailscalectl/i18n/` for scaffolding). +- Follows current `dms-plugin-dev` + DMS 1.4 plugin best practices (capabilities, requires, no raw Process for one-shots, etc.). +- Toggle uses intentional defensive poll-act-poll (see code comments). + ## Testing ```bash diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index 9c54120..0c2ea12 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -4,7 +4,6 @@ import qs.Common import qs.Services import qs.Widgets import qs.Modules.Plugins -import Quickshell.Io import "./lib.js" as TailscaleLib PluginComponent { @@ -21,127 +20,101 @@ PluginComponent { popoutWidth: 360 popoutHeight: 400 + // Transient coordination for the defensive poll-act-poll toggle (exact behavior preserved). + // We poll for on-the-ground truth (so we choose the correct "up"/"down" and don't lie to the user), + // act, then poll again for verification. This is *not* long-term cached state (see AGENTS.md). + // The _pendingAction is short-lived per user action only. + property string _pendingAction: "" + Component.onCompleted: { // Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification). - statusCheck.running = true + root._runStatusCheck(); } - Process { - id: toggleProcess - - stderr: StdioCollector {} - - onExited: (code, status) => { - if (code !== 0) { - var action = root.isConnected ? "disconnect" : "connect"; - var detail = toggleProcess.stderr.text.trim(); - ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail)); - } - root.refreshStatus(); - } - } - - Process { - id: copyProcess - - stderr: StdioCollector {} - - onExited: (code, status) => { + function _runStatusCheck() { + Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { if (code === 0) { - ToastService.showInfo(TailscaleLib.getStrings().copied(root._copyText)) + const state = TailscaleLib.parseStatusResult(stdout); + root.isConnected = state.isConnected; + root.tailscaleIP = state.tailscaleIP; + root.currentExitNode = state.currentExitNode; + root.peers = state.peers; } else { - root._copyIndex += 1 - root._runNextCopy() - } - } - } - - Process { - id: exitNodeProcess - - // Command is set dynamically by setExitNode() - - stderr: StdioCollector {} - - onExited: (code, status) => { - if (code !== 0) { - var detail = exitNodeProcess.stderr.text.trim(); - ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail)); - } - root.refreshStatus(); - } - } - - Process { - id: statusCheck - - command: ["tailscale", "status", "--json"] - - stdout: StdioCollector {} - - onExited: (code, status) => { - if (code === 0) { - const state = TailscaleLib.parseStatusResult(statusCheck.stdout.text) - root.isConnected = state.isConnected - root.tailscaleIP = state.tailscaleIP - root.currentExitNode = state.currentExitNode - root.peers = state.peers - } else { - root.isConnected = false - root.tailscaleIP = "" - root.currentExitNode = "" - root.peers = [] - ToastService.showError("tailscalectl", TailscaleLib.formatError("status")) + root.isConnected = false; + root.tailscaleIP = ""; + root.currentExitNode = ""; + root.peers = []; + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); } - const cmd = TailscaleLib.commandForPendingAction(statusCheck._pendingAction, root.isConnected); + const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected); if (cmd) { - toggleProcess.command = cmd; - toggleProcess.running = true; + // Exact same decision point as before: act based on fresh poll truth, then verify. + Proc.runCommand("tailscale-toggle", cmd, (out, c) => { + if (c !== 0) { + const action = root.isConnected ? "disconnect" : "connect"; + // Note: Proc callback provides no stderr (per chosen strategy); generic error only. + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action))); + } + root._pendingAction = ""; + root._runStatusCheck(); // post-action verification poll (exact behavior) + }); + } else { + root._pendingAction = ""; } - statusCheck._pendingAction = null; - } + }); } function toggleTailscale() { - statusCheck._pendingAction = "toggle"; - statusCheck.running = true; + root._pendingAction = "toggle"; + root._runStatusCheck(); } function refreshStatus() { - statusCheck.running = true; + root._runStatusCheck(); } function setExitNode(hostname) { - const cmd = TailscaleLib.makeExitNodeCommand(hostname) + const cmd = TailscaleLib.makeExitNodeCommand(hostname); if (!cmd) { - ToastService.showError("tailscalectl", TailscaleLib.getStrings().invalidExitNodeHostname) - return + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname)); + return; } - exitNodeProcess.command = cmd - exitNodeProcess.running = true + Proc.runCommand("tailscale-exit", cmd, (stdout, code) => { + if (code !== 0) { + // Note: no stderr detail available from Proc (accepted per plan). + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set"))); + } + root._runStatusCheck(); + }); } function copyToClipboard(text) { - root._copyText = text - root._copyIndex = 0 - root._runNextCopy() + root._copyText = text; + root._copyIndex = 0; + root._runNextCopy(); } function _runNextCopy() { - const cmds = TailscaleLib.getClipboardCommands(root._copyText) + const cmds = TailscaleLib.getClipboardCommands(root._copyText); if (root._copyIndex >= cmds.length) { - ToastService.showError("tailscalectl", TailscaleLib.formatError("clipboard")) - return + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard"))); + return; } - copyProcess.command = cmds[root._copyIndex] - copyProcess.running = true + Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => { + if (code === 0) { + ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText)); + } else { + root._copyIndex += 1; + root._runNextCopy(); + } + }); } popoutContent: Component { PopoutComponent { - headerText: "Tailscale" - detailsText: root.isConnected ? TailscaleLib.getStrings().connected : TailscaleLib.getStrings().disconnected + headerText: I18n.tr(TailscaleLib.getStrings().header) + detailsText: root.isConnected ? I18n.tr(TailscaleLib.getStrings().connected) : I18n.tr(TailscaleLib.getStrings().disconnected) showCloseButton: true Item { @@ -188,7 +161,7 @@ PluginComponent { Item { width: 1; height: 1; Layout.fillWidth: true } StyledText { - text: TailscaleLib.getStrings().exitNodePrefix + (root.currentExitNode || TailscaleLib.getStrings().none) + text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none)) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter diff --git a/tailscalectl/i18n/README.md b/tailscalectl/i18n/README.md new file mode 100644 index 0000000..5e29d35 --- /dev/null +++ b/tailscalectl/i18n/README.md @@ -0,0 +1,40 @@ +# I18n for tailscalectl + +This plugin is fully instrumented with `I18n.tr(...)` (from `qs.Common`) for all user-facing strings. + +- Source keys are the American English strings defined in `lib.js` (returned by `getStrings()`, plus bases from `errorMessage()` / `formatError()`). +- Call sites in `TailscaleWidget.qml` wrap them: `I18n.tr(TailscaleLib.getStrings().foo)` or `I18n.tr(TailscaleLib.getStrings().copied).arg(text)`. +- Today: falls back to the key (perfect en-US). +- Future: Drop additional `xx.json` here (or contribute keys to DMS core translations) when a loader or extraction process supports per-plugin locales. + +## Current keys (source of truth) + +See `getStrings()` and `errorMessage()` in `lib.js` for the canonical list. + +Example `en.json` (for documentation / future tools): + +```json +{ + "Tailscale": "Tailscale", + "Connected": "Connected", + "Disconnected": "Disconnected", + "Exit node: ": "Exit node: ", + "None": "None", + "Copied %1 to clipboard": "Copied %1 to clipboard", + "Invalid exit node hostname": "Invalid exit node hostname", + "Failed to connect to Tailscale": "Failed to connect to Tailscale", + "Failed to disconnect from Tailscale": "Failed to disconnect from Tailscale", + "Failed to set exit node": "Failed to set exit node", + "Failed to read Tailscale status": "Failed to read Tailscale status", + "Error copying to clipboard": "Error copying to clipboard", + "Tailscale command failed": "Tailscale command failed" +} +``` + +## Notes + +- Symbols/glyphs ("×", "↗", "—") are intentionally left as literals in the UI (not run through I18n as they are not linguistic content). +- Plugin name/description in `plugin.json` and technical IDs ("tailscalectl") remain English. +- This follows DMS `dms-plugin-dev` best practice for future-proofing even when only en is shipped. + +Written by AI agent working for @jtmorris. Model: grok-build-0.1. diff --git a/tailscalectl/i18n/en.json b/tailscalectl/i18n/en.json new file mode 100644 index 0000000..bd5b57a --- /dev/null +++ b/tailscalectl/i18n/en.json @@ -0,0 +1,15 @@ +{ + "Tailscale": "Tailscale", + "Connected": "Connected", + "Disconnected": "Disconnected", + "Exit node: ": "Exit node: ", + "None": "None", + "Copied %1 to clipboard": "Copied %1 to clipboard", + "Invalid exit node hostname": "Invalid exit node hostname", + "Failed to connect to Tailscale": "Failed to connect to Tailscale", + "Failed to disconnect from Tailscale": "Failed to disconnect from Tailscale", + "Failed to set exit node": "Failed to set exit node", + "Failed to read Tailscale status": "Failed to read Tailscale status", + "Error copying to clipboard": "Error copying to clipboard", + "Tailscale command failed": "Tailscale command failed" +} diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index a1185e1..b27a4c3 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -52,9 +52,7 @@ function getStrings() { disconnected: "Disconnected", exitNodePrefix: "Exit node: ", none: "None", - copied: function (text) { return "Copied " + text + " to clipboard"; }, - clearExitNode: "×", - setExitNode: "↗", + copied: "Copied %1 to clipboard", invalidExitNodeHostname: "Invalid exit node hostname" }; } diff --git a/tailscalectl/plugin.json b/tailscalectl/plugin.json index 6d9280b..6f9c82f 100644 --- a/tailscalectl/plugin.json +++ b/tailscalectl/plugin.json @@ -6,6 +6,9 @@ "author": "John Morris", "icon": "vpn_key", "type": "widget", + "capabilities": ["dankbar-widget"], "component": "./TailscaleWidget.qml", - "permissions": ["process"] + "permissions": ["process"], + "requires": ["tailscale"], + "version": "0.2.0" } diff --git a/test/lib.test.js b/test/lib.test.js index 21d593e..ee06864 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -8,9 +8,9 @@ const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, forma * * All functions in lib.js are exercised via Node's built-in test runner. * - * TailscaleWidget.qml has no automated test coverage. The four Process - * objects, their onExited handlers, and all widget UI behavior must be - * verified manually. + * TailscaleWidget.qml has no automated test coverage. The Proc.runCommand + * calls, callback-based coordination (exact poll-act-poll preserved), and all + * widget UI behavior must be verified manually in a running DMS instance. */ test("parsePeers extracts exitNode from ExitNodeOption", () => { @@ -115,10 +115,9 @@ test("getStrings returns canonical UI strings for the widget", () => { assert.ok(s.copied) }) -test("getStrings.copied interpolates the text", () => { - const s = getStrings() - const msg = s.copied("100.64.0.5") - assert.ok(msg.includes("100.64.0.5")) +test("getStrings.copied is the I18n template key (interpolation happens at call site via .arg)", () => { + const s = getStrings(); + assert.strictEqual(s.copied, "Copied %1 to clipboard"); }) test("shouldShowClearExitNode returns true only when there is a current exit node", () => {