Files
arthur-os/shell/plugins/panels/tailscale/Service.qml
T

545 lines
18 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import "Model.js" as Model
Item {
id: root
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 exitNodes: []
property var tailnetExitNodes: []
property var mullvadExitNodes: []
property var mullvadRegions: []
property var accounts: []
property string selectedAccountId: ""
property string selectedAccountLabel: ""
property string switchingAccountId: ""
property string settingExitNodeId: ""
property bool accountsAccessDenied: false
property string actionStatus: ""
property string lastError: ""
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600)
readonly property bool busy: whichProcess.running || statusProcess.running || mullvadExitNodesProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running || operatorProcess.running || exitNodeProcess.running
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
property string _statusOutput: ""
property string _statusError: ""
property string _accountsOutput: ""
property string _accountsError: ""
property string _mullvadExitNodesOutput: ""
property string _mullvadExitNodesError: ""
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: ""
property string _exitNodeOutput: ""
property string _exitNodeError: ""
property string _operatorOutput: ""
property string _operatorError: ""
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) {
return Model.filterIPv4(ips)
}
function cleanDnsName(name) {
return Model.cleanDnsName(name)
}
function shortDnsName(name) {
return Model.shortDnsName(name)
}
function displayHostName(hostName, dnsName) {
return Model.displayHostName(hostName, dnsName)
}
function osIcon(os) {
return Model.osIcon(os)
}
function accountLabel(account) {
return Model.accountLabel(account)
}
function copyToClipboard(value, label) {
var text = String(value || "")
if (text === "") return
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"])
}
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
}
if (!mullvadExitNodesProcess.running) {
_mullvadExitNodesOutput = ""
_mullvadExitNodesError = ""
mullvadExitNodesProcess.command = ["tailscale", "exit-node", "list"]
mullvadExitNodesProcess.running = true
}
var now = Date.now()
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
if (shouldRefreshAccounts && !accountsProcess.running) {
_accountsOutput = ""
_accountsError = ""
_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 = []
exitNodes = []
tailnetExitNodes = []
mullvadExitNodes = []
mullvadRegions = []
accounts = []
selectedAccountId = ""
selectedAccountLabel = ""
switchingAccountId = ""
settingExitNodeId = ""
accountsAccessDenied = false
}
function parseStatus(raw) {
var parsed = Model.parseStatus(raw)
if (!parsed.ok) {
resetUnavailable(parsed.message || "Status error")
lastError = parsed.error || "Failed to parse tailscale status"
console.warn("tailscale", lastError)
return
}
if (parsed.unavailable) {
resetUnavailable(parsed.message || "Disconnected")
return
}
backendState = parsed.backendState
running = parsed.running
needsLogin = parsed.needsLogin
authUrl = parsed.authUrl
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false)
selfName = parsed.selfName
selfDnsName = parsed.selfDnsName
selfIp = parsed.selfIp
peers = parsed.running ? parsed.peers : []
tailnetExitNodes = parsed.running ? parsed.exitNodes : []
exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadRegions) : []
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 = ""
}
function parseAccounts(raw) {
var parsed = Model.parseAccounts(raw)
accounts = parsed.accounts
selectedAccountId = parsed.selectedAccountId
selectedAccountLabel = parsed.selectedAccountLabel
accountsAccessDenied = false
}
function parseMullvadExitNodes(raw) {
mullvadExitNodes = Model.parseExitNodeList(raw)
mullvadRegions = Model.mullvadRegionOptions(mullvadExitNodes)
exitNodes = running ? tailnetExitNodes.concat(mullvadRegions) : []
}
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 = ""
switchingAccountId = accountId
switchProcess.command = ["tailscale", "switch", accountId]
switchProcess.running = true
}
function exitNodeTarget(peer) {
if (!peer) return ""
if (peer.Mullvad === true) {
var mullvadIps = filterIPv4(peer.TailscaleIPs || [])
if (mullvadIps.length > 0) return mullvadIps[0]
}
if (peer.DNSName) return cleanDnsName(peer.DNSName)
if (peer.HostName) return String(peer.HostName)
var ips = filterIPv4(peer.TailscaleIPs || [])
return ips.length > 0 ? ips[0] : ""
}
function setExitNode(peer) {
if (!installed || !running || !peer || exitNodeProcess.running) return
var active = peer.ExitNode === true
var target = active ? "" : exitNodeTarget(peer)
if (!active && target === "") return
_exitNodeOutput = ""
_exitNodeError = ""
settingExitNodeId = String(peer.id || "")
exitNodeProcess.command = ["tailscale", "set", "--exit-node=" + target]
exitNodeProcess.running = true
}
function authorizeTailscaleOperator() {
if (!installed || operatorProcess.running || userName === "") return
_operatorOutput = ""
_operatorError = ""
actionStatus = "Authorizing Tailscale operator..."
operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName]
operatorProcess.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 }
stderr: StdioCollector { id: accountsStderr; waitForEnd: true; onStreamFinished: root._accountsError = text }
onExited: function(exitCode) {
var stdout = String(accountsStdout.text || root._accountsOutput || "")
var stderr = String(accountsStderr.text || root._accountsError || "")
if (exitCode === 0) root.parseAccounts(stdout)
else {
root.parseAccounts("")
if (/profiles access denied/i.test(stderr) || /profiles access denied/i.test(stdout)) {
root.accountsAccessDenied = true
root.lastError = "Authorize Tailscale operator to show connections"
} else {
root.lastError = elideStatus(stderr || stdout || "Could not list Tailscale connections")
}
}
}
}
Process {
id: mullvadExitNodesProcess
running: false
command: []
stdout: StdioCollector { id: mullvadExitNodesStdout; waitForEnd: true; onStreamFinished: root._mullvadExitNodesOutput = text }
stderr: StdioCollector { id: mullvadExitNodesStderr; waitForEnd: true; onStreamFinished: root._mullvadExitNodesError = text }
onExited: function(exitCode) {
var stdout = String(mullvadExitNodesStdout.text || root._mullvadExitNodesOutput || "")
if (exitCode === 0) root.parseMullvadExitNodes(stdout)
else root.parseMullvadExitNodes("")
}
}
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 = ""
root._lastAccountsRefreshMs = 0
}
root.switchingAccountId = ""
delayedRefresh.restart()
}
}
Process {
id: exitNodeProcess
running: false
command: []
stdout: StdioCollector { id: exitNodeStdout; waitForEnd: true; onStreamFinished: root._exitNodeOutput = text }
stderr: StdioCollector { id: exitNodeStderr; waitForEnd: true; onStreamFinished: root._exitNodeError = text }
onExited: function(exitCode) {
var stdout = String(exitNodeStdout.text || root._exitNodeOutput || "")
var stderr = String(exitNodeStderr.text || root._exitNodeError || "")
if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Exit node selection failed")
root.actionStatus = root.lastError
} else {
root.lastError = ""
root.actionStatus = ""
}
root.settingExitNodeId = ""
delayedRefresh.restart()
}
}
Process {
id: operatorProcess
running: false
command: []
stdout: StdioCollector { id: operatorStdout; waitForEnd: true; onStreamFinished: root._operatorOutput = text }
stderr: StdioCollector { id: operatorStderr; waitForEnd: true; onStreamFinished: root._operatorError = text }
onExited: function(exitCode) {
var stdout = String(operatorStdout.text || root._operatorOutput || "")
var stderr = String(operatorStderr.text || root._operatorError || "")
if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed")
root.actionStatus = root.lastError
} else {
root.accountsAccessDenied = false
root.lastError = ""
root.actionStatus = "Tailscale operator authorized"
actionStatusTimer.restart()
root._lastAccountsRefreshMs = 0
}
delayedRefresh.restart()
}
}
}