diff --git a/bin/omarchy-config-shell b/bin/omarchy-config-shell new file mode 100755 index 00000000..8de50270 --- /dev/null +++ b/bin/omarchy-config-shell @@ -0,0 +1,82 @@ +#!/bin/bash + +# omarchy:summary=Mutate the user shell.json bar layout +# omarchy:args= +# 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 " >&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 diff --git a/bin/omarchy-install-tailscale b/bin/omarchy-install-tailscale index f15a5224..0f194026 100755 --- a/bin/omarchy-install-tailscale +++ b/bin/omarchy-install-tailscale @@ -13,4 +13,7 @@ sudo tailscale up --accept-routes echo -e "\nAllowing $USER to manage Tailscale..." 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 diff --git a/shell/Ui/PanelHero.qml b/shell/Ui/PanelHero.qml new file mode 100644 index 00000000..2f44f162 --- /dev/null +++ b/shell/Ui/PanelHero.qml @@ -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 + } + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index a386a4c3..aa97bd45 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -13,6 +13,7 @@ Panel 1.0 Panel.qml PanelActionButton 1.0 PanelActionButton.qml PanelController 1.0 PanelController.qml PanelKeyCatcher 1.0 PanelKeyCatcher.qml +PanelHero 1.0 PanelHero.qml PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSeparator 1.0 PanelSeparator.qml PanelSlider 1.0 PanelSlider.qml diff --git a/shell/plugins/README.md b/shell/plugins/README.md index fc411785..38536482 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -25,7 +25,7 @@ User-installed plugins live alongside these conceptually but on disk under | Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` | | Network | `omarchy.network` | `bar-widget` | `panels/network/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` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index ab7d8d90..7e90bb52 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -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.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.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 | diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js new file mode 100644 index 00000000..c18a764f --- /dev/null +++ b/shell/plugins/panels/tailscale/Model.js @@ -0,0 +1,149 @@ +function filterIPv4(ips) { + var result = [] + if (!ips || typeof ips.length !== "number") return result + for (var i = 0; i < ips.length; i++) { + var ip = String(ips[i] || "") + if (/^100\./.test(ip)) result.push(ip) + } + return result +} + +function filterIPv6(ips) { + var result = [] + if (!ips || typeof ips.length !== "number") return result + for (var i = 0; i < ips.length; i++) { + var ip = String(ips[i] || "") + if (/^fd7a:115c:a1e0:/i.test(ip)) result.push(ip) + } + return result +} + +function cleanDnsName(name) { + var value = String(name || "") + return value.charAt(value.length - 1) === "." ? value.slice(0, -1) : value +} + +function shortDnsName(name) { + var clean = cleanDnsName(name) + if (clean === "") return "" + return clean.split(".")[0] || clean +} + +function displayHostName(hostName, dnsName) { + var host = String(hostName || "") + if (host !== "" && host.toLowerCase() !== "localhost") return host + return shortDnsName(dnsName) || host || "Unknown" +} + +function osIcon(os) { + var value = String(os || "").toLowerCase() + if (value === "linux") return "󰌽" + if (value === "macos" || value === "ios") return "󰀵" + if (value === "windows") return "󰍲" + if (value === "android") return "󰀲" + return "󰟀" +} + +function accountLabel(account) { + if (!account) return "Unknown account" + if (account.nickname) return String(account.nickname) + if (account.tailnet) return String(account.tailnet) + if (account.account) return String(account.account) + return String(account.id || "Unknown account") +} + +function parseStatus(raw) { + var text = String(raw || "").trim() + if (text === "") return { ok: true, unavailable: true, message: "Disconnected" } + + try { + var data = JSON.parse(text) + var backendState = String(data.BackendState || "Unknown") + var self = data.Self || {} + var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || []) + var peers = [] + var rawPeers = data.Peer || {} + + for (var id in rawPeers) { + var peer = rawPeers[id] || {} + if (peer.Online !== true) continue + peers.push({ + id: id, + HostName: displayHostName(peer.HostName, peer.DNSName), + DNSName: cleanDnsName(peer.DNSName), + TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), + TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []), + Online: true, + OS: String(peer.OS || ""), + Tags: peer.Tags || [], + ExitNodeOption: peer.ExitNodeOption === true, + ExitNode: peer.ExitNode === true + }) + } + + peers.sort(function(a, b) { + return String(a.HostName).localeCompare(String(b.HostName)) + }) + + return { + ok: true, + unavailable: false, + backendState: backendState, + running: backendState === "Running", + needsLogin: backendState === "NeedsLogin", + authUrl: String(data.AuthURL || ""), + selfName: displayHostName(self.HostName, self.DNSName), + selfDnsName: cleanDnsName(self.DNSName), + selfIp: selfIps.length > 0 ? selfIps[0] : "", + peers: peers + } + } catch (e) { + return { ok: false, unavailable: true, message: "Status error", error: "Failed to parse tailscale status" } + } +} + +function parseAccounts(raw) { + var text = String(raw || "").trim() + if (text === "") return { accounts: [], selectedAccountId: "", selectedAccountLabel: "" } + + try { + var parsed = JSON.parse(text) + var next = [] + var selected = null + if (parsed && typeof parsed.length === "number") { + for (var i = 0; i < parsed.length; i++) { + var rawAccount = parsed[i] || {} + var account = { + id: String(rawAccount.id || rawAccount.ID || ""), + nickname: String(rawAccount.nickname || rawAccount.Nickname || rawAccount.name || rawAccount.Name || ""), + tailnet: String(rawAccount.tailnet || rawAccount.Tailnet || ""), + account: String(rawAccount.account || rawAccount.Account || rawAccount.loginName || rawAccount.LoginName || rawAccount.user || rawAccount.User || ""), + selected: rawAccount.selected === true || rawAccount.Selected === true + } + next.push(account) + if (account.selected === true) selected = account + } + } + return { + accounts: next, + selectedAccountId: selected ? String(selected.id || "") : "", + selectedAccountLabel: selected ? accountLabel(selected) : "" + } + } catch (e) { + return { accounts: [], selectedAccountId: "", selectedAccountLabel: "" } + } +} + +if (typeof module !== "undefined") { + module.exports = { + filterIPv4: filterIPv4, + filterIPv6: filterIPv6, + cleanDnsName: cleanDnsName, + shortDnsName: shortDnsName, + displayHostName: displayHostName, + osIcon: osIcon, + accountLabel: accountLabel, + parseStatus: parseStatus, + parseAccounts: parseAccounts + } +} diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml new file mode 100644 index 00000000..5711de55 --- /dev/null +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -0,0 +1,919 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +Panel { + id: root + moduleName: "omarchy.tailscale" + ipcTarget: "omarchy.tailscale" + manageIpc: false + + property string focusSection: "header" + property int headerIndex: 0 + property int accountIndex: 0 + property int peerIndex: 0 + property int exitNodeIndex: 0 + property bool cursorActive: false + property int phraseIndex: 0 + readonly property var activePhrases: [ + "Encrypting connections", + "Sending secrets", + "Guarding wires", + "Braiding packets", + "Polishing tunnels", + "Hiding routes", + "Sealing ports", + "Sorting tailnets", + "Shuffling keys", + "Watching machines" + ] + readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length] + + readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color dim: Qt.darker(foreground, 1.55) + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied + readonly property bool showPeers: tailscale.running && tailscale.peers.length > 0 + readonly property var exitNodes: { + var nodes = [] + for (var i = 0; i < tailscale.peers.length; i++) { + var peer = tailscale.peers[i] + if (peer && peer.ExitNodeOption === true) nodes.push(peer) + } + return nodes + } + readonly property bool showExitNodes: tailscale.running && exitNodes.length > 0 + readonly property color iconColor: tailscale.running ? foreground : dim + readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" + readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" + + function selectedPeer() { + if (tailscale.peers.length === 0) return null + return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.length - 1))] + } + + function selectedExitNode() { + if (exitNodes.length === 0) return null + return exitNodes[Math.max(0, Math.min(exitNodeIndex, exitNodes.length - 1))] + } + + function selectedAccount() { + if (tailscale.accounts.length === 0) return null + return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))] + } + + function ensureCursor() { + if (headerIndex < 0) headerIndex = 0 + if (headerIndex > 0) headerIndex = 0 + if (accountIndex >= tailscale.accounts.length) accountIndex = Math.max(0, tailscale.accounts.length - 1) + if (peerIndex >= tailscale.peers.length) peerIndex = Math.max(0, tailscale.peers.length - 1) + if (exitNodeIndex >= exitNodes.length) exitNodeIndex = Math.max(0, exitNodes.length - 1) + if (focusSection === "auth" && !tailscale.accountsAccessDenied) focusSection = tailscale.accounts.length > 1 ? "accounts" : (showExitNodes ? "exitNodes" : (showPeers ? "peers" : "header")) + if (focusSection === "accounts" && tailscale.accounts.length <= 1) focusSection = tailscale.accountsAccessDenied ? "auth" : (showExitNodes ? "exitNodes" : (showPeers ? "peers" : "header")) + if (focusSection === "peers" && !showPeers) focusSection = showExitNodes ? "exitNodes" : (tailscale.accountsAccessDenied ? "auth" : (tailscale.accounts.length > 1 ? "accounts" : "header")) + if (focusSection === "exitNodes" && !showExitNodes) focusSection = showPeers ? "peers" : (tailscale.accountsAccessDenied ? "auth" : (tailscale.accounts.length > 1 ? "accounts" : "header")) + } + + function moveCursor(dx, dy) { + cursorActive = true + ensureCursor() + if (dy !== 0) { + if (focusSection === "header") { + if (dy > 0) { + if (tailscale.accountsAccessDenied) focusSection = "auth" + else if (tailscale.accounts.length > 1) focusSection = "accounts" + else if (showExitNodes) focusSection = "exitNodes" + else if (showPeers) focusSection = "peers" + } + } else if (focusSection === "auth") { + if (dy < 0) focusSection = "header" + else if (tailscale.accounts.length > 1) focusSection = "accounts" + else if (showExitNodes) focusSection = "exitNodes" + else if (showPeers) focusSection = "peers" + } else if (focusSection === "accounts") { + if (dy < 0) { + if (accountIndex <= 0) focusSection = tailscale.accountsAccessDenied ? "auth" : "header" + else accountIndex-- + } else { + if (accountIndex < tailscale.accounts.length - 1) accountIndex++ + else if (showExitNodes) focusSection = "exitNodes" + else if (showPeers) focusSection = "peers" + } + } else if (focusSection === "peers") { + if (dy < 0) { + if (peerIndex <= 0) focusSection = showExitNodes ? "exitNodes" : (tailscale.accounts.length > 1 ? "accounts" : (tailscale.accountsAccessDenied ? "auth" : "header")) + else peerIndex-- + } else if (peerIndex < tailscale.peers.length - 1) { + peerIndex++ + } + } else if (focusSection === "exitNodes") { + if (dy < 0) { + if (exitNodeIndex <= 0) focusSection = tailscale.accounts.length > 1 ? "accounts" : (tailscale.accountsAccessDenied ? "auth" : "header") + else exitNodeIndex-- + } else if (exitNodeIndex < exitNodes.length - 1) { + exitNodeIndex++ + } else if (showPeers) { + focusSection = "peers" + } + } + } + ensureCursor() + scrollCursorIntoView() + } + + function activateCursor() { + ensureCursor() + if (focusSection === "header") { + tailscale.toggleTailscale() + } else if (focusSection === "auth") { + tailscale.authorizeProfileSwitching() + } else if (focusSection === "accounts") { + var account = selectedAccount() + if (account) tailscale.switchAccount(account.id) + } else if (focusSection === "peers") { + tailscale.copyPeerIp(selectedPeer()) + } else if (focusSection === "exitNodes") { + tailscale.setExitNode(selectedExitNode()) + } + } + + function scrollItemIntoView(item) { + if (!panelFlick || !item) return + Qt.callLater(function() { + if (!item) return + var margin = Style.space(6) + var point = item.mapToItem(panelFlick.contentItem, 0, 0) + var top = point.y + var bottom = top + item.height + var viewTop = panelFlick.contentY + var viewBottom = viewTop + panelFlick.height + var maxY = Math.max(0, panelFlick.contentHeight - panelFlick.height) + if (top < viewTop + margin) panelFlick.contentY = Math.max(0, top - margin) + else if (bottom > viewBottom - margin) panelFlick.contentY = Math.min(maxY, bottom + margin - panelFlick.height) + }) + } + + function scrollCursorIntoView() { + if (focusSection === "peers" && peerColumn && peerIndex >= 0 && peerIndex < peerColumn.children.length) scrollItemIntoView(peerColumn.children[peerIndex]) + else if (focusSection === "exitNodes" && exitNodeColumn && exitNodeIndex >= 0 && exitNodeIndex < exitNodeColumn.children.length) scrollItemIntoView(exitNodeColumn.children[exitNodeIndex]) + } + + function setPeerCursor(index) { + cursorActive = true + focusSection = "peers" + peerIndex = index + scrollCursorIntoView() + } + + function setExitNodeCursor(index) { + cursorActive = true + focusSection = "exitNodes" + exitNodeIndex = index + scrollCursorIntoView() + } + + function setAccountCursor(index) { + cursorActive = true + focusSection = "accounts" + accountIndex = index + } + + function setAuthCursor() { + cursorActive = true + focusSection = "auth" + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onOpenedChanged: if (opened) { + cursorActive = false + if (panelFlick) panelFlick.contentY = 0 + tailscale.refresh() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + onPeerIndexChanged: scrollCursorIntoView() + onExitNodeIndexChanged: scrollCursorIntoView() + onShowConnectionsChanged: ensureCursor() + onShowPeersChanged: ensureCursor() + onShowExitNodesChanged: ensureCursor() + + Service { + id: tailscale + settings: root.settings + } + + Connections { + target: tailscale + function onPeersChanged() { root.ensureCursor() } + function onAccountsChanged() { root.ensureCursor() } + function onAccountsAccessDeniedChanged() { root.ensureCursor() } + } + + IpcHandler { + target: root.ipcTarget + function open(): void { root.open() } + function close(): void { root.close() } + function show(): void { root.open() } + function hide(): void { root.close() } + function toggle(): void { root.toggle() } + function refresh(): string { tailscale.refresh(); return "ok" } + function up(): string { tailscale.loginOrUp(); return "ok" } + function down(): string { tailscale.runAction(["tailscale", "down"], "Turning Tailscale off…"); return "ok" } + function status(): string { return tailscale.statusText } + } + + Item { + id: button + anchors.fill: parent + implicitWidth: root.bar && root.bar.vertical ? root.bar.barSize : Style.space(26) + implicitHeight: root.bar && root.bar.vertical ? Style.space(26) : (root.bar ? root.bar.barSize : Style.space(26)) + + property var registeredBar: null + + function triggerPress(buttonCode) { + if (buttonCode === Qt.RightButton) tailscale.toggleTailscale() + else if (buttonCode === Qt.MiddleButton) tailscale.refresh() + else root.toggle() + } + + function syncClickRegistration() { + if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button) + registeredBar = root.bar + if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(button) + } + + Component.onCompleted: syncClickRegistration() + Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button) + + Connections { + target: root + function onBarChanged() { button.syncClickRegistration() } + } + + TailscaleIcon { + anchors.centerIn: parent + iconSize: Style.space(12) + color: root.iconColor + badgeColor: root.urgent + crossed: !tailscale.running && !tailscale.needsLogin + warning: tailscale.needsLogin + } + + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: function(mouse) { button.triggerPress(mouse.button) } + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(380)) + contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(560)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } + root.moveCursor(dx, dy) + } + onActivateRequested: if (root.cursorActive) root.activateCursor() + onCloseRequested: root.close() + onTextKey: function(t) { + if (t === "t" || t === "T") tailscale.toggleTailscale() + else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer()) + else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer()) + else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer()) + } + + Flickable { + id: panelFlick + anchors.fill: parent + contentWidth: width + contentHeight: column.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: column + width: panelFlick.width + spacing: Style.space(12) + + Item { + id: header + width: parent.width + implicitHeight: hero.implicitHeight + + PanelHero { + id: hero + width: parent.width + title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" + meta: root.heroPhraseText + foreground: root.foreground + fontFamily: root.fontFamily + iconOpacity: tailscale.running ? 1.0 : 0.5 + iconComponent: Component { + Item { + implicitWidth: icon.implicitWidth + implicitHeight: icon.implicitHeight + + TailscaleIcon { + id: icon + iconSize: Style.font.display + color: root.iconColor + badgeColor: root.urgent + crossed: !tailscale.running && !tailscale.needsLogin + warning: tailscale.needsLogin + anchors.centerIn: parent + } + + MouseArea { + id: heroIconMouse + anchors.fill: parent + hoverEnabled: true + enabled: tailscale.installed && !tailscale.busy + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "header" + root.headerIndex = 0 + } + onClicked: tailscale.toggleTailscale() + } + } + } + } + } + + Text { + visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" + width: parent.width + text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError + color: tailscale.lastError !== "" && tailscale.actionStatus === "" ? root.urgent : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + } + + CursorSurface { + visible: !tailscale.installed + width: parent.width + implicitHeight: missingText.implicitHeight + Style.spacing.rowPaddingX + foreground: root.foreground + + Text { + id: missingText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Style.space(12) + text: "Tailscale CLI is not installed or not on PATH." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + wrapMode: Text.WordWrap + } + } + + PanelSeparator { + visible: root.showConnections + foreground: root.foreground + } + + Column { + visible: root.showConnections + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "CONNECTIONS" + foreground: root.foreground + fontFamily: root.fontFamily + } + + AuthRow { + visible: tailscale.accountsAccessDenied + width: parent.width + } + + Repeater { + model: tailscale.accounts + AccountRow { + required property var modelData + required property int index + width: parent.width + account: modelData + rowIndex: index + } + } + } + + PanelSeparator { + visible: root.showExitNodes + foreground: root.foreground + } + + Column { + visible: root.showExitNodes + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "EXIT NODES" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Column { + id: exitNodeColumn + width: parent.width + spacing: Style.space(6) + + Repeater { + model: root.exitNodes + ExitNodeRow { + required property var modelData + required property int index + width: exitNodeColumn.width + peer: modelData + rowIndex: index + } + } + } + } + + PanelSeparator { + visible: tailscale.installed && tailscale.running + foreground: root.foreground + } + + Column { + visible: tailscale.installed && tailscale.running + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "MACHINES" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Text { + visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0 + width: parent.width + text: "No machines found on this tailnet." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + horizontalAlignment: Text.AlignHCenter + } + + Column { + id: peerColumn + visible: root.showPeers + width: parent.width + spacing: Style.space(6) + + Repeater { + model: tailscale.peers + PeerRow { + required property var modelData + required property int index + width: peerColumn.width + peer: modelData + rowIndex: index + } + } + } + } + } + } + } + } + + Timer { + id: phraseTimer + interval: 2800 + running: root.opened + repeat: true + onTriggered: phraseSwap.restart() + } + + SequentialAnimation { + id: phraseSwap + PropertyAnimation { + target: hero; property: "metaOpacity" + to: 0.0; duration: 180; easing.type: Easing.OutQuad + } + ScriptAction { + script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length + } + PropertyAnimation { + target: hero; property: "metaOpacity" + to: 1.0; duration: 260; easing.type: Easing.InQuad + } + } + + component AuthRow: CursorSurface { + id: authRow + + hasCursor: root.cursorActive && root.focusSection === "auth" + foreground: root.foreground + + implicitHeight: row.implicitHeight + Style.spacing.rowPaddingX + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: tailscale.busy ? Qt.ArrowCursor : Qt.PointingHandCursor + enabled: !tailscale.busy + onEntered: root.setAuthCursor() + onClicked: tailscale.authorizeProfileSwitching() + } + + RowLayout { + id: row + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(10) + anchors.rightMargin: Style.space(10) + spacing: Style.space(8) + + Text { + text: "󰒃" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.heading + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.space(1) + + Text { + Layout.fillWidth: true + text: "Authorize switching" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: "Allow this user to see and switch Tailscale connections" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + + PanelActionButton { + iconText: "󰄬" + foreground: root.foreground + fontFamily: root.fontFamily + enabled: !tailscale.busy + Layout.alignment: Qt.AlignVCenter + onClicked: tailscale.authorizeProfileSwitching() + } + } + } + + component AccountRow: CursorSurface { + id: accountRow + property var account: null + property int rowIndex: 0 + readonly property bool selectedAccount: account && account.selected === true + readonly property bool switchingAccount: account && tailscale.switchingAccountId === String(account.id || "") + readonly property string accountText: account ? tailscale.accountLabel(account) : "Account" + + hasCursor: root.cursorActive && root.focusSection === "accounts" && root.accountIndex === rowIndex + current: selectedAccount + foreground: root.foreground + fill: root.hoverFill + currentFill: root.selectedFill + + implicitHeight: accountInner.implicitHeight + Style.spacing.xl + + Row { + id: accountInner + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(8) + + Text { + id: accountGlyph + text: "" + color: accountRow.selectedAccount || accountRow.switchingAccount ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + + NumberAnimation on rotation { + running: accountRow.switchingAccount + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + } + + onRotationChanged: if (!accountRow.switchingAccount && rotation !== 0) rotation = 0 + } + + Text { + text: accountRow.accountText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: accountRow.selectedAccount + elide: Text.ElideRight + width: parent.width - Style.space(22) - Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.setAccountCursor(accountRow.rowIndex) + onClicked: if (accountRow.account) tailscale.switchAccount(accountRow.account.id) + } + } + + component PeerRow: CursorSurface { + id: peerRow + property var peer: null + property int rowIndex: 0 + readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown" + readonly property string peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : "" + readonly property string peerIpv6: { + if (!peer || !peer.TailscaleIPv6 || peer.TailscaleIPv6.length === 0) return "" + return String(peer.TailscaleIPv6[0] || "") + } + readonly property string peerDns: peer ? String(peer.DNSName || "") : "" + + hasCursor: root.cursorActive && root.focusSection === "peers" && root.peerIndex === rowIndex + foreground: root.foreground + + implicitHeight: Math.max(peerContent.implicitHeight, copyButton.implicitHeight) + Style.spacing.rowPaddingX + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + hoverEnabled: true + cursorShape: Qt.ArrowCursor + onContainsMouseChanged: if (containsMouse) root.setPeerCursor(peerRow.rowIndex) + } + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(10) + anchors.rightMargin: Style.space(8) + spacing: Style.space(8) + + Text { + text: tailscale.osIcon(peer ? peer.OS : "") + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.icon + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + id: peerContent + Layout.fillWidth: true + spacing: Style.space(1) + + Text { + Layout.fillWidth: true + text: peerRow.peerName + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: { + var parts = [] + if (peerRow.peerIp !== "") parts.push(peerRow.peerIp) + if (peerRow.peerDns !== "") parts.push(peerRow.peerDns) + return parts.join(" · ") + } + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + + PanelActionButton { + id: copyButton + iconText: "󰆏" + foreground: root.foreground + fontFamily: root.fontFamily + enabled: peerRow.peerIp !== "" || peerRow.peerName !== "" || peerRow.peerDns !== "" || peerRow.peerIpv6 !== "" + Layout.alignment: Qt.AlignVCenter + onClicked: copyPopup.open() + } + + Popup { + id: copyPopup + x: copyButton.x + copyButton.width - width + y: copyButton.y + copyButton.height + Style.space(4) + width: Style.space(280) + padding: 0 + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + background: Rectangle { + color: Color.background + border.color: root.dim + border.width: 1 + radius: Style.radius.md + } + + contentItem: Column { + width: parent.width + + CopyChoice { + width: parent.width + label: peerRow.peerName + enabled: peerRow.peerName !== "" + onChosen: { + copyPopup.close() + tailscale.copyPeerName(peerRow.peer) + } + } + + CopyChoice { + width: parent.width + label: peerRow.peerDns + enabled: peerRow.peerDns !== "" + onChosen: { + copyPopup.close() + tailscale.copyPeerDnsName(peerRow.peer) + } + } + + CopyChoice { + width: parent.width + label: peerRow.peerIpv6 + enabled: peerRow.peerIpv6 !== "" + onChosen: { + copyPopup.close() + tailscale.copyToClipboard(peerRow.peerIpv6, peerRow.peerName + " IPv6") + } + } + + CopyChoice { + width: parent.width + label: peerRow.peerIp + enabled: peerRow.peerIp !== "" + onChosen: { + copyPopup.close() + tailscale.copyPeerIp(peerRow.peer) + } + } + } + } + } + } + + component CopyChoice: CursorSurface { + id: copyChoice + signal chosen() + property string label: "" + + visible: enabled + foreground: root.foreground + implicitHeight: Style.space(48) + radius: 0 + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: copyChoice.chosen() + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(10) + + Text { + Layout.fillWidth: true + text: copyChoice.label + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + Text { + text: "󰆏" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.icon + Layout.alignment: Qt.AlignVCenter + } + } + } + + component ExitNodeRow: CursorSurface { + id: exitNodeRow + property var peer: null + property int rowIndex: 0 + readonly property bool activeExitNode: peer && peer.ExitNode === true + readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "") + readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown" + + hasCursor: root.cursorActive && root.focusSection === "exitNodes" && root.exitNodeIndex === rowIndex + current: activeExitNode || settingExitNode + foreground: root.foreground + fill: root.hoverFill + currentFill: root.selectedFill + + implicitHeight: exitNodeInner.implicitHeight + Style.spacing.xl + + Row { + id: exitNodeInner + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(8) + + Text { + id: exitNodeGlyph + text: tailscale.osIcon(peer ? peer.OS : "") + color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + + NumberAnimation on rotation { + running: exitNodeRow.settingExitNode + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + } + + onRotationChanged: if (!exitNodeRow.settingExitNode && rotation !== 0) rotation = 0 + } + + Text { + text: exitNodeRow.peerName + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: exitNodeRow.activeExitNode + elide: Text.ElideRight + width: parent.width - Style.space(22) - Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.setExitNodeCursor(exitNodeRow.rowIndex) + onClicked: if (exitNodeRow.peer) tailscale.setExitNode(exitNodeRow.peer) + } + } +} diff --git a/shell/plugins/tailscale/README.md b/shell/plugins/panels/tailscale/README.md similarity index 84% rename from shell/plugins/tailscale/README.md rename to shell/plugins/panels/tailscale/README.md index 11d5cfc3..e22bd031 100644 --- a/shell/plugins/tailscale/README.md +++ b/shell/plugins/panels/tailscale/README.md @@ -7,9 +7,9 @@ Native Omarchy bar widget for Tailscale. - Shows Tailscale connection state in the bar - Left click opens a keyboard-friendly panel - Right click toggles Tailscale on/off -- Switch between logged-in Tailscale accounts when multiple are available -- Browse peers from `tailscale status --json` -- Copy a peer's Tailscale IP, host name, or DNS name +- Switch between available Tailscale connections when multiple are available +- Browse machines from `tailscale status --json` +- Copy a machine's Tailscale IP, host name, or DNS name ## Keyboard shortcuts diff --git a/shell/plugins/tailscale/Main.qml b/shell/plugins/panels/tailscale/Service.qml similarity index 65% rename from shell/plugins/tailscale/Main.qml rename to shell/plugins/panels/tailscale/Service.qml index d081afae..25bf36d2 100644 --- a/shell/plugins/tailscale/Main.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -2,10 +2,10 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import "Model.js" as Model Item { id: root - visible: false property var settings: ({}) @@ -23,15 +23,20 @@ Item { property var accounts: [] property string selectedAccountId: "" property string selectedAccountLabel: "" + property string switchingAccountId: "" + property string settingExitNodeId: "" + property bool accountsAccessDenied: false property string actionStatus: "" property string lastError: "" readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600) - readonly property bool busy: whichProcess.running || statusProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running + readonly property bool busy: whichProcess.running || statusProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running || operatorProcess.running || exitNodeProcess.running + readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME") property string _statusOutput: "" property string _statusError: "" property string _accountsOutput: "" + property string _accountsError: "" property string _actionOutput: "" property string _actionError: "" property string _loginOutput: "" @@ -42,6 +47,10 @@ Item { property double _lastAccountsRefreshMs: 0 property string _switchOutput: "" property string _switchError: "" + property string _exitNodeOutput: "" + property string _exitNodeError: "" + property string _operatorOutput: "" + property string _operatorError: "" function setting(name, fallback) { var value = settings ? settings[name] : undefined @@ -57,56 +66,33 @@ Item { } 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 + return Model.filterIPv4(ips) } function cleanDnsName(name) { - var value = String(name || "") - return value.charAt(value.length - 1) === "." ? value.slice(0, -1) : value + return Model.cleanDnsName(name) } function shortDnsName(name) { - var clean = cleanDnsName(name) - if (clean === "") return "" - return clean.split(".")[0] || clean + return Model.shortDnsName(name) } function displayHostName(hostName, dnsName) { - var host = String(hostName || "") - if (host !== "" && host.toLowerCase() !== "localhost") return host - return shortDnsName(dnsName) || host || "Unknown" + return Model.displayHostName(hostName, dnsName) } 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 "󰟀" + return Model.osIcon(os) } function accountLabel(account) { - if (!account) return "Unknown 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") + return Model.accountLabel(account) } function copyToClipboard(value, label) { var text = String(value || "") if (text === "") return Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"]) - actionStatus = elideStatus("Copied " + (label || text)) - actionStatusTimer.restart() } function copyPeerIp(peer) { @@ -150,6 +136,7 @@ Item { var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000 if (shouldRefreshAccounts && !accountsProcess.running) { _accountsOutput = "" + _accountsError = "" _lastAccountsRefreshMs = now accountsProcess.command = ["tailscale", "switch", "--list", "--json"] accountsProcess.running = true @@ -174,116 +161,61 @@ Item { accounts = [] selectedAccountId = "" selectedAccountLabel = "" + switchingAccountId = "" + settingExitNodeId = "" + accountsAccessDenied = false } function parseStatus(raw) { - var text = String(raw || "").trim() - if (text === "") { - resetUnavailable("Disconnected") + var parsed = Model.parseStatus(raw) + if (!parsed.ok) { + resetUnavailable(parsed.message || "Status error") + lastError = parsed.error || "Failed to parse tailscale status" + console.warn("tailscale", lastError) + return + } + if (parsed.unavailable) { + resetUnavailable(parsed.message || "Disconnected") return } - try { - var data = JSON.parse(text) - backendState = String(data.BackendState || "Unknown") - running = backendState === "Running" - needsLogin = backendState === "NeedsLogin" - authUrl = String(data.AuthURL || "") - if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) { - openAuthUrlFrom(authUrl, false) - } + backendState = parsed.backendState + running = parsed.running + needsLogin = parsed.needsLogin + authUrl = parsed.authUrl + if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false) + selfName = parsed.selfName + selfDnsName = parsed.selfDnsName + selfIp = parsed.selfIp + peers = parsed.running ? parsed.peers : [] - var self = data.Self || {} - selfName = displayHostName(self.HostName, self.DNSName) - selfDnsName = cleanDnsName(self.DNSName) - var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || []) - selfIp = selfIps.length > 0 ? selfIps[0] : "" - - var nextPeers = [] - var rawPeers = data.Peer || {} - for (var id in rawPeers) { - var peer = rawPeers[id] || {} - var ipv4s = filterIPv4(peer.TailscaleIPs || []) - 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) + if (needsLogin) statusText = "Needs login" + else if (running) { + statusText = "Connected" + _loginInProgress = false + _loginUrlOpened = false + _preLoginAuthUrl = "" + loginTimeoutTimer.stop() + } else if (backendState === "Stopped") { + statusText = "Disconnected" + } else { + statusText = backendState } + lastError = "" } function parseAccounts(raw) { - var text = String(raw || "").trim() - if (text === "") { - accounts = [] - selectedAccountId = "" - selectedAccountLabel = "" - 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) - } + var parsed = Model.parseAccounts(raw) + accounts = parsed.accounts + selectedAccountId = parsed.selectedAccountId + selectedAccountLabel = parsed.selectedAccountLabel + accountsAccessDenied = false } function toggleTailscale() { if (!installed) return - if (running) { - runAction(["tailscale", "down"], "Turning Tailscale off…") - } else { - loginOrUp() - } + if (running) runAction(["tailscale", "down"], "Turning Tailscale off…") + else loginOrUp() } function loginOrUp() { @@ -306,11 +238,39 @@ Item { if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return _switchOutput = "" _switchError = "" - actionStatus = "Switching Tailscale account…" + switchingAccountId = accountId switchProcess.command = ["tailscale", "switch", accountId] switchProcess.running = true } + function exitNodeTarget(peer) { + if (!peer) return "" + if (peer.DNSName) return cleanDnsName(peer.DNSName) + if (peer.HostName) return String(peer.HostName) + var ips = filterIPv4(peer.TailscaleIPs || []) + return ips.length > 0 ? ips[0] : "" + } + + function setExitNode(peer) { + if (!installed || !running || !peer || exitNodeProcess.running) return + var target = exitNodeTarget(peer) + if (target === "") return + _exitNodeOutput = "" + _exitNodeError = "" + settingExitNodeId = String(peer.id || "") + exitNodeProcess.command = ["tailscale", "set", "--exit-node=" + target] + exitNodeProcess.running = true + } + + function authorizeProfileSwitching() { + if (!installed || operatorProcess.running || userName === "") return + _operatorOutput = "" + _operatorError = "" + actionStatus = "Authorizing Tailscale profiles…" + operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName] + operatorProcess.running = true + } + function runAction(command, label) { if (actionProcess.running) return _actionOutput = "" @@ -416,10 +376,20 @@ Item { running: false command: [] stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text } + stderr: StdioCollector { id: accountsStderr; waitForEnd: true; onStreamFinished: root._accountsError = text } onExited: function(exitCode) { var stdout = String(accountsStdout.text || root._accountsOutput || "") + var stderr = String(accountsStderr.text || root._accountsError || "") if (exitCode === 0) root.parseAccounts(stdout) - else root.parseAccounts("") + 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 } else { 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() root._lastAccountsRefreshMs = 0 } 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 } - } } diff --git a/shell/plugins/tailscale/TailscaleIcon.qml b/shell/plugins/panels/tailscale/TailscaleIcon.qml similarity index 100% rename from shell/plugins/tailscale/TailscaleIcon.qml rename to shell/plugins/panels/tailscale/TailscaleIcon.qml diff --git a/shell/plugins/tailscale/manifest.json b/shell/plugins/panels/tailscale/manifest.json similarity index 69% rename from shell/plugins/tailscale/manifest.json rename to shell/plugins/panels/tailscale/manifest.json index 71c78272..c0ace7e5 100644 --- a/shell/plugins/tailscale/manifest.json +++ b/shell/plugins/panels/tailscale/manifest.json @@ -5,16 +5,16 @@ "version": "1.0.0", "author": "Omarchy", "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": [ "bar-widget" ], "entryPoints": { - "barWidget": "Widget.qml" + "barWidget": "Panel.qml" }, "barWidget": { "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", "allowMultiple": false, "defaults": { diff --git a/shell/plugins/tailscale/Widget.qml b/shell/plugins/tailscale/Widget.qml deleted file mode 100644 index e9e916c7..00000000 --- a/shell/plugins/tailscale/Widget.qml +++ /dev/null @@ -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) - } - } - } -} diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index dc2047de..7a8059fb 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -94,6 +94,56 @@ migration=$(grep -rl 'Place the system update indicator next to weather in the b TMPDIR=$(mktemp -d) 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' { "version": 1, diff --git a/test/shell.d/fixtures/indicator-contract/shell.qml b/test/shell.d/fixtures/indicator-contract/shell.qml index b381b596..4b4a32ee 100644 --- a/test/shell.d/fixtures/indicator-contract/shell.qml +++ b/test/shell.d/fixtures/indicator-contract/shell.qml @@ -130,7 +130,10 @@ ShellRoot { screenRecording.moduleName = "ScreenRecording" root.injectBar(screenRecording) 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") diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh new file mode 100644 index 00000000..076db86d --- /dev/null +++ b/test/shell.d/tailscale-test.sh @@ -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