mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-01 20:28:16 +02:00
914 lines
30 KiB
QML
914 lines
30 KiB
QML
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: tailscale.exitNodes
|
|
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
|
|
anchors.verticalCenterOffset: -Style.space(1)
|
|
iconSize: Style.space(12) * 0.85
|
|
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: ""
|
|
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)
|
|
}
|
|
}
|
|
}
|