Compare commits
48 commits
7d0c02267b
...
bbce7f0100
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbce7f0100 | ||
|
|
7c31598594 | ||
|
|
61626dff51 | ||
| 00a40a9aa2 | |||
| 3986ee3490 | |||
| 3403bf2c15 | |||
| 4b9d6ce6d8 | |||
| 843e3d01f0 | |||
| cb280d1e4e | |||
| 7942bc3471 | |||
| aacc027984 | |||
| 7b41d7177f | |||
| 12ef07040c | |||
| b02f8ba45b | |||
| 6e4e5d51ec | |||
| e695db6650 | |||
| a40d8d49a4 | |||
| 4a7803dc42 | |||
| 5ace4095c6 | |||
| 619c988f56 | |||
| 9dd73b6d55 | |||
| 6b71197770 | |||
| ee36ae5c72 | |||
| e1569dd870 | |||
| b10503f4d5 | |||
| 440e954099 | |||
| 925233dfb9 | |||
| 89d95cb864 | |||
| 62a364d14e | |||
| 2a53748895 | |||
| 8a31851769 | |||
| cafc281949 | |||
| 028be9d571 | |||
| 1cb01144a5 | |||
| 1a937a8249 | |||
| afdcba9d4c | |||
| da4d566f0c | |||
| f08b01314e | |||
| 2a6b3aea6a | |||
| 2bad002c69 | |||
| 97ec919170 | |||
| 2fa5694895 | |||
| b6ead317e1 | |||
| 42d3bef5c6 | |||
| dce33692ad | |||
| 3d62e4c4b2 | |||
| 6385f6b19c | |||
| 5523738dc5 |
8 changed files with 843 additions and 391 deletions
125
AGENTS.md
125
AGENTS.md
|
|
@ -5,6 +5,8 @@ You **MUST** read each of these documents before contributing to this repository
|
||||||
- Repo interaction rules: `docs/agents/repo-instructions.md`
|
- Repo interaction rules: `docs/agents/repo-instructions.md`
|
||||||
- Domain model & context rules: `docs/agents/domain.md`
|
- Domain model & context rules: `docs/agents/domain.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Critical Coding & Version Control Conduct
|
## Critical Coding & Version Control Conduct
|
||||||
|
|
||||||
- **NEVER** commit directly to `master` or `testing without explicit human instruction.
|
- **NEVER** commit directly to `master` or `testing without explicit human instruction.
|
||||||
|
|
@ -12,5 +14,128 @@ You **MUST** read each of these documents before contributing to this repository
|
||||||
- **ALWAYS** create new branches when working on non-trivial coding tasks.
|
- **ALWAYS** create new branches when working on non-trivial coding tasks.
|
||||||
- **ALWAYS** commit self-contained logical unit of work.
|
- **ALWAYS** commit self-contained logical unit of work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Issues, PRs, and Comments Conduct
|
## Issues, PRs, and Comments Conduct
|
||||||
- **ALWAYS** end your written contributions with `Written by AI agent working for @jtmorris. Model: <MODEL NAME>.`. Replace `<MODEL NAME>` with the LLM model, version, and, if relevant, number of parameters. For example: `Claude Sonnet 4.7`, `Grok 4.3`, `Qwen 3.6 27B`.
|
- **ALWAYS** end your written contributions with `Written by AI agent working for @jtmorris. Model: <MODEL NAME>.`. Replace `<MODEL NAME>` with the LLM model, version, and, if relevant, number of parameters. For example: `Claude Sonnet 4.7`, `Grok 4.3`, `Qwen 3.6 27B`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Design Rules
|
||||||
|
|
||||||
|
### 1. Only Store or Cache State When It Serves a Purpose
|
||||||
|
|
||||||
|
**Never store or cache state unless doing so meets at least one of:**
|
||||||
|
1. It is the authoritative source of truth owned by this component.
|
||||||
|
2. It delivers actionable feedback that meaningfully improves the user experience or a decision.
|
||||||
|
3. It provides a demonstrable and reasonably argued security or performance improvement (the burden of proof is on the proposer).
|
||||||
|
4. The information cannot be obtained at the moment it is needed and keeping it produces a clear net benefit.
|
||||||
|
|
||||||
|
Storing state creates a potential disconnect with reality. Managing that disconnect requires extra tests, defensive checks, and cognitive overhead. State should be added only when the benefit is obvious and defensible.
|
||||||
|
|
||||||
|
**Textbook Example (this repository – issue #11)**
|
||||||
|
Proposal: run `which tailscale` at startup, set a `binaryAvailable` boolean, and guard every `tailscale` invocation behind it.
|
||||||
|
|
||||||
|
**Why this was harmful**
|
||||||
|
1. Wrong solution to the actual problem. This project is a GUI wrapper around the `tailscale` binary. If the binary doesn’t exist, there is nothing to do except surface an error. Storing a flag and guarding UI actions adds state for no gain.
|
||||||
|
2. Creates a new class of edge cases that must be tested: the binary existed when the flag was set but later disappears. The code must now defend against both “flag is false” and “flag is wrong.”
|
||||||
|
3. Unreliable guard for a failure the code must handle anyway. A missing binary produces a clear exit-code failure on the real command. Checking the flag *and* handling the failure duplicates work.
|
||||||
|
Reference: #11.
|
||||||
|
|
||||||
|
|
||||||
|
## Code Style Guidance
|
||||||
|
|
||||||
|
### 1. **NEVER** Write Conditional and Loop Blocks Without Curly Braces
|
||||||
|
|
||||||
|
**ALWAYS** use curly braces, `{}`, for if, else, while, and for blocks, even when they contain only a single statement. This ensures that any future additions to the block remain within the intended control flow. Reference: `CVE-2014-1266`.
|
||||||
|
|
||||||
|
**GOOD** Examples:
|
||||||
|
```javascript
|
||||||
|
if (myvar) {
|
||||||
|
do_action();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```javascript
|
||||||
|
if (myvar) { do_action(); }
|
||||||
|
```
|
||||||
|
```javascript
|
||||||
|
while (myvar) {
|
||||||
|
do_action();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```javascript
|
||||||
|
while (myvar) { do_action(); }
|
||||||
|
```
|
||||||
|
|
||||||
|
**BAD** Examples:
|
||||||
|
```javascript
|
||||||
|
if (myvar)
|
||||||
|
do_action();
|
||||||
|
```
|
||||||
|
```javascript
|
||||||
|
if (myvar) do_action();
|
||||||
|
```
|
||||||
|
```javascript
|
||||||
|
while (myvar)
|
||||||
|
do_action();
|
||||||
|
```
|
||||||
|
```javascript
|
||||||
|
while (myvar) do_action();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ALWAYS Terminate JavaScript Statements with Semicolons
|
||||||
|
|
||||||
|
ALWAYS end every JavaScript statement with a semicolon (;).
|
||||||
|
|
||||||
|
Rationale: Semicolons make intent explicit, eliminate ASI surprises, improve readability, and produce clearer diffs and tooling output.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Applies to all JavaScript statements — inside signal handlers (onClicked: { ... }), custom methods (function foo() { ... }), arrow function bodies, standalone .js files, and test files.
|
||||||
|
- Does NOT apply to QML declarative property bindings (width: 100, text: "foo", anchors.centerIn: parent, model: myModel, etc.). These are not statements; adding ; after them is either a syntax error or non-idiomatic in QML.
|
||||||
|
- When multiple QML bindings are written on a single line for compactness, ; is used only as a separator between bindings (Qt/QML convention), not as a statement terminator.
|
||||||
|
|
||||||
|
GOOD (JavaScript statements):
|
||||||
|
```javascript
|
||||||
|
// Inside a QML signal handler or method
|
||||||
|
onClicked: {
|
||||||
|
root.toggleTailscale();
|
||||||
|
ToastService.showInfo("Toggled");
|
||||||
|
}
|
||||||
|
function buildCommand(hostname) {
|
||||||
|
if (hostname === "") {
|
||||||
|
return ["tailscale", "set", "--exit-node="];
|
||||||
|
}
|
||||||
|
return ["tailscale", "set", "--exit-node=" + hostname];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In a .js file
|
||||||
|
if (!peerMap) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.keys(peerMap).map(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
BAD (missing semicolons on statements):
|
||||||
|
```javascript
|
||||||
|
if (condition) {
|
||||||
|
doAction()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
```
|
||||||
|
|
||||||
|
BAD (incorrect semicolon on QML binding — never do this):
|
||||||
|
```javascript
|
||||||
|
width: 360; // OK (separator on same line)
|
||||||
|
height: 400; // WRONG — this is a binding, not a statement
|
||||||
|
text: "Tailscale"; // WRONG
|
||||||
|
```
|
||||||
|
|
||||||
|
GOOD (correct QML binding style):
|
||||||
|
```javascript
|
||||||
|
width: 360
|
||||||
|
height: 400
|
||||||
|
text: "Tailscale"
|
||||||
|
Item { width: 1; height: 1; Layout.fillWidth: true } // ; only as separator
|
||||||
|
```
|
||||||
33
README.md
33
README.md
|
|
@ -2,26 +2,28 @@
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Status icon** in the bar — `vpn_key` when connected, `vpn_key_off` when disconnected
|
- **Status icon** in the bar — `vpn_key` when connected, `vpn_key_off` when disconnected
|
||||||
- **Right-click** to toggle Tailscale on/off
|
- **Right-click** to toggle Tailscale on/off
|
||||||
- **Left-click** to open a popout showing:
|
- **Left-click** to open a popout showing:
|
||||||
- Your current Tailscale IP
|
- Your current Tailscale IP (when connected)
|
||||||
- Active exit node (with clear button)
|
- Active exit node (with clear button)
|
||||||
- Peer list with hostnames and IPs
|
- Peer list with hostnames and IPs (when connected)
|
||||||
|
- A clear "Not connected" empty state when disconnected (no stale peer list)
|
||||||
- **Click-to-copy** any hostname or IP to clipboard
|
- **Click-to-copy** any hostname or IP to clipboard
|
||||||
- **Exit node selection** — click `↗` on any exit-node-capable peer to route through it
|
- **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)
|
||||||
|
- **Single-flight actions** — concurrent status/toggle/exit/copy chains are rejected while an operation is in flight
|
||||||
- **Toast notifications** for all errors
|
- **Toast notifications** for all errors
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Dank Material Shell installed and running
|
- Dank Material Shell installed and running
|
||||||
- `tailscale` CLI available on `PATH`
|
- `tailscale` CLI available on `PATH`
|
||||||
- A clipboard tool (`dms`, `wl-copy`, or `clipmanctl`)
|
- A clipboard tool (`dms` or `wl-copy`)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -42,7 +44,8 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
||||||
tailscalectl/
|
tailscalectl/
|
||||||
├── plugin.json
|
├── plugin.json
|
||||||
├── TailscaleWidget.qml
|
├── TailscaleWidget.qml
|
||||||
└── lib.js
|
├── lib.js
|
||||||
|
└── i18n/
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Reload the plugin:
|
3. Reload the plugin:
|
||||||
|
|
@ -70,15 +73,27 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
||||||
"id": "tailscalectl",
|
"id": "tailscalectl",
|
||||||
"name": "Tailscale",
|
"name": "Tailscale",
|
||||||
"description": "Tailscale status and controls on the Dank Bar",
|
"description": "Tailscale status and controls on the Dank Bar",
|
||||||
"version": "0.1.0",
|
"author": "John Morris",
|
||||||
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)",
|
|
||||||
"icon": "vpn_key",
|
"icon": "vpn_key",
|
||||||
"type": "widget",
|
"type": "widget",
|
||||||
|
"capabilities": ["dankbar-widget"],
|
||||||
"component": "./TailscaleWidget.qml",
|
"component": "./TailscaleWidget.qml",
|
||||||
"permissions": ["settings_read", "settings_write", "process"]
|
"permissions": ["process"],
|
||||||
|
"requires": ["tailscale"],
|
||||||
|
"version": "0.2.1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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; a failed status poll aborts the pending toggle (does not invent `up`/`down`).
|
||||||
|
- When `BackendState` is not `Running`, peer list / exit node / self IP are cleared so the UI never shows a stale connected-looking peer list.
|
||||||
|
- Status row uses `RowLayout` with a real `Layout.fillWidth` spacer (not a no-op on plain `Row`).
|
||||||
|
- Peer `ListView` uses `Flickable.StopAtBounds` (no desktop rubber-band overshoot).
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -4,178 +4,170 @@ import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
import qs.Modules.Plugins
|
import qs.Modules.Plugins
|
||||||
import Quickshell.Io
|
|
||||||
import "./lib.js" as TailscaleLib
|
import "./lib.js" as TailscaleLib
|
||||||
|
|
||||||
PluginComponent {
|
PluginComponent {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool isConnected: false
|
property bool isConnected: false
|
||||||
property bool binaryAvailable: true
|
|
||||||
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 int _copyIndex: 0
|
||||||
property int _copyAttempted: 0
|
|
||||||
|
// Transient coordination for defensive poll-act-poll toggle (not long-term cache).
|
||||||
|
// Poll for ground truth → act → poll again for verification.
|
||||||
|
property string _pendingAction: ""
|
||||||
|
|
||||||
|
// Single-flight guard: prevent interleaved status/toggle/exit/copy chains (#15/#30 class).
|
||||||
|
property bool _busy: false
|
||||||
|
|
||||||
layerNamespacePlugin: "tailscalectl"
|
layerNamespacePlugin: "tailscalectl"
|
||||||
popoutWidth: 360
|
popoutWidth: 360
|
||||||
popoutHeight: 400
|
popoutHeight: 400
|
||||||
|
|
||||||
Timer {
|
|
||||||
interval: 5000
|
|
||||||
running: true
|
|
||||||
repeat: true
|
|
||||||
onTriggered: statusCheck.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
statusCheck.running = true
|
root._runStatusCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
function _runStatusCheck() {
|
||||||
id: toggleProcess
|
if (root._busy && root._pendingAction === "") {
|
||||||
|
// A non-toggle status refresh while something is already in flight: skip.
|
||||||
stderr: StdioCollector {}
|
// Toggle path sets _pendingAction first and is allowed to chain after actions clear busy carefully.
|
||||||
|
return;
|
||||||
onExited: (code, status) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
var action = root.isConnected ? "disconnect" : "connect"
|
|
||||||
var detail = toggleProcess.stderr.text.trim().slice(0, 120)
|
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : ""))
|
|
||||||
}
|
|
||||||
statusCheck.running = true
|
|
||||||
}
|
}
|
||||||
|
root._busy = true;
|
||||||
|
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
||||||
|
var statusOk = (code === 0);
|
||||||
|
if (statusOk) {
|
||||||
|
const state = TailscaleLib.parseStatusResult(stdout);
|
||||||
|
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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk);
|
||||||
id: copyProcess
|
if (cmd) {
|
||||||
|
// Fresh poll succeeded; act, then verify with another status poll.
|
||||||
|
Proc.runCommand("tailscale-toggle", cmd, (out, c) => {
|
||||||
|
if (c !== 0) {
|
||||||
|
const action = root.isConnected ? "disconnect" : "connect";
|
||||||
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action)));
|
||||||
|
}
|
||||||
|
root._pendingAction = "";
|
||||||
|
// Keep busy through verification poll: call internal runner that assumes we own the lock.
|
||||||
|
root._runStatusCheckUnlocked();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Includes failed status while a toggle was pending: abort rather than invent up/down.
|
||||||
|
root._pendingAction = "";
|
||||||
|
root._busy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
stderr: StdioCollector {}
|
// Used only as the continuation after toggle action; assumes _busy is already true.
|
||||||
|
function _runStatusCheckUnlocked() {
|
||||||
onExited: (code, status) => {
|
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
root.cachedClipboardTool = root._copyCurrentTool
|
const state = TailscaleLib.parseStatusResult(stdout);
|
||||||
ToastService.showInfo("Copied " + root._copyText + " to clipboard")
|
root.isConnected = state.isConnected;
|
||||||
|
root.tailscaleIP = state.tailscaleIP;
|
||||||
|
root.currentExitNode = state.currentExitNode;
|
||||||
|
root.peers = state.peers;
|
||||||
} else {
|
} else {
|
||||||
root._copyAttempted += 1
|
root.isConnected = false;
|
||||||
if (root._copyAttempted < TailscaleLib.allClipboardTools().length) {
|
root.tailscaleIP = "";
|
||||||
root._copyCurrentTool = TailscaleLib.nextClipboardTool(root._copyCurrentTool)
|
root.currentExitNode = "";
|
||||||
root._executeCopy()
|
root.peers = [];
|
||||||
} else {
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
||||||
var detail = copyProcess.stderr.text.trim().slice(0, 120)
|
|
||||||
ToastService.showError("tailscalectl", "No clipboard tool found" + (detail ? " — " + detail : ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: exitNodeProcess
|
|
||||||
|
|
||||||
stderr: StdioCollector {}
|
|
||||||
|
|
||||||
onExited: (code, status) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
var detail = exitNodeProcess.stderr.text.trim().slice(0, 120)
|
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set") + (detail ? " — " + detail : ""))
|
|
||||||
}
|
|
||||||
statusCheck.running = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: statusCheck
|
|
||||||
|
|
||||||
command: ["tailscale", "status", "--json"]
|
|
||||||
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
const state = TailscaleLib.parseStatusResult(this.text)
|
|
||||||
root.isConnected = state.isConnected
|
|
||||||
root.tailscaleIP = state.tailscaleIP
|
|
||||||
root.currentExitNode = state.currentExitNode
|
|
||||||
root.peers = state.peers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onExited: (code, status) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
root.isConnected = false
|
|
||||||
root.tailscaleIP = ""
|
|
||||||
root.currentExitNode = ""
|
|
||||||
root.peers = []
|
|
||||||
if (code === 127) {
|
|
||||||
root.binaryAvailable = false
|
|
||||||
ToastService.showError("tailscalectl", "Tailscale binary not found")
|
|
||||||
} else {
|
|
||||||
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
root._busy = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTailscale() {
|
function toggleTailscale() {
|
||||||
if (!root.binaryAvailable) {
|
if (root._busy) {
|
||||||
ToastService.showError("tailscalectl", "Tailscale not available")
|
return;
|
||||||
return
|
|
||||||
}
|
}
|
||||||
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
|
root._pendingAction = TailscaleLib.PendingAction.TOGGLE;
|
||||||
toggleProcess.running = true
|
root._runStatusCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStatus() {
|
||||||
|
if (root._busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root._runStatusCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setExitNode(hostname) {
|
function setExitNode(hostname) {
|
||||||
if (!root.binaryAvailable) {
|
if (root._busy) {
|
||||||
ToastService.showError("tailscalectl", "Tailscale not available")
|
return;
|
||||||
return
|
|
||||||
}
|
}
|
||||||
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
|
const cmd = TailscaleLib.makeExitNodeCommand(hostname);
|
||||||
exitNodeProcess.running = true
|
if (!cmd) {
|
||||||
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root._busy = true;
|
||||||
|
Proc.runCommand("tailscale-exit", cmd, (stdout, code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set")));
|
||||||
|
}
|
||||||
|
// Verify via unlocked status continuation (busy already held).
|
||||||
|
root._runStatusCheckUnlocked();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
root._copyText = text
|
if (root._busy) {
|
||||||
root._copyAttempted = 0
|
return;
|
||||||
if (root.cachedClipboardTool) {
|
|
||||||
root._copyCurrentTool = root.cachedClipboardTool
|
|
||||||
} else {
|
|
||||||
root._copyCurrentTool = TailscaleLib.allClipboardTools()[0]
|
|
||||||
}
|
}
|
||||||
root._executeCopy()
|
root._copyText = text;
|
||||||
|
root._copyIndex = 0;
|
||||||
|
root._busy = true;
|
||||||
|
root._runNextCopy();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _executeCopy() {
|
function _runNextCopy() {
|
||||||
var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool)
|
const cmds = TailscaleLib.getClipboardCommands(root._copyText);
|
||||||
if (!cmd) {
|
if (root._copyIndex >= cmds.length) {
|
||||||
ToastService.showError("tailscalectl", "Invalid clipboard command")
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard")));
|
||||||
return
|
root._busy = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
copyProcess.command = cmd
|
Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => {
|
||||||
copyProcess.running = true
|
if (code === 0) {
|
||||||
|
ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText));
|
||||||
|
root._busy = false;
|
||||||
|
} else {
|
||||||
|
root._copyIndex += 1;
|
||||||
|
root._runNextCopy();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
popoutContent: Component {
|
popoutContent: Component {
|
||||||
PopoutComponent {
|
PopoutComponent {
|
||||||
headerText: "Tailscale"
|
headerText: I18n.tr(TailscaleLib.getStrings().header)
|
||||||
detailsText: root.isConnected ? "Connected" : "Disconnected"
|
detailsText: root.isConnected ? I18n.tr(TailscaleLib.getStrings().connected) : I18n.tr(TailscaleLib.getStrings().disconnected)
|
||||||
showCloseButton: true
|
showCloseButton: true
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: contentItem
|
id: contentItem
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM
|
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerArea.height + Theme.spacingM
|
||||||
|
|
||||||
StyledText {
|
RowLayout {
|
||||||
visible: !root.binaryAvailable
|
|
||||||
text: "Tailscale not available"
|
|
||||||
anchors.centerIn: parent
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceVariantText
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
id: statusRow
|
id: statusRow
|
||||||
y: Theme.spacingM
|
y: Theme.spacingM
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
|
@ -188,11 +180,11 @@ PluginComponent {
|
||||||
MouseArea {
|
MouseArea {
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
width: toggleIcon.implicitWidth
|
width: toggleIcon.implicitWidth
|
||||||
height: toggleIcon.implicitHeight
|
height: toggleIcon.implicitHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.toggleTailscale()
|
root.toggleTailscale();
|
||||||
}
|
}
|
||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
|
|
@ -208,27 +200,30 @@ PluginComponent {
|
||||||
text: root.tailscaleIP || "—"
|
text: root.tailscaleIP || "—"
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.primary
|
color: Theme.primary
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
Item { width: 1; height: 1; Layout.fillWidth: true }
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: "Exit node: " + (root.currentExitNode || "None")
|
text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none))
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
visible: root.currentExitNode !== ""
|
visible: root.currentExitNode !== ""
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
width: clearExitNodeText.implicitWidth
|
width: clearExitNodeText.implicitWidth
|
||||||
height: clearExitNodeText.implicitHeight
|
height: clearExitNodeText.implicitHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.setExitNode("")
|
root.setExitNode("");
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
|
|
@ -240,25 +235,46 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ListView {
|
// Peer list when connected; empty-state hint when not (#55).
|
||||||
id: peerList
|
Item {
|
||||||
|
id: peerArea
|
||||||
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM
|
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM
|
||||||
width: parent.width - Theme.spacingM * 2
|
width: parent.width - Theme.spacingM * 2
|
||||||
height: Math.min(root.peers.length * (Theme.fontSizeSmall + Theme.spacingXS), 200)
|
height: root.isConnected
|
||||||
|
? Math.min(Math.max(root.peers.length, 1) * (Theme.fontSizeSmall + Theme.spacingXS), 200)
|
||||||
|
: (Theme.fontSizeSmall + Theme.spacingXS)
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Theme.spacingM
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
visible: !root.isConnected
|
||||||
|
anchors.fill: parent
|
||||||
|
text: I18n.tr(TailscaleLib.getStrings().notConnectedHint)
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: peerList
|
||||||
|
visible: root.isConnected
|
||||||
|
anchors.fill: parent
|
||||||
model: root.peers
|
model: root.peers
|
||||||
interactive: true
|
interactive: true
|
||||||
boundsBehavior: Flickable.DragAndOvershootBounds
|
// Desktop popout: no rubber-band overshoot (#27).
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
clip: true
|
||||||
|
|
||||||
delegate: Item {
|
delegate: Item {
|
||||||
width: peerList.width
|
width: peerList.width
|
||||||
height: Theme.fontSizeSmall + Theme.spacingXS
|
height: Theme.fontSizeSmall + Theme.spacingXS
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.fill: parent
|
anchors.left: parent.left
|
||||||
spacing: Theme.spacingS
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
|
@ -267,7 +283,7 @@ PluginComponent {
|
||||||
width: peerHostnameText.implicitWidth
|
width: peerHostnameText.implicitWidth
|
||||||
height: peerHostnameText.implicitHeight
|
height: peerHostnameText.implicitHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.copyToClipboard(modelData.hostname)
|
root.copyToClipboard(modelData.hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
|
|
@ -285,7 +301,7 @@ PluginComponent {
|
||||||
width: peerIpText.implicitWidth
|
width: peerIpText.implicitWidth
|
||||||
height: peerIpText.implicitHeight
|
height: peerIpText.implicitHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.copyToClipboard(modelData.ip)
|
root.copyToClipboard(modelData.ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
|
|
@ -304,14 +320,15 @@ PluginComponent {
|
||||||
width: exitNodeButton.implicitWidth
|
width: exitNodeButton.implicitWidth
|
||||||
height: exitNodeButton.implicitHeight
|
height: exitNodeButton.implicitHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.setExitNode(modelData.hostname)
|
root.setExitNode(modelData.hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
id: exitNodeButton
|
id: exitNodeButton
|
||||||
text: "↗"
|
text: "↗"
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
|
color: (root.currentExitNode === modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -321,16 +338,11 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// propagateComposedEvents: true so that right-clicks both trigger our context menu
|
|
||||||
// *and* bubble to any parent MouseArea (e.g. for shell-level drag handling).
|
|
||||||
// Documented because the default (false) is far more common and this choice
|
|
||||||
// frequently surprises future maintainers.
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.RightButton
|
acceptedButtons: Qt.RightButton
|
||||||
propagateComposedEvents: true
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.toggleTailscale()
|
root.toggleTailscale();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
22
tailscalectl/i18n/README.md
Normal file
22
tailscalectl/i18n/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 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) lives beside this README.
|
||||||
|
|
||||||
|
## 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 4.5.
|
||||||
16
tailscalectl/i18n/en.json
Normal file
16
tailscalectl/i18n/en.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"Not connected": "Not connected",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
|
@ -1,77 +1,113 @@
|
||||||
function parsePeers(peerMap) {
|
function parsePeers(peerMap) {
|
||||||
if (!peerMap) return []
|
if (!peerMap) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return Object.keys(peerMap).map(function (key) {
|
return Object.keys(peerMap).map(function (key) {
|
||||||
var p = peerMap[key]
|
var p = peerMap[key];
|
||||||
return {
|
return {
|
||||||
hostname: p.HostName || key,
|
hostname: p.HostName || key,
|
||||||
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
|
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
|
||||||
online: p.Online || false,
|
online: p.Online || false,
|
||||||
exitNode: p.ExitNodeOption || false
|
exitNode: p.ExitNodeOption || false
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeExitNodeCommand(hostname) {
|
function makeExitNodeCommand(hostname) {
|
||||||
return ["tailscale", "set", "--exit-node=" + hostname]
|
if (!isValidExitNodeHostname(hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (hostname === "") {
|
||||||
|
return ["tailscale", "set", "--exit-node="];
|
||||||
|
}
|
||||||
|
return ["tailscale", "set", "--exit-node=" + hostname];
|
||||||
}
|
}
|
||||||
|
|
||||||
function findActiveExitNode(peerMap) {
|
function findActiveExitNode(peerMap) {
|
||||||
if (!peerMap) return ""
|
if (!peerMap) {
|
||||||
for (const key in peerMap) {
|
return "";
|
||||||
const p = peerMap[key]
|
}
|
||||||
|
for (const key of Object.keys(peerMap)) {
|
||||||
|
const p = peerMap[key];
|
||||||
if (p.ExitNode) {
|
if (p.ExitNode) {
|
||||||
return p.HostName || key
|
return p.HostName || key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"]
|
const clipboardTools = [
|
||||||
|
{ argv: ["dms", "cl", "copy"] },
|
||||||
|
{ argv: ["wl-copy"] }
|
||||||
|
];
|
||||||
|
|
||||||
var clipboardCmdMap = {
|
function getClipboardCommands(text) {
|
||||||
"dms": "dms cl copy",
|
return clipboardTools.map(function (tool) {
|
||||||
"wl-copy": "wl-copy",
|
return tool.argv.concat([text]);
|
||||||
"clipmanctl": "clipmanctl copy"
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateClipboardTool(tool) {
|
function getStrings() {
|
||||||
return typeof tool === "string" && safeClipboardTools.includes(tool)
|
return {
|
||||||
|
header: "Tailscale",
|
||||||
|
connected: "Connected",
|
||||||
|
disconnected: "Disconnected",
|
||||||
|
exitNodePrefix: "Exit node: ",
|
||||||
|
none: "None",
|
||||||
|
copied: "Copied %1 to clipboard",
|
||||||
|
invalidExitNodeHostname: "Invalid exit node hostname",
|
||||||
|
notConnectedHint: "Not connected"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCopyCommand(text, tool) {
|
// Security: validate hostnames coming from tailscale status JSON.
|
||||||
if (!validateClipboardTool(tool)) return null
|
// Fail closed on obviously malicious input. Allow multi-label MagicDNS names
|
||||||
var cmd = clipboardCmdMap[tool]
|
// up to DNS FQDN length (253).
|
||||||
if (!cmd) return null
|
function isValidExitNodeHostname(hostname) {
|
||||||
var escaped = text.replace(/'/g, "'\\''")
|
if (typeof hostname !== "string") {
|
||||||
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd]
|
return false;
|
||||||
}
|
}
|
||||||
|
if (hostname === "") {
|
||||||
function nextClipboardTool(currentTool) {
|
return true;
|
||||||
var idx = safeClipboardTools.indexOf(currentTool)
|
|
||||||
if (idx < 0) return safeClipboardTools[0]
|
|
||||||
return safeClipboardTools[(idx + 1) % safeClipboardTools.length]
|
|
||||||
}
|
}
|
||||||
|
if (hostname.length > 253) {
|
||||||
function allClipboardTools() {
|
return false;
|
||||||
return safeClipboardTools.slice()
|
}
|
||||||
|
// Each label: alnum start/end, alnum/hyphen/underscore inside; dots separate labels.
|
||||||
|
return /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?)(\.([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?))*$/.test(hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStatusResult(jsonText) {
|
function parseStatusResult(jsonText) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonText)
|
const data = JSON.parse(jsonText);
|
||||||
|
const isConnected = data.BackendState === "Running";
|
||||||
|
if (!isConnected) {
|
||||||
|
// #55: when not Running, do not surface stale peer list / exit node / IP.
|
||||||
return {
|
return {
|
||||||
isConnected: data.BackendState === "Running",
|
isConnected: false,
|
||||||
|
tailscaleIP: "",
|
||||||
|
currentExitNode: "",
|
||||||
|
peers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isConnected: true,
|
||||||
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
||||||
currentExitNode: findActiveExitNode(data.Peer || {}),
|
currentExitNode: findActiveExitNode(data.Peer || {}),
|
||||||
peers: parsePeers(data.Peer || {})
|
peers: parsePeers(data.Peer || {})
|
||||||
}
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
|
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToggleCommand(isConnected) {
|
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) {
|
||||||
|
|
@ -81,12 +117,52 @@ 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";
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommonJS export for Node.js tests (ignored by QML)
|
// Central error formatting for the widget. detail is optional truncated stderr or extra context.
|
||||||
if (typeof module !== "undefined" && module.exports) {
|
function formatError(action, detail) {
|
||||||
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult }
|
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"
|
||||||
|
});
|
||||||
|
|
||||||
|
// statusOk must be true (successful status poll) before acting on pending toggle.
|
||||||
|
// Never invent up/down from a failed poll (would force "up" after clearing isConnected).
|
||||||
|
function commandForPendingAction(pending, freshIsConnected, statusOk) {
|
||||||
|
if (!statusOk) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
PendingAction,
|
||||||
|
commandForPendingAction
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
"id": "tailscalectl",
|
"id": "tailscalectl",
|
||||||
"name": "Tailscale",
|
"name": "Tailscale",
|
||||||
"description": "Tailscale status and controls on the Dank Bar",
|
"description": "Tailscale status and controls on the Dank Bar",
|
||||||
"version": "0.1.0",
|
"author": "John Morris",
|
||||||
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)",
|
|
||||||
"icon": "vpn_key",
|
"icon": "vpn_key",
|
||||||
"type": "widget",
|
"type": "widget",
|
||||||
|
"capabilities": ["dankbar-widget"],
|
||||||
"component": "./TailscaleWidget.qml",
|
"component": "./TailscaleWidget.qml",
|
||||||
"permissions": ["settings_read", "settings_write", "process"]
|
"permissions": ["process"],
|
||||||
|
"requires": ["tailscale"],
|
||||||
|
"version": "0.2.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
460
test/lib.test.js
460
test/lib.test.js
|
|
@ -1,7 +1,32 @@
|
||||||
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, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib
|
|
||||||
|
const {
|
||||||
|
parsePeers,
|
||||||
|
makeExitNodeCommand,
|
||||||
|
findActiveExitNode,
|
||||||
|
errorMessage,
|
||||||
|
formatError,
|
||||||
|
getStatusCommand,
|
||||||
|
isValidExitNodeHostname,
|
||||||
|
getClipboardCommands,
|
||||||
|
buildToggleCommand,
|
||||||
|
parseStatusResult,
|
||||||
|
getStrings,
|
||||||
|
PendingAction,
|
||||||
|
commandForPendingAction
|
||||||
|
} = lib;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unit tests for pure functions in lib.js.
|
||||||
|
*
|
||||||
|
* TailscaleWidget.qml has no automated test coverage. Proc.runCommand
|
||||||
|
* coordination, busy-mutex behavior, and widget UI must be verified
|
||||||
|
* manually in a running DMS instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- parsePeers ---
|
||||||
|
|
||||||
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||||
const peerMap = {
|
const peerMap = {
|
||||||
|
|
@ -17,185 +42,344 @@ test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||||
Online: true,
|
Online: true,
|
||||||
ExitNodeOption: false
|
ExitNodeOption: false
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const peers = parsePeers(peerMap)
|
const peers = parsePeers(peerMap);
|
||||||
|
|
||||||
assert.strictEqual(peers[0].exitNode, true)
|
assert.strictEqual(peers.length, 2);
|
||||||
assert.strictEqual(peers[1].exitNode, false)
|
assert.strictEqual(peers[0].exitNode, true);
|
||||||
})
|
assert.strictEqual(peers[1].exitNode, false);
|
||||||
|
assert.strictEqual(peers[0].hostname, "router");
|
||||||
|
assert.strictEqual(peers[0].ip, "100.64.0.1");
|
||||||
|
assert.strictEqual(peers[0].online, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parsePeers returns empty array for null/undefined peerMap", () => {
|
||||||
|
assert.deepStrictEqual(parsePeers(null), []);
|
||||||
|
assert.deepStrictEqual(parsePeers(undefined), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parsePeers returns empty array for empty peerMap", () => {
|
||||||
|
assert.deepStrictEqual(parsePeers({}), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parsePeers falls back to key when HostName missing", () => {
|
||||||
|
const peers = parsePeers({
|
||||||
|
"node-key-abc": { TailscaleIPs: ["100.64.0.9"], Online: false }
|
||||||
|
});
|
||||||
|
assert.strictEqual(peers[0].hostname, "node-key-abc");
|
||||||
|
assert.strictEqual(peers[0].ip, "100.64.0.9");
|
||||||
|
assert.strictEqual(peers[0].online, false);
|
||||||
|
assert.strictEqual(peers[0].exitNode, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parsePeers uses empty ip when TailscaleIPs missing or empty", () => {
|
||||||
|
const peers = parsePeers({
|
||||||
|
a: { HostName: "a" },
|
||||||
|
b: { HostName: "b", TailscaleIPs: [] }
|
||||||
|
});
|
||||||
|
assert.strictEqual(peers[0].ip, "");
|
||||||
|
assert.strictEqual(peers[1].ip, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- makeExitNodeCommand / hostname validation ---
|
||||||
|
|
||||||
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
|
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
|
||||||
const cmd = makeExitNodeCommand("router")
|
const cmd = makeExitNodeCommand("router");
|
||||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"])
|
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]);
|
||||||
})
|
});
|
||||||
|
|
||||||
test("makeExitNodeCommand with empty string clears exit node", () => {
|
test("makeExitNodeCommand with empty string clears exit node", () => {
|
||||||
const cmd = makeExitNodeCommand("")
|
const cmd = makeExitNodeCommand("");
|
||||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="])
|
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]);
|
||||||
})
|
});
|
||||||
|
|
||||||
|
test("makeExitNodeCommand returns null for invalid hostname", () => {
|
||||||
|
assert.strictEqual(makeExitNodeCommand("; rm"), null);
|
||||||
|
assert.strictEqual(makeExitNodeCommand("$(whoami)"), null);
|
||||||
|
assert.strictEqual(makeExitNodeCommand(null), null);
|
||||||
|
assert.strictEqual(makeExitNodeCommand(42), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("makeExitNodeCommand still produces correct argv for valid input", () => {
|
||||||
|
assert.deepStrictEqual(makeExitNodeCommand(""), ["tailscale", "set", "--exit-node="]);
|
||||||
|
assert.deepStrictEqual(makeExitNodeCommand("gluetun-sjc"), ["tailscale", "set", "--exit-node=gluetun-sjc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname accepts empty string (clear)", () => {
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(""), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname accepts realistic Tailscale hostnames", () => {
|
||||||
|
["router", "gluetun-sjc", "my-exit-node-01", "peer_with_underscore", "a.b.c"].forEach((h) => {
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(h), true, h);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname accepts MagicDNS-style FQDNs under 253 chars", () => {
|
||||||
|
assert.strictEqual(isValidExitNodeHostname("my-node.tail1234.ts.net"), true);
|
||||||
|
const longButValid = "a".repeat(60) + "." + "b".repeat(60) + "." + "c".repeat(60);
|
||||||
|
assert.ok(longButValid.length < 253);
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(longButValid), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname rejects injection attempts and garbage", () => {
|
||||||
|
["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)", " spacy ", "-leading", "trailing-"].forEach((h) => {
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(h), false, h);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname rejects non-strings and oversized names", () => {
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(undefined), false);
|
||||||
|
assert.strictEqual(isValidExitNodeHostname({}), false);
|
||||||
|
assert.strictEqual(isValidExitNodeHostname("x".repeat(254)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- findActiveExitNode ---
|
||||||
|
|
||||||
test("findActiveExitNode returns hostname of peer with ExitNode=true", () => {
|
test("findActiveExitNode returns hostname of peer with ExitNode=true", () => {
|
||||||
const peerMap = {
|
const peerMap = {
|
||||||
"peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true },
|
"peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true },
|
||||||
"peer-2": { HostName: "gluetun-den", ExitNode: false, ExitNodeOption: true }
|
"peer-2": { HostName: "gluetun-den", ExitNode: false, ExitNodeOption: true }
|
||||||
}
|
};
|
||||||
assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc")
|
assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc");
|
||||||
})
|
});
|
||||||
|
|
||||||
test("findActiveExitNode returns empty string when no exit node", () => {
|
test("findActiveExitNode returns empty string when no exit node", () => {
|
||||||
const peerMap = {
|
const peerMap = {
|
||||||
"peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false }
|
"peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false }
|
||||||
}
|
};
|
||||||
assert.strictEqual(findActiveExitNode(peerMap), "")
|
assert.strictEqual(findActiveExitNode(peerMap), "");
|
||||||
})
|
});
|
||||||
|
|
||||||
test("errorMessage returns user-friendly message for tailscale up failure", () => {
|
test("findActiveExitNode returns empty string for null peerMap", () => {
|
||||||
const msg = errorMessage("up", 1)
|
assert.strictEqual(findActiveExitNode(null), "");
|
||||||
assert.strictEqual(msg, "Failed to connect to Tailscale")
|
});
|
||||||
})
|
|
||||||
|
|
||||||
test("errorMessage returns user-friendly message for tailscale down failure", () => {
|
test("findActiveExitNode falls back to map key when HostName missing", () => {
|
||||||
const msg = errorMessage("down", 1)
|
const peerMap = {
|
||||||
assert.strictEqual(msg, "Failed to disconnect from Tailscale")
|
"key-only": { ExitNode: true }
|
||||||
})
|
};
|
||||||
|
assert.strictEqual(findActiveExitNode(peerMap), "key-only");
|
||||||
|
});
|
||||||
|
|
||||||
test("errorMessage returns user-friendly message for tailscale set failure", () => {
|
// --- errorMessage / formatError ---
|
||||||
const msg = errorMessage("set", 1)
|
|
||||||
assert.strictEqual(msg, "Failed to set exit node")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("errorMessage returns user-friendly message for tailscale status failure", () => {
|
test("errorMessage returns user-friendly messages for known actions", () => {
|
||||||
const msg = errorMessage("status", 1)
|
assert.strictEqual(errorMessage("up"), "Failed to connect to Tailscale");
|
||||||
assert.strictEqual(msg, "Failed to read Tailscale status")
|
assert.strictEqual(errorMessage("connect"), "Failed to connect to Tailscale");
|
||||||
})
|
assert.strictEqual(errorMessage("down"), "Failed to disconnect from Tailscale");
|
||||||
|
assert.strictEqual(errorMessage("disconnect"), "Failed to disconnect from Tailscale");
|
||||||
|
assert.strictEqual(errorMessage("set"), "Failed to set exit node");
|
||||||
|
assert.strictEqual(errorMessage("status"), "Failed to read Tailscale status");
|
||||||
|
assert.strictEqual(errorMessage("clipboard"), "Error copying to clipboard");
|
||||||
|
});
|
||||||
|
|
||||||
test("errorMessage returns generic message for unknown command", () => {
|
test("errorMessage returns generic message for unknown command", () => {
|
||||||
const msg = errorMessage("unknown", 1)
|
assert.strictEqual(errorMessage("unknown"), "Tailscale command failed");
|
||||||
assert.strictEqual(msg, "Tailscale command failed")
|
});
|
||||||
})
|
|
||||||
|
|
||||||
// --- validateClipboardTool ---
|
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("validateClipboardTool accepts whitelisted tool names", () => {
|
test("formatError appends and truncates detail to 120 chars", () => {
|
||||||
assert.strictEqual(validateClipboardTool("dms"), true)
|
const longDetail = "x".repeat(200);
|
||||||
assert.strictEqual(validateClipboardTool("wl-copy"), true)
|
const msg = formatError("up", longDetail);
|
||||||
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
|
assert.ok(msg.includes("Failed to connect to Tailscale"));
|
||||||
})
|
assert.ok(msg.endsWith("x".repeat(120)));
|
||||||
|
assert.ok(msg.length < 200);
|
||||||
|
});
|
||||||
|
|
||||||
test("validateClipboardTool rejects malicious inputs", () => {
|
test("formatError handles empty or falsy detail gracefully", () => {
|
||||||
assert.strictEqual(validateClipboardTool("rm -rf /"), false)
|
assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale");
|
||||||
assert.strictEqual(validateClipboardTool("echo hi; malicious"), false)
|
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale");
|
||||||
assert.strictEqual(validateClipboardTool("$(whoami)"), false)
|
});
|
||||||
assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("validateClipboardTool rejects empty and falsy inputs", () => {
|
// --- clipboard ---
|
||||||
assert.strictEqual(validateClipboardTool(""), false)
|
|
||||||
assert.strictEqual(validateClipboardTool(null), false)
|
|
||||||
assert.strictEqual(validateClipboardTool(undefined), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- buildCopyCommand ---
|
test("getClipboardCommands returns ordered argv arrays with text appended", () => {
|
||||||
|
const cmds = getClipboardCommands("1.2.3.4");
|
||||||
|
assert.ok(Array.isArray(cmds));
|
||||||
|
assert.strictEqual(cmds.length, 2);
|
||||||
|
assert.deepStrictEqual(cmds[0], ["dms", "cl", "copy", "1.2.3.4"]);
|
||||||
|
assert.deepStrictEqual(cmds[1], ["wl-copy", "1.2.3.4"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => {
|
test("getClipboardCommands handles text with special characters safely (direct argv)", () => {
|
||||||
const cmd = buildCopyCommand("hello", "wl-copy")
|
const cmds = getClipboardCommands("it's a 'test' with \"quotes\" and\nnewlines");
|
||||||
assert.ok(Array.isArray(cmd))
|
assert.ok(Array.isArray(cmds));
|
||||||
assert.strictEqual(cmd[0], "sh")
|
assert.strictEqual(cmds.length, 2);
|
||||||
assert.strictEqual(cmd[1], "-c")
|
assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines"));
|
||||||
})
|
});
|
||||||
|
|
||||||
test("buildCopyCommand returns null for invalid clipboard tool", () => {
|
// --- strings ---
|
||||||
assert.strictEqual(buildCopyCommand("hello", "rm -rf /"), null)
|
|
||||||
assert.strictEqual(buildCopyCommand("hello", ""), null)
|
|
||||||
assert.strictEqual(buildCopyCommand("hello", null), null)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("buildCopyCommand safely handles text with special characters", () => {
|
test("getStrings returns canonical UI strings for the widget", () => {
|
||||||
const cmd = buildCopyCommand("it's a 'test' with \"quotes\" and\nnewlines", "wl-copy")
|
const s = getStrings();
|
||||||
assert.ok(Array.isArray(cmd))
|
assert.ok(s.header);
|
||||||
})
|
assert.ok(s.connected);
|
||||||
|
assert.ok(s.disconnected);
|
||||||
|
assert.ok(s.exitNodePrefix);
|
||||||
|
assert.ok(s.none);
|
||||||
|
assert.ok(s.copied);
|
||||||
|
assert.ok(s.invalidExitNodeHostname);
|
||||||
|
assert.ok(s.notConnectedHint);
|
||||||
|
});
|
||||||
|
|
||||||
// --- nextClipboardTool ---
|
test("getStrings.copied is the I18n template key (interpolation via .arg at call site)", () => {
|
||||||
|
const s = getStrings();
|
||||||
|
assert.strictEqual(s.copied, "Copied %1 to clipboard");
|
||||||
|
});
|
||||||
|
|
||||||
test("nextClipboardTool cycles through tools", () => {
|
// --- toggle helpers ---
|
||||||
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
|
|
||||||
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
|
|
||||||
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("nextClipboardTool falls back to first tool for unknown input", () => {
|
test("buildToggleCommand returns down when connected", () => {
|
||||||
assert.strictEqual(nextClipboardTool(""), "dms")
|
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]);
|
||||||
assert.strictEqual(nextClipboardTool("unknown"), "dms")
|
});
|
||||||
})
|
|
||||||
|
|
||||||
// --- allClipboardTools ---
|
test("buildToggleCommand returns up when disconnected", () => {
|
||||||
|
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"]);
|
||||||
test("allClipboardTools returns the list of safe tools", () => {
|
});
|
||||||
const tools = allClipboardTools()
|
|
||||||
assert.ok(Array.isArray(tools))
|
|
||||||
assert.strictEqual(tools.length, 3)
|
|
||||||
assert.ok(tools.includes("dms"))
|
|
||||||
assert.ok(tools.includes("wl-copy"))
|
|
||||||
assert.ok(tools.includes("clipmanctl"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- buildToggleCommand ---
|
|
||||||
|
|
||||||
test("buildToggleCommand returns down command when connected", () => {
|
|
||||||
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("buildToggleCommand returns up command when disconnected", () => {
|
|
||||||
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("buildToggleCommand treats null and undefined as disconnected", () => {
|
test("buildToggleCommand treats null and undefined as disconnected", () => {
|
||||||
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"])
|
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"]);
|
||||||
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
|
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]);
|
||||||
})
|
});
|
||||||
|
|
||||||
// --- parseStatusResult ---
|
test("commandForPendingAction returns toggle command when pending is TOGGLE and statusOk", () => {
|
||||||
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true, true), ["tailscale", "down"]);
|
||||||
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false, true), ["tailscale", "up"]);
|
||||||
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null, true), ["tailscale", "up"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("parseStatusResult produces correct state from valid JSON", () => {
|
test("commandForPendingAction returns null when status poll failed (do not invent up/down)", () => {
|
||||||
|
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, false), null);
|
||||||
|
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, true, false), null);
|
||||||
|
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("commandForPendingAction returns null for no pending action or unknown pending value", () => {
|
||||||
|
assert.strictEqual(commandForPendingAction(null, true, true), null);
|
||||||
|
assert.strictEqual(commandForPendingAction(undefined, false, true), null);
|
||||||
|
assert.strictEqual(commandForPendingAction("something-else", true, true), null);
|
||||||
|
assert.strictEqual(commandForPendingAction("", true, true), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- parseStatusResult (#55 and ground-truth parsing) ---
|
||||||
|
|
||||||
|
test("parseStatusResult produces correct state from valid Running JSON", () => {
|
||||||
const json = JSON.stringify({
|
const json = JSON.stringify({
|
||||||
BackendState: "Running",
|
BackendState: "Running",
|
||||||
Self: { TailscaleIPs: ["100.64.0.5"] },
|
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||||
Peer: {
|
Peer: {
|
||||||
"key-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNode: true, ExitNodeOption: true }
|
"key-1": {
|
||||||
|
HostName: "router",
|
||||||
|
TailscaleIPs: ["100.64.0.1"],
|
||||||
|
Online: true,
|
||||||
|
ExitNode: true,
|
||||||
|
ExitNodeOption: true
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
const state = parseStatusResult(json)
|
});
|
||||||
assert.strictEqual(state.isConnected, true)
|
const state = parseStatusResult(json);
|
||||||
assert.strictEqual(state.tailscaleIP, "100.64.0.5")
|
assert.strictEqual(state.isConnected, true);
|
||||||
assert.strictEqual(state.currentExitNode, "router")
|
assert.strictEqual(state.tailscaleIP, "100.64.0.5");
|
||||||
assert.strictEqual(state.peers.length, 1)
|
assert.strictEqual(state.currentExitNode, "router");
|
||||||
})
|
assert.strictEqual(state.peers.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("parseStatusResult returns safe defaults for invalid JSON", () => {
|
test("parseStatusResult returns safe defaults for invalid JSON", () => {
|
||||||
const state = parseStatusResult("not json at all")
|
const state = parseStatusResult("not json at all");
|
||||||
assert.strictEqual(state.isConnected, false)
|
assert.strictEqual(state.isConnected, false);
|
||||||
assert.strictEqual(state.tailscaleIP, "")
|
assert.strictEqual(state.tailscaleIP, "");
|
||||||
assert.strictEqual(state.currentExitNode, "")
|
assert.strictEqual(state.currentExitNode, "");
|
||||||
assert.strictEqual(state.peers.length, 0)
|
assert.deepStrictEqual(state.peers, []);
|
||||||
})
|
});
|
||||||
|
|
||||||
test("parseStatusResult handles missing Self gracefully", () => {
|
test("parseStatusResult handles missing Self gracefully when Running", () => {
|
||||||
const json = JSON.stringify({ BackendState: "Running", Peer: {} })
|
const json = JSON.stringify({ BackendState: "Running", Peer: {} });
|
||||||
const state = parseStatusResult(json)
|
const state = parseStatusResult(json);
|
||||||
assert.strictEqual(state.isConnected, true)
|
assert.strictEqual(state.isConnected, true);
|
||||||
assert.strictEqual(state.tailscaleIP, "")
|
assert.strictEqual(state.tailscaleIP, "");
|
||||||
})
|
});
|
||||||
|
|
||||||
test("parseStatusResult handles missing and empty Peer gracefully", () => {
|
test("parseStatusResult handles missing and empty Peer gracefully when Running", () => {
|
||||||
const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } })
|
const json = JSON.stringify({
|
||||||
const state = parseStatusResult(json)
|
BackendState: "Running",
|
||||||
assert.strictEqual(state.peers.length, 0)
|
Self: { TailscaleIPs: ["100.64.0.5"] }
|
||||||
assert.strictEqual(state.currentExitNode, "")
|
});
|
||||||
})
|
const state = parseStatusResult(json);
|
||||||
|
assert.strictEqual(state.peers.length, 0);
|
||||||
|
assert.strictEqual(state.currentExitNode, "");
|
||||||
|
});
|
||||||
|
|
||||||
test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
|
test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
|
||||||
const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} })
|
const json = JSON.stringify({
|
||||||
const state = parseStatusResult(json)
|
BackendState: "NeedsLogin",
|
||||||
assert.strictEqual(state.isConnected, false)
|
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||||
})
|
Peer: {}
|
||||||
|
});
|
||||||
|
const state = parseStatusResult(json);
|
||||||
|
assert.strictEqual(state.isConnected, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #55: disconnected must not surface peer list / exit node from leftover JSON
|
||||||
|
test("parseStatusResult clears peers and exit node when BackendState is not Running (#55)", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
BackendState: "Stopped",
|
||||||
|
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||||
|
Peer: {
|
||||||
|
"k1": {
|
||||||
|
HostName: "router",
|
||||||
|
TailscaleIPs: ["100.64.0.1"],
|
||||||
|
Online: false,
|
||||||
|
ExitNode: true,
|
||||||
|
ExitNodeOption: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const state = parseStatusResult(json);
|
||||||
|
assert.strictEqual(state.isConnected, false);
|
||||||
|
assert.deepStrictEqual(state.peers, []);
|
||||||
|
assert.strictEqual(state.currentExitNode, "");
|
||||||
|
// Self IP may still appear in raw JSON; we clear display IP when disconnected
|
||||||
|
// so the popout does not look "half connected".
|
||||||
|
assert.strictEqual(state.tailscaleIP, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStatusResult clears peers for NeedsLogin even if Peer map is populated (#55)", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
BackendState: "NeedsLogin",
|
||||||
|
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||||
|
Peer: {
|
||||||
|
"k1": { HostName: "ghost", TailscaleIPs: ["100.64.0.2"], Online: false }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const state = parseStatusResult(json);
|
||||||
|
assert.strictEqual(state.isConnected, false);
|
||||||
|
assert.deepStrictEqual(state.peers, []);
|
||||||
|
assert.strictEqual(state.currentExitNode, "");
|
||||||
|
assert.strictEqual(state.tailscaleIP, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- getStatusCommand ---
|
||||||
|
|
||||||
|
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
||||||
|
const cmd = getStatusCommand();
|
||||||
|
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- PendingAction constant ---
|
||||||
|
|
||||||
|
test("PendingAction.TOGGLE is the stable string used by the widget", () => {
|
||||||
|
assert.strictEqual(PendingAction.TOGGLE, "toggle");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- export surface: no over-abstracted UI predicates ---
|
||||||
|
|
||||||
|
test("lib does not export trivial UI predicates shouldShowClearExitNode / isActiveExitNode", () => {
|
||||||
|
assert.strictEqual(lib.shouldShowClearExitNode, undefined);
|
||||||
|
assert.strictEqual(lib.isActiveExitNode, undefined);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue