Refine Mullvad exit node picker

This commit is contained in:
David Heinemeier Hansson
2026-05-28 21:21:33 +02:00
parent d3666c5e98
commit 7bb18c1dd3
4 changed files with 184 additions and 75 deletions
+25 -14
View File
@@ -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
+133 -47
View File
@@ -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)
}
}
}
+9 -5
View File
@@ -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 || [])