From eb5325af9b61bbd4eb80dd259ce8095d00ffd2b9 Mon Sep 17 00:00:00 2001 From: vybe Date: Sun, 24 May 2026 20:57:14 +0000 Subject: [PATCH 1/4] feat(i18n): make lib.js strings I18n source keys (strategy A); remove dead symbol exports; update tests; add i18n/ scaffolding + en.json placeholder - getStrings().copied now returns template key "Copied %1 to clipboard" (interpolation at QML call site via .arg) - Removed unused clearExitNode/setExitNode from getStrings (were dead code) - Tests updated for new shape while preserving coverage - New tailscalectl/i18n/ with README + en.json for future-proofing (per dms-plugin-dev best practice) - Exact behavior preserved; no logic changes Written by AI agent working for @jtmorris. Model: grok-build-0.1. --- tailscalectl/i18n/README.md | 40 +++++++++++++++++++++++++++++++++++++ tailscalectl/i18n/en.json | 15 ++++++++++++++ tailscalectl/lib.js | 4 +--- test/lib.test.js | 7 +++---- 4 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 tailscalectl/i18n/README.md create mode 100644 tailscalectl/i18n/en.json 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/test/lib.test.js b/test/lib.test.js index 21d593e..0c7ab47 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -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", () => { From 4c0dff58536da7713ffdddba37e80ade0e3a4597 Mon Sep 17 00:00:00 2001 From: vybe Date: Sun, 24 May 2026 20:57:49 +0000 Subject: [PATCH 2/4] feat(i18n): wrap all user-facing strings in TailscaleWidget.qml with I18n.tr (strategy A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - header, details, exit node prefix/none, invalid hostname, copied (with .arg), all formatError toasts - Glyph literals ("×", "↗", "—") left as-is (decorative, not linguistic) - "tailscalectl" toast titles kept as technical IDs - All call sites now go through I18n for future translation - Tests unaffected (still green) Written by AI agent working for @jtmorris. Model: grok-build-0.1. --- tailscalectl/TailscaleWidget.qml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index 9c54120..a47423a 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -35,7 +35,7 @@ PluginComponent { if (code !== 0) { var action = root.isConnected ? "disconnect" : "connect"; var detail = toggleProcess.stderr.text.trim(); - ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail)); + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action, detail))); } root.refreshStatus(); } @@ -48,7 +48,7 @@ PluginComponent { onExited: (code, status) => { if (code === 0) { - ToastService.showInfo(TailscaleLib.getStrings().copied(root._copyText)) + ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText)) } else { root._copyIndex += 1 root._runNextCopy() @@ -66,7 +66,7 @@ PluginComponent { onExited: (code, status) => { if (code !== 0) { var detail = exitNodeProcess.stderr.text.trim(); - ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail)); + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set", detail))); } root.refreshStatus(); } @@ -91,7 +91,7 @@ PluginComponent { root.tailscaleIP = "" root.currentExitNode = "" root.peers = [] - ToastService.showError("tailscalectl", TailscaleLib.formatError("status")) + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))) } const cmd = TailscaleLib.commandForPendingAction(statusCheck._pendingAction, root.isConnected); @@ -115,7 +115,7 @@ PluginComponent { function setExitNode(hostname) { const cmd = TailscaleLib.makeExitNodeCommand(hostname) if (!cmd) { - ToastService.showError("tailscalectl", TailscaleLib.getStrings().invalidExitNodeHostname) + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname)) return } exitNodeProcess.command = cmd @@ -131,7 +131,7 @@ PluginComponent { function _runNextCopy() { const cmds = TailscaleLib.getClipboardCommands(root._copyText) if (root._copyIndex >= cmds.length) { - ToastService.showError("tailscalectl", TailscaleLib.formatError("clipboard")) + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard"))) return } copyProcess.command = cmds[root._copyIndex] @@ -140,8 +140,8 @@ PluginComponent { 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 +188,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 From d1763842250d56767573fb47fe0c1cc6e7945956 Mon Sep 17 00:00:00 2001 From: vybe Date: Sun, 24 May 2026 20:58:31 +0000 Subject: [PATCH 3/4] feat(proc): direct drop-in port from 4x raw Process to Proc singleton (exact poll-act-poll behavior preserved) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed import Quickshell.Io + all 4 Process { toggleProcess, copyProcess, exitNodeProcess, statusCheck } - All one-shot commands now use Proc.runCommand(id, argv, (stdout, exitCode) => {...}) - _pendingAction moved to clean root property (was hacked onto statusCheck object) - Introduced _runStatusCheck() helper + updated wrappers (toggleTailscale, refreshStatus, setExitNode, copyToClipboard, _runNextCopy) - Exact same sequencing and defensive logic: * toggle: set pending → status poll for truth → decide up/down → act → post-act status verify * copy retry state machine unchanged (just Proc instead of .command/.running) * exit-node and generic refresh paths identical - Added explicit comment documenting why the poll-act-poll exists (defensive, not forbidden state per AGENTS.md) - stderr detail dropped from action errors (per chosen A strategy; generic messages only) - All new JS follows repo ; + {} style - I18n wrappers already in place from prior slice Written by AI agent working for @jtmorris. Model: grok-build-0.1. --- tailscalectl/TailscaleWidget.qml | 147 +++++++++++++------------------ 1 file changed, 60 insertions(+), 87 deletions(-) diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index a47423a..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,121 +20,95 @@ 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", I18n.tr(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(I18n.tr(TailscaleLib.getStrings().copied).arg(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", I18n.tr(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", I18n.tr(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", I18n.tr(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", I18n.tr(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 { From 7166cc0f19819b8c34029c1a8d6e61faf01d4b36 Mon Sep 17 00:00:00 2001 From: vybe Date: Sun, 24 May 2026 20:59:00 +0000 Subject: [PATCH 4/4] chore(polish): manifest, docs, tests, and nits for dms-plugin-dev alignment - plugin.json: added capabilities + requires; bumped to 0.2.0 - README: corrected "auto-refresh" claim (now documents the intentional on-demand + defensive poll-act-poll); added Implementation notes section covering Proc + I18n + best practices - test/lib.test.js: updated the manual verification comment (now references Proc + preserved behavior) - Minor version references in README example + caption - All changes keep exact toggle semantics and follow repo style Written by AI agent working for @jtmorris. Model: grok-build-0.1. --- README.md | 13 ++++++++++--- tailscalectl/plugin.json | 5 ++++- test/lib.test.js | 6 +++--- 3 files changed, 17 insertions(+), 7 deletions(-) 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/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 0c7ab47..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", () => {