Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Sources/VPNBypassCore/RoutesTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -589,13 +589,19 @@ struct RouteEditorSheet: View {
/// Live tunnels PLUS ones seen before, so a VPN that is currently disconnected can still
/// be chosen — live enumeration alone only ever reports tunnels that are UP.
@State private var selectableLinks: [(link: RouteManager.RememberedLink, isLive: Bool)] = []
/// False when the tunnels were handed in: the sheet shows them as given, so a test or a
/// render can show any tunnel state without reading the real ones.
private let readsTunnels: Bool

init(
editingRoute: Route?,
selectableLinks: [(link: RouteManager.RememberedLink, isLive: Bool)]? = nil,
onSave: @escaping (Route) -> Void,
onCancel: @escaping () -> Void
) {
self.editingRoute = editingRoute
_selectableLinks = State(initialValue: selectableLinks ?? [])
readsTunnels = selectableLinks == nil
self.onSave = onSave
self.onCancel = onCancel
_name = State(initialValue: editingRoute?.name ?? "")
Expand Down Expand Up @@ -828,6 +834,7 @@ struct RouteEditorSheet: View {
.frame(width: 400)
.background(Theme.bgSecondary)
.task {
guard readsTunnels else { return }
peers = await RouteManager.shared.listTailscalePeers()
vpnLinks = await RouteManager.shared.listVPNLinks()
selectableLinks = await RouteManager.shared.selectableVPNLinks()
Expand Down
254 changes: 254 additions & 0 deletions Tests/VPNBypassTests/UnknownVPNRenderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// UnknownVPNRenderTests.swift
// A tunnel the app could not name carries the label "Unknown VPN", an English placeholder.
// The Status page's tunnel list, its Act on picker and the route editor's VPN picker show
// the localized "VPN" in its place (#159). These draw the real views offscreen in English,
// Spanish and French, with one such tunnel in fixed fake state, and read what they show.
// Nothing is routed: the VPN check is off and the tunnels are handed in, never read.
//
// Set VPNB_RENDERS to a directory to also write each render there as a PNG.

import AppKit
import ObjectiveC
import SwiftUI
import XCTest
@testable import VPNBypassCore

@MainActor
final class UnknownVPNRenderTests: XCTestCase {

private var rm: RouteManager { RouteManager.shared }
private var window: NSWindow?
/// Whether the app was already set up as an assistive client, put back in `tearDown`.
private var wasAccessibilityClient = false
private var saved: (config: RouteManager.Config, connected: Bool, iface: String?, type: VPNType?,
gateway: String?, ssid: String?, routes: [RouteManager.ActiveRoute], update: Date?,
loading: Bool, change: RouteManager.RouteChangeOutcome?, dns: Date?,
logs: [RouteManager.LogEntry], helper: HelperState, helperVersion: String?, helperError: String?)!

private static let languages = ["en", "es", "fr"]
private static let unknown = VPNType.unknown.rawValue

override func setUp() async throws {
let helper = HelperManager.shared
saved = (rm.config, rm.isVPNConnected, rm.vpnInterface, rm.vpnType, rm.localGateway, rm.currentNetworkSSID,
rm.activeRoutes, rm.lastUpdate, rm.isLoading, rm.lastRouteChange, rm.lastDNSRefresh,
rm.recentLogs, helper.helperState, helper.helperVersion, helper.installationError)
rm.refreshStatusOverrideForTests = {}
var cfg = RouteManager.Config()
cfg.routingMode = .bypass
cfg.manageHostsFile = false
cfg.domains = []
cfg.inverseDomains = []
rm.config = cfg
rm.isVPNConnected = true
rm.vpnInterface = "utun7"
rm.vpnType = .unknown
rm.localGateway = "192.168.1.1"
rm.currentNetworkSSID = nil
rm.activeRoutes = []
rm.lastUpdate = Date().addingTimeInterval(-23)
rm.isLoading = false
rm.lastRouteChange = nil
rm.lastDNSRefresh = Date().addingTimeInterval(-12 * 60)
rm.recentLogs = []
helper.helperState = .ready
helper.helperVersion = nil
helper.installationError = nil
// SwiftUI builds its accessibility tree only for an assistive client, which this sets
// the app up as; without it the page reads as one empty group.
wasAccessibilityClient = Self.isAccessibilityClient
Self.setAccessibilityClient(true)
XCTAssertTrue(Self.isAccessibilityClient, "control: the setting reads back")
}

override func tearDown() async throws {
Self.speak(nil)
Self.setAccessibilityClient(wasAccessibilityClient)
window?.orderOut(nil)
window = nil
guard saved != nil else { return }
let helper = HelperManager.shared
rm.refreshStatusOverrideForTests = nil
rm.config = saved.config
rm.isVPNConnected = saved.connected
rm.vpnInterface = saved.iface
rm.vpnType = saved.type
rm.localGateway = saved.gateway
rm.currentNetworkSSID = saved.ssid
rm.activeRoutes = saved.routes
rm.lastUpdate = saved.update
rm.isLoading = saved.loading
rm.lastRouteChange = saved.change
rm.lastDNSRefresh = saved.dns
rm.recentLogs = saved.logs
helper.helperState = saved.helper
helper.helperVersion = saved.helperVersion
helper.installationError = saved.helperError
}

// MARK: - The three spots

func testTheTunnelListShowsVPN() throws {
for language in Self.languages {
let shown = try renderStatusPage(language)
assertNoPlaceholder(shown, "\(language) Status page")
let row = try XCTUnwrap(shown.text.firstIndex(of: "utun7"), "\(language): control, the tunnel row is on the page")
XCTAssertEqual(shown.text[row + 1], "VPN", "\(language): the tunnel row's label")
}
}

func testTheActOnPickerShowsVPN() throws {
let automatic = ["en": "Automatic (recommended)", "es": "Automático (recomendado)", "fr": "Automatique (recommandé)"]
for language in Self.languages {
let shown = try renderStatusPage(language)
assertNoPlaceholder(shown, "\(language) Status page")
XCTAssertEqual(shown.menus, [[automatic[language]!, "VPN · utun7"]], "\(language): the Act on picker")
}
}

func testTheRouteEditorPickerShowsVPN() throws {
let items = ["en": ["Primary VPN (automatic)", "VPN · utun7", "VPN · utun9 — not connected"],
"es": ["VPN principal (automática)", "VPN · utun7", "VPN · utun9 — no conectada"],
"fr": ["VPN principal (automatique)", "VPN · utun7", "VPN · utun9 — non connecté"]]
for language in Self.languages {
let shown = try renderRouteEditor(language)
assertNoPlaceholder(shown, "\(language) route editor")
XCTAssertEqual(shown.menus, [items[language]!], "\(language): the VPN picker")
}
}

private func assertNoPlaceholder(_ shown: Shown, _ what: String, file: StaticString = #filePath, line: UInt = #line) {
let all = shown.text + shown.menus.flatMap { $0 }
XCTAssertFalse(all.isEmpty, "\(what): control, the render shows text", file: file, line: line)
let english = all.filter { $0.contains(Self.unknown) }
XCTAssertEqual(english, [], "\(what) shows the English placeholder", file: file, line: line)
}

// MARK: - Rendering

/// What a render shows: every label, value and title in its accessibility tree, and the
/// items of each menu picker, which exist while the menu is closed.
private struct Shown {
var text: [String] = []
var menus: [[String]] = []
}

private func renderStatusPage(_ language: String) throws -> Shown {
let tunnel = RouteManager.VPNLink(interface: "utun7", addresses: ["10.0.0.2"], label: Self.unknown, isTailscale: false)
let snapshot = RouteManager.CoexistenceSnapshot(links: [tunnel], selectedInterface: "utun7",
defaultRouteInterface: "utun7", taggedDestinations: [])
return try render(StatusTab(snapshot: snapshot).environmentObject(rm).frame(width: 532).padding(24)
.background(Theme.bgPrimary),
language: language, name: "status-\(language).png")
}

private func renderRouteEditor(_ language: String) throws -> Shown {
let route = Route(name: "ACME VPN", egress: .vpnDefault,
vpnSelector: VPNSelector(kind: .interface, interfaceName: "utun7", productHint: Self.unknown))
let links = [(link: RouteManager.RememberedLink(interface: "utun7", label: Self.unknown), isLive: true),
(link: RouteManager.RememberedLink(interface: "utun9", label: Self.unknown), isLive: false)]
return try render(RouteEditorSheet(editingRoute: route, selectableLinks: links, onSave: { _ in }, onCancel: {}),
language: language, name: "route-editor-\(language).png")
}

private func render<V: View>(_ view: V, language: String, name: String) throws -> Shown {
Self.speak(language)
defer { Self.speak(nil) }
window?.orderOut(nil)
let hosting = NSHostingView(rootView: view.environment(\.locale, Locale(identifier: language)))
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 580, height: 400),
styleMask: [.borderless], backing: .buffered, defer: false)
window.appearance = NSAppearance(named: .darkAqua)
window.contentView = hosting
window.setFrameOrigin(NSPoint(x: -20_000, y: -20_000))
window.orderFront(nil)
self.window = window
settle(hosting)
window.setContentSize(hosting.fittingSize)
settle(hosting)
if let dir = ProcessInfo.processInfo.environment["VPNB_RENDERS"], !dir.isEmpty {
try write(hosting, to: URL(fileURLWithPath: dir, isDirectory: true).appendingPathComponent(name))
}
var shown = Shown()
collectText(window, into: &shown.text, depth: 0)
collectMenus(hosting, into: &shown.menus)
return shown
}

private func settle(_ view: NSView) {
for _ in 0..<10 {
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
view.layoutSubtreeIfNeeded()
}
}

/// SwiftUI's own nodes answer the accessibility getters without declaring the protocol
/// to Swift, so each getter is sent by name.
private func collectText(_ element: Any, into out: inout [String], depth: Int) {
guard depth < 80, let element = element as? NSObject else { return }
func get(_ name: String) -> Any? {
let selector = NSSelectorFromString(name)
return element.responds(to: selector) ? element.perform(selector)?.takeUnretainedValue() : nil
}
for name in ["accessibilityLabel", "accessibilityTitle", "accessibilityValue"] {
let text = (get(name) as? String) ?? (get(name) as? NSAttributedString)?.string
if let text, !text.isEmpty { out.append(text) }
}
for child in get("accessibilityChildren") as? [Any] ?? [] {
collectText(child, into: &out, depth: depth + 1)
}
}

private func collectMenus(_ view: NSView, into out: inout [[String]]) {
if let popUp = view as? NSPopUpButton { out.append(popUp.itemTitles) }
view.subviews.forEach { collectMenus($0, into: &out) }
}

private func write(_ view: NSView, to url: URL) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: rep)
try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: url)
}

private static let enhancedUserInterface = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface")

private static var isAccessibilityClient: Bool {
(NSApplication.shared.accessibilityAttributeValue(enhancedUserInterface) as? Bool) ?? false
}

private static func setAccessibilityClient(_ on: Bool) {
NSApplication.shared.accessibilitySetValue(on, forAttribute: enhancedUserInterface)
}

// MARK: - The language

/// The views look their words up in `Bundle.main`, which under XCTest is the test runner
/// and has no translations. While a language is set, `Bundle.main` is that language's
/// folder in the source tree; nil swaps the original back. Swapping
/// `localizedString(forKey:value:table:)` instead does not reach SwiftUI's `Text` or
/// `String(localized:)`, which look up through other entry points.
nonisolated(unsafe) fileprivate static var spoken: Bundle?
private static var swapped = false

private static func speak(_ language: String?) {
spoken = language.flatMap { lproj($0) }
guard (spoken != nil) != swapped else { return }
swapped.toggle()
let original = class_getClassMethod(Bundle.self, #selector(getter: Bundle.main))!
let replacement = class_getClassMethod(Bundle.self, #selector(getter: Bundle.unknownVPNRenderMain))!
method_exchangeImplementations(original, replacement)
}

private static func lproj(_ language: String) -> Bundle? {
let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
return Bundle(url: root.appendingPathComponent("Sources/VPNBypassCore/Resources/\(language).lproj"))
}
}

extension Bundle {
/// Swapped with `Bundle.main` while a language is set; reading itself reads the original.
@objc fileprivate class var unknownVPNRenderMain: Bundle {
UnknownVPNRenderTests.spoken ?? unknownVPNRenderMain
}
}
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **A stuck DNS-cache flush can no longer hang the privileged helper.** After writing `/etc/hosts`, the helper waited forever for `dscacheutil -flushcache` and `killall -HUP mDNSResponder`. If either child wedged, that helper thread stayed blocked for the life of the daemon even after the app gave up at 30 seconds. Those two processes now have a 3-second deadline, then SIGTERM and SIGKILL if they do not exit.
- **With two VPNs running, the app could pick the wrong one.** Several tunnels being up at once is ordinary — a corporate VPN alongside Tailscale — but the app took the first one it happened to see, which had nothing to do with which tunnel was carrying traffic. Because Tailscale usually appears earlier in that list, it could win, and in VPN Only mode the app would then install its rules to escape the wrong tunnel and send the other one's traffic straight out. It now prefers the tunnel actually carrying the default route, never picks Tailscale, keeps its choice stable while several remain valid, and breaks any remaining tie the same way every time.
- **No test that dials a listener sits on a port that can collide or be taken.** Two tests were still exposed after the proxy test fix. The live egress test started its forwarder on port 0, which puts the listener in the range client sockets draw their source ports from, and a client that later lands on an old pair fails to connect with EADDRINUSE. The control socket test that re-points a route expected the fixed port 18099, and three others expected 18077, 18443 and 18944. Each failed whenever something on the Mac held its port, because the listener then fell back to a random one. Every test that opens a listener a client dials now takes its port from one shared helper, `TestPorts.nextListenPort()`, which hands out each port in 20000-48999 once per run and only after binding it first. New tests check that it refuses a port a listener holds and a port a closed connection left in TIME_WAIT.
- **Tests now draw the three places that name a VPN the app does not recognise.** Settings > Status's tunnel list and its Act on menu, and the route editor's VPN menu, are drawn offscreen in English, Spanish and French with one such tunnel, and the tests read what each shows: "VPN", never "Unknown VPN". Before, only the function that picks the label had a test, so a view that stopped calling it would still have passed.

### Changed
- **"Route" means one thing on screen, and the Bypass list is no longer called Custom Domains.** "Route" named three things: the kernel entries the app installs ("62 routes" in the dropdown and on the Status page), the ways out in Custom mode (the Routes page, "1/1 active"), and the ways out that have rules (Routes In Use). A user reading "62 routes" next to "1/1 active" was counting two different things under one word. "Route" now means only a way out in Custom mode: Direct, a VPN, a proxy or a Tailscale peer. Every count of kernel entries reads as addresses: "Telegram, 12 addresses", "62 addresses routed 23 s ago, none failed", "Checked 10 of 62 addresses", the Addresses fact and the Addresses section on the Status page, the NOTHING ROUTED pill, the notifications and the "Last change" line after a `routes.clear`. One address now reads in the singular ("1 address stays routed for when it reconnects."), where "1 route" used to land in a plural sentence. The Domains page is titled Bypass list or VPN Only list, after its mode, instead of Custom Domains or VPN Only Domains. The command names Refresh Routes, Verify Routes and Remove All Routes… keep their names, and so do the socket verbs (`routes.active`, `routes.clear`) and the log lines. The new text is in English, Spanish and French.
Expand Down
Loading