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 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 || 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 _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 } 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 = [] 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 : [] exitNodes = parsed.running ? parsed.exitNodes : [] 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 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.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 authorizeProfileSwitching() { if (!installed || operatorProcess.running || userName === "") return _operatorOutput = "" _operatorError = "" actionStatus = "Authorizing Tailscale profiles…" 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 profiles to show connections" } else { root.lastError = elideStatus(stderr || stdout || "Could not list Tailscale connections") } } } } 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 profiles authorized" actionStatusTimer.restart() root._lastAccountsRefreshMs = 0 } delayedRefresh.restart() } } }