Files
arthur-os/shell/plugins/tailscale/Main.qml
T
2026-05-26 11:27:11 -04:00

498 lines
16 KiB
QML

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 }
}
}