diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index 5dbc890d..46089a63 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -143,34 +143,44 @@ function parseExitNodeList(raw) { return result } -function mullvadCountryOptions(nodes) { - var byCountry = {} +function mullvadRegionOptions(nodes) { + var byRegion = {} var values = Array.isArray(nodes) ? nodes : [] for (var i = 0; i < values.length; i++) { var node = values[i] || {} if (node.Mullvad !== true) continue var country = String(node.Country || "").trim() + var city = String(node.City || "").trim() if (country === "") continue - var current = byCountry[country] - if (!current || node.City === "Any") { - var option = {} - for (var key in node) option[key] = node[key] - option.id = "mullvad-country:" + country - option.DisplayName = country - option.Country = country - option.MullvadCountry = true - byCountry[country] = option - } + if (city === "" || city === "Any") continue + + var key = country + "\n" + city + if (byRegion[key]) continue + + var option = {} + for (var propertyName in node) option[propertyName] = node[propertyName] + option.id = "mullvad-region:" + key + option.DisplayName = city + ", " + country + option.Country = country + option.City = city + option.MullvadRegion = true + byRegion[key] = option } var result = [] - for (var name in byCountry) result.push(byCountry[name]) + for (var name in byRegion) result.push(byRegion[name]) result.sort(function(a, b) { - return String(a.Country).localeCompare(String(b.Country)) + var countryCompare = String(a.Country).localeCompare(String(b.Country)) + if (countryCompare !== 0) return countryCompare + return String(a.City).localeCompare(String(b.City)) }) return result } +function mullvadCountryOptions(nodes) { + return mullvadRegionOptions(nodes) +} + function parseStatus(raw) { var text = String(raw || "").trim() if (text === "") return { ok: true, unavailable: true, message: "Disconnected" } @@ -263,6 +273,7 @@ if (typeof module !== "undefined") { isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, parseExitNodeList: parseExitNodeList, + mullvadRegionOptions: mullvadRegionOptions, mullvadCountryOptions: mullvadCountryOptions, parseStatus: parseStatus, parseAccounts: parseAccounts diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index e7847450..2cbdf9b4 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -17,6 +17,7 @@ Panel { property int accountIndex: 0 property int peerIndex: 0 property int exitNodeIndex: 0 + property int mullvadRegionIndex: 0 property bool cursorActive: false property bool copyMenuOpen: false property bool mullvadPickerOpen: false @@ -42,11 +43,11 @@ Panel { 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 recentMullvadCountries: Array.isArray(settings.recentMullvadCountries) ? settings.recentMullvadCountries : [] + readonly property var recentMullvadRegions: Array.isArray(settings.recentMullvadRegions) ? settings.recentMullvadRegions : (Array.isArray(settings.recentMullvadCountries) ? settings.recentMullvadCountries : []) readonly property var recentMullvadExitNodes: recentMullvadNodes() readonly property var exitNodes: displayExitNodes() - readonly property bool showExitNodes: tailscale.running && (exitNodes.length > 0 || tailscale.mullvadCountries.length > 0) - readonly property var filteredMullvadCountries: filteredMullvadCountryNodes() + readonly property bool showExitNodes: tailscale.running && (exitNodes.length > 0 || tailscale.mullvadRegions.length > 0) + readonly property var filteredMullvadRegions: filteredMullvadRegionNodes() readonly property color iconColor: tailscale.running ? foreground : dim readonly property color barIconColor: tailscale.running ? barForeground : Qt.darker(barForeground, 1.55) readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" @@ -62,68 +63,95 @@ Panel { return exitNodes[Math.max(0, Math.min(exitNodeIndex, exitNodes.length - 1))] } + function selectedMullvadRegion() { + if (filteredMullvadRegions.length === 0) return null + return filteredMullvadRegions[Math.max(0, Math.min(mullvadRegionIndex, filteredMullvadRegions.length - 1))] + } + function displayExitNodes() { var nodes = [] for (var i = 0; i < tailscale.tailnetExitNodes.length; i++) nodes.push(tailscale.tailnetExitNodes[i]) for (var j = 0; j < recentMullvadExitNodes.length; j++) nodes.push(recentMullvadExitNodes[j]) - if (tailscale.mullvadCountries.length > 0) nodes.push({ id: "mullvad:add", AddMullvad: true, DisplayName: "Choose Mullvad region" }) + if (tailscale.mullvadRegions.length > 0) nodes.push({ id: "mullvad:add", AddMullvad: true, DisplayName: "Choose Mullvad region" }) return nodes } function recentMullvadNodes() { var nodes = [] var seen = {} - for (var a = 0; a < tailscale.mullvadCountries.length && nodes.length < 5; a++) { - var active = tailscale.mullvadCountries[a] - var activeCountry = String(active.Country || "") - if (active.ExitNode === true && activeCountry !== "" && !seen[activeCountry]) { + for (var a = 0; a < tailscale.mullvadRegions.length && nodes.length < 5; a++) { + var active = tailscale.mullvadRegions[a] + var activeKey = mullvadRegionKey(active) + if (active.ExitNode === true && activeKey !== "" && !seen[activeKey]) { nodes.push(active) - seen[activeCountry] = true + seen[activeKey] = true } } - for (var i = 0; i < recentMullvadCountries.length && nodes.length < 5; i++) { - var country = String(recentMullvadCountries[i] || "") - if (country === "" || seen[country]) continue - var node = mullvadCountryNode(country) + for (var i = 0; i < recentMullvadRegions.length && nodes.length < 5; i++) { + var region = String(recentMullvadRegions[i] || "") + if (region === "" || seen[region]) continue + var node = mullvadRegionNode(region) if (node) { nodes.push(node) - seen[country] = true + seen[region] = true } } return nodes } - function mullvadCountryNode(country) { - for (var i = 0; i < tailscale.mullvadCountries.length; i++) { - var node = tailscale.mullvadCountries[i] - if (String(node.Country || "") === String(country || "")) return node + function mullvadRegionKey(node) { + if (!node) return "" + var country = String(node.Country || "") + var city = String(node.City || "") + if (country === "" || city === "") return "" + return country + "\n" + city + } + + function mullvadRegionNode(region) { + for (var i = 0; i < tailscale.mullvadRegions.length; i++) { + var node = tailscale.mullvadRegions[i] + if (mullvadRegionKey(node) === String(region || "")) return node + if (String(node.Country || "") === String(region || "")) return node } return null } - function filteredMullvadCountryNodes() { + function filteredMullvadRegionNodes() { var query = String(mullvadQuery || "").trim().toLowerCase() var result = [] - for (var i = 0; i < tailscale.mullvadCountries.length; i++) { - var node = tailscale.mullvadCountries[i] - var label = String(node.DisplayName || node.Country || "").toLowerCase() + for (var i = 0; i < tailscale.mullvadRegions.length; i++) { + var node = tailscale.mullvadRegions[i] + var label = (String(node.City || "") + " " + String(node.Country || "")).toLowerCase() if (query === "" || label.indexOf(query) !== -1) result.push(node) } return result } - function persistRecentMullvad(country) { - var name = String(country || "") + function mullvadRegionTitle(peer) { + if (!peer) return "Unknown" + var city = String(peer.City || "").trim() + var country = String(peer.Country || "").trim() + if (city === "" || city === "Any") return country || String(peer.DisplayName || "Unknown") + return city + } + + function mullvadRegionSubtitle(peer) { + if (!peer) return "" + return String(peer.Country || "").trim() + } + + function persistRecentMullvad(region) { + var name = String(region || "") if (name === "") return var next = [name] - for (var i = 0; i < recentMullvadCountries.length && next.length < 5; i++) { - var existing = String(recentMullvadCountries[i] || "") + for (var i = 0; i < recentMullvadRegions.length && next.length < 5; i++) { + var existing = String(recentMullvadRegions[i] || "") if (existing !== "" && existing !== name && next.indexOf(existing) === -1) next.push(existing) } if (!root.bar || !root.bar.shell || typeof root.bar.shell.updateEntryInline !== "function") return var entry = { id: root.moduleName } for (var key in settings) if (key !== "id") entry[key] = settings[key] - entry.recentMullvadCountries = next + entry.recentMullvadRegions = next root.bar.shell.updateEntryInline(root.moduleName, entry) } @@ -131,10 +159,11 @@ Panel { if (!peer) return if (peer.AddMullvad === true) { mullvadPickerOpen = !mullvadPickerOpen + mullvadRegionIndex = 0 if (mullvadPickerOpen) Qt.callLater(function() { if (mullvadSearch) mullvadSearch.forceActiveFocus() }) return } - if (peer.Mullvad === true) persistRecentMullvad(peer.Country) + if (peer.Mullvad === true) persistRecentMullvad(mullvadRegionKey(peer)) tailscale.setExitNode(peer) mullvadPickerOpen = false } @@ -150,6 +179,7 @@ Panel { 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 (mullvadRegionIndex >= filteredMullvadRegions.length) mullvadRegionIndex = Math.max(0, filteredMullvadRegions.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")) @@ -219,6 +249,18 @@ Panel { } } + function moveMullvadRegionCursor(delta) { + if (filteredMullvadRegions.length === 0) return + cursorActive = true + mullvadRegionIndex = Math.max(0, Math.min(filteredMullvadRegions.length - 1, mullvadRegionIndex + delta)) + scrollMullvadRegionCursorIntoView() + } + + function activateMullvadRegionCursor() { + var region = selectedMullvadRegion() + if (region) chooseExitNode(region) + } + function scrollItemIntoView(item) { if (!panelFlick || !item) return Qt.callLater(function() { @@ -240,6 +282,10 @@ Panel { else if (focusSection === "exitNodes" && exitNodeColumn && exitNodeIndex >= 0 && exitNodeIndex < exitNodeColumn.children.length) scrollItemIntoView(exitNodeColumn.children[exitNodeIndex]) } + function scrollMullvadRegionCursorIntoView() { + if (mullvadRegionColumn && mullvadRegionIndex >= 0 && mullvadRegionIndex < mullvadRegionColumn.children.length) scrollItemIntoView(mullvadRegionColumn.children[mullvadRegionIndex]) + } + function setPeerCursor(index) { cursorActive = true focusSection = "peers" @@ -282,9 +328,11 @@ Panel { } onPeerIndexChanged: scrollCursorIntoView() onExitNodeIndexChanged: scrollCursorIntoView() + onMullvadRegionIndexChanged: if (mullvadPickerOpen) scrollMullvadRegionCursorIntoView() onShowConnectionsChanged: ensureCursor() onShowPeersChanged: ensureCursor() onShowExitNodesChanged: ensureCursor() + onFilteredMullvadRegionsChanged: ensureCursor() Service { id: tailscale @@ -551,16 +599,41 @@ Panel { id: mullvadSearch width: parent.width foreground: root.foreground - placeholderText: "Search countries" + placeholderText: "Search regions" text: root.mullvadQuery - onTextChanged: root.mullvadQuery = text + onTextChanged: { + root.mullvadQuery = text + root.mullvadRegionIndex = 0 + } onAccepted: { - if (root.filteredMullvadCountries.length > 0) root.chooseExitNode(root.filteredMullvadCountries[0]) + root.activateMullvadRegionCursor() + } + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Down || event.text === "j") { + root.moveMullvadRegionCursor(1) + event.accepted = true + return + } + if (event.key === Qt.Key_Up || event.text === "k") { + root.moveMullvadRegionCursor(-1) + event.accepted = true + return + } + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.activateMullvadRegionCursor() + event.accepted = true + return + } + if (event.key === Qt.Key_Escape) { + root.mullvadPickerOpen = false + keyCatcher.forceActiveFocus() + event.accepted = true + } } } Text { - visible: root.filteredMullvadCountries.length === 0 + visible: root.filteredMullvadRegions.length === 0 width: parent.width text: "No Mullvad regions found." color: root.dim @@ -569,12 +642,20 @@ Panel { horizontalAlignment: Text.AlignHCenter } - Repeater { - model: root.filteredMullvadCountries - MullvadCountryRow { - required property var modelData - width: parent.width - peer: modelData + Column { + id: mullvadRegionColumn + width: parent.width + spacing: Style.space(6) + + Repeater { + model: root.filteredMullvadRegions + MullvadRegionRow { + required property var modelData + required property int index + width: parent.width + peer: modelData + rowIndex: index + } } } } @@ -1082,18 +1163,21 @@ Panel { } } - component MullvadCountryRow: CursorSurface { - id: countryRow + component MullvadRegionRow: CursorSurface { + id: regionRow property var peer: null - readonly property string countryName: peer ? String(peer.DisplayName || peer.Country || "Unknown") : "Unknown" + property int rowIndex: 0 + readonly property string regionName: root.mullvadRegionTitle(peer) + readonly property string regionDetail: root.mullvadRegionSubtitle(peer) readonly property bool activeExitNode: peer && peer.ExitNode === true readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "") + readonly property bool selectedRegion: root.mullvadPickerOpen && root.mullvadRegionIndex === rowIndex foreground: root.foreground fill: root.hoverFill currentFill: root.selectedFill - current: activeExitNode || settingExitNode + current: activeExitNode || settingExitNode || selectedRegion implicitHeight: row.implicitHeight + Style.spacing.lg Row { @@ -1107,7 +1191,7 @@ Panel { Text { text: "󰖂" - color: countryRow.current ? root.foreground : root.dim + color: regionRow.current ? root.foreground : root.dim font.family: root.fontFamily font.pixelSize: Style.font.body width: Style.space(22) @@ -1122,17 +1206,18 @@ Panel { Text { width: parent.width - text: countryRow.countryName + text: regionRow.regionName color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body - font.bold: countryRow.activeExitNode + font.bold: regionRow.activeExitNode elide: Text.ElideRight } Text { width: parent.width - text: peer && peer.City === "Any" ? "Best available city" : String(peer && peer.City ? peer.City : "") + text: regionRow.regionDetail + visible: text !== "" color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -1145,7 +1230,8 @@ Panel { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.chooseExitNode(countryRow.peer) + onEntered: root.mullvadRegionIndex = regionRow.rowIndex + onClicked: root.chooseExitNode(regionRow.peer) } } } diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index c5259f99..8a2a33eb 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -23,7 +23,7 @@ Item { property var exitNodes: [] property var tailnetExitNodes: [] property var mullvadExitNodes: [] - property var mullvadCountries: [] + property var mullvadRegions: [] property var accounts: [] property string selectedAccountId: "" property string selectedAccountLabel: "" @@ -173,7 +173,7 @@ Item { exitNodes = [] tailnetExitNodes = [] mullvadExitNodes = [] - mullvadCountries = [] + mullvadRegions = [] accounts = [] selectedAccountId = "" selectedAccountLabel = "" @@ -205,7 +205,7 @@ Item { selfIp = parsed.selfIp peers = parsed.running ? parsed.peers : [] tailnetExitNodes = parsed.running ? parsed.exitNodes : [] - exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadCountries) : [] + exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadRegions) : [] if (needsLogin) statusText = "Needs login" else if (running) { @@ -232,8 +232,8 @@ Item { function parseMullvadExitNodes(raw) { mullvadExitNodes = Model.parseExitNodeList(raw) - mullvadCountries = Model.mullvadCountryOptions(mullvadExitNodes) - exitNodes = running ? tailnetExitNodes.concat(mullvadCountries) : [] + mullvadRegions = Model.mullvadRegionOptions(mullvadExitNodes) + exitNodes = running ? tailnetExitNodes.concat(mullvadRegions) : [] } function toggleTailscale() { @@ -269,6 +269,10 @@ Item { function exitNodeTarget(peer) { if (!peer) return "" + if (peer.Mullvad === true) { + var mullvadIps = filterIPv4(peer.TailscaleIPs || []) + if (mullvadIps.length > 0) return mullvadIps[0] + } if (peer.DNSName) return cleanDnsName(peer.DNSName) if (peer.HostName) return String(peer.HostName) var ips = filterIPv4(peer.TailscaleIPs || []) diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index c4e38a67..13daf429 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -87,7 +87,10 @@ const mullvadNodes = tailscale.parseExitNodeList(` IP HOSTNAME COUNTRY CITY STATUS 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Any - 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Adelaide - + 100.70.240.117 au-bne-wg-301.mullvad.ts.net Australia Brisbane - 100.66.11.119 dk-cph-wg-001.mullvad.ts.net Denmark Copenhagen - + 100.101.10.10 us-chi-wg-001.mullvad.ts.net United States Chicago - + 100.102.10.10 us-nyc-wg-001.mullvad.ts.net United States New York - 100.1.2.3 office.tailnet.ts.net Denmark Office - # To use an exit node, use tailscale set --exit-node= @@ -95,21 +98,26 @@ const mullvadNodes = tailscale.parseExitNodeList(` assertDeepEqual( mullvadNodes.map(node => node.DisplayName), - ['Adelaide, Australia', 'Copenhagen, Denmark'], + ['Adelaide, Australia', 'Brisbane, Australia', 'Copenhagen, Denmark', 'Chicago, United States', 'New York, United States'], 'tailscale parses Mullvad exit nodes and skips duplicate country rows' ) -assertEqual(mullvadNodes[1].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale preserves Mullvad hostname as exit node target') -assertDeepEqual(mullvadNodes[1].TailscaleIPs, ['100.66.11.119'], 'tailscale preserves Mullvad exit node IP') +assertEqual(mullvadNodes[2].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale preserves Mullvad hostname as exit node target') +assertDeepEqual(mullvadNodes[2].TailscaleIPs, ['100.66.11.119'], 'tailscale preserves Mullvad exit node IP') assert(mullvadNodes.every(node => node.Mullvad === true && node.ExitNodeOption === true), 'tailscale marks Mullvad rows as exit nodes') -const mullvadCountries = tailscale.mullvadCountryOptions(mullvadNodes) +const mullvadRegions = tailscale.mullvadRegionOptions(mullvadNodes) assertDeepEqual( - mullvadCountries.map(node => node.DisplayName), - ['Australia', 'Denmark'], - 'tailscale groups Mullvad exit nodes by country' + mullvadRegions.map(node => node.DisplayName), + ['Adelaide, Australia', 'Brisbane, Australia', 'Copenhagen, Denmark', 'Chicago, United States', 'New York, United States'], + 'tailscale groups Mullvad exit nodes by unique city region' ) -assertEqual(mullvadCountries[0].DNSName, 'au-adl-wg-301.mullvad.ts.net', 'tailscale uses a country endpoint for grouped countries') -assertEqual(mullvadCountries[1].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale falls back to first city endpoint without Any') +assertDeepEqual( + mullvadRegions.filter(node => node.Country === 'United States').map(node => node.City), + ['Chicago', 'New York'], + 'tailscale keeps multiple Mullvad cities within a country' +) +assertEqual(mullvadRegions[0].DNSName, 'au-adl-wg-301.mullvad.ts.net', 'tailscale uses a concrete city endpoint for grouped regions') +assertEqual(mullvadRegions[2].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale preserves first available city endpoint') const stopped = tailscale.parseStatus(JSON.stringify({ BackendState: 'Stopped',