commit f2d3456979941037d2ee5ca0b03937bc6133eb1f Author: David Heinemeier Hansson Date: Tue May 26 22:07:20 2026 +0200 Add Tailscale panel connection controls diff --git a/Model.js b/Model.js new file mode 100644 index 0000000..c18a764 --- /dev/null +++ b/Model.js @@ -0,0 +1,149 @@ +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 filterIPv6(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 (/^fd7a:115c:a1e0:/i.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" + if (account.nickname) return String(account.nickname) + if (account.tailnet) return String(account.tailnet) + if (account.account) return String(account.account) + return String(account.id || "Unknown account") +} + +function parseStatus(raw) { + var text = String(raw || "").trim() + if (text === "") return { ok: true, unavailable: true, message: "Disconnected" } + + try { + var data = JSON.parse(text) + var backendState = String(data.BackendState || "Unknown") + var self = data.Self || {} + var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || []) + var peers = [] + var rawPeers = data.Peer || {} + + for (var id in rawPeers) { + var peer = rawPeers[id] || {} + if (peer.Online !== true) continue + peers.push({ + id: id, + HostName: displayHostName(peer.HostName, peer.DNSName), + DNSName: cleanDnsName(peer.DNSName), + TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), + TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []), + Online: true, + OS: String(peer.OS || ""), + Tags: peer.Tags || [], + ExitNodeOption: peer.ExitNodeOption === true, + ExitNode: peer.ExitNode === true + }) + } + + peers.sort(function(a, b) { + return String(a.HostName).localeCompare(String(b.HostName)) + }) + + return { + ok: true, + unavailable: false, + backendState: backendState, + running: backendState === "Running", + needsLogin: backendState === "NeedsLogin", + authUrl: String(data.AuthURL || ""), + selfName: displayHostName(self.HostName, self.DNSName), + selfDnsName: cleanDnsName(self.DNSName), + selfIp: selfIps.length > 0 ? selfIps[0] : "", + peers: peers + } + } catch (e) { + return { ok: false, unavailable: true, message: "Status error", error: "Failed to parse tailscale status" } + } +} + +function parseAccounts(raw) { + var text = String(raw || "").trim() + if (text === "") return { accounts: [], selectedAccountId: "", selectedAccountLabel: "" } + + 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 rawAccount = parsed[i] || {} + var account = { + id: String(rawAccount.id || rawAccount.ID || ""), + nickname: String(rawAccount.nickname || rawAccount.Nickname || rawAccount.name || rawAccount.Name || ""), + tailnet: String(rawAccount.tailnet || rawAccount.Tailnet || ""), + account: String(rawAccount.account || rawAccount.Account || rawAccount.loginName || rawAccount.LoginName || rawAccount.user || rawAccount.User || ""), + selected: rawAccount.selected === true || rawAccount.Selected === true + } + next.push(account) + if (account.selected === true) selected = account + } + } + return { + accounts: next, + selectedAccountId: selected ? String(selected.id || "") : "", + selectedAccountLabel: selected ? accountLabel(selected) : "" + } + } catch (e) { + return { accounts: [], selectedAccountId: "", selectedAccountLabel: "" } + } +} + +if (typeof module !== "undefined") { + module.exports = { + filterIPv4: filterIPv4, + filterIPv6: filterIPv6, + cleanDnsName: cleanDnsName, + shortDnsName: shortDnsName, + displayHostName: displayHostName, + osIcon: osIcon, + accountLabel: accountLabel, + parseStatus: parseStatus, + parseAccounts: parseAccounts + } +} diff --git a/Panel.qml b/Panel.qml new file mode 100644 index 0000000..5711de5 --- /dev/null +++ b/Panel.qml @@ -0,0 +1,919 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +Panel { + id: root + moduleName: "omarchy.tailscale" + ipcTarget: "omarchy.tailscale" + manageIpc: false + + property string focusSection: "header" + property int headerIndex: 0 + property int accountIndex: 0 + property int peerIndex: 0 + property int exitNodeIndex: 0 + property bool cursorActive: false + property int phraseIndex: 0 + readonly property var activePhrases: [ + "Encrypting connections", + "Sending secrets", + "Guarding wires", + "Braiding packets", + "Polishing tunnels", + "Hiding routes", + "Sealing ports", + "Sorting tailnets", + "Shuffling keys", + "Watching machines" + ] + readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length] + + 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 string fontFamily: bar ? bar.fontFamily : Style.font.family + readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied + readonly property bool showPeers: tailscale.running && tailscale.peers.length > 0 + readonly property var exitNodes: { + var nodes = [] + for (var i = 0; i < tailscale.peers.length; i++) { + var peer = tailscale.peers[i] + if (peer && peer.ExitNodeOption === true) nodes.push(peer) + } + return nodes + } + readonly property bool showExitNodes: tailscale.running && exitNodes.length > 0 + readonly property color iconColor: tailscale.running ? foreground : dim + readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" + readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" + + function selectedPeer() { + if (tailscale.peers.length === 0) return null + return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.length - 1))] + } + + function selectedExitNode() { + if (exitNodes.length === 0) return null + return exitNodes[Math.max(0, Math.min(exitNodeIndex, exitNodes.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 > 0) headerIndex = 0 + 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 (exitNodeIndex >= exitNodes.length) exitNodeIndex = Math.max(0, exitNodes.length - 1) + if (focusSection === "auth" && !tailscale.accountsAccessDenied) focusSection = tailscale.accounts.length > 1 ? "accounts" : (showExitNodes ? "exitNodes" : (showPeers ? "peers" : "header")) + if (focusSection === "accounts" && tailscale.accounts.length <= 1) focusSection = tailscale.accountsAccessDenied ? "auth" : (showExitNodes ? "exitNodes" : (showPeers ? "peers" : "header")) + if (focusSection === "peers" && !showPeers) focusSection = showExitNodes ? "exitNodes" : (tailscale.accountsAccessDenied ? "auth" : (tailscale.accounts.length > 1 ? "accounts" : "header")) + if (focusSection === "exitNodes" && !showExitNodes) focusSection = showPeers ? "peers" : (tailscale.accountsAccessDenied ? "auth" : (tailscale.accounts.length > 1 ? "accounts" : "header")) + } + + function moveCursor(dx, dy) { + cursorActive = true + ensureCursor() + if (dy !== 0) { + if (focusSection === "header") { + if (dy > 0) { + if (tailscale.accountsAccessDenied) focusSection = "auth" + else if (tailscale.accounts.length > 1) focusSection = "accounts" + else if (showExitNodes) focusSection = "exitNodes" + else if (showPeers) focusSection = "peers" + } + } else if (focusSection === "auth") { + if (dy < 0) focusSection = "header" + else if (tailscale.accounts.length > 1) focusSection = "accounts" + else if (showExitNodes) focusSection = "exitNodes" + else if (showPeers) focusSection = "peers" + } else if (focusSection === "accounts") { + if (dy < 0) { + if (accountIndex <= 0) focusSection = tailscale.accountsAccessDenied ? "auth" : "header" + else accountIndex-- + } else { + if (accountIndex < tailscale.accounts.length - 1) accountIndex++ + else if (showExitNodes) focusSection = "exitNodes" + else if (showPeers) focusSection = "peers" + } + } else if (focusSection === "peers") { + if (dy < 0) { + if (peerIndex <= 0) focusSection = showExitNodes ? "exitNodes" : (tailscale.accounts.length > 1 ? "accounts" : (tailscale.accountsAccessDenied ? "auth" : "header")) + else peerIndex-- + } else if (peerIndex < tailscale.peers.length - 1) { + peerIndex++ + } + } else if (focusSection === "exitNodes") { + if (dy < 0) { + if (exitNodeIndex <= 0) focusSection = tailscale.accounts.length > 1 ? "accounts" : (tailscale.accountsAccessDenied ? "auth" : "header") + else exitNodeIndex-- + } else if (exitNodeIndex < exitNodes.length - 1) { + exitNodeIndex++ + } else if (showPeers) { + focusSection = "peers" + } + } + } + ensureCursor() + scrollCursorIntoView() + } + + function activateCursor() { + ensureCursor() + if (focusSection === "header") { + tailscale.toggleTailscale() + } else if (focusSection === "auth") { + tailscale.authorizeProfileSwitching() + } else if (focusSection === "accounts") { + var account = selectedAccount() + if (account) tailscale.switchAccount(account.id) + } else if (focusSection === "peers") { + tailscale.copyPeerIp(selectedPeer()) + } else if (focusSection === "exitNodes") { + tailscale.setExitNode(selectedExitNode()) + } + } + + function scrollItemIntoView(item) { + if (!panelFlick || !item) return + Qt.callLater(function() { + if (!item) return + var margin = Style.space(6) + var point = item.mapToItem(panelFlick.contentItem, 0, 0) + var top = point.y + var bottom = top + item.height + var viewTop = panelFlick.contentY + var viewBottom = viewTop + panelFlick.height + var maxY = Math.max(0, panelFlick.contentHeight - panelFlick.height) + if (top < viewTop + margin) panelFlick.contentY = Math.max(0, top - margin) + else if (bottom > viewBottom - margin) panelFlick.contentY = Math.min(maxY, bottom + margin - panelFlick.height) + }) + } + + function scrollCursorIntoView() { + if (focusSection === "peers" && peerColumn && peerIndex >= 0 && peerIndex < peerColumn.children.length) scrollItemIntoView(peerColumn.children[peerIndex]) + else if (focusSection === "exitNodes" && exitNodeColumn && exitNodeIndex >= 0 && exitNodeIndex < exitNodeColumn.children.length) scrollItemIntoView(exitNodeColumn.children[exitNodeIndex]) + } + + function setPeerCursor(index) { + cursorActive = true + focusSection = "peers" + peerIndex = index + scrollCursorIntoView() + } + + function setExitNodeCursor(index) { + cursorActive = true + focusSection = "exitNodes" + exitNodeIndex = index + scrollCursorIntoView() + } + + function setAccountCursor(index) { + cursorActive = true + focusSection = "accounts" + accountIndex = index + } + + function setAuthCursor() { + cursorActive = true + focusSection = "auth" + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onOpenedChanged: if (opened) { + cursorActive = false + if (panelFlick) panelFlick.contentY = 0 + tailscale.refresh() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + onPeerIndexChanged: scrollCursorIntoView() + onExitNodeIndexChanged: scrollCursorIntoView() + onShowConnectionsChanged: ensureCursor() + onShowPeersChanged: ensureCursor() + onShowExitNodesChanged: ensureCursor() + + Service { + id: tailscale + settings: root.settings + } + + Connections { + target: tailscale + function onPeersChanged() { root.ensureCursor() } + function onAccountsChanged() { root.ensureCursor() } + function onAccountsAccessDeniedChanged() { root.ensureCursor() } + } + + IpcHandler { + target: root.ipcTarget + function open(): void { root.open() } + function close(): void { root.close() } + function show(): void { root.open() } + function hide(): void { root.close() } + function toggle(): void { root.toggle() } + function refresh(): string { tailscale.refresh(); return "ok" } + function up(): string { tailscale.loginOrUp(); return "ok" } + function down(): string { tailscale.runAction(["tailscale", "down"], "Turning Tailscale off…"); return "ok" } + function status(): string { return tailscale.statusText } + } + + 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 + + function triggerPress(buttonCode) { + if (buttonCode === Qt.RightButton) tailscale.toggleTailscale() + else if (buttonCode === Qt.MiddleButton) tailscale.refresh() + else root.toggle() + } + + 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 + onClicked: function(mouse) { button.triggerPress(mouse.button) } + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(380)) + contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(560)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } + root.moveCursor(dx, dy) + } + onActivateRequested: if (root.cursorActive) root.activateCursor() + onCloseRequested: root.close() + onTextKey: function(t) { + 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: hero.implicitHeight + + PanelHero { + id: hero + width: parent.width + title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" + meta: root.heroPhraseText + foreground: root.foreground + fontFamily: root.fontFamily + iconOpacity: tailscale.running ? 1.0 : 0.5 + iconComponent: Component { + Item { + implicitWidth: icon.implicitWidth + implicitHeight: icon.implicitHeight + + TailscaleIcon { + id: icon + iconSize: Style.font.display + color: root.iconColor + badgeColor: root.urgent + crossed: !tailscale.running && !tailscale.needsLogin + warning: tailscale.needsLogin + anchors.centerIn: parent + } + + MouseArea { + id: heroIconMouse + anchors.fill: parent + hoverEnabled: true + enabled: tailscale.installed && !tailscale.busy + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "header" + root.headerIndex = 0 + } + onClicked: tailscale.toggleTailscale() + } + } + } + } + } + + 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 + } + + CursorSurface { + visible: !tailscale.installed + width: parent.width + implicitHeight: missingText.implicitHeight + Style.spacing.rowPaddingX + foreground: root.foreground + + 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.body + wrapMode: Text.WordWrap + } + } + + PanelSeparator { + visible: root.showConnections + foreground: root.foreground + } + + Column { + visible: root.showConnections + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "CONNECTIONS" + foreground: root.foreground + fontFamily: root.fontFamily + } + + AuthRow { + visible: tailscale.accountsAccessDenied + width: parent.width + } + + Repeater { + model: tailscale.accounts + AccountRow { + required property var modelData + required property int index + width: parent.width + account: modelData + rowIndex: index + } + } + } + + PanelSeparator { + visible: root.showExitNodes + foreground: root.foreground + } + + Column { + visible: root.showExitNodes + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "EXIT NODES" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Column { + id: exitNodeColumn + width: parent.width + spacing: Style.space(6) + + Repeater { + model: root.exitNodes + ExitNodeRow { + required property var modelData + required property int index + width: exitNodeColumn.width + peer: modelData + rowIndex: index + } + } + } + } + + PanelSeparator { + visible: tailscale.installed && tailscale.running + foreground: root.foreground + } + + Column { + visible: tailscale.installed && tailscale.running + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "MACHINES" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Text { + visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0 + width: parent.width + text: "No machines found on this tailnet." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + horizontalAlignment: Text.AlignHCenter + } + + Column { + id: peerColumn + visible: root.showPeers + width: parent.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 + } + } + } + } + } + } + } + } + + Timer { + id: phraseTimer + interval: 2800 + running: root.opened + repeat: true + onTriggered: phraseSwap.restart() + } + + SequentialAnimation { + id: phraseSwap + PropertyAnimation { + target: hero; property: "metaOpacity" + to: 0.0; duration: 180; easing.type: Easing.OutQuad + } + ScriptAction { + script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length + } + PropertyAnimation { + target: hero; property: "metaOpacity" + to: 1.0; duration: 260; easing.type: Easing.InQuad + } + } + + component AuthRow: CursorSurface { + id: authRow + + hasCursor: root.cursorActive && root.focusSection === "auth" + foreground: root.foreground + + implicitHeight: row.implicitHeight + Style.spacing.rowPaddingX + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: tailscale.busy ? Qt.ArrowCursor : Qt.PointingHandCursor + enabled: !tailscale.busy + onEntered: root.setAuthCursor() + onClicked: tailscale.authorizeProfileSwitching() + } + + 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: "󰒃" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.heading + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.space(1) + + Text { + Layout.fillWidth: true + text: "Authorize switching" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: "Allow this user to see and switch Tailscale connections" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + + PanelActionButton { + iconText: "󰄬" + foreground: root.foreground + fontFamily: root.fontFamily + enabled: !tailscale.busy + Layout.alignment: Qt.AlignVCenter + onClicked: tailscale.authorizeProfileSwitching() + } + } + } + + component AccountRow: CursorSurface { + id: accountRow + property var account: null + property int rowIndex: 0 + readonly property bool selectedAccount: account && account.selected === true + readonly property bool switchingAccount: account && tailscale.switchingAccountId === String(account.id || "") + readonly property string accountText: account ? tailscale.accountLabel(account) : "Account" + + hasCursor: root.cursorActive && root.focusSection === "accounts" && root.accountIndex === rowIndex + current: selectedAccount + foreground: root.foreground + fill: root.hoverFill + currentFill: root.selectedFill + + implicitHeight: accountInner.implicitHeight + Style.spacing.xl + + Row { + id: accountInner + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(8) + + Text { + id: accountGlyph + text: "" + color: accountRow.selectedAccount || accountRow.switchingAccount ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + + NumberAnimation on rotation { + running: accountRow.switchingAccount + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + } + + onRotationChanged: if (!accountRow.switchingAccount && rotation !== 0) rotation = 0 + } + + Text { + text: accountRow.accountText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: accountRow.selectedAccount + elide: Text.ElideRight + width: parent.width - Style.space(22) - Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + } + + 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: CursorSurface { + id: peerRow + property var peer: null + property int rowIndex: 0 + 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 peerIpv6: { + if (!peer || !peer.TailscaleIPv6 || peer.TailscaleIPv6.length === 0) return "" + return String(peer.TailscaleIPv6[0] || "") + } + readonly property string peerDns: peer ? String(peer.DNSName || "") : "" + + hasCursor: root.cursorActive && root.focusSection === "peers" && root.peerIndex === rowIndex + foreground: root.foreground + + implicitHeight: Math.max(peerContent.implicitHeight, copyButton.implicitHeight) + Style.spacing.rowPaddingX + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + hoverEnabled: true + cursorShape: Qt.ArrowCursor + onContainsMouseChanged: if (containsMouse) root.setPeerCursor(peerRow.rowIndex) + } + + 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.body + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: { + var parts = [] + if (peerRow.peerIp !== "") parts.push(peerRow.peerIp) + if (peerRow.peerDns !== "") parts.push(peerRow.peerDns) + return parts.join(" · ") + } + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + + PanelActionButton { + id: copyButton + iconText: "󰆏" + foreground: root.foreground + fontFamily: root.fontFamily + enabled: peerRow.peerIp !== "" || peerRow.peerName !== "" || peerRow.peerDns !== "" || peerRow.peerIpv6 !== "" + Layout.alignment: Qt.AlignVCenter + onClicked: copyPopup.open() + } + + Popup { + id: copyPopup + x: copyButton.x + copyButton.width - width + y: copyButton.y + copyButton.height + Style.space(4) + width: Style.space(280) + padding: 0 + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + background: Rectangle { + color: Color.background + border.color: root.dim + border.width: 1 + radius: Style.radius.md + } + + contentItem: Column { + width: parent.width + + CopyChoice { + width: parent.width + label: peerRow.peerName + enabled: peerRow.peerName !== "" + onChosen: { + copyPopup.close() + tailscale.copyPeerName(peerRow.peer) + } + } + + CopyChoice { + width: parent.width + label: peerRow.peerDns + enabled: peerRow.peerDns !== "" + onChosen: { + copyPopup.close() + tailscale.copyPeerDnsName(peerRow.peer) + } + } + + CopyChoice { + width: parent.width + label: peerRow.peerIpv6 + enabled: peerRow.peerIpv6 !== "" + onChosen: { + copyPopup.close() + tailscale.copyToClipboard(peerRow.peerIpv6, peerRow.peerName + " IPv6") + } + } + + CopyChoice { + width: parent.width + label: peerRow.peerIp + enabled: peerRow.peerIp !== "" + onChosen: { + copyPopup.close() + tailscale.copyPeerIp(peerRow.peer) + } + } + } + } + } + } + + component CopyChoice: CursorSurface { + id: copyChoice + signal chosen() + property string label: "" + + visible: enabled + foreground: root.foreground + implicitHeight: Style.space(48) + radius: 0 + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: copyChoice.chosen() + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(10) + + Text { + Layout.fillWidth: true + text: copyChoice.label + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + Text { + text: "󰆏" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.icon + Layout.alignment: Qt.AlignVCenter + } + } + } + + component ExitNodeRow: CursorSurface { + id: exitNodeRow + property var peer: null + property int rowIndex: 0 + readonly property bool activeExitNode: peer && peer.ExitNode === true + readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "") + readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown" + + hasCursor: root.cursorActive && root.focusSection === "exitNodes" && root.exitNodeIndex === rowIndex + current: activeExitNode || settingExitNode + foreground: root.foreground + fill: root.hoverFill + currentFill: root.selectedFill + + implicitHeight: exitNodeInner.implicitHeight + Style.spacing.xl + + Row { + id: exitNodeInner + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(8) + + Text { + id: exitNodeGlyph + text: tailscale.osIcon(peer ? peer.OS : "") + color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + + NumberAnimation on rotation { + running: exitNodeRow.settingExitNode + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + } + + onRotationChanged: if (!exitNodeRow.settingExitNode && rotation !== 0) rotation = 0 + } + + Text { + text: exitNodeRow.peerName + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: exitNodeRow.activeExitNode + elide: Text.ElideRight + width: parent.width - Style.space(22) - Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.setExitNodeCursor(exitNodeRow.rowIndex) + onClicked: if (exitNodeRow.peer) tailscale.setExitNode(exitNodeRow.peer) + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e22bd03 --- /dev/null +++ b/README.md @@ -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 available Tailscale connections when multiple are available +- Browse machines from `tailscale status --json` +- Copy a machine'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. diff --git a/Service.qml b/Service.qml new file mode 100644 index 0000000..25bf36d --- /dev/null +++ b/Service.qml @@ -0,0 +1,502 @@ +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 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 = [] + 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 : [] + + 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 target = exitNodeTarget(peer) + if (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() + } + } +} diff --git a/TailscaleIcon.qml b/TailscaleIcon.qml new file mode 100644 index 0000000..e0f3a1b --- /dev/null +++ b/TailscaleIcon.qml @@ -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 + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c0ace7e --- /dev/null +++ b/manifest.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "id": "omarchy.tailscale", + "name": "Tailscale", + "version": "1.0.0", + "author": "Omarchy", + "license": "MIT", + "description": "Tailscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.", + "kinds": [ + "bar-widget" + ], + "entryPoints": { + "barWidget": "Panel.qml" + }, + "barWidget": { + "displayName": "Tailscale", + "description": "Toggle Tailscale, switch connections, browse machines, and copy machine 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 + } + ] + } +}