Add tailscale plugin
This commit is contained in:
@@ -25,6 +25,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
|||||||
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
||||||
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
||||||
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
||||||
|
| Tailscale | `omarchy.tailscale` | `bar-widget` | `tailscale/Widget.qml` |
|
||||||
| Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
|
| Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
|
||||||
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
|
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
|
||||||
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
|
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ the shell for its whole session.
|
|||||||
- `manifest.json` declares the plugin (`id: omarchy.bar`, `kind: bar`) and points at `Bar.qml` as the entry point.
|
- `manifest.json` declares the plugin (`id: omarchy.bar`, `kind: bar`) and points at `Bar.qml` as the entry point.
|
||||||
- `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly.
|
- `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly.
|
||||||
- `widgets/` holds simple first-party bar widgets with sibling manifests.
|
- `widgets/` holds simple first-party bar widgets with sibling manifests.
|
||||||
- Feature plugins such as `../panels/audio/`, `../panels/network/`, and `../panels/power/` provide richer popup bar widgets.
|
- Feature plugins such as `../panels/audio/`, `../panels/network/`, `../panels/power/`, and `../tailscale/` provide richer popup bar plugins.
|
||||||
- The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/omarchy/shell.json` (or `config/omarchy/shell.json` when the user has no file).
|
- The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/omarchy/shell.json` (or `config/omarchy/shell.json` when the user has no file).
|
||||||
- `omarchy-style-bar-position` updates only the user shell.json file.
|
- `omarchy-style-bar-position` updates only the user shell.json file.
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@ Example `shell.json` (bar subtree only shown):
|
|||||||
|
|
||||||
| `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
|
| `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
|
||||||
| `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
|
| `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
|
||||||
|
| `omarchy.tailscale` | Tailscale status, account switcher, peer browser, and copy actions | left = popup · right = toggle · middle = refresh |
|
||||||
| `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup |
|
| `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup |
|
||||||
| `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
|
| `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
|
||||||
| `omarchy.monitor` | Brightness and laptop display controls | left = popup |
|
| `omarchy.monitor` | Brightness and laptop display controls | left = popup |
|
||||||
@@ -167,8 +168,8 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o
|
|||||||
|
|
||||||
First-party bar widgets are manifest-backed just like third-party widgets.
|
First-party bar widgets are manifest-backed just like third-party widgets.
|
||||||
Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`;
|
Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`;
|
||||||
richer popup widgets live in feature directories such as `../panels/audio/` and
|
richer popup plugins live in feature directories such as `../panels/audio/`,
|
||||||
`../panels/network/`; and feature plugins such as `omarchy.menu`, `omarchy.media`, and
|
`../panels/network/`, and `../tailscale/`; and feature plugins such as `omarchy.menu`, `omarchy.media`, and
|
||||||
`omarchy.notifications` declare their bar-widget entry points in their own
|
`omarchy.notifications` declare their bar-widget entry points in their own
|
||||||
`manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`,
|
`manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`,
|
||||||
`omarchy.network`, and `omarchy.clock`. Older UpperCamelCase ids such as
|
`omarchy.network`, and `omarchy.clock`. Older UpperCamelCase ids such as
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
visible: false
|
||||||
|
|
||||||
|
property var settings: ({})
|
||||||
|
|
||||||
|
property bool installed: false
|
||||||
|
property bool running: false
|
||||||
|
property bool needsLogin: false
|
||||||
|
property bool refreshing: false
|
||||||
|
property string backendState: "Unknown"
|
||||||
|
property string statusText: "Checking…"
|
||||||
|
property string selfName: ""
|
||||||
|
property string selfDnsName: ""
|
||||||
|
property string selfIp: ""
|
||||||
|
property string authUrl: ""
|
||||||
|
property var peers: []
|
||||||
|
property var accounts: []
|
||||||
|
property string selectedAccountId: ""
|
||||||
|
property string selectedAccountLabel: ""
|
||||||
|
property string actionStatus: ""
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600)
|
||||||
|
readonly property bool busy: whichProcess.running || statusProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running
|
||||||
|
|
||||||
|
property string _statusOutput: ""
|
||||||
|
property string _statusError: ""
|
||||||
|
property string _accountsOutput: ""
|
||||||
|
property string _actionOutput: ""
|
||||||
|
property string _actionError: ""
|
||||||
|
property string _loginOutput: ""
|
||||||
|
property string _loginError: ""
|
||||||
|
property bool _loginInProgress: false
|
||||||
|
property bool _loginUrlOpened: false
|
||||||
|
property string _preLoginAuthUrl: ""
|
||||||
|
property double _lastAccountsRefreshMs: 0
|
||||||
|
property string _switchOutput: ""
|
||||||
|
property string _switchError: ""
|
||||||
|
|
||||||
|
function setting(name, fallback) {
|
||||||
|
var value = settings ? settings[name] : undefined
|
||||||
|
return value === undefined || value === null ? fallback : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function intSetting(name, fallback, min, max) {
|
||||||
|
var n = parseInt(String(setting(name, fallback)), 10)
|
||||||
|
if (!isFinite(n)) n = fallback
|
||||||
|
if (n < min) n = min
|
||||||
|
if (n > max) n = max
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterIPv4(ips) {
|
||||||
|
var result = []
|
||||||
|
if (!ips || typeof ips.length !== "number") return result
|
||||||
|
for (var i = 0; i < ips.length; i++) {
|
||||||
|
var ip = String(ips[i] || "")
|
||||||
|
if (/^100\./.test(ip)) result.push(ip)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanDnsName(name) {
|
||||||
|
var value = String(name || "")
|
||||||
|
return value.charAt(value.length - 1) === "." ? value.slice(0, -1) : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDnsName(name) {
|
||||||
|
var clean = cleanDnsName(name)
|
||||||
|
if (clean === "") return ""
|
||||||
|
return clean.split(".")[0] || clean
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayHostName(hostName, dnsName) {
|
||||||
|
var host = String(hostName || "")
|
||||||
|
if (host !== "" && host.toLowerCase() !== "localhost") return host
|
||||||
|
return shortDnsName(dnsName) || host || "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function osIcon(os) {
|
||||||
|
var value = String(os || "").toLowerCase()
|
||||||
|
if (value === "linux") return ""
|
||||||
|
if (value === "macos" || value === "ios") return ""
|
||||||
|
if (value === "windows") return ""
|
||||||
|
if (value === "android") return ""
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLabel(account) {
|
||||||
|
if (!account) return "Unknown account"
|
||||||
|
var parts = []
|
||||||
|
if (account.nickname) parts.push(String(account.nickname))
|
||||||
|
if (account.tailnet && String(account.tailnet) !== String(account.nickname || "")) parts.push(String(account.tailnet))
|
||||||
|
if (account.account) parts.push(String(account.account))
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : String(account.id || "Unknown account")
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(value, label) {
|
||||||
|
var text = String(value || "")
|
||||||
|
if (text === "") return
|
||||||
|
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"])
|
||||||
|
actionStatus = elideStatus("Copied " + (label || text))
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPeerIp(peer) {
|
||||||
|
if (!peer) return
|
||||||
|
var ips = filterIPv4(peer.TailscaleIPs || [])
|
||||||
|
copyToClipboard(ips.length > 0 ? ips[0] : "", displayHostName(peer.HostName, peer.DNSName) + " IP")
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPeerName(peer) {
|
||||||
|
if (!peer) return
|
||||||
|
copyToClipboard(displayHostName(peer.HostName, peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " name")
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPeerDnsName(peer) {
|
||||||
|
if (!peer) return
|
||||||
|
copyToClipboard(cleanDnsName(peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " DNS name")
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(forceAccounts) {
|
||||||
|
if (installed) {
|
||||||
|
refreshStatusAndAccounts(forceAccounts === true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!whichProcess.running) {
|
||||||
|
refreshing = true
|
||||||
|
whichProcess.command = ["which", "tailscale"]
|
||||||
|
whichProcess.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStatusAndAccounts(forceAccounts) {
|
||||||
|
if (!installed) return
|
||||||
|
if (!statusProcess.running) {
|
||||||
|
_statusOutput = ""
|
||||||
|
_statusError = ""
|
||||||
|
refreshing = true
|
||||||
|
statusProcess.command = ["tailscale", "status", "--json"]
|
||||||
|
statusProcess.running = true
|
||||||
|
}
|
||||||
|
var now = Date.now()
|
||||||
|
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
|
||||||
|
if (shouldRefreshAccounts && !accountsProcess.running) {
|
||||||
|
_accountsOutput = ""
|
||||||
|
_lastAccountsRefreshMs = now
|
||||||
|
accountsProcess.command = ["tailscale", "switch", "--list", "--json"]
|
||||||
|
accountsProcess.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function elideStatus(text) {
|
||||||
|
var value = String(text || "").replace(/\s+/g, " ").trim()
|
||||||
|
return value.length > 140 ? value.substring(0, 137) + "…" : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUnavailable(message) {
|
||||||
|
running = false
|
||||||
|
needsLogin = false
|
||||||
|
backendState = "Unavailable"
|
||||||
|
statusText = message
|
||||||
|
selfName = ""
|
||||||
|
selfDnsName = ""
|
||||||
|
selfIp = ""
|
||||||
|
authUrl = ""
|
||||||
|
peers = []
|
||||||
|
accounts = []
|
||||||
|
selectedAccountId = ""
|
||||||
|
selectedAccountLabel = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatus(raw) {
|
||||||
|
var text = String(raw || "").trim()
|
||||||
|
if (text === "") {
|
||||||
|
resetUnavailable("Disconnected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(text)
|
||||||
|
backendState = String(data.BackendState || "Unknown")
|
||||||
|
running = backendState === "Running"
|
||||||
|
needsLogin = backendState === "NeedsLogin"
|
||||||
|
authUrl = String(data.AuthURL || "")
|
||||||
|
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) {
|
||||||
|
openAuthUrlFrom(authUrl, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = data.Self || {}
|
||||||
|
selfName = displayHostName(self.HostName, self.DNSName)
|
||||||
|
selfDnsName = cleanDnsName(self.DNSName)
|
||||||
|
var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || [])
|
||||||
|
selfIp = selfIps.length > 0 ? selfIps[0] : ""
|
||||||
|
|
||||||
|
var nextPeers = []
|
||||||
|
var rawPeers = data.Peer || {}
|
||||||
|
for (var id in rawPeers) {
|
||||||
|
var peer = rawPeers[id] || {}
|
||||||
|
var ipv4s = filterIPv4(peer.TailscaleIPs || [])
|
||||||
|
nextPeers.push({
|
||||||
|
id: id,
|
||||||
|
HostName: displayHostName(peer.HostName, peer.DNSName),
|
||||||
|
DNSName: cleanDnsName(peer.DNSName),
|
||||||
|
TailscaleIPs: ipv4s,
|
||||||
|
Online: peer.Online === true,
|
||||||
|
OS: String(peer.OS || ""),
|
||||||
|
Tags: peer.Tags || [],
|
||||||
|
ExitNodeOption: peer.ExitNodeOption === true,
|
||||||
|
ExitNode: peer.ExitNode === true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
nextPeers.sort(function(a, b) {
|
||||||
|
if (a.Online !== b.Online) return a.Online ? -1 : 1
|
||||||
|
return String(a.HostName).localeCompare(String(b.HostName))
|
||||||
|
})
|
||||||
|
peers = nextPeers
|
||||||
|
|
||||||
|
if (needsLogin) statusText = "Needs login"
|
||||||
|
else if (running) {
|
||||||
|
statusText = "Connected"
|
||||||
|
_loginInProgress = false
|
||||||
|
_loginUrlOpened = false
|
||||||
|
_preLoginAuthUrl = ""
|
||||||
|
loginTimeoutTimer.stop()
|
||||||
|
} else if (backendState === "Stopped") statusText = "Disconnected"
|
||||||
|
else statusText = backendState
|
||||||
|
lastError = ""
|
||||||
|
} catch (e) {
|
||||||
|
resetUnavailable("Status error")
|
||||||
|
lastError = "Failed to parse tailscale status"
|
||||||
|
console.warn("tailscale", lastError, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccounts(raw) {
|
||||||
|
var text = String(raw || "").trim()
|
||||||
|
if (text === "") {
|
||||||
|
accounts = []
|
||||||
|
selectedAccountId = ""
|
||||||
|
selectedAccountLabel = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(text)
|
||||||
|
var next = []
|
||||||
|
var selected = null
|
||||||
|
if (parsed && typeof parsed.length === "number") {
|
||||||
|
for (var i = 0; i < parsed.length; i++) {
|
||||||
|
var raw = parsed[i] || {}
|
||||||
|
var account = {
|
||||||
|
id: String(raw.id || raw.ID || ""),
|
||||||
|
nickname: String(raw.nickname || raw.Nickname || raw.name || raw.Name || ""),
|
||||||
|
tailnet: String(raw.tailnet || raw.Tailnet || ""),
|
||||||
|
account: String(raw.account || raw.Account || raw.loginName || raw.LoginName || raw.user || raw.User || ""),
|
||||||
|
selected: raw.selected === true || raw.Selected === true
|
||||||
|
}
|
||||||
|
next.push(account)
|
||||||
|
if (account.selected === true) selected = account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts = next
|
||||||
|
selectedAccountId = selected ? String(selected.id || "") : ""
|
||||||
|
selectedAccountLabel = selected ? accountLabel(selected) : ""
|
||||||
|
} catch (e) {
|
||||||
|
accounts = []
|
||||||
|
selectedAccountId = ""
|
||||||
|
selectedAccountLabel = ""
|
||||||
|
console.warn("tailscale", "Failed to parse account list", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTailscale() {
|
||||||
|
if (!installed) return
|
||||||
|
if (running) {
|
||||||
|
runAction(["tailscale", "down"], "Turning Tailscale off…")
|
||||||
|
} else {
|
||||||
|
loginOrUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginOrUp() {
|
||||||
|
if (!installed || loginProcess.running) return
|
||||||
|
_loginOutput = ""
|
||||||
|
_loginError = ""
|
||||||
|
actionStatus = needsLogin ? "Starting Tailscale login…" : "Turning Tailscale on…"
|
||||||
|
_loginInProgress = needsLogin
|
||||||
|
_loginUrlOpened = false
|
||||||
|
_preLoginAuthUrl = authUrl
|
||||||
|
var command = ["tailscale", "up"]
|
||||||
|
if (needsLogin) command.push("--force-reauth")
|
||||||
|
loginProcess.command = command
|
||||||
|
loginProcess.running = true
|
||||||
|
if (needsLogin) loginTimeoutTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchAccount(id) {
|
||||||
|
var accountId = String(id || "")
|
||||||
|
if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return
|
||||||
|
_switchOutput = ""
|
||||||
|
_switchError = ""
|
||||||
|
actionStatus = "Switching Tailscale account…"
|
||||||
|
switchProcess.command = ["tailscale", "switch", accountId]
|
||||||
|
switchProcess.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(command, label) {
|
||||||
|
if (actionProcess.running) return
|
||||||
|
_actionOutput = ""
|
||||||
|
_actionError = ""
|
||||||
|
actionStatus = label || "Working…"
|
||||||
|
actionProcess.command = command
|
||||||
|
actionProcess.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAuthUrlFrom(text, allowFallback) {
|
||||||
|
if (_loginUrlOpened) return true
|
||||||
|
var match = String(text || "").match(/https?:\/\/\S+/)
|
||||||
|
var url = match && match[0] ? match[0] : (allowFallback === true ? authUrl : "")
|
||||||
|
if (url !== "") {
|
||||||
|
_loginUrlOpened = true
|
||||||
|
_loginInProgress = false
|
||||||
|
loginTimeoutTimer.stop()
|
||||||
|
Qt.openUrlExternally(url)
|
||||||
|
actionStatus = "Opened login link"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoginOutput(data, isError) {
|
||||||
|
var text = String(data || "")
|
||||||
|
if (isError) _loginError += text + "\n"
|
||||||
|
else _loginOutput += text + "\n"
|
||||||
|
if (_loginInProgress && !_loginUrlOpened) openAuthUrlFrom(text, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshTimer
|
||||||
|
interval: root.refreshIntervalSec * 1000
|
||||||
|
repeat: true
|
||||||
|
running: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: delayedRefresh
|
||||||
|
interval: 600
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: actionStatusTimer
|
||||||
|
interval: 2200
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.actionStatus = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: loginTimeoutTimer
|
||||||
|
interval: 10000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (!root._loginInProgress || root._loginUrlOpened) return
|
||||||
|
if (!root.openAuthUrlFrom(root.authUrl, true)) {
|
||||||
|
root._loginInProgress = false
|
||||||
|
root.actionStatus = "Tailscale login link not available yet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: whichProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
root.installed = exitCode === 0
|
||||||
|
if (root.installed) root.refreshStatusAndAccounts()
|
||||||
|
else {
|
||||||
|
root.refreshing = false
|
||||||
|
root.resetUnavailable("Not installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: StdioCollector { id: statusStdout; waitForEnd: true; onStreamFinished: root._statusOutput = text }
|
||||||
|
stderr: StdioCollector { id: statusStderr; waitForEnd: true; onStreamFinished: root._statusError = text }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
root.refreshing = false
|
||||||
|
var stdout = String(statusStdout.text || root._statusOutput || "")
|
||||||
|
var stderr = String(statusStderr.text || root._statusError || "")
|
||||||
|
if (exitCode === 0) root.parseStatus(stdout)
|
||||||
|
else {
|
||||||
|
root.resetUnavailable("Disconnected")
|
||||||
|
root.lastError = stderr.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: accountsProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
var stdout = String(accountsStdout.text || root._accountsOutput || "")
|
||||||
|
if (exitCode === 0) root.parseAccounts(stdout)
|
||||||
|
else root.parseAccounts("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: actionProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: StdioCollector { id: actionStdout; waitForEnd: true; onStreamFinished: root._actionOutput = text }
|
||||||
|
stderr: StdioCollector { id: actionStderr; waitForEnd: true; onStreamFinished: root._actionError = text }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
var stdout = String(actionStdout.text || root._actionOutput || "")
|
||||||
|
var stderr = String(actionStderr.text || root._actionError || "")
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.lastError = elideStatus(stderr || stdout || "Tailscale command failed")
|
||||||
|
root.actionStatus = root.lastError
|
||||||
|
} else {
|
||||||
|
root.lastError = ""
|
||||||
|
root.actionStatus = ""
|
||||||
|
}
|
||||||
|
delayedRefresh.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: loginProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: SplitParser { onRead: function(data) { root.handleLoginOutput(data, false) } }
|
||||||
|
stderr: SplitParser { onRead: function(data) { root.handleLoginOutput(data, true) } }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
var combined = String(root._loginOutput || "") + "\n" + String(root._loginError || "")
|
||||||
|
var opened = root.openAuthUrlFrom(combined, true)
|
||||||
|
if (exitCode !== 0 && !opened) {
|
||||||
|
root._loginInProgress = false
|
||||||
|
root.lastError = elideStatus(combined || "tailscale up failed")
|
||||||
|
root.actionStatus = root.lastError
|
||||||
|
} else if (!opened) {
|
||||||
|
root.lastError = ""
|
||||||
|
root.actionStatus = ""
|
||||||
|
}
|
||||||
|
delayedRefresh.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: switchProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
stdout: StdioCollector { id: switchStdout; waitForEnd: true; onStreamFinished: root._switchOutput = text }
|
||||||
|
stderr: StdioCollector { id: switchStderr; waitForEnd: true; onStreamFinished: root._switchError = text }
|
||||||
|
onExited: function(exitCode) {
|
||||||
|
var stdout = String(switchStdout.text || root._switchOutput || "")
|
||||||
|
var stderr = String(switchStderr.text || root._switchError || "")
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.lastError = elideStatus(stderr || stdout || "Account switch failed")
|
||||||
|
root.actionStatus = root.lastError
|
||||||
|
} else {
|
||||||
|
root.lastError = ""
|
||||||
|
root.actionStatus = "Switched account"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
root._lastAccountsRefreshMs = 0
|
||||||
|
}
|
||||||
|
delayedRefresh.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "omarchy.tailscale"
|
||||||
|
function refresh(): string { root.refresh(); return "ok" }
|
||||||
|
function toggle(): string { root.toggleTailscale(); return "ok" }
|
||||||
|
function up(): string { root.loginOrUp(); return "ok" }
|
||||||
|
function down(): string { root.runAction(["tailscale", "down"], "Turning Tailscale off…"); return "ok" }
|
||||||
|
function status(): string { return root.statusText }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Tailscale Omarchy Widget
|
||||||
|
|
||||||
|
Native Omarchy bar widget for Tailscale.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Shows Tailscale connection state in the bar
|
||||||
|
- Left click opens a keyboard-friendly panel
|
||||||
|
- Right click toggles Tailscale on/off
|
||||||
|
- Switch between logged-in Tailscale accounts when multiple are available
|
||||||
|
- Browse peers from `tailscale status --json`
|
||||||
|
- Copy a peer's Tailscale IP, host name, or DNS name
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
Inside the panel:
|
||||||
|
|
||||||
|
- `j` / `k` or arrows: move cursor
|
||||||
|
- `enter` / `space`: activate current row
|
||||||
|
- `c`: copy selected peer IP
|
||||||
|
- `n`: copy selected peer name
|
||||||
|
- `d`: copy selected peer DNS name
|
||||||
|
- `t`: toggle Tailscale
|
||||||
|
- `r`: refresh status
|
||||||
|
- `esc`: close
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- `tailscale` CLI on `PATH`
|
||||||
|
- `wl-copy` for clipboard copy actions
|
||||||
|
|
||||||
|
## Icon
|
||||||
|
|
||||||
|
Renders the Tailscale mark natively as a theme-colored 3×3 dot grid, matching the official SVG silhouette while avoiding tiny-SVG rendering quirks in the bar.
|
||||||
|
|
||||||
|
## Add to the bar
|
||||||
|
|
||||||
|
This widget ships as first-party plugin `omarchy.tailscale`. Add it through Omarchy's bar settings UI, or add an entry such as `{ "id": "omarchy.tailscale" }` to one of the `bar.layout` sections in `~/.config/omarchy/shell.json` and restart the shell.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property real iconSize: Style.font.icon
|
||||||
|
property color color: Color.foreground
|
||||||
|
property color badgeColor: Color.urgent
|
||||||
|
property bool crossed: false
|
||||||
|
property bool warning: false
|
||||||
|
|
||||||
|
width: iconSize
|
||||||
|
height: iconSize
|
||||||
|
implicitWidth: iconSize
|
||||||
|
implicitHeight: iconSize
|
||||||
|
|
||||||
|
readonly property real dotSize: Math.max(2, root.iconSize * 0.24)
|
||||||
|
readonly property real mid: (root.iconSize - dotSize) / 2
|
||||||
|
readonly property real end: root.iconSize - dotSize
|
||||||
|
|
||||||
|
// Native rendering of the Tailscale mark from the SVG: a 3×3 dot grid
|
||||||
|
// with the inactive dots faded. This avoids Qt SVG/effect rendering quirks
|
||||||
|
// in tiny bar slots while keeping the official silhouette.
|
||||||
|
Dot { x: 0; y: 0; opacity: 0.24 }
|
||||||
|
Dot { x: root.mid; y: 0; opacity: 0.24 }
|
||||||
|
Dot { x: root.end; y: 0; opacity: 0.24 }
|
||||||
|
Dot { x: 0; y: root.mid; opacity: 1.0 }
|
||||||
|
Dot { x: root.mid; y: root.mid; opacity: 1.0 }
|
||||||
|
Dot { x: root.end; y: root.mid; opacity: 1.0 }
|
||||||
|
Dot { x: 0; y: root.end; opacity: 0.24 }
|
||||||
|
Dot { x: root.mid; y: root.end; opacity: 1.0 }
|
||||||
|
Dot { x: root.end; y: root.end; opacity: 0.24 }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.crossed
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width * 1.22
|
||||||
|
height: Math.max(2, parent.height * 0.14)
|
||||||
|
radius: height / 2
|
||||||
|
color: root.badgeColor
|
||||||
|
rotation: -45
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.warning
|
||||||
|
width: Math.max(7, parent.width * 0.42)
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: root.badgeColor
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
border.color: Color.popups.background
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "!"
|
||||||
|
color: Color.background
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Math.max(6, parent.height * 0.72)
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component Dot: Rectangle {
|
||||||
|
width: root.dotSize
|
||||||
|
height: root.dotSize
|
||||||
|
radius: width / 2
|
||||||
|
color: root.color
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
|
||||||
|
BarWidget {
|
||||||
|
id: root
|
||||||
|
moduleName: "omarchy.tailscale"
|
||||||
|
|
||||||
|
property bool popupOpen: false
|
||||||
|
property string focusSection: "header"
|
||||||
|
property int headerIndex: 0
|
||||||
|
property int accountIndex: 0
|
||||||
|
property int peerIndex: 0
|
||||||
|
|
||||||
|
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||||
|
readonly property color urgent: bar ? bar.urgent : Color.urgent
|
||||||
|
readonly property color dim: Qt.darker(foreground, 1.55)
|
||||||
|
readonly property color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055)
|
||||||
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||||
|
readonly property bool showAccounts: tailscale.accounts.length > 1
|
||||||
|
readonly property bool showPeers: tailscale.peers.length > 0
|
||||||
|
readonly property color iconColor: tailscale.running ? foreground : (tailscale.needsLogin ? urgent : dim)
|
||||||
|
|
||||||
|
function close() { popupOpen = false }
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
popupOpen = true
|
||||||
|
tailscale.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
if (popupOpen) close()
|
||||||
|
else openPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipText() {
|
||||||
|
var lines = ["Tailscale: " + tailscale.statusText]
|
||||||
|
if (tailscale.selfIp !== "") lines.push(tailscale.selfName + " · " + tailscale.selfIp)
|
||||||
|
if (tailscale.selectedAccountLabel !== "") lines.push(tailscale.selectedAccountLabel)
|
||||||
|
if (tailscale.running) lines.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedPeer() {
|
||||||
|
if (tailscale.peers.length === 0) return null
|
||||||
|
return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.length - 1))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedAccount() {
|
||||||
|
if (tailscale.accounts.length === 0) return null
|
||||||
|
return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCursor() {
|
||||||
|
if (headerIndex < 0) headerIndex = 0
|
||||||
|
if (headerIndex > 1) headerIndex = 1
|
||||||
|
if (accountIndex >= tailscale.accounts.length) accountIndex = Math.max(0, tailscale.accounts.length - 1)
|
||||||
|
if (peerIndex >= tailscale.peers.length) peerIndex = Math.max(0, tailscale.peers.length - 1)
|
||||||
|
if (focusSection === "accounts" && !showAccounts) focusSection = showPeers ? "peers" : "header"
|
||||||
|
if (focusSection === "peers" && !showPeers) focusSection = showAccounts ? "accounts" : "header"
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursor(dx, dy) {
|
||||||
|
ensureCursor()
|
||||||
|
if (dy !== 0) {
|
||||||
|
if (focusSection === "header") {
|
||||||
|
if (dy > 0) {
|
||||||
|
if (showAccounts) focusSection = "accounts"
|
||||||
|
else if (showPeers) focusSection = "peers"
|
||||||
|
}
|
||||||
|
} else if (focusSection === "accounts") {
|
||||||
|
if (dy < 0) {
|
||||||
|
if (accountIndex <= 0) focusSection = "header"
|
||||||
|
else accountIndex--
|
||||||
|
} else {
|
||||||
|
if (accountIndex < tailscale.accounts.length - 1) accountIndex++
|
||||||
|
else if (showPeers) focusSection = "peers"
|
||||||
|
}
|
||||||
|
} else if (focusSection === "peers") {
|
||||||
|
if (dy < 0) {
|
||||||
|
if (peerIndex <= 0) focusSection = showAccounts ? "accounts" : "header"
|
||||||
|
else peerIndex--
|
||||||
|
} else if (peerIndex < tailscale.peers.length - 1) {
|
||||||
|
peerIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dx !== 0 && focusSection === "header") headerIndex = (headerIndex + dx + 2) % 2
|
||||||
|
ensureCursor()
|
||||||
|
scrollPeerIntoView()
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateCursor() {
|
||||||
|
ensureCursor()
|
||||||
|
if (focusSection === "header") {
|
||||||
|
if (headerIndex === 0) tailscale.toggleTailscale()
|
||||||
|
else tailscale.refresh()
|
||||||
|
} else if (focusSection === "accounts") {
|
||||||
|
var account = selectedAccount()
|
||||||
|
if (account) tailscale.switchAccount(account.id)
|
||||||
|
} else if (focusSection === "peers") {
|
||||||
|
tailscale.copyPeerIp(selectedPeer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollPeerIntoView() {
|
||||||
|
if (focusSection !== "peers" || !peerFlick || !peerColumn) return
|
||||||
|
Qt.callLater(function() {
|
||||||
|
if (root.focusSection !== "peers" || root.peerIndex < 0 || root.peerIndex >= peerColumn.children.length) return
|
||||||
|
var item = peerColumn.children[root.peerIndex]
|
||||||
|
if (!item) return
|
||||||
|
var margin = Style.space(6)
|
||||||
|
var top = item.y
|
||||||
|
var bottom = top + item.height
|
||||||
|
var viewTop = peerFlick.contentY
|
||||||
|
var viewBottom = viewTop + peerFlick.height
|
||||||
|
var maxY = Math.max(0, peerFlick.contentHeight - peerFlick.height)
|
||||||
|
if (top < viewTop + margin) peerFlick.contentY = Math.max(0, top - margin)
|
||||||
|
else if (bottom > viewBottom - margin) peerFlick.contentY = Math.min(maxY, bottom + margin - peerFlick.height)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPeerCursor(index) {
|
||||||
|
focusSection = "peers"
|
||||||
|
peerIndex = index
|
||||||
|
scrollPeerIntoView()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAccountCursor(index) {
|
||||||
|
focusSection = "accounts"
|
||||||
|
accountIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: button.implicitWidth
|
||||||
|
implicitHeight: button.implicitHeight
|
||||||
|
|
||||||
|
onPopupOpenChanged: if (popupOpen) Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||||
|
onPeerIndexChanged: scrollPeerIntoView()
|
||||||
|
onShowAccountsChanged: ensureCursor()
|
||||||
|
onShowPeersChanged: ensureCursor()
|
||||||
|
|
||||||
|
Main {
|
||||||
|
id: tailscale
|
||||||
|
settings: root.settings
|
||||||
|
onPeersChanged: root.ensureCursor()
|
||||||
|
onAccountsChanged: root.ensureCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: button
|
||||||
|
anchors.fill: parent
|
||||||
|
implicitWidth: root.bar && root.bar.vertical ? root.bar.barSize : Style.space(26)
|
||||||
|
implicitHeight: root.bar && root.bar.vertical ? Style.space(26) : (root.bar ? root.bar.barSize : Style.space(26))
|
||||||
|
|
||||||
|
property var registeredBar: null
|
||||||
|
readonly property bool tooltipHovered: mouseArea.containsMouse
|
||||||
|
|
||||||
|
function triggerPress(buttonCode) {
|
||||||
|
if (root.bar) root.bar.hideTooltip(button)
|
||||||
|
if (buttonCode === Qt.RightButton) tailscale.toggleTailscale()
|
||||||
|
else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
|
||||||
|
else root.togglePanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncClickRegistration() {
|
||||||
|
if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
|
||||||
|
registeredBar = root.bar
|
||||||
|
if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: syncClickRegistration()
|
||||||
|
Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root
|
||||||
|
function onBarChanged() { button.syncClickRegistration() }
|
||||||
|
}
|
||||||
|
|
||||||
|
TailscaleIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
iconSize: Style.space(12)
|
||||||
|
color: root.iconColor
|
||||||
|
badgeColor: root.urgent
|
||||||
|
crossed: !tailscale.running && !tailscale.needsLogin
|
||||||
|
warning: tailscale.needsLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onEntered: if (root.bar) root.bar.showTooltip(button, root.tooltipText())
|
||||||
|
onExited: if (root.bar) root.bar.hideTooltip(button)
|
||||||
|
onClicked: function(mouse) { button.triggerPress(mouse.button) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardPanel {
|
||||||
|
id: panel
|
||||||
|
anchorItem: button
|
||||||
|
owner: root
|
||||||
|
bar: root.bar
|
||||||
|
open: root.popupOpen
|
||||||
|
focusTarget: keyCatcher
|
||||||
|
contentWidth: panel.fittedContentWidth(Style.space(400))
|
||||||
|
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(580))
|
||||||
|
|
||||||
|
PanelKeyCatcher {
|
||||||
|
id: keyCatcher
|
||||||
|
anchors.fill: parent
|
||||||
|
onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
|
||||||
|
onActivateRequested: root.activateCursor()
|
||||||
|
onCloseRequested: root.close()
|
||||||
|
onTextKey: function(t) {
|
||||||
|
if (t === "r" || t === "R") tailscale.refresh()
|
||||||
|
else if (t === "t" || t === "T") tailscale.toggleTailscale()
|
||||||
|
else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer())
|
||||||
|
else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer())
|
||||||
|
else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer())
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: panelFlick
|
||||||
|
anchors.fill: parent
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: column.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
width: panelFlick.width
|
||||||
|
spacing: Style.space(12)
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: header
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: Math.max(heroIcon.implicitHeight, heroText.implicitHeight, headerActions.implicitHeight)
|
||||||
|
|
||||||
|
TailscaleIcon {
|
||||||
|
id: heroIcon
|
||||||
|
iconSize: Style.font.display
|
||||||
|
color: root.iconColor
|
||||||
|
badgeColor: root.urgent
|
||||||
|
crossed: !tailscale.running && !tailscale.needsLogin
|
||||||
|
warning: tailscale.needsLogin
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: heroText
|
||||||
|
anchors.left: heroIcon.right
|
||||||
|
anchors.leftMargin: Style.space(12)
|
||||||
|
anchors.right: headerActions.left
|
||||||
|
anchors.rightMargin: Style.space(10)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.space(2)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.title
|
||||||
|
font.bold: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: {
|
||||||
|
var parts = [tailscale.statusText]
|
||||||
|
if (tailscale.selfIp !== "") parts.push(tailscale.selfIp)
|
||||||
|
if (tailscale.running) parts.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
|
||||||
|
return parts.join(" · ").toUpperCase()
|
||||||
|
}
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: headerActions
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.space(5)
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
id: toggleBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
iconText: "⏻"
|
||||||
|
fontSize: Style.font.heading
|
||||||
|
size: Style.space(30)
|
||||||
|
tooltipText: tailscale.running ? "Disconnect Tailscale" : (tailscale.needsLogin ? "Log in to Tailscale" : "Connect Tailscale")
|
||||||
|
foreground: root.foreground
|
||||||
|
hoverColor: tailscale.running ? root.urgent : root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
hasCursor: root.focusSection === "header" && root.headerIndex === 0
|
||||||
|
enabled: tailscale.installed && !tailscale.busy
|
||||||
|
onHovered: function(h) {
|
||||||
|
if (!h) return
|
||||||
|
root.focusSection = "header"
|
||||||
|
root.headerIndex = 0
|
||||||
|
}
|
||||||
|
onClicked: tailscale.toggleTailscale()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
id: refreshBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
iconText: ""
|
||||||
|
fontSize: Style.font.heading
|
||||||
|
size: Style.space(30)
|
||||||
|
tooltipText: "Refresh"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
hasCursor: root.focusSection === "header" && root.headerIndex === 1
|
||||||
|
enabled: !tailscale.busy
|
||||||
|
onHovered: function(h) {
|
||||||
|
if (!h) return
|
||||||
|
root.focusSection = "header"
|
||||||
|
root.headerIndex = 1
|
||||||
|
}
|
||||||
|
onClicked: tailscale.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
|
||||||
|
width: parent.width
|
||||||
|
text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError
|
||||||
|
color: tailscale.lastError !== "" && tailscale.actionStatus === "" ? root.urgent : root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: !tailscale.installed
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: missingText.implicitHeight + Style.space(24)
|
||||||
|
color: root.card
|
||||||
|
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06)
|
||||||
|
border.width: Style.normalBorderWidth
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: missingText
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: Style.space(12)
|
||||||
|
text: "Tailscale CLI is not installed or not on PATH."
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator {
|
||||||
|
visible: root.showAccounts
|
||||||
|
foreground: root.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
visible: root.showAccounts
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(10)
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
text: "ACCOUNTS"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: tailscale.accounts
|
||||||
|
AccountRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
width: parent.width
|
||||||
|
account: modelData
|
||||||
|
rowIndex: index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator {
|
||||||
|
visible: tailscale.installed
|
||||||
|
foreground: root.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
visible: tailscale.installed
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(10)
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
text: "PEERS"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0
|
||||||
|
width: parent.width
|
||||||
|
text: "No peers found on this tailnet."
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: tailscale.installed && !tailscale.running
|
||||||
|
width: parent.width
|
||||||
|
text: tailscale.needsLogin ? "Log in to see tailnet peers." : "Turn Tailscale on to see tailnet peers."
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: peerFlick
|
||||||
|
visible: tailscale.peers.length > 0
|
||||||
|
width: parent.width
|
||||||
|
height: Math.min(peerColumn.implicitHeight, Style.space(340))
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: peerColumn.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: peerColumn
|
||||||
|
width: peerFlick.width
|
||||||
|
spacing: Style.space(6)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: tailscale.peers
|
||||||
|
PeerRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
width: peerColumn.width
|
||||||
|
peer: modelData
|
||||||
|
rowIndex: index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component AccountRow: Rectangle {
|
||||||
|
id: accountRow
|
||||||
|
property var account: null
|
||||||
|
property int rowIndex: 0
|
||||||
|
readonly property bool selectedAccount: account && account.selected === true
|
||||||
|
readonly property bool hasCursor: root.focusSection === "accounts" && root.accountIndex === rowIndex
|
||||||
|
|
||||||
|
implicitHeight: row.implicitHeight + Style.space(12)
|
||||||
|
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : (selectedAccount ? root.card : "transparent")
|
||||||
|
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : "transparent"
|
||||||
|
border.width: hasCursor ? Style.hoverBorderWidth : 0
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: row
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Style.space(10)
|
||||||
|
anchors.rightMargin: Style.space(10)
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: accountRow.selectedAccount ? "●" : "○"
|
||||||
|
color: accountRow.selectedAccount ? root.foreground : root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(1)
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: account ? (account.nickname || account.tailnet || account.account || account.id || "Account") : "Account"
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
font.bold: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: account ? [account.tailnet || "", account.account || ""].filter(function(x) { return String(x) !== "" }).join(" · ") : ""
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onEntered: root.setAccountCursor(accountRow.rowIndex)
|
||||||
|
onClicked: if (accountRow.account) tailscale.switchAccount(accountRow.account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component PeerRow: Rectangle {
|
||||||
|
id: peerRow
|
||||||
|
property var peer: null
|
||||||
|
property int rowIndex: 0
|
||||||
|
readonly property bool online: peer && peer.Online === true
|
||||||
|
readonly property bool hasCursor: root.focusSection === "peers" && root.peerIndex === rowIndex
|
||||||
|
readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown"
|
||||||
|
readonly property string peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : ""
|
||||||
|
readonly property string peerDns: peer ? String(peer.DNSName || "") : ""
|
||||||
|
|
||||||
|
implicitHeight: Math.max(peerContent.implicitHeight, actions.implicitHeight) + Style.space(12)
|
||||||
|
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : root.card
|
||||||
|
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
|
||||||
|
border.width: hasCursor ? Style.hoverBorderWidth : Style.normalBorderWidth
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
opacity: online ? 1.0 : 0.62
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Style.space(10)
|
||||||
|
anchors.rightMargin: Style.space(8)
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: tailscale.osIcon(peer ? peer.OS : "")
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.icon
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: peerContent
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Style.space(1)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: peerRow.peerName
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
font.bold: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: {
|
||||||
|
var parts = []
|
||||||
|
if (peerRow.peerIp !== "") parts.push(peerRow.peerIp)
|
||||||
|
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
|
||||||
|
if (!peerRow.online) parts.push("offline")
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: actions
|
||||||
|
spacing: Style.space(2)
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: ""
|
||||||
|
tooltipText: "Copy IP"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
enabled: peerRow.peerIp !== ""
|
||||||
|
onClicked: tailscale.copyPeerIp(peerRow.peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelActionButton {
|
||||||
|
iconText: ""
|
||||||
|
tooltipText: "Copy name"
|
||||||
|
foreground: root.foreground
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
enabled: peerRow.peerName !== ""
|
||||||
|
onClicked: tailscale.copyPeerName(peerRow.peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
z: -1
|
||||||
|
onEntered: root.setPeerCursor(peerRow.rowIndex)
|
||||||
|
onClicked: function(mouse) {
|
||||||
|
if (mouse.button === Qt.RightButton) tailscale.copyPeerName(peerRow.peer)
|
||||||
|
else if (mouse.button === Qt.MiddleButton) tailscale.copyPeerDnsName(peerRow.peer)
|
||||||
|
else tailscale.copyPeerIp(peerRow.peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "omarchy.tailscale",
|
||||||
|
"name": "Tailscale",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Omarchy",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Tailscale status, account switching, peer browsing, and quick copy actions in the Omarchy bar.",
|
||||||
|
"kinds": [
|
||||||
|
"bar-widget"
|
||||||
|
],
|
||||||
|
"entryPoints": {
|
||||||
|
"barWidget": "Widget.qml"
|
||||||
|
},
|
||||||
|
"barWidget": {
|
||||||
|
"displayName": "Tailscale",
|
||||||
|
"description": "Toggle Tailscale, switch accounts, browse peers, and copy peer IPs or names.",
|
||||||
|
"category": "Network",
|
||||||
|
"allowMultiple": false,
|
||||||
|
"defaults": {
|
||||||
|
"refreshIntervalSec": 30
|
||||||
|
},
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"key": "refreshIntervalSec",
|
||||||
|
"type": "integer",
|
||||||
|
"label": "Refresh interval (seconds)",
|
||||||
|
"min": 5,
|
||||||
|
"max": 3600,
|
||||||
|
"step": 5,
|
||||||
|
"defaultValue": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user