mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-01 20:28:16 +02:00
545 lines
18 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|