Add Tailscale panel connection controls
This commit is contained in:
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# omarchy:summary=Mutate the user shell.json bar layout
|
||||||
|
# omarchy:args=<append|prepend> <left|center|right> <plugin>
|
||||||
|
# omarchy:examples=omarchy config shell append right omarchy.tailscale
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG_FILE="$HOME/.config/omarchy/shell.json"
|
||||||
|
OMARCHY_ROOT="${OMARCHY_PATH:-}"
|
||||||
|
DEFAULTS_FILE="$OMARCHY_ROOT/config/omarchy/shell.json"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: omarchy-config-shell <append|prepend> <left|center|right> <plugin>" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "omarchy-config-shell: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
operation="${1:-}"
|
||||||
|
section="${2:-}"
|
||||||
|
plugin="${3:-}"
|
||||||
|
|
||||||
|
if (( $# != 3 )); then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ $operation == "append" || $operation == "prepend" ]] || fail "operation must be append or prepend"
|
||||||
|
[[ $section =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right"
|
||||||
|
[[ -n $plugin ]] || fail "plugin is required"
|
||||||
|
[[ -n $OMARCHY_ROOT ]] || fail "OMARCHY_PATH is not set"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||||
|
|
||||||
|
source_file="$CONFIG_FILE"
|
||||||
|
if [[ ! -s $source_file ]]; then
|
||||||
|
source_file="$DEFAULTS_FILE"
|
||||||
|
fi
|
||||||
|
[[ -s $source_file ]] || fail "could not find shell config or defaults"
|
||||||
|
|
||||||
|
tmp=$(mktemp)
|
||||||
|
trap 'rm -f "$tmp"' EXIT
|
||||||
|
|
||||||
|
jq --arg operation "$operation" --arg section "$section" --arg plugin "$plugin" '
|
||||||
|
def object_or_empty: if type == "object" then . else {} end;
|
||||||
|
def array_or_empty: if type == "array" then . else [] end;
|
||||||
|
def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;
|
||||||
|
def append_anchor($section):
|
||||||
|
{
|
||||||
|
left: "omarchy.workspaces",
|
||||||
|
center: "omarchy.weather",
|
||||||
|
right: "omarchy.tray"
|
||||||
|
}[$section];
|
||||||
|
def insert_after_anchor($entries; $entry; $anchor):
|
||||||
|
($entries | map(entry_id) | index($anchor)) as $index
|
||||||
|
| if $index == null then
|
||||||
|
$entries + [$entry]
|
||||||
|
else
|
||||||
|
$entries[0:$index + 1] + [$entry] + $entries[$index + 1:]
|
||||||
|
end;
|
||||||
|
|
||||||
|
object_or_empty
|
||||||
|
| .version = 1
|
||||||
|
| .bar = (.bar | object_or_empty)
|
||||||
|
| .bar.layout = (.bar.layout | object_or_empty)
|
||||||
|
| .bar.layout.left = (.bar.layout.left | array_or_empty | map(select(entry_id != $plugin)))
|
||||||
|
| .bar.layout.center = (.bar.layout.center | array_or_empty | map(select(entry_id != $plugin)))
|
||||||
|
| .bar.layout.right = (.bar.layout.right | array_or_empty | map(select(entry_id != $plugin)))
|
||||||
|
| .plugins = (.plugins | array_or_empty)
|
||||||
|
| if $operation == "prepend" then
|
||||||
|
.bar.layout[$section] = ([{ id: $plugin }] + .bar.layout[$section])
|
||||||
|
else
|
||||||
|
.bar.layout[$section] = insert_after_anchor(.bar.layout[$section]; { id: $plugin }; append_anchor($section))
|
||||||
|
end
|
||||||
|
' "$source_file" >"$tmp"
|
||||||
|
|
||||||
|
mv "$tmp" "$CONFIG_FILE"
|
||||||
|
trap - EXIT
|
||||||
|
omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true
|
||||||
@@ -13,4 +13,7 @@ sudo tailscale up --accept-routes
|
|||||||
echo -e "\nAllowing $USER to manage Tailscale..."
|
echo -e "\nAllowing $USER to manage Tailscale..."
|
||||||
sudo tailscale set --operator="$USER"
|
sudo tailscale set --operator="$USER"
|
||||||
|
|
||||||
|
echo -e "\nAdding Tailscale to the bar..."
|
||||||
|
omarchy-config-shell append right omarchy.tailscale
|
||||||
|
|
||||||
omarchy-webapp-install "Tailscale" "https://login.tailscale.com/admin/machines" https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tailscale-light.png
|
omarchy-webapp-install "Tailscale" "https://login.tailscale.com/admin/machines" https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tailscale-light.png
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property Component iconComponent: null
|
||||||
|
property string title: ""
|
||||||
|
property string meta: ""
|
||||||
|
property string detail: ""
|
||||||
|
property color foreground: Color.foreground
|
||||||
|
property string fontFamily: Style.font.family
|
||||||
|
property real iconSize: Style.font.display
|
||||||
|
property real iconOpacity: 1.0
|
||||||
|
property alias metaOpacity: metaText.opacity
|
||||||
|
|
||||||
|
readonly property color dim: Qt.darker(foreground, 1.4)
|
||||||
|
|
||||||
|
width: parent ? parent.width : implicitWidth
|
||||||
|
implicitHeight: Math.max(iconLoader.implicitHeight, heroLabels.implicitHeight)
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: iconLoader
|
||||||
|
sourceComponent: root.iconComponent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
opacity: root.iconOpacity
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: heroLabels
|
||||||
|
anchors.left: iconLoader.right
|
||||||
|
anchors.leftMargin: Style.space(14)
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.space(2)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: titleRow
|
||||||
|
visible: root.title !== "" || detailPill.visible
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: root.title !== ""
|
||||||
|
text: root.title
|
||||||
|
width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0)))
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.title
|
||||||
|
font.bold: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: Math.max(0, parent.width - parent.children[0].width - detailPill.implicitWidth)
|
||||||
|
height: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: detailPill
|
||||||
|
visible: root.detail !== ""
|
||||||
|
implicitWidth: detailText.implicitWidth + Style.space(10)
|
||||||
|
implicitHeight: detailText.implicitHeight + Style.space(4)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Style.normalBorderFor(root.foreground)
|
||||||
|
border.width: Style.normalBorderWidth
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: detailText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.detail
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: metaText
|
||||||
|
width: parent.width
|
||||||
|
text: root.meta.toUpperCase()
|
||||||
|
visible: text !== ""
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ Panel 1.0 Panel.qml
|
|||||||
PanelActionButton 1.0 PanelActionButton.qml
|
PanelActionButton 1.0 PanelActionButton.qml
|
||||||
PanelController 1.0 PanelController.qml
|
PanelController 1.0 PanelController.qml
|
||||||
PanelKeyCatcher 1.0 PanelKeyCatcher.qml
|
PanelKeyCatcher 1.0 PanelKeyCatcher.qml
|
||||||
|
PanelHero 1.0 PanelHero.qml
|
||||||
PanelSectionHeader 1.0 PanelSectionHeader.qml
|
PanelSectionHeader 1.0 PanelSectionHeader.qml
|
||||||
PanelSeparator 1.0 PanelSeparator.qml
|
PanelSeparator 1.0 PanelSeparator.qml
|
||||||
PanelSlider 1.0 PanelSlider.qml
|
PanelSlider 1.0 PanelSlider.qml
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
|||||||
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
||||||
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
||||||
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
||||||
| Tailscale | `omarchy.tailscale` | `bar-widget` | `tailscale/Widget.qml` |
|
| Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` |
|
||||||
| Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
|
| Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
|
||||||
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
|
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
|
||||||
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
|
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Example `shell.json` (bar subtree only shown):
|
|||||||
|
|
||||||
| `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
|
| `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
|
||||||
| `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
|
| `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
|
||||||
| `omarchy.tailscale` | Tailscale status, account switcher, peer browser, and copy actions | left = popup · right = toggle · middle = refresh |
|
| `omarchy.tailscale` | Tailscale status, connection switcher, machine browser, and copy actions | left = popup · right = toggle · middle = refresh |
|
||||||
| `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup |
|
| `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup |
|
||||||
| `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
|
| `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
|
||||||
| `omarchy.monitor` | Brightness and laptop display controls | left = popup |
|
| `omarchy.monitor` | Brightness and laptop display controls | left = popup |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ Native Omarchy bar widget for Tailscale.
|
|||||||
- Shows Tailscale connection state in the bar
|
- Shows Tailscale connection state in the bar
|
||||||
- Left click opens a keyboard-friendly panel
|
- Left click opens a keyboard-friendly panel
|
||||||
- Right click toggles Tailscale on/off
|
- Right click toggles Tailscale on/off
|
||||||
- Switch between logged-in Tailscale accounts when multiple are available
|
- Switch between available Tailscale connections when multiple are available
|
||||||
- Browse peers from `tailscale status --json`
|
- Browse machines from `tailscale status --json`
|
||||||
- Copy a peer's Tailscale IP, host name, or DNS name
|
- Copy a machine's Tailscale IP, host name, or DNS name
|
||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
@@ -2,10 +2,10 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "Model.js" as Model
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
visible: false
|
|
||||||
|
|
||||||
property var settings: ({})
|
property var settings: ({})
|
||||||
|
|
||||||
@@ -23,15 +23,20 @@ Item {
|
|||||||
property var accounts: []
|
property var accounts: []
|
||||||
property string selectedAccountId: ""
|
property string selectedAccountId: ""
|
||||||
property string selectedAccountLabel: ""
|
property string selectedAccountLabel: ""
|
||||||
|
property string switchingAccountId: ""
|
||||||
|
property string settingExitNodeId: ""
|
||||||
|
property bool accountsAccessDenied: false
|
||||||
property string actionStatus: ""
|
property string actionStatus: ""
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600)
|
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
|
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 _statusOutput: ""
|
||||||
property string _statusError: ""
|
property string _statusError: ""
|
||||||
property string _accountsOutput: ""
|
property string _accountsOutput: ""
|
||||||
|
property string _accountsError: ""
|
||||||
property string _actionOutput: ""
|
property string _actionOutput: ""
|
||||||
property string _actionError: ""
|
property string _actionError: ""
|
||||||
property string _loginOutput: ""
|
property string _loginOutput: ""
|
||||||
@@ -42,6 +47,10 @@ Item {
|
|||||||
property double _lastAccountsRefreshMs: 0
|
property double _lastAccountsRefreshMs: 0
|
||||||
property string _switchOutput: ""
|
property string _switchOutput: ""
|
||||||
property string _switchError: ""
|
property string _switchError: ""
|
||||||
|
property string _exitNodeOutput: ""
|
||||||
|
property string _exitNodeError: ""
|
||||||
|
property string _operatorOutput: ""
|
||||||
|
property string _operatorError: ""
|
||||||
|
|
||||||
function setting(name, fallback) {
|
function setting(name, fallback) {
|
||||||
var value = settings ? settings[name] : undefined
|
var value = settings ? settings[name] : undefined
|
||||||
@@ -57,56 +66,33 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterIPv4(ips) {
|
function filterIPv4(ips) {
|
||||||
var result = []
|
return Model.filterIPv4(ips)
|
||||||
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 cleanDnsName(name) {
|
function cleanDnsName(name) {
|
||||||
var value = String(name || "")
|
return Model.cleanDnsName(name)
|
||||||
return value.charAt(value.length - 1) === "." ? value.slice(0, -1) : value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortDnsName(name) {
|
function shortDnsName(name) {
|
||||||
var clean = cleanDnsName(name)
|
return Model.shortDnsName(name)
|
||||||
if (clean === "") return ""
|
|
||||||
return clean.split(".")[0] || clean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayHostName(hostName, dnsName) {
|
function displayHostName(hostName, dnsName) {
|
||||||
var host = String(hostName || "")
|
return Model.displayHostName(hostName, dnsName)
|
||||||
if (host !== "" && host.toLowerCase() !== "localhost") return host
|
|
||||||
return shortDnsName(dnsName) || host || "Unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function osIcon(os) {
|
function osIcon(os) {
|
||||||
var value = String(os || "").toLowerCase()
|
return Model.osIcon(os)
|
||||||
if (value === "linux") return ""
|
|
||||||
if (value === "macos" || value === "ios") return ""
|
|
||||||
if (value === "windows") return ""
|
|
||||||
if (value === "android") return ""
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountLabel(account) {
|
function accountLabel(account) {
|
||||||
if (!account) return "Unknown account"
|
return Model.accountLabel(account)
|
||||||
var parts = []
|
|
||||||
if (account.nickname) parts.push(String(account.nickname))
|
|
||||||
if (account.tailnet && String(account.tailnet) !== String(account.nickname || "")) parts.push(String(account.tailnet))
|
|
||||||
if (account.account) parts.push(String(account.account))
|
|
||||||
return parts.length > 0 ? parts.join(" · ") : String(account.id || "Unknown account")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(value, label) {
|
function copyToClipboard(value, label) {
|
||||||
var text = String(value || "")
|
var text = String(value || "")
|
||||||
if (text === "") return
|
if (text === "") return
|
||||||
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"])
|
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"])
|
||||||
actionStatus = elideStatus("Copied " + (label || text))
|
|
||||||
actionStatusTimer.restart()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyPeerIp(peer) {
|
function copyPeerIp(peer) {
|
||||||
@@ -150,6 +136,7 @@ Item {
|
|||||||
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
|
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
|
||||||
if (shouldRefreshAccounts && !accountsProcess.running) {
|
if (shouldRefreshAccounts && !accountsProcess.running) {
|
||||||
_accountsOutput = ""
|
_accountsOutput = ""
|
||||||
|
_accountsError = ""
|
||||||
_lastAccountsRefreshMs = now
|
_lastAccountsRefreshMs = now
|
||||||
accountsProcess.command = ["tailscale", "switch", "--list", "--json"]
|
accountsProcess.command = ["tailscale", "switch", "--list", "--json"]
|
||||||
accountsProcess.running = true
|
accountsProcess.running = true
|
||||||
@@ -174,116 +161,61 @@ Item {
|
|||||||
accounts = []
|
accounts = []
|
||||||
selectedAccountId = ""
|
selectedAccountId = ""
|
||||||
selectedAccountLabel = ""
|
selectedAccountLabel = ""
|
||||||
|
switchingAccountId = ""
|
||||||
|
settingExitNodeId = ""
|
||||||
|
accountsAccessDenied = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStatus(raw) {
|
function parseStatus(raw) {
|
||||||
var text = String(raw || "").trim()
|
var parsed = Model.parseStatus(raw)
|
||||||
if (text === "") {
|
if (!parsed.ok) {
|
||||||
resetUnavailable("Disconnected")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
backendState = parsed.backendState
|
||||||
var data = JSON.parse(text)
|
running = parsed.running
|
||||||
backendState = String(data.BackendState || "Unknown")
|
needsLogin = parsed.needsLogin
|
||||||
running = backendState === "Running"
|
authUrl = parsed.authUrl
|
||||||
needsLogin = backendState === "NeedsLogin"
|
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false)
|
||||||
authUrl = String(data.AuthURL || "")
|
selfName = parsed.selfName
|
||||||
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) {
|
selfDnsName = parsed.selfDnsName
|
||||||
openAuthUrlFrom(authUrl, false)
|
selfIp = parsed.selfIp
|
||||||
}
|
peers = parsed.running ? parsed.peers : []
|
||||||
|
|
||||||
var self = data.Self || {}
|
if (needsLogin) statusText = "Needs login"
|
||||||
selfName = displayHostName(self.HostName, self.DNSName)
|
else if (running) {
|
||||||
selfDnsName = cleanDnsName(self.DNSName)
|
statusText = "Connected"
|
||||||
var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || [])
|
_loginInProgress = false
|
||||||
selfIp = selfIps.length > 0 ? selfIps[0] : ""
|
_loginUrlOpened = false
|
||||||
|
_preLoginAuthUrl = ""
|
||||||
var nextPeers = []
|
loginTimeoutTimer.stop()
|
||||||
var rawPeers = data.Peer || {}
|
} else if (backendState === "Stopped") {
|
||||||
for (var id in rawPeers) {
|
statusText = "Disconnected"
|
||||||
var peer = rawPeers[id] || {}
|
} else {
|
||||||
var ipv4s = filterIPv4(peer.TailscaleIPs || [])
|
statusText = backendState
|
||||||
nextPeers.push({
|
|
||||||
id: id,
|
|
||||||
HostName: displayHostName(peer.HostName, peer.DNSName),
|
|
||||||
DNSName: cleanDnsName(peer.DNSName),
|
|
||||||
TailscaleIPs: ipv4s,
|
|
||||||
Online: peer.Online === true,
|
|
||||||
OS: String(peer.OS || ""),
|
|
||||||
Tags: peer.Tags || [],
|
|
||||||
ExitNodeOption: peer.ExitNodeOption === true,
|
|
||||||
ExitNode: peer.ExitNode === true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
nextPeers.sort(function(a, b) {
|
|
||||||
if (a.Online !== b.Online) return a.Online ? -1 : 1
|
|
||||||
return String(a.HostName).localeCompare(String(b.HostName))
|
|
||||||
})
|
|
||||||
peers = nextPeers
|
|
||||||
|
|
||||||
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 = ""
|
|
||||||
} catch (e) {
|
|
||||||
resetUnavailable("Status error")
|
|
||||||
lastError = "Failed to parse tailscale status"
|
|
||||||
console.warn("tailscale", lastError, e)
|
|
||||||
}
|
}
|
||||||
|
lastError = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAccounts(raw) {
|
function parseAccounts(raw) {
|
||||||
var text = String(raw || "").trim()
|
var parsed = Model.parseAccounts(raw)
|
||||||
if (text === "") {
|
accounts = parsed.accounts
|
||||||
accounts = []
|
selectedAccountId = parsed.selectedAccountId
|
||||||
selectedAccountId = ""
|
selectedAccountLabel = parsed.selectedAccountLabel
|
||||||
selectedAccountLabel = ""
|
accountsAccessDenied = false
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 raw = parsed[i] || {}
|
|
||||||
var account = {
|
|
||||||
id: String(raw.id || raw.ID || ""),
|
|
||||||
nickname: String(raw.nickname || raw.Nickname || raw.name || raw.Name || ""),
|
|
||||||
tailnet: String(raw.tailnet || raw.Tailnet || ""),
|
|
||||||
account: String(raw.account || raw.Account || raw.loginName || raw.LoginName || raw.user || raw.User || ""),
|
|
||||||
selected: raw.selected === true || raw.Selected === true
|
|
||||||
}
|
|
||||||
next.push(account)
|
|
||||||
if (account.selected === true) selected = account
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accounts = next
|
|
||||||
selectedAccountId = selected ? String(selected.id || "") : ""
|
|
||||||
selectedAccountLabel = selected ? accountLabel(selected) : ""
|
|
||||||
} catch (e) {
|
|
||||||
accounts = []
|
|
||||||
selectedAccountId = ""
|
|
||||||
selectedAccountLabel = ""
|
|
||||||
console.warn("tailscale", "Failed to parse account list", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTailscale() {
|
function toggleTailscale() {
|
||||||
if (!installed) return
|
if (!installed) return
|
||||||
if (running) {
|
if (running) runAction(["tailscale", "down"], "Turning Tailscale off…")
|
||||||
runAction(["tailscale", "down"], "Turning Tailscale off…")
|
else loginOrUp()
|
||||||
} else {
|
|
||||||
loginOrUp()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginOrUp() {
|
function loginOrUp() {
|
||||||
@@ -306,11 +238,39 @@ Item {
|
|||||||
if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return
|
if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return
|
||||||
_switchOutput = ""
|
_switchOutput = ""
|
||||||
_switchError = ""
|
_switchError = ""
|
||||||
actionStatus = "Switching Tailscale account…"
|
switchingAccountId = accountId
|
||||||
switchProcess.command = ["tailscale", "switch", accountId]
|
switchProcess.command = ["tailscale", "switch", accountId]
|
||||||
switchProcess.running = true
|
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) {
|
function runAction(command, label) {
|
||||||
if (actionProcess.running) return
|
if (actionProcess.running) return
|
||||||
_actionOutput = ""
|
_actionOutput = ""
|
||||||
@@ -416,10 +376,20 @@ Item {
|
|||||||
running: false
|
running: false
|
||||||
command: []
|
command: []
|
||||||
stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text }
|
stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text }
|
||||||
|
stderr: StdioCollector { id: accountsStderr; waitForEnd: true; onStreamFinished: root._accountsError = text }
|
||||||
onExited: function(exitCode) {
|
onExited: function(exitCode) {
|
||||||
var stdout = String(accountsStdout.text || root._accountsOutput || "")
|
var stdout = String(accountsStdout.text || root._accountsOutput || "")
|
||||||
|
var stderr = String(accountsStderr.text || root._accountsError || "")
|
||||||
if (exitCode === 0) root.parseAccounts(stdout)
|
if (exitCode === 0) root.parseAccounts(stdout)
|
||||||
else root.parseAccounts("")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,20 +448,55 @@ Item {
|
|||||||
root.actionStatus = root.lastError
|
root.actionStatus = root.lastError
|
||||||
} else {
|
} else {
|
||||||
root.lastError = ""
|
root.lastError = ""
|
||||||
root.actionStatus = "Switched account"
|
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()
|
actionStatusTimer.restart()
|
||||||
root._lastAccountsRefreshMs = 0
|
root._lastAccountsRefreshMs = 0
|
||||||
}
|
}
|
||||||
delayedRefresh.restart()
|
delayedRefresh.restart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IpcHandler {
|
|
||||||
target: "omarchy.tailscale"
|
|
||||||
function refresh(): string { root.refresh(); return "ok" }
|
|
||||||
function toggle(): string { root.toggleTailscale(); return "ok" }
|
|
||||||
function up(): string { root.loginOrUp(); return "ok" }
|
|
||||||
function down(): string { root.runAction(["tailscale", "down"], "Turning Tailscale off…"); return "ok" }
|
|
||||||
function status(): string { return root.statusText }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+3
-3
@@ -5,16 +5,16 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "Omarchy",
|
"author": "Omarchy",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "Tailscale status, account switching, peer browsing, and quick copy actions in the Omarchy bar.",
|
"description": "Tailscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.",
|
||||||
"kinds": [
|
"kinds": [
|
||||||
"bar-widget"
|
"bar-widget"
|
||||||
],
|
],
|
||||||
"entryPoints": {
|
"entryPoints": {
|
||||||
"barWidget": "Widget.qml"
|
"barWidget": "Panel.qml"
|
||||||
},
|
},
|
||||||
"barWidget": {
|
"barWidget": {
|
||||||
"displayName": "Tailscale",
|
"displayName": "Tailscale",
|
||||||
"description": "Toggle Tailscale, switch accounts, browse peers, and copy peer IPs or names.",
|
"description": "Toggle Tailscale, switch connections, browse machines, and copy machine IPs or names.",
|
||||||
"category": "Network",
|
"category": "Network",
|
||||||
"allowMultiple": false,
|
"allowMultiple": false,
|
||||||
"defaults": {
|
"defaults": {
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import Quickshell
|
|
||||||
import qs.Commons
|
|
||||||
import qs.Ui
|
|
||||||
|
|
||||||
BarWidget {
|
|
||||||
id: root
|
|
||||||
moduleName: "omarchy.tailscale"
|
|
||||||
|
|
||||||
property bool popupOpen: false
|
|
||||||
property string focusSection: "header"
|
|
||||||
property int headerIndex: 0
|
|
||||||
property int accountIndex: 0
|
|
||||||
property int peerIndex: 0
|
|
||||||
|
|
||||||
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 color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055)
|
|
||||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
|
||||||
readonly property bool showAccounts: tailscale.accounts.length > 1
|
|
||||||
readonly property bool showPeers: tailscale.peers.length > 0
|
|
||||||
readonly property color iconColor: tailscale.running ? foreground : (tailscale.needsLogin ? urgent : dim)
|
|
||||||
|
|
||||||
function close() { popupOpen = false }
|
|
||||||
|
|
||||||
function openPanel() {
|
|
||||||
popupOpen = true
|
|
||||||
tailscale.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePanel() {
|
|
||||||
if (popupOpen) close()
|
|
||||||
else openPanel()
|
|
||||||
}
|
|
||||||
|
|
||||||
function tooltipText() {
|
|
||||||
var lines = ["Tailscale: " + tailscale.statusText]
|
|
||||||
if (tailscale.selfIp !== "") lines.push(tailscale.selfName + " · " + tailscale.selfIp)
|
|
||||||
if (tailscale.selectedAccountLabel !== "") lines.push(tailscale.selectedAccountLabel)
|
|
||||||
if (tailscale.running) lines.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedPeer() {
|
|
||||||
if (tailscale.peers.length === 0) return null
|
|
||||||
return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.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 > 1) headerIndex = 1
|
|
||||||
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 (focusSection === "accounts" && !showAccounts) focusSection = showPeers ? "peers" : "header"
|
|
||||||
if (focusSection === "peers" && !showPeers) focusSection = showAccounts ? "accounts" : "header"
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveCursor(dx, dy) {
|
|
||||||
ensureCursor()
|
|
||||||
if (dy !== 0) {
|
|
||||||
if (focusSection === "header") {
|
|
||||||
if (dy > 0) {
|
|
||||||
if (showAccounts) focusSection = "accounts"
|
|
||||||
else if (showPeers) focusSection = "peers"
|
|
||||||
}
|
|
||||||
} else if (focusSection === "accounts") {
|
|
||||||
if (dy < 0) {
|
|
||||||
if (accountIndex <= 0) focusSection = "header"
|
|
||||||
else accountIndex--
|
|
||||||
} else {
|
|
||||||
if (accountIndex < tailscale.accounts.length - 1) accountIndex++
|
|
||||||
else if (showPeers) focusSection = "peers"
|
|
||||||
}
|
|
||||||
} else if (focusSection === "peers") {
|
|
||||||
if (dy < 0) {
|
|
||||||
if (peerIndex <= 0) focusSection = showAccounts ? "accounts" : "header"
|
|
||||||
else peerIndex--
|
|
||||||
} else if (peerIndex < tailscale.peers.length - 1) {
|
|
||||||
peerIndex++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dx !== 0 && focusSection === "header") headerIndex = (headerIndex + dx + 2) % 2
|
|
||||||
ensureCursor()
|
|
||||||
scrollPeerIntoView()
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateCursor() {
|
|
||||||
ensureCursor()
|
|
||||||
if (focusSection === "header") {
|
|
||||||
if (headerIndex === 0) tailscale.toggleTailscale()
|
|
||||||
else tailscale.refresh()
|
|
||||||
} else if (focusSection === "accounts") {
|
|
||||||
var account = selectedAccount()
|
|
||||||
if (account) tailscale.switchAccount(account.id)
|
|
||||||
} else if (focusSection === "peers") {
|
|
||||||
tailscale.copyPeerIp(selectedPeer())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollPeerIntoView() {
|
|
||||||
if (focusSection !== "peers" || !peerFlick || !peerColumn) return
|
|
||||||
Qt.callLater(function() {
|
|
||||||
if (root.focusSection !== "peers" || root.peerIndex < 0 || root.peerIndex >= peerColumn.children.length) return
|
|
||||||
var item = peerColumn.children[root.peerIndex]
|
|
||||||
if (!item) return
|
|
||||||
var margin = Style.space(6)
|
|
||||||
var top = item.y
|
|
||||||
var bottom = top + item.height
|
|
||||||
var viewTop = peerFlick.contentY
|
|
||||||
var viewBottom = viewTop + peerFlick.height
|
|
||||||
var maxY = Math.max(0, peerFlick.contentHeight - peerFlick.height)
|
|
||||||
if (top < viewTop + margin) peerFlick.contentY = Math.max(0, top - margin)
|
|
||||||
else if (bottom > viewBottom - margin) peerFlick.contentY = Math.min(maxY, bottom + margin - peerFlick.height)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPeerCursor(index) {
|
|
||||||
focusSection = "peers"
|
|
||||||
peerIndex = index
|
|
||||||
scrollPeerIntoView()
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAccountCursor(index) {
|
|
||||||
focusSection = "accounts"
|
|
||||||
accountIndex = index
|
|
||||||
}
|
|
||||||
|
|
||||||
implicitWidth: button.implicitWidth
|
|
||||||
implicitHeight: button.implicitHeight
|
|
||||||
|
|
||||||
onPopupOpenChanged: if (popupOpen) Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
|
||||||
onPeerIndexChanged: scrollPeerIntoView()
|
|
||||||
onShowAccountsChanged: ensureCursor()
|
|
||||||
onShowPeersChanged: ensureCursor()
|
|
||||||
|
|
||||||
Main {
|
|
||||||
id: tailscale
|
|
||||||
settings: root.settings
|
|
||||||
onPeersChanged: root.ensureCursor()
|
|
||||||
onAccountsChanged: root.ensureCursor()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
readonly property bool tooltipHovered: mouseArea.containsMouse
|
|
||||||
|
|
||||||
function triggerPress(buttonCode) {
|
|
||||||
if (root.bar) root.bar.hideTooltip(button)
|
|
||||||
if (buttonCode === Qt.RightButton) tailscale.toggleTailscale()
|
|
||||||
else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
|
|
||||||
else root.togglePanel()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
onEntered: if (root.bar) root.bar.showTooltip(button, root.tooltipText())
|
|
||||||
onExited: if (root.bar) root.bar.hideTooltip(button)
|
|
||||||
onClicked: function(mouse) { button.triggerPress(mouse.button) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyboardPanel {
|
|
||||||
id: panel
|
|
||||||
anchorItem: button
|
|
||||||
owner: root
|
|
||||||
bar: root.bar
|
|
||||||
open: root.popupOpen
|
|
||||||
focusTarget: keyCatcher
|
|
||||||
contentWidth: panel.fittedContentWidth(Style.space(400))
|
|
||||||
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(580))
|
|
||||||
|
|
||||||
PanelKeyCatcher {
|
|
||||||
id: keyCatcher
|
|
||||||
anchors.fill: parent
|
|
||||||
onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
|
|
||||||
onActivateRequested: root.activateCursor()
|
|
||||||
onCloseRequested: root.close()
|
|
||||||
onTextKey: function(t) {
|
|
||||||
if (t === "r" || t === "R") tailscale.refresh()
|
|
||||||
else 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: Math.max(heroIcon.implicitHeight, heroText.implicitHeight, headerActions.implicitHeight)
|
|
||||||
|
|
||||||
TailscaleIcon {
|
|
||||||
id: heroIcon
|
|
||||||
iconSize: Style.font.display
|
|
||||||
color: root.iconColor
|
|
||||||
badgeColor: root.urgent
|
|
||||||
crossed: !tailscale.running && !tailscale.needsLogin
|
|
||||||
warning: tailscale.needsLogin
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: heroText
|
|
||||||
anchors.left: heroIcon.right
|
|
||||||
anchors.leftMargin: Style.space(12)
|
|
||||||
anchors.right: headerActions.left
|
|
||||||
anchors.rightMargin: Style.space(10)
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Style.space(2)
|
|
||||||
|
|
||||||
Text {
|
|
||||||
width: parent.width
|
|
||||||
text: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
|
|
||||||
color: root.foreground
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.title
|
|
||||||
font.bold: true
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
width: parent.width
|
|
||||||
text: {
|
|
||||||
var parts = [tailscale.statusText]
|
|
||||||
if (tailscale.selfIp !== "") parts.push(tailscale.selfIp)
|
|
||||||
if (tailscale.running) parts.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
|
|
||||||
return parts.join(" · ").toUpperCase()
|
|
||||||
}
|
|
||||||
color: root.dim
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.caption
|
|
||||||
font.bold: true
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
id: headerActions
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Style.space(5)
|
|
||||||
|
|
||||||
PanelActionButton {
|
|
||||||
id: toggleBtn
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
iconText: "⏻"
|
|
||||||
fontSize: Style.font.heading
|
|
||||||
size: Style.space(30)
|
|
||||||
tooltipText: tailscale.running ? "Disconnect Tailscale" : (tailscale.needsLogin ? "Log in to Tailscale" : "Connect Tailscale")
|
|
||||||
foreground: root.foreground
|
|
||||||
hoverColor: tailscale.running ? root.urgent : root.foreground
|
|
||||||
fontFamily: root.fontFamily
|
|
||||||
hasCursor: root.focusSection === "header" && root.headerIndex === 0
|
|
||||||
enabled: tailscale.installed && !tailscale.busy
|
|
||||||
onHovered: function(h) {
|
|
||||||
if (!h) return
|
|
||||||
root.focusSection = "header"
|
|
||||||
root.headerIndex = 0
|
|
||||||
}
|
|
||||||
onClicked: tailscale.toggleTailscale()
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelActionButton {
|
|
||||||
id: refreshBtn
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
iconText: ""
|
|
||||||
fontSize: Style.font.heading
|
|
||||||
size: Style.space(30)
|
|
||||||
tooltipText: "Refresh"
|
|
||||||
foreground: root.foreground
|
|
||||||
fontFamily: root.fontFamily
|
|
||||||
hasCursor: root.focusSection === "header" && root.headerIndex === 1
|
|
||||||
enabled: !tailscale.busy
|
|
||||||
onHovered: function(h) {
|
|
||||||
if (!h) return
|
|
||||||
root.focusSection = "header"
|
|
||||||
root.headerIndex = 1
|
|
||||||
}
|
|
||||||
onClicked: tailscale.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
visible: !tailscale.installed
|
|
||||||
width: parent.width
|
|
||||||
implicitHeight: missingText.implicitHeight + Style.space(24)
|
|
||||||
color: root.card
|
|
||||||
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06)
|
|
||||||
border.width: Style.normalBorderWidth
|
|
||||||
radius: Style.cornerRadius
|
|
||||||
|
|
||||||
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.bodySmall
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelSeparator {
|
|
||||||
visible: root.showAccounts
|
|
||||||
foreground: root.foreground
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
visible: root.showAccounts
|
|
||||||
width: parent.width
|
|
||||||
spacing: Style.space(10)
|
|
||||||
|
|
||||||
PanelSectionHeader {
|
|
||||||
text: "ACCOUNTS"
|
|
||||||
foreground: root.foreground
|
|
||||||
fontFamily: root.fontFamily
|
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: tailscale.accounts
|
|
||||||
AccountRow {
|
|
||||||
required property var modelData
|
|
||||||
required property int index
|
|
||||||
width: parent.width
|
|
||||||
account: modelData
|
|
||||||
rowIndex: index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelSeparator {
|
|
||||||
visible: tailscale.installed
|
|
||||||
foreground: root.foreground
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
visible: tailscale.installed
|
|
||||||
width: parent.width
|
|
||||||
spacing: Style.space(10)
|
|
||||||
|
|
||||||
PanelSectionHeader {
|
|
||||||
text: "PEERS"
|
|
||||||
foreground: root.foreground
|
|
||||||
fontFamily: root.fontFamily
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0
|
|
||||||
width: parent.width
|
|
||||||
text: "No peers found on this tailnet."
|
|
||||||
color: root.dim
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.bodySmall
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
visible: tailscale.installed && !tailscale.running
|
|
||||||
width: parent.width
|
|
||||||
text: tailscale.needsLogin ? "Log in to see tailnet peers." : "Turn Tailscale on to see tailnet peers."
|
|
||||||
color: root.dim
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.bodySmall
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Flickable {
|
|
||||||
id: peerFlick
|
|
||||||
visible: tailscale.peers.length > 0
|
|
||||||
width: parent.width
|
|
||||||
height: Math.min(peerColumn.implicitHeight, Style.space(340))
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: peerColumn.implicitHeight
|
|
||||||
clip: true
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: peerColumn
|
|
||||||
width: peerFlick.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component AccountRow: Rectangle {
|
|
||||||
id: accountRow
|
|
||||||
property var account: null
|
|
||||||
property int rowIndex: 0
|
|
||||||
readonly property bool selectedAccount: account && account.selected === true
|
|
||||||
readonly property bool hasCursor: root.focusSection === "accounts" && root.accountIndex === rowIndex
|
|
||||||
|
|
||||||
implicitHeight: row.implicitHeight + Style.space(12)
|
|
||||||
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : (selectedAccount ? root.card : "transparent")
|
|
||||||
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : "transparent"
|
|
||||||
border.width: hasCursor ? Style.hoverBorderWidth : 0
|
|
||||||
radius: Style.cornerRadius
|
|
||||||
|
|
||||||
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: accountRow.selectedAccount ? "●" : "○"
|
|
||||||
color: accountRow.selectedAccount ? root.foreground : root.dim
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.bodySmall
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Style.space(1)
|
|
||||||
Text {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: account ? (account.nickname || account.tailnet || account.account || account.id || "Account") : "Account"
|
|
||||||
color: root.foreground
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.bodySmall
|
|
||||||
font.bold: true
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: account ? [account.tailnet || "", account.account || ""].filter(function(x) { return String(x) !== "" }).join(" · ") : ""
|
|
||||||
color: root.dim
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.caption
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: Rectangle {
|
|
||||||
id: peerRow
|
|
||||||
property var peer: null
|
|
||||||
property int rowIndex: 0
|
|
||||||
readonly property bool online: peer && peer.Online === true
|
|
||||||
readonly property bool hasCursor: root.focusSection === "peers" && root.peerIndex === rowIndex
|
|
||||||
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 peerDns: peer ? String(peer.DNSName || "") : ""
|
|
||||||
|
|
||||||
implicitHeight: Math.max(peerContent.implicitHeight, actions.implicitHeight) + Style.space(12)
|
|
||||||
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : root.card
|
|
||||||
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
|
|
||||||
border.width: hasCursor ? Style.hoverBorderWidth : Style.normalBorderWidth
|
|
||||||
radius: Style.cornerRadius
|
|
||||||
opacity: online ? 1.0 : 0.62
|
|
||||||
|
|
||||||
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.bodySmall
|
|
||||||
font.bold: true
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: {
|
|
||||||
var parts = []
|
|
||||||
if (peerRow.peerIp !== "") parts.push(peerRow.peerIp)
|
|
||||||
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
|
|
||||||
if (!peerRow.online) parts.push("offline")
|
|
||||||
return parts.join(" · ")
|
|
||||||
}
|
|
||||||
color: root.dim
|
|
||||||
font.family: root.fontFamily
|
|
||||||
font.pixelSize: Style.font.caption
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
id: actions
|
|
||||||
spacing: Style.space(2)
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
|
|
||||||
PanelActionButton {
|
|
||||||
iconText: ""
|
|
||||||
tooltipText: "Copy IP"
|
|
||||||
foreground: root.foreground
|
|
||||||
fontFamily: root.fontFamily
|
|
||||||
enabled: peerRow.peerIp !== ""
|
|
||||||
onClicked: tailscale.copyPeerIp(peerRow.peer)
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelActionButton {
|
|
||||||
iconText: ""
|
|
||||||
tooltipText: "Copy name"
|
|
||||||
foreground: root.foreground
|
|
||||||
fontFamily: root.fontFamily
|
|
||||||
enabled: peerRow.peerName !== ""
|
|
||||||
onClicked: tailscale.copyPeerName(peerRow.peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
z: -1
|
|
||||||
onEntered: root.setPeerCursor(peerRow.rowIndex)
|
|
||||||
onClicked: function(mouse) {
|
|
||||||
if (mouse.button === Qt.RightButton) tailscale.copyPeerName(peerRow.peer)
|
|
||||||
else if (mouse.button === Qt.MiddleButton) tailscale.copyPeerDnsName(peerRow.peer)
|
|
||||||
else tailscale.copyPeerIp(peerRow.peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -94,6 +94,56 @@ migration=$(grep -rl 'Place the system update indicator next to weather in the b
|
|||||||
|
|
||||||
TMPDIR=$(mktemp -d)
|
TMPDIR=$(mktemp -d)
|
||||||
mkdir -p "$TMPDIR/home/.config/omarchy"
|
mkdir -p "$TMPDIR/home/.config/omarchy"
|
||||||
|
|
||||||
|
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"bar": {
|
||||||
|
"layout": {
|
||||||
|
"left": [{ "id": "omarchy.menu" }, { "id": "omarchy.workspaces" }],
|
||||||
|
"center": [{ "id": "omarchy.clock" }, { "id": "omarchy.weather" }],
|
||||||
|
"right": [{ "id": "omarchy.tray" }, { "id": "omarchy.bluetooth" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append right omarchy.tailscale
|
||||||
|
jq -e '
|
||||||
|
def ids: map(.id // .);
|
||||||
|
.bar.layout.right | ids == ["omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
|
||||||
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
|
pass "shell config appends right widgets after tray"
|
||||||
|
|
||||||
|
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append left local.left
|
||||||
|
jq -e '
|
||||||
|
def ids: map(.id // .);
|
||||||
|
.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "local.left"]
|
||||||
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
|
pass "shell config appends left widgets after workspaces"
|
||||||
|
|
||||||
|
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append center local.center
|
||||||
|
jq -e '
|
||||||
|
def ids: map(.id // .);
|
||||||
|
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "local.center"]
|
||||||
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
|
pass "shell config appends center widgets after weather"
|
||||||
|
|
||||||
|
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell prepend right local.first
|
||||||
|
jq -e '
|
||||||
|
def ids: map(.id // .);
|
||||||
|
.bar.layout.right | ids == ["local.first", "omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
|
||||||
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
|
pass "shell config prepends widgets to section start"
|
||||||
|
|
||||||
|
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append right local.first
|
||||||
|
jq -e '
|
||||||
|
def ids: map(.id // .);
|
||||||
|
.bar.layout.right | ids == ["omarchy.tray", "local.first", "omarchy.tailscale", "omarchy.bluetooth"]
|
||||||
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
|
pass "shell config moves existing widgets without duplicates"
|
||||||
|
|
||||||
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
|
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ ShellRoot {
|
|||||||
screenRecording.moduleName = "ScreenRecording"
|
screenRecording.moduleName = "ScreenRecording"
|
||||||
root.injectBar(screenRecording)
|
root.injectBar(screenRecording)
|
||||||
screenRecording.triggerPress(Qt.LeftButton)
|
screenRecording.triggerPress(Qt.LeftButton)
|
||||||
root.assertTrue(root.commandCount("omarchy-capture-screenrecording") === 1, "Screen Recording left click runs capture command")
|
root.assertTrue(root.commandCount("omarchy-menu toggle trigger.capture.screenrecord") === 1, "Screen Recording left click opens capture menu when idle")
|
||||||
|
screenRecording.recording = true
|
||||||
|
screenRecording.triggerPress(Qt.LeftButton)
|
||||||
|
root.assertTrue(root.commandCount("omarchy-capture-screenrecording --stop-recording") === 1, "Screen Recording left click stops active recording")
|
||||||
}
|
}
|
||||||
|
|
||||||
var dictation = root.createIndicator("Dictation")
|
var dictation = root.createIndicator("Dictation")
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||||
|
|
||||||
|
run_node_test <<'JS'
|
||||||
|
const tailscale = requireFromRoot('shell/plugins/panels/tailscale/Model.js')
|
||||||
|
|
||||||
|
assertDeepEqual(
|
||||||
|
tailscale.filterIPv4(['100.64.0.1', 'fd7a:115c:a1e0::1', '192.168.1.2']),
|
||||||
|
['100.64.0.1'],
|
||||||
|
'tailscale keeps only Tailscale IPv4 addresses'
|
||||||
|
)
|
||||||
|
assertDeepEqual(
|
||||||
|
tailscale.filterIPv6(['100.64.0.1', 'fd7a:115c:a1e0::1', 'fe80::1']),
|
||||||
|
['fd7a:115c:a1e0::1'],
|
||||||
|
'tailscale keeps only Tailscale IPv6 addresses'
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEqual(tailscale.cleanDnsName('work.tailnet.ts.net.'), 'work.tailnet.ts.net', 'tailscale strips trailing DNS dot')
|
||||||
|
assertEqual(tailscale.displayHostName('localhost', 'work.tailnet.ts.net.'), 'work', 'tailscale falls back from localhost to short DNS name')
|
||||||
|
|
||||||
|
const status = tailscale.parseStatus(JSON.stringify({
|
||||||
|
BackendState: 'Running',
|
||||||
|
AuthURL: '',
|
||||||
|
TailscaleIPs: ['100.74.97.73', 'fd7a:115c:a1e0::ff32:6149'],
|
||||||
|
Self: {
|
||||||
|
HostName: 'dhh-fd',
|
||||||
|
DNSName: 'dhh-fd.tail32f559.ts.net.',
|
||||||
|
TailscaleIPs: ['100.74.97.73']
|
||||||
|
},
|
||||||
|
Peer: {
|
||||||
|
onlineB: {
|
||||||
|
HostName: 'zed',
|
||||||
|
DNSName: 'zed.tail32f559.ts.net.',
|
||||||
|
TailscaleIPs: ['100.1.1.2'],
|
||||||
|
Online: true,
|
||||||
|
OS: 'linux',
|
||||||
|
ExitNodeOption: true,
|
||||||
|
ExitNode: true
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
HostName: 'offline',
|
||||||
|
DNSName: 'offline.tail32f559.ts.net.',
|
||||||
|
TailscaleIPs: ['100.1.1.3'],
|
||||||
|
Online: false,
|
||||||
|
OS: 'linux'
|
||||||
|
},
|
||||||
|
onlineA: {
|
||||||
|
HostName: 'alpha',
|
||||||
|
DNSName: 'alpha.tail32f559.ts.net.',
|
||||||
|
TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'],
|
||||||
|
Online: true,
|
||||||
|
OS: 'macos'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert(status.ok && status.running, 'tailscale parses running status')
|
||||||
|
assertEqual(status.selfIp, '100.74.97.73', 'tailscale parses self IP')
|
||||||
|
assertDeepEqual(status.peers.map(peer => peer.HostName), ['alpha', 'zed'], 'tailscale filters offline peers and sorts online peers')
|
||||||
|
assertDeepEqual(status.peers[0].TailscaleIPv6, ['fd7a:115c:a1e0::1901:334b'], 'tailscale preserves peer IPv6 addresses for copy menu')
|
||||||
|
assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale preserves exit node flags')
|
||||||
|
|
||||||
|
const stopped = tailscale.parseStatus(JSON.stringify({
|
||||||
|
BackendState: 'Stopped',
|
||||||
|
Peer: {
|
||||||
|
online: {
|
||||||
|
HostName: 'alpha',
|
||||||
|
DNSName: 'alpha.tail32f559.ts.net.',
|
||||||
|
TailscaleIPs: ['100.1.1.1'],
|
||||||
|
Online: true,
|
||||||
|
OS: 'macos'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert(stopped.ok && !stopped.running, 'tailscale parses stopped status')
|
||||||
|
|
||||||
|
const accounts = tailscale.parseAccounts(JSON.stringify([
|
||||||
|
{
|
||||||
|
id: 'db1b',
|
||||||
|
nickname: 'Home',
|
||||||
|
tailnet: 'dhh.github',
|
||||||
|
account: 'dhh@github',
|
||||||
|
selected: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1785',
|
||||||
|
nickname: 'Work',
|
||||||
|
tailnet: '37signals.com',
|
||||||
|
account: 'david@37signals.com',
|
||||||
|
selected: false
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
|
||||||
|
assertEqual(accounts.accounts.length, 2, 'tailscale parses multiple connections')
|
||||||
|
assertEqual(accounts.selectedAccountId, 'db1b', 'tailscale records selected connection id')
|
||||||
|
assertEqual(accounts.selectedAccountLabel, 'Home', 'tailscale labels connections by nickname')
|
||||||
|
assertDeepEqual(
|
||||||
|
accounts.accounts.map(account => account.nickname),
|
||||||
|
['Home', 'Work'],
|
||||||
|
'tailscale preserves connection nicknames'
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
tailscale.accountLabel({ nickname: '', tailnet: 'tailnet.example', account: 'user@example', id: 'abcd' }),
|
||||||
|
'tailnet.example',
|
||||||
|
'tailscale labels connections by tailnet when nickname is missing'
|
||||||
|
)
|
||||||
|
|
||||||
|
assertDeepEqual(tailscale.parseStatus('{'), { ok: false, unavailable: true, message: 'Status error', error: 'Failed to parse tailscale status' }, 'tailscale reports invalid status JSON')
|
||||||
|
assertDeepEqual(tailscale.parseAccounts('{'), { accounts: [], selectedAccountId: '', selectedAccountLabel: '' }, 'tailscale handles invalid account JSON')
|
||||||
|
JS
|
||||||
Reference in New Issue
Block a user