├── .media
├── screenshot.png
└── screenshot2.png
├── Sources
├── main.swift
├── LaunchAgentManager.swift
├── SparklineRenderer.swift
├── DNSManager.swift
├── PingManager.swift
├── NetworkUtilities.swift
├── PreferencesWindowController.swift
└── PingBarApp.swift
├── Info.plist
├── bundle_direct.sh
├── Tests
└── PingBarTests
│ ├── DNSManagerTests.swift
│ ├── SparklineRendererTests.swift
│ ├── PingManagerTests.swift
│ └── NetworkUtilitiesTests.swift
├── LICENSE
├── bundle_pingbar_app.sh
├── sign_and_notarize.sh
├── Package.swift
├── Makefile
├── Casks
└── pingbar.rb
├── .gitignore
├── HOMEBREW.md
├── INSTALL.md
└── README.md
/.media/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jedisct1/pingbar/HEAD/.media/screenshot.png
--------------------------------------------------------------------------------
/.media/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jedisct1/pingbar/HEAD/.media/screenshot2.png
--------------------------------------------------------------------------------
/Sources/main.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import PingBarLib
3 |
4 | let delegate = AppDelegate()
5 | NSApplication.shared.delegate = delegate
6 | _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
--------------------------------------------------------------------------------
/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleName
6 | PingBar
7 | CFBundleIdentifier
8 | com.example.PingBar
9 | CFBundleVersion
10 | 1.0
11 | CFBundleShortVersionString
12 | 1.0
13 | CFBundleExecutable
14 | PingBar
15 | LSUIElement
16 |
17 | NSPrincipalClass
18 | NSApplication
19 |
20 |
--------------------------------------------------------------------------------
/bundle_direct.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | APP_NAME="PingBar"
5 | EXECUTABLE=".build/direct/$APP_NAME"
6 | APP_BUNDLE="$APP_NAME.app"
7 | CONTENTS_DIR="$APP_BUNDLE/Contents"
8 | MACOS_DIR="$CONTENTS_DIR/MacOS"
9 | RESOURCES_DIR="$CONTENTS_DIR/Resources"
10 |
11 | # Build if executable doesn't exist
12 | if [ ! -f "$EXECUTABLE" ]; then
13 | echo "Building with direct compilation..."
14 | ./build_direct.sh
15 | fi
16 |
17 | echo "Creating app bundle..."
18 |
19 | # Clean up any previous bundle
20 | rm -rf "$APP_BUNDLE"
21 |
22 | # Create bundle structure
23 | mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
24 |
25 | # Copy executable
26 | cp "$EXECUTABLE" "$MACOS_DIR/"
27 |
28 | # Copy Info.plist
29 | cp Info.plist "$CONTENTS_DIR/Info.plist"
30 |
31 | # Copy icon if it exists
32 | if [ -f "Resources/AppIcon.icns" ]; then
33 | cp "Resources/AppIcon.icns" "$RESOURCES_DIR/"
34 | fi
35 |
36 | echo "App bundle created: $APP_BUNDLE"
37 | echo "You can now run: open $APP_BUNDLE"
--------------------------------------------------------------------------------
/Tests/PingBarTests/DNSManagerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import PingBarLib
3 |
4 | final class DNSManagerTests: XCTestCase {
5 |
6 | func testDNSNameMapping() {
7 | XCTAssertEqual(DNSManager.dnsNameMap["1.1.1.1"], "Cloudflare")
8 | XCTAssertEqual(DNSManager.dnsNameMap["8.8.8.8"], "Google")
9 | XCTAssertEqual(DNSManager.dnsNameMap["9.9.9.9"], "Quad9")
10 | XCTAssertEqual(DNSManager.dnsNameMap["127.0.0.1"], "dnscrypt-proxy")
11 | XCTAssertEqual(DNSManager.dnsNameMap["114.114.114.114"], "114DNS")
12 | }
13 |
14 | func testDNSNameMappingUnknown() {
15 | XCTAssertNil(DNSManager.dnsNameMap["192.168.1.1"])
16 | XCTAssertNil(DNSManager.dnsNameMap["unknown.dns"])
17 | }
18 |
19 | // Note: We don't test setDNSWithOsascript here as it requires:
20 | // 1. Administrator privileges
21 | // 2. Actual system modification
22 | // 3. AppleScript execution
23 | // These would be better suited for integration tests or manual testing
24 | }
--------------------------------------------------------------------------------
/Sources/LaunchAgentManager.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct LaunchAgentManager {
4 |
5 | static func setLaunchAtLogin(enabled: Bool) {
6 | let fileManager = FileManager.default
7 | let label = "com.example.PingBar"
8 | guard let agentDir = (fileManager.homeDirectoryForCurrentUser as NSURL).appendingPathComponent("Library/LaunchAgents") else { return }
9 | let agentPlist = agentDir.appendingPathComponent("\(label).plist")
10 | let appPath = Bundle.main.bundlePath + "/Contents/MacOS/PingBar"
11 | let plist: [String: Any] = [
12 | "Label": label,
13 | "ProgramArguments": [appPath],
14 | "RunAtLoad": true
15 | ]
16 | if enabled {
17 | try? fileManager.createDirectory(at: agentDir, withIntermediateDirectories: true)
18 | let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
19 | try? data?.write(to: agentPlist)
20 | } else {
21 | try? fileManager.removeItem(at: agentPlist)
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Frank Denis
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/bundle_pingbar_app.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | APP_NAME="PingBar"
5 | BUILD_DIR=".build/release"
6 | EXECUTABLE="$BUILD_DIR/$APP_NAME"
7 | APP_BUNDLE="$APP_NAME.app"
8 | CONTENTS_DIR="$APP_BUNDLE/Contents"
9 | MACOS_DIR="$CONTENTS_DIR/MacOS"
10 | RESOURCES_DIR="$CONTENTS_DIR/Resources"
11 |
12 | # Build release version if executable doesn't exist
13 | if [ ! -f "$EXECUTABLE" ]; then
14 | echo "Building release version..."
15 | swift build -c release
16 | fi
17 |
18 | # Clean up any previous bundle
19 | rm -rf "$APP_BUNDLE"
20 |
21 | # Create bundle structure
22 | mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
23 |
24 | # Copy executable
25 | cp "$EXECUTABLE" "$MACOS_DIR/"
26 |
27 | # Copy Info.plist
28 | cp Info.plist "$CONTENTS_DIR/Info.plist"
29 |
30 | # Copy icon if it exists
31 | if [ -f "PingBar.icns" ]; then
32 | cp PingBar.icns "$RESOURCES_DIR/"
33 | fi
34 |
35 | # Make executable
36 | chmod +x "$MACOS_DIR/$APP_NAME"
37 |
38 | echo "Created $APP_BUNDLE"
39 |
40 | # Optional: Create a symlink for easy access
41 | if [ "$1" = "--link" ]; then
42 | ln -sf "$(pwd)/$APP_BUNDLE" ~/Applications/
43 | echo "Linked to ~/Applications/$APP_BUNDLE"
44 | fi
--------------------------------------------------------------------------------
/sign_and_notarize.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # === USER: FILL THESE VARIABLES ===
5 | DEV_ID="Developer ID Application: Your Name (TEAMID)" # Change this to your certificate name
6 | APPLE_ID="your@appleid.com" # Your Apple ID
7 | TEAM_ID="TEAMID" # Your Apple Developer Team ID
8 | APP_SPECIFIC_PW="app-specific-password" # App-specific password (generate at appleid.apple.com)
9 | # ===================================
10 |
11 | APP="PingBar.app"
12 | ZIP="PingBar.zip"
13 |
14 | if [ ! -d "$APP" ]; then
15 | echo "Error: $APP not found. Build and bundle the app first."
16 | exit 1
17 | fi
18 |
19 | echo "[1/4] Signing the app..."
20 | codesign --deep --force --verify --verbose --sign "$DEV_ID" "$APP"
21 |
22 |
23 | echo "[2/4] Zipping the app for notarization..."
24 | rm -f "$ZIP"
25 | ditto -c -k --keepParent "$APP" "$ZIP"
26 |
27 |
28 | echo "[3/4] Submitting for notarization..."
29 | xcrun notarytool submit "$ZIP" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APP_SPECIFIC_PW" --wait
30 |
31 |
32 | echo "[4/4] Stapling the notarization ticket..."
33 | xcrun stapler staple "$APP"
34 |
35 | echo "Done! $APP is signed and notarized."
--------------------------------------------------------------------------------
/Tests/PingBarTests/SparklineRendererTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import PingBarLib
3 |
4 | final class SparklineRendererTests: XCTestCase {
5 |
6 | func testEmptyPings() {
7 | let result = SparklineRenderer.renderSparkline(pings: [])
8 | XCTAssertEqual(result, "")
9 | }
10 |
11 | func testSinglePing() {
12 | let result = SparklineRenderer.renderSparkline(pings: [100])
13 | XCTAssertEqual(result, "▁")
14 | }
15 |
16 | func testIdenticalPings() {
17 | let result = SparklineRenderer.renderSparkline(pings: [100, 100, 100])
18 | XCTAssertEqual(result, "▁▁▁")
19 | }
20 |
21 | func testVariedPings() {
22 | let pings = [10, 50, 100, 150, 200]
23 | let result = SparklineRenderer.renderSparkline(pings: pings)
24 |
25 | // Should render as increasing sparkline
26 | XCTAssertEqual(result.count, 5)
27 | XCTAssertTrue(result.contains("▁"))
28 | XCTAssertTrue(result.contains("█"))
29 | }
30 |
31 | func testMinMaxRange() {
32 | let pings = [1, 1000] // Large range
33 | let result = SparklineRenderer.renderSparkline(pings: pings)
34 |
35 | XCTAssertEqual(result, "▁█")
36 | }
37 | }
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "PingBar",
8 | platforms: [
9 | .macOS(.v12)
10 | ],
11 | products: [
12 | .executable(name: "PingBar", targets: ["PingBar"])
13 | ],
14 | targets: [
15 | .target(
16 | name: "PingBarLib",
17 | path: "Sources",
18 | exclude: ["main.swift"]
19 | ),
20 | .executableTarget(
21 | name: "PingBar",
22 | dependencies: ["PingBarLib"],
23 | path: "Sources",
24 | exclude: [
25 | "PingBarApp.swift",
26 | "PingManager.swift",
27 | "DNSManager.swift",
28 | "NetworkUtilities.swift",
29 | "PreferencesWindowController.swift",
30 | "LaunchAgentManager.swift",
31 | "SparklineRenderer.swift"
32 | ],
33 | sources: ["main.swift"]
34 | ),
35 | .testTarget(
36 | name: "PingBarTests",
37 | dependencies: ["PingBarLib"],
38 | path: "Tests"
39 | ),
40 | ]
41 | )
42 |
--------------------------------------------------------------------------------
/Tests/PingBarTests/PingManagerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import PingBarLib
3 |
4 | @MainActor
5 | final class PingManagerTests: XCTestCase {
6 |
7 | var pingManager: PingManager!
8 |
9 | override func setUp() {
10 | super.setUp()
11 | pingManager = PingManager()
12 | }
13 |
14 | override func tearDown() {
15 | pingManager.stop()
16 | pingManager = nil
17 | super.tearDown()
18 | }
19 |
20 | func testInitialization() {
21 | XCTAssertNotNil(pingManager)
22 | XCTAssertEqual(pingManager.currentHost, "www.google.com")
23 | XCTAssertEqual(pingManager.highPingThreshold, 200)
24 | }
25 |
26 | func testUpdateSettings() {
27 | let newHost = "https://www.example.com"
28 | let newInterval = 10.0
29 |
30 | pingManager.updateSettings(host: newHost, interval: newInterval)
31 |
32 | XCTAssertEqual(pingManager.currentHost, "www.example.com")
33 | }
34 |
35 | func testHighPingThresholdUpdate() {
36 | let newThreshold = 150
37 | pingManager.highPingThreshold = newThreshold
38 | XCTAssertEqual(pingManager.highPingThreshold, newThreshold)
39 | }
40 |
41 | func testRecentPingsTracking() {
42 | // Initially empty
43 | XCTAssertTrue(pingManager.getRecentPings().isEmpty)
44 |
45 | // Simulate adding pings (would normally come from network requests)
46 | // This test would need to be expanded with proper mocking for actual network tests
47 | }
48 | }
--------------------------------------------------------------------------------
/Sources/SparklineRenderer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct SparklineRenderer {
4 |
5 | static func renderSparkline(pings: [Int]) -> String {
6 | // Enhanced block characters for better visual distinction
7 | let blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
8 |
9 | guard !pings.isEmpty else { return "" }
10 | guard let min = pings.min(), let max = pings.max(), max > min else {
11 | return String(repeating: blocks[0], count: Swift.min(pings.count, 20))
12 | }
13 |
14 | let range = max - min
15 | let limitedPings = Array(pings.suffix(20)) // Show last 20 pings for better readability
16 |
17 | return limitedPings.map { ping in
18 | let normalized = Double(ping - min) / Double(range)
19 | let idx = Int(normalized * Double(blocks.count - 1))
20 | return blocks[Swift.max(0, Swift.min(idx, blocks.count - 1))]
21 | }.joined()
22 | }
23 |
24 | // Add color-coded sparkline for different ping ranges
25 | static func renderColorCodedSparkline(pings: [Int], threshold: Int = 200) -> String {
26 | guard !pings.isEmpty else { return "" }
27 |
28 | let limitedPings = Array(pings.suffix(20))
29 |
30 | return limitedPings.map { ping in
31 | switch ping {
32 | case 0..<50:
33 | return "🟢" // Excellent
34 | case 50..<100:
35 | return "🟡" // Good
36 | case 100..= 0)
12 |
13 | for (name, ip) in interfaces {
14 | XCTAssertFalse(name.isEmpty)
15 | XCTAssertFalse(ip.isEmpty)
16 | // Basic IP format check
17 | XCTAssertTrue(ip.contains(".") || ip.contains(":"))
18 | }
19 | }
20 |
21 | func testCurrentDNSResolvers() {
22 | let resolvers = NetworkUtilities.currentDNSResolvers()
23 |
24 | // Should return some DNS resolvers on a typical system
25 | // This test is environment-dependent
26 | for resolver in resolvers {
27 | XCTAssertFalse(resolver.isEmpty)
28 | // Basic format check - should be an IP address
29 | XCTAssertTrue(resolver.contains(".") || resolver.contains(":"))
30 | }
31 | }
32 |
33 | func testDefaultInterface() {
34 | let defaultInterface = NetworkUtilities.defaultInterface
35 |
36 | // May be nil if no network interface is up
37 | if let interface = defaultInterface {
38 | XCTAssertFalse(interface.isEmpty)
39 | }
40 | }
41 |
42 | func testNetworkServiceName() {
43 | // This test requires a real network interface to be present
44 | if let defaultInterface = NetworkUtilities.defaultInterface {
45 | let serviceName = NetworkUtilities.networkServiceName(for: defaultInterface)
46 |
47 | if let name = serviceName {
48 | XCTAssertFalse(name.isEmpty)
49 | }
50 | }
51 | }
52 | }
--------------------------------------------------------------------------------
/Casks/pingbar.rb:
--------------------------------------------------------------------------------
1 | cask "pingbar" do
2 | version :latest
3 | sha256 :no_check
4 |
5 | url "file://#{__dir__}/../", using: :git, branch: "main"
6 | name "PingBar"
7 | desc "macOS menu bar application for network connectivity monitoring and DNS management"
8 | homepage "https://github.com/jedisct1/pingbar"
9 |
10 | depends_on formula: "swift"
11 | depends_on macos: ">= :monterey"
12 |
13 | # Build the app during installation
14 | preflight do
15 | system_command "swift",
16 | args: ["build", "-c", "release", "--disable-sandbox"],
17 | chdir: staged_path
18 |
19 | # Create the app bundle structure
20 | app_bundle = staged_path/"PingBar.app"
21 | contents_dir = app_bundle/"Contents"
22 | macos_dir = contents_dir/"MacOS"
23 | resources_dir = contents_dir/"Resources"
24 |
25 | # Create directories
26 | FileUtils.mkdir_p(macos_dir)
27 | FileUtils.mkdir_p(resources_dir)
28 |
29 | # Copy executable
30 | FileUtils.cp(staged_path/".build/release/PingBar", macos_dir/"PingBar")
31 |
32 | # Copy Info.plist
33 | FileUtils.cp(staged_path/"Info.plist", contents_dir/"Info.plist")
34 |
35 | # Copy icon if it exists
36 | icon_path = staged_path/"PingBar.icns"
37 | FileUtils.cp(icon_path, resources_dir/"PingBar.icns") if File.exist?(icon_path)
38 |
39 | # Make executable
40 | File.chmod(0755, macos_dir/"PingBar")
41 | end
42 |
43 | app "PingBar.app"
44 |
45 | zap trash: [
46 | "~/Library/Preferences/com.pingbar.app.plist",
47 | "~/Library/LaunchAgents/com.pingbar.app.plist",
48 | ]
49 |
50 | caveats <<~EOS
51 | PingBar has been installed to /Applications/PingBar.app
52 |
53 | To launch PingBar:
54 | - Open from Applications folder, Spotlight, or:
55 | open /Applications/PingBar.app
56 |
57 | On first launch:
58 | - Grant necessary permissions when prompted
59 | - DNS changes require admin privileges
60 |
61 | To enable launch at login:
62 | - Open PingBar and go to Preferences
63 | - Check "Launch at Login"
64 |
65 | Note: Requires macOS Monterey (12.0) or later.
66 | EOS
67 | end
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 | /Packages
51 |
52 | # CocoaPods
53 | #
54 | # We recommend against adding the Pods directory to your .gitignore. However
55 | # you should judge for yourself, the pros and cons are mentioned at:
56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
57 | #
58 | # Pods/
59 | #
60 | # Add this line if you want to avoid checking in source code from the Xcode workspace
61 | # *.xcworkspace
62 |
63 | # Carthage
64 | #
65 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
66 | # Carthage/Checkouts
67 |
68 | Carthage/Build/
69 |
70 | # Accio dependency management
71 | Dependencies/
72 | .accio/
73 |
74 | # fastlane
75 | #
76 | # It is recommended to not store the screenshots in the git repo.
77 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
78 | # For more information about the recommended setup visit:
79 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
80 |
81 | fastlane/report.xml
82 | fastlane/Preview.html
83 | fastlane/screenshots/**/*.png
84 | fastlane/test_output
85 |
86 | # Code Injection
87 | #
88 | # After new code Injection tools there's a generated folder /iOSInjectionProject
89 | # https://github.com/johnno1962/injectionforxcode
90 |
91 | iOSInjectionProject/
92 |
93 | # Swift Package Manager specific
94 | .swiftpm/configuration/registries.json
95 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
96 |
97 | # macOS
98 | .DS_Store
99 |
100 | # Security
101 | .netrc
102 |
103 | # PingBar specific
104 | PingBar.app
105 | *.zip
106 |
--------------------------------------------------------------------------------
/HOMEBREW.md:
--------------------------------------------------------------------------------
1 | # Homebrew Installation Guide
2 |
3 | ## Quick Installation
4 |
5 | ```bash
6 | git clone https://github.com/jedisct1/pingbar.git
7 | cd pingbar
8 | brew install --cask ./Casks/pingbar.rb
9 | open /Applications/PingBar.app
10 | ```
11 |
12 | ## What Happens During Installation
13 |
14 | 1. Homebrew clones the PingBar source code locally
15 | 2. Builds PingBar using Swift Package Manager during the `preflight` phase
16 | 3. Creates the proper app bundle structure
17 | 4. Installs PingBar.app directly to `/Applications/PingBar.app`
18 | 5. Sets proper permissions and makes the app executable
19 |
20 | ## After Installation
21 |
22 | - **Location**: `/Applications/PingBar.app`
23 | - **Launch**: Use Spotlight, Applications folder, or `open /Applications/PingBar.app`
24 | - **Permissions**: Grant admin privileges when prompted for DNS management features
25 |
26 | ## Uninstallation
27 |
28 | ```bash
29 | brew uninstall --cask pingbar
30 | ```
31 |
32 | The Cask installation automatically removes the app from `/Applications` and can clean up preferences.
33 |
34 | ## Troubleshooting
35 |
36 | ### Cask Not Found
37 | If you get "cask not found", ensure you're in the correct directory and using the relative path:
38 | ```bash
39 | # Make sure you're in the pingbar directory
40 | cd pingbar
41 | ls Casks/pingbar.rb # Should exist
42 |
43 | # Use relative path to cask
44 | brew install --cask ./Casks/pingbar.rb
45 | ```
46 |
47 | ### Build Failures
48 | - Ensure you have Xcode Command Line Tools: `xcode-select --install`
49 | - Check that Swift is available: `swift --version`
50 | - Verify macOS version is Monterey (12.0) or later
51 |
52 | ### Permission Issues
53 | - The formula requires admin privileges to install to `/Applications`
54 | - You may be prompted for your password during installation
55 |
56 | ## Advanced Usage
57 |
58 | ### Check Cask Before Installing
59 | ```bash
60 | brew audit --strict --cask ./Casks/pingbar.rb
61 | ```
62 |
63 | ### Force Reinstall
64 | ```bash
65 | brew uninstall --cask pingbar
66 | brew install --cask ./Casks/pingbar.rb
67 | ```
68 |
69 | ### View Installation Details
70 | ```bash
71 | brew info pingbar
72 | ```
73 |
74 | ## Why Local Cask Installation?
75 |
76 | This approach avoids the need to create and maintain a separate `homebrew-pingbar` tap repository. Users can install directly from the main PingBar repository using the included Cask file.
77 |
78 | Benefits:
79 | - ✅ Single repository to maintain
80 | - ✅ Cask stays in sync with source code
81 | - ✅ No separate tap repository needed
82 | - ✅ Direct `/Applications` installation
83 | - ✅ Proper app bundle management
84 | - ✅ Clean uninstall with preferences cleanup
85 |
86 | ## Alternative: Create a Homebrew Tap
87 |
88 | If you prefer the traditional `brew tap` approach, you would need to:
89 |
90 | 1. Create a separate `homebrew-pingbar` repository
91 | 2. Copy the Cask file to that repository
92 | 3. Users could then run: `brew tap jedisct1/pingbar && brew install --cask pingbar`
93 |
94 | However, the local Cask approach is simpler and equally effective.
--------------------------------------------------------------------------------
/INSTALL.md:
--------------------------------------------------------------------------------
1 | # Installation Guide
2 |
3 | ## Homebrew Installation (Recommended)
4 |
5 | 1. Clone this repository:
6 | ```bash
7 | git clone https://github.com/jedisct1/pingbar.git
8 | cd pingbar
9 | ```
10 |
11 | 2. Install using Homebrew Cask:
12 | ```bash
13 | brew install --cask ./Casks/pingbar.rb
14 | ```
15 |
16 | 3. Launch PingBar (automatically installed to `/Applications`):
17 | ```bash
18 | open /Applications/PingBar.app
19 | ```
20 |
21 |
22 | ## Manual Installation
23 |
24 | ### Prerequisites
25 |
26 | - macOS Monterey (12.0) or later
27 | - Xcode Command Line Tools or Xcode
28 | - Swift 5.9 or later
29 |
30 | ### Build from Source
31 |
32 | 1. Clone the repository:
33 | ```bash
34 | git clone https://github.com/jedisct1/pingbar.git
35 | cd pingbar
36 | ```
37 |
38 | 2. Build and create app bundle:
39 | ```bash
40 | make bundle
41 | ```
42 |
43 | 3. Install to Applications:
44 | ```bash
45 | make install
46 | ```
47 |
48 | Or manually copy:
49 | ```bash
50 | cp -r PingBar.app /Applications/
51 | ```
52 |
53 | 4. Launch PingBar from Applications or Spotlight
54 |
55 | ## Development Installation
56 |
57 | For developers who want to work on PingBar:
58 |
59 | 1. Clone and build:
60 | ```bash
61 | git clone https://github.com/jedisct1/pingbar.git
62 | cd pingbar
63 | make build
64 | ```
65 |
66 | 2. Create a development link:
67 | ```bash
68 | ./bundle_pingbar_app.sh --link
69 | ```
70 |
71 | 3. Run tests (requires Xcode):
72 | ```bash
73 | make test
74 | ```
75 |
76 | ## First Launch
77 |
78 | 1. **Grant Permissions**: On first launch, macOS may ask for permissions
79 | 2. **DNS Management**: For DNS switching features, you'll need to enter your admin password
80 | 3. **Launch at Login**: Enable in Preferences if desired
81 |
82 | ## Uninstallation
83 |
84 | ### Homebrew
85 | ```bash
86 | brew uninstall --cask pingbar
87 | ```
88 |
89 | ### Manual
90 | ```bash
91 | make uninstall
92 | # Or manually:
93 | rm -rf /Applications/PingBar.app
94 | ```
95 |
96 | ## Troubleshooting
97 |
98 | - **"PingBar can't be opened"**: Right-click → Open, then click "Open" again
99 | - **DNS changes not working**: Ensure you're entering the correct admin password
100 | - **App not starting**: Check Console.app for error messages
101 | - **Build issues**: Ensure you have Xcode Command Line Tools: `xcode-select --install`
102 |
103 | ## Build Options
104 |
105 | | Command | Description |
106 | | -------------------- | ------------------------- |
107 | | `make build` | Debug build |
108 | | `make build-release` | Release build |
109 | | `make bundle` | Create app bundle |
110 | | `make test` | Run tests |
111 | | `make clean` | Clean build artifacts |
112 | | `make install` | Install to /Applications |
113 | | `make uninstall` | Remove from /Applications |
114 | | `make archive` | Create release archive |
--------------------------------------------------------------------------------
/Sources/DNSManager.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct DNSManager {
4 |
5 | static let dnsNameMap: [String: String] = [
6 | "1.1.1.1": "Cloudflare",
7 | "8.8.8.8": "Google",
8 | "9.9.9.9": "Quad9",
9 | "127.0.0.1": "dnscrypt-proxy",
10 | "114.114.114.114": "114DNS"
11 | ]
12 |
13 | static func displayName(for dnsServer: String) -> String {
14 | // Check if it's a predefined DNS server
15 | if let name = dnsNameMap[dnsServer] {
16 | return name
17 | }
18 |
19 | // Check if there's a custom DNS server configured
20 | let customDNS = UserDefaults.standard.string(forKey: "CustomDNSServer") ?? ""
21 | if !customDNS.isEmpty {
22 | // If the custom DNS contains a space, treat it as "IP Name" format
23 | let components = customDNS.components(separatedBy: " ")
24 | if components.count >= 2 {
25 | let ip = components[0]
26 | let name = components.dropFirst().joined(separator: " ")
27 | if dnsServer == ip {
28 | return name
29 | }
30 | } else if dnsServer == customDNS {
31 | // If custom DNS is just an IP, return "Custom (IP)"
32 | return "Custom (\(customDNS))"
33 | }
34 | }
35 |
36 | // Default to returning the IP address itself
37 | return dnsServer
38 | }
39 |
40 | static func getCustomDNSIP() -> String? {
41 | let customDNS = UserDefaults.standard.string(forKey: "CustomDNSServer") ?? ""
42 | if customDNS.isEmpty {
43 | return nil
44 | }
45 |
46 | // If the custom DNS contains a space, extract the IP part
47 | let components = customDNS.components(separatedBy: " ")
48 | return components[0]
49 | }
50 |
51 | static func setDNSWithOsascript(service: String, dnsArg: String) -> (success: Bool, message: String) {
52 | let dnsString = dnsArg == "Empty" ? "Empty" : dnsArg
53 | let command = "/usr/sbin/networksetup -setdnsservers \"\(service)\" \(dnsString)"
54 | let escapedCommand = escapeForAppleScript(command)
55 |
56 | // Create a descriptive prompt for the authorization dialog
57 | let dnsDescription = dnsArg == "Empty" ? "System Default" : displayName(for: dnsArg)
58 | let promptMessage = "PingBar is changing DNS settings for \(service) to \(dnsDescription)"
59 |
60 | // Use the prompt parameter in the do shell script command
61 | let promptScript = """
62 | do shell script "\(escapedCommand)" with administrator privileges with prompt "\(promptMessage)"
63 | """
64 |
65 | let task = Process()
66 | task.launchPath = "/usr/bin/osascript"
67 | task.arguments = ["-e", promptScript]
68 |
69 | let pipe = Pipe()
70 | task.standardOutput = pipe
71 | task.standardError = pipe
72 |
73 | task.launch()
74 | task.waitUntilExit()
75 |
76 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
77 | let output = String(data: data, encoding: .utf8) ?? ""
78 |
79 | if task.terminationStatus == 0 {
80 | return (true, "DNS settings updated")
81 | } else {
82 | if output.contains("User cancelled") || output.contains("canceled") {
83 | return (false, "Operation cancelled by user")
84 | }
85 | return (false, output.isEmpty ? "Failed to update DNS settings" : output)
86 | }
87 | }
88 |
89 | private static func escapeForAppleScript(_ str: String) -> String {
90 | str.replacingOccurrences(of: "\\", with: "\\\\")
91 | .replacingOccurrences(of: "\"", with: "\\\"")
92 | }
93 | }
--------------------------------------------------------------------------------
/Sources/PingManager.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | @MainActor
4 | final class PingManager {
5 | private var timer: Timer?
6 | private var url: URL
7 | private var interval: TimeInterval
8 | private var lastPingFailedAt: Date?
9 | var onPingResult: ((PingStatus, String) -> Void)?
10 | var highPingThreshold: Int {
11 | get { UserDefaults.standard.integer(forKey: "HighPingThreshold").nonZeroOr(200) }
12 | set { UserDefaults.standard.set(newValue, forKey: "HighPingThreshold") }
13 | }
14 | enum PingStatus {
15 | case good, warning, bad, captivePortal
16 | }
17 | private(set) var recentPings: [Int] = []
18 | private let maxRecentPings = 30
19 |
20 | init() {
21 | let host = UserDefaults.standard.string(forKey: "PingHost") ?? "https://www.google.com"
22 | self.url = URL(string: host) ?? URL(string: "https://www.google.com")!
23 | let intervalValue = UserDefaults.standard.double(forKey: "PingInterval")
24 | self.interval = intervalValue > 0 ? intervalValue : 5.0
25 | }
26 |
27 | func updateSettings(host: String, interval: TimeInterval) {
28 | url = URL(string: host) ?? URL(string: "https://www.google.com")!
29 | self.interval = interval
30 | start()
31 | }
32 |
33 | func start() {
34 | stop()
35 | timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
36 | guard let self else { return }
37 | DispatchQueue.main.async {
38 | self.ping()
39 | }
40 | }
41 | ping()
42 | }
43 |
44 | func stop() {
45 | timer?.invalidate()
46 | timer = nil
47 | }
48 |
49 | private func ping() {
50 | let start = Date()
51 | var request = URLRequest(url: url)
52 | request.httpMethod = "HEAD"
53 | request.timeoutInterval = 3.0
54 | let task = URLSession.shared.dataTask(with: request) { [weak self] _, _, error in
55 | guard let self else { return }
56 | DispatchQueue.main.async {
57 | if error != nil {
58 | self.detectCaptivePortal { @Sendable [weak self] isCaptive in
59 | Task { @MainActor [weak self] in
60 | guard let self else { return }
61 | if isCaptive {
62 | self.onPingResult?(.captivePortal, "Captive Portal Detected")
63 | } else {
64 | if self.lastPingFailedAt == nil {
65 | self.lastPingFailedAt = Date()
66 | }
67 | let downFor = Int(Date().timeIntervalSince(self.lastPingFailedAt ?? Date()))
68 | self.onPingResult?(.bad, "No Network (\(downFor)s)")
69 | }
70 | }
71 | }
72 | } else {
73 | self.lastPingFailedAt = nil
74 | let ms = Int(Date().timeIntervalSince(start) * 1000)
75 | self.recentPings.append(ms)
76 | if self.recentPings.count > self.maxRecentPings {
77 | self.recentPings.removeFirst(self.recentPings.count - self.maxRecentPings)
78 | }
79 | let status: PingStatus = ms > self.highPingThreshold ? .warning : .good
80 | self.onPingResult?(status, "Ping: \(ms)ms")
81 | }
82 | }
83 | }
84 | task.resume()
85 | }
86 |
87 | private func detectCaptivePortal(completion: @escaping @Sendable (Bool) -> Void) {
88 | guard let url = URL(string: "http://www.gstatic.com/generate_204") else {
89 | completion(false)
90 | return
91 | }
92 | var request = URLRequest(url: url)
93 | request.httpMethod = "GET"
94 | request.timeoutInterval = 3.0
95 | let task = URLSession.shared.dataTask(with: request) { _, response, _ in
96 | Task { @MainActor in
97 | if let http = response as? HTTPURLResponse {
98 | completion(http.statusCode != 204)
99 | } else {
100 | completion(false)
101 | }
102 | }
103 | }
104 | task.resume()
105 | }
106 |
107 | var currentHost: String {
108 | url.host ?? url.absoluteString
109 | }
110 |
111 | func getRecentPings() -> [Int] {
112 | recentPings
113 | }
114 | }
115 |
116 | private extension Int {
117 | func nonZeroOr(_ fallback: Int) -> Int {
118 | self > 0 ? self : fallback
119 | }
120 | }
--------------------------------------------------------------------------------
/Sources/NetworkUtilities.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct NetworkUtilities {
4 |
5 | static func localInterfaceAddresses() -> [(String, String)] {
6 | var results: [(String, String)] = []
7 | var ifaddrPtr: UnsafeMutablePointer?
8 | guard getifaddrs(&ifaddrPtr) == 0, let firstAddr = ifaddrPtr else { return results }
9 | defer { freeifaddrs(firstAddr) }
10 | var ptr = firstAddr
11 | while true {
12 | let flags = Int32(ptr.pointee.ifa_flags)
13 | let addr = ptr.pointee.ifa_addr.pointee
14 | let name = String(cString: ptr.pointee.ifa_name)
15 | if (flags & IFF_UP) == IFF_UP && (flags & IFF_LOOPBACK) == 0 {
16 | if addr.sa_family == UInt8(AF_INET) {
17 | let sin = UnsafeRawPointer(ptr.pointee.ifa_addr).assumingMemoryBound(to: sockaddr_in.self).pointee
18 | let ip = String(cString: inet_ntoa(sin.sin_addr))
19 | if isRoutableIPv4(sin.sin_addr) && !ip.isEmpty {
20 | results.append((name, ip))
21 | }
22 | } else if addr.sa_family == UInt8(AF_INET6) {
23 | var sin6 = UnsafeRawPointer(ptr.pointee.ifa_addr).assumingMemoryBound(to: sockaddr_in6.self).pointee
24 | var ipBuffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
25 | let ipPtr = inet_ntop(AF_INET6, &sin6.sin6_addr, &ipBuffer, socklen_t(INET6_ADDRSTRLEN))
26 | if let ipPtr {
27 | let ip = String(cString: ipPtr)
28 | if isRoutableIPv6(sin6.sin6_addr) && !ip.isEmpty {
29 | results.append((name, ip))
30 | }
31 | }
32 | }
33 | }
34 | if let next = ptr.pointee.ifa_next {
35 | ptr = next
36 | } else {
37 | break
38 | }
39 | }
40 | return results
41 | }
42 |
43 | private static func isRoutableIPv4(_ addr: in_addr) -> Bool {
44 | let ip = UInt32(bigEndian: addr.s_addr)
45 | if ip == 0 { return false }
46 | return true
47 | }
48 |
49 | private static func isRoutableIPv6(_ addr: in6_addr) -> Bool {
50 | let addrBytes = Mirror(reflecting: addr.__u6_addr.__u6_addr8).children.map { $0.value as! UInt8 }
51 | return !addrBytes.starts(with: [0xfe, 0x80]) // link-local
52 | }
53 |
54 | static func currentDNSResolvers() -> [String] {
55 | var resolvers: [String] = []
56 |
57 | if let file = fopen("/etc/resolv.conf", "r") {
58 | defer { fclose(file) }
59 | var line: UnsafeMutablePointer?
60 | var linecap: Int = 0
61 | var buffer: UnsafeMutablePointer? = nil
62 | while getline(&buffer, &linecap, file) > 0 {
63 | line = buffer
64 | if let lineStr = line.flatMap({ String(cString: $0).trimmingCharacters(in: .whitespacesAndNewlines) }),
65 | lineStr.hasPrefix("nameserver") {
66 | let parts = lineStr.components(separatedBy: .whitespaces)
67 | if parts.count >= 2 {
68 | resolvers.append(parts[1])
69 | }
70 | }
71 | }
72 | free(buffer)
73 | }
74 |
75 | return resolvers
76 | }
77 |
78 | static func networkServiceName(for interface: String) -> String? {
79 | let task = Process()
80 | task.executableURL = URL(fileURLWithPath: "/usr/sbin/networksetup")
81 | task.arguments = ["-listnetworkserviceorder"]
82 |
83 | let pipe = Pipe()
84 | task.standardOutput = pipe
85 | task.standardError = pipe
86 |
87 | do {
88 | try task.run()
89 | } catch {
90 | print("Failed to run networksetup: \(error)")
91 | return nil
92 | }
93 |
94 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
95 | guard let output = String(data: data, encoding: .utf8) else { return nil }
96 |
97 | // Regex to match: (n) Service Name (Hardware Port: ..., Device: enX)
98 | let pattern = #"^\(\d+\)\s(.+?)\s+\(Hardware Port:.*?, Device: \b\#(interface)\b\)"#
99 | let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines])
100 |
101 | if let match = regex?.firstMatch(in: output, range: NSRange(location: 0, length: output.utf16.count)),
102 | let range = Range(match.range(at: 1), in: output) {
103 | return String(output[range])
104 | }
105 |
106 | return nil
107 | }
108 |
109 | static func defaultInterface() -> String? {
110 | let process = Process()
111 | process.executableURL = URL(fileURLWithPath: "/usr/sbin/netstat")
112 | process.arguments = ["-rn"]
113 |
114 | let pipe = Pipe()
115 | process.standardOutput = pipe
116 | process.standardError = pipe
117 |
118 | do {
119 | try process.run()
120 | } catch {
121 | print("Failed to run netstat: \(error)")
122 | return nil
123 | }
124 |
125 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
126 | guard let output = String(data: data, encoding: .utf8) else { return nil }
127 |
128 | for line in output.components(separatedBy: "\n") {
129 | if line.starts(with: "default") {
130 | let components = line.split(separator: " ", omittingEmptySubsequences: true)
131 | if let iface = components.last {
132 | return String(iface)
133 | }
134 | }
135 | }
136 | return nil
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PingBar
2 |
3 | [](LICENSE)
4 | [](https://www.apple.com/macos/)
5 | [](https://swift.org)
6 |
7 | PingBar is a lightweight, modern, non-intrusive, native macOS menu bar app that continuously monitors your network connectivity and DNS settings.
8 |
9 | It provides real-time ping statistics, network interface information, and **one-click DNS management including dnscrypt-proxy control**, all from your menu bar with minimal system resource usage.
10 |
11 | ## Screenshots
12 |
13 | ### Main Menu
14 |
15 | 
16 |
17 | The main menu shows real-time network status with:
18 |
19 | - Current ping time and status (🟢 for good connectivity)
20 | - Sparkline graph with ping statistics (avg/min/max)
21 | - Active network interfaces with IP addresses
22 | - Current DNS resolver information
23 | - Quick access to DNS management and preferences
24 |
25 | ### Preferences Window
26 |
27 | 
28 |
29 | The preferences window allows you to configure:
30 | - Ping interval and target host
31 | - High ping threshold for warnings
32 | - DNS auto-revert behavior for captive portals
33 | - Launch at login option
34 |
35 | ## Features
36 |
37 | - **Live Network Status**: Pings a configurable host (default: Google) and shows status with colored icons (🟢/🟡/🔴/🟠) in the menu bar.
38 | - **Historical Ping Graph**: Unicode sparkline graph and statistics (avg/min/max) for recent pings.
39 | - **Network Interface Info**: Displays active local IP addresses with interface names.
40 | - **DNS Resolver Display**: Shows current DNS resolvers for your default interface.
41 | - **DNS Management**: Change DNS for your default interface with one click (System Default, Cloudflare, Google, Quad9, 114DNS, dnscrypt-proxy, etc.). **Perfect for controlling dnscrypt-proxy usage from the menu bar.**
42 | - **Captive Portal Detection**: Detects captive portals and can auto-revert DNS to default, restoring your custom DNS after login.
43 | - **Preferences Dialog**: Configure ping interval, target host, high ping threshold, DNS auto-revert, and launch at login.
44 | - **Auto-Start**: Optionally launch PingBar at login using a LaunchAgent.
45 | - **Lightweight & Native**: Built with Swift, AppKit, and SwiftPM. No Python or Electron, no bloat. Minimal memory footprint.
46 |
47 | ## Installation
48 |
49 | ### Homebrew (Recommended)
50 |
51 | Install PingBar using Homebrew Cask:
52 |
53 | ```bash
54 | git clone https://github.com/jedisct1/pingbar.git
55 | cd pingbar
56 | brew install --cask ./Casks/pingbar.rb
57 | open /Applications/PingBar.app
58 | ```
59 |
60 | ### Download
61 |
62 | Download the latest release from the [Releases](https://github.com/jedisct1/pingbar/releases) page. Pre-built binaries are available for both Intel and Apple Silicon Macs.
63 |
64 | ### Build from Source
65 |
66 | #### Requirements
67 |
68 | - macOS 12.0+ (Monterey) recommended
69 | - Xcode 14+ or Swift 6.1+
70 | - Command line tools: `swift`, `codesign` (for signing)
71 |
72 | #### Quick Start
73 |
74 | ```sh
75 | git clone https://github.com/jedisct1/pingbar.git
76 | cd pingbar
77 | make bundle
78 | make install
79 | ```
80 |
81 | See [INSTALL.md](INSTALL.md) for detailed installation instructions.
82 |
83 | ### Uninstall
84 |
85 | #### Homebrew Installation
86 | ```bash
87 | brew uninstall --cask pingbar
88 | ```
89 |
90 | #### Manual Installation
91 | To completely remove PingBar from your system:
92 |
93 | 1. **Quit PingBar**: Click the menu bar icon → "Quit PingBar"
94 | 2. **Remove the app**: Drag `PingBar.app` to Trash (usually in `/Applications/` or wherever you placed it)
95 | 3. **Remove launch agent** (if enabled):
96 | ```sh
97 | rm ~/Library/LaunchAgents/com.pingbar.app.plist
98 | ```
99 | 4. **Remove preferences** (optional):
100 | ```sh
101 | defaults delete com.pingbar.app
102 | ```
103 |
104 | That's it! PingBar stores minimal data and leaves no background processes running.
105 |
106 | #### Development
107 |
108 | ```sh
109 | # Clone the repository
110 | git clone https://github.com/jedisct1/pingbar.git
111 | cd pingbar
112 |
113 | # Build debug version
114 | swift build
115 |
116 | # Build release version
117 | swift build -c release
118 |
119 | # Run tests
120 | swift test
121 |
122 | # Create app bundle
123 | ./bundle_pingbar_app.sh
124 |
125 | ```
126 |
127 | ## Usage
128 |
129 | ### Getting Started
130 |
131 | 1. **Launch PingBar**: Double-click `PingBar.app` or run from the command line
132 | 2. **Menu Bar Icon**: Look for the colored icon in your menu bar (🟢/🟡/🔴/🟠)
133 | 3. **Click the Icon**: View network status, ping statistics, and current settings
134 | 4. **Configure Settings**: Select "Preferences…" to customize behavior
135 |
136 | ### Menu Overview
137 |
138 | - **Network Status**: Current ping time and connection status
139 | - **Ping Graph**: Visual sparkline showing recent ping history with statistics
140 | - **Network Interfaces**: List of active network interfaces and their IP addresses
141 | - **DNS Servers**: Current DNS resolvers for your default interface
142 | - **DNS Management**: Quick access to change DNS settings
143 | - **Preferences**: Configure all app settings
144 |
145 | ### DNS Management
146 |
147 | PingBar provides one-click DNS switching for your default network interface:
148 |
149 | - **System Default**: Use your network's default DNS
150 | - **Cloudflare (1.1.1.1)**: Fast, privacy-focused DNS
151 | - **Google (8.8.8.8)**: Reliable public DNS
152 | - **Quad9 (9.9.9.9)**: Security-focused DNS with malware blocking
153 | - **114DNS (114.114.114.114)**: Popular DNS service in China
154 | - **dnscrypt-proxy (127.0.0.1)**: **Local encrypted DNS proxy - easily toggle dnscrypt-proxy on/off from the menu bar**
155 |
156 | ⚠️ **Note**: DNS changes require administrator privileges. You'll be prompted for your password.
157 |
158 | ## Configuration
159 |
160 | Access preferences via the menu bar icon → "Preferences…"
161 |
162 | ### Settings
163 |
164 | | Setting | Description | Default |
165 | | ----------------------- | -------------------------------------------------------- | ---------------------- |
166 | | **Ping Interval** | How often to ping the target (seconds) | 5.0 |
167 | | **Target Host** | URL or IP to ping | https://www.google.com |
168 | | **High Ping Threshold** | Latency threshold for warning state (ms) | 200 |
169 | | **DNS Auto-Revert** | Revert DNS to system default when network is unreachable | false |
170 | | **Restore Custom DNS** | Restore custom DNS after captive portal login | false |
171 | | **Launch at Login** | Auto-start PingBar when you log in | false |
172 |
173 | ### Status Icons
174 |
175 | | Icon | Status | Description |
176 | | ---- | -------------- | ---------------------------------------- |
177 | | 🟢 | Good | Network is responsive (ping < threshold) |
178 | | 🟡 | Warning | High latency (ping ≥ threshold) |
179 | | 🔴 | Bad | Network unreachable or failed |
180 | | 🟠 | Captive Portal | Captive portal detected |
181 |
182 | ### Captive Portal Handling
183 |
184 | When enabled, PingBar can automatically:
185 |
186 | 1. **Detect** captive portals (hotel/airport WiFi login pages)
187 | 2. **Revert** your custom DNS to system default to allow portal access
188 | 3. **Restore** your custom DNS after successful login
189 |
190 | This ensures seamless connectivity while preserving your DNS preferences.
191 |
192 | ### Testing
193 |
194 | ```sh
195 | # Run all tests
196 | swift test
197 |
198 | # Run specific test
199 | swift test --filter TestName
200 | ```
201 |
202 | ## Troubleshooting
203 |
204 | ### Common Issues
205 |
206 | **PingBar doesn't appear in menu bar**
207 | - Ensure macOS 12.0+ is installed
208 | - Check that the app has accessibility permissions if needed
209 | - Try relaunching the app
210 |
211 | **DNS changes don't work**
212 | - Verify you have administrator privileges
213 | - Check that `networksetup` command line tool is available
214 | - Ensure your network interface is active
215 |
216 | **App won't launch**
217 | - Check Console.app for error messages
218 | - Verify code signing if you built from source
219 | - Try removing and reinstalling
220 |
221 | ### Performance Tips
222 |
223 | - Use a reliable, fast target host for pinging
224 | - Set reasonable ping intervals (5-10 seconds recommended)
225 | - Enable launch at login for continuous monitoring
226 |
227 | ## Security
228 |
229 | PingBar follows security best practices:
230 |
231 | - **Minimal Permissions**: Only requests necessary system access
232 | - **No Data Collection**: All data stays local on your device
233 | - **Open Source**: Code is publicly auditable
234 | - **Code Signing**: Release builds are signed and notarized
235 |
236 | ### Privacy
237 |
238 | PingBar does not:
239 |
240 | - Collect or transmit personal data
241 | - Track your browsing habits
242 | - Store sensitive information
243 | - Connect to external services (except for ping tests)
244 |
245 | ## System Requirements
246 |
247 | - **macOS**: 12.0 (Monterey) or later
248 | - **Architecture**: Intel x64 or Apple Silicon (Universal Binary)
249 | - **Memory**: 50MB RAM typical usage
250 | - **Permissions**: Administrator access for DNS changes (optional)
251 |
252 | ## License
253 |
254 | PingBar is released under the [MIT License](LICENSE).
255 |
256 | ## Acknowledgments
257 |
258 | - Inspired by classic menu bar network tools like MenuMeters and iStat Menus
259 | - Uses [Apple's AppKit](https://developer.apple.com/documentation/appkit) for native UI
260 | - Built with [Swift Package Manager](https://swift.org/package-manager/)
261 | - DNS management via `networksetup` and AppleScript
262 | - Unicode sparkline graphs for visual ping history
263 |
264 | ---
265 |
266 | **PingBar** — Network and DNS monitoring at a glance, right from your Mac menu bar. 🌐
267 |
--------------------------------------------------------------------------------
/Sources/PreferencesWindowController.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | class PreferencesViewController: NSViewController {
4 | let intervalField = NSTextField()
5 | let hostField = NSTextField()
6 | let highPingField = NSTextField()
7 | let customDNSField = NSTextField()
8 | let revertDNSCheckbox = NSButton(checkboxWithTitle: "Revert DNS to System Default when network is unreachable", target: nil, action: nil)
9 | let restoreDNSCheckbox = NSButton(checkboxWithTitle: "Restore my custom DNS after passing captive portal", target: nil, action: nil)
10 | let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch PingBar at login", target: nil, action: nil)
11 | var onSave: ((String, Double, Int, String, Bool, Bool, Bool) -> Void)?
12 |
13 | override func loadView() {
14 | let view = NSView()
15 | self.view = view
16 | view.translatesAutoresizingMaskIntoConstraints = false
17 | view.setFrameSize(NSSize(width: 480, height: 340))
18 |
19 | // Set a nice background color
20 | view.wantsLayer = true
21 | view.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor
22 |
23 | // Header
24 | let headerLabel = NSTextField(labelWithString: "⚙️ PingBar Preferences")
25 | headerLabel.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
26 | headerLabel.alignment = .center
27 | headerLabel.textColor = NSColor.labelColor
28 |
29 | // Section headers
30 | let networkSectionLabel = NSTextField(labelWithString: "🌐 Network Settings")
31 | networkSectionLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
32 | networkSectionLabel.textColor = NSColor.secondaryLabelColor
33 |
34 | let dnsSectionLabel = NSTextField(labelWithString: "🔧 DNS Management")
35 | dnsSectionLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
36 | dnsSectionLabel.textColor = NSColor.secondaryLabelColor
37 |
38 | let systemSectionLabel = NSTextField(labelWithString: "💻 System Integration")
39 | systemSectionLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
40 | systemSectionLabel.textColor = NSColor.secondaryLabelColor
41 |
42 | // Labels
43 | let intervalLabel = NSTextField(labelWithString: "⏱ Ping interval (seconds):")
44 | let hostLabel = NSTextField(labelWithString: "🎯 Target host (URL):")
45 | let highPingLabel = NSTextField(labelWithString: "⚠️ High ping threshold (ms):")
46 | let customDNSLabel = NSTextField(labelWithString: "🔧 Custom DNS (optional):")
47 | intervalLabel.alignment = .right
48 | hostLabel.alignment = .right
49 | highPingLabel.alignment = .right
50 | customDNSLabel.alignment = .right
51 | intervalLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
52 | hostLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
53 | highPingLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
54 | customDNSLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
55 | intervalLabel.textColor = NSColor.labelColor
56 | hostLabel.textColor = NSColor.labelColor
57 | highPingLabel.textColor = NSColor.labelColor
58 | customDNSLabel.textColor = NSColor.labelColor
59 |
60 | // Fields with enhanced styling
61 | self.styleTextField(intervalField)
62 | intervalField.stringValue = String(UserDefaults.standard.double(forKey: "PingInterval") > 0 ? UserDefaults.standard.double(forKey: "PingInterval") : 5.0)
63 | intervalField.placeholderString = "e.g. 5"
64 |
65 | self.styleTextField(hostField)
66 | hostField.stringValue = UserDefaults.standard.string(forKey: "PingHost") ?? "https://www.google.com"
67 | hostField.placeholderString = "e.g. https://www.google.com"
68 |
69 | self.styleTextField(highPingField)
70 | highPingField.stringValue = String(UserDefaults.standard.integer(forKey: "HighPingThreshold") > 0 ? UserDefaults.standard.integer(forKey: "HighPingThreshold") : 200)
71 | highPingField.placeholderString = "e.g. 200"
72 |
73 | self.styleTextField(customDNSField)
74 | customDNSField.stringValue = UserDefaults.standard.string(forKey: "CustomDNSServer") ?? ""
75 | customDNSField.placeholderString = "e.g. 1.1.1.1 or My Server"
76 |
77 | // Styled checkboxes
78 | self.styleCheckbox(revertDNSCheckbox)
79 | self.styleCheckbox(restoreDNSCheckbox)
80 | self.styleCheckbox(launchAtLoginCheckbox)
81 |
82 | // Enhanced buttons
83 | let saveButton = NSButton(title: "💾 Save Settings", target: self, action: #selector(saveClicked))
84 | let cancelButton = NSButton(title: "❌ Cancel", target: self, action: #selector(cancelClicked))
85 | self.styleButton(saveButton, isPrimary: true)
86 | self.styleButton(cancelButton, isPrimary: false)
87 | saveButton.setContentHuggingPriority(.required, for: .horizontal)
88 | cancelButton.setContentHuggingPriority(.required, for: .horizontal)
89 | saveButton.translatesAutoresizingMaskIntoConstraints = false
90 | cancelButton.translatesAutoresizingMaskIntoConstraints = false
91 |
92 | // Create organized sections
93 | let headerStack = NSStackView(views: [headerLabel])
94 | headerStack.orientation = .vertical
95 | headerStack.spacing = 20
96 | headerStack.translatesAutoresizingMaskIntoConstraints = false
97 |
98 | // Network settings section
99 | let networkStack = NSStackView()
100 | networkStack.orientation = .vertical
101 | networkStack.spacing = 12
102 | networkStack.translatesAutoresizingMaskIntoConstraints = false
103 |
104 | let intervalRow = NSStackView(views: [intervalLabel, intervalField])
105 | intervalRow.orientation = .horizontal
106 | intervalRow.spacing = 12
107 | intervalRow.alignment = .centerY
108 |
109 | let hostRow = NSStackView(views: [hostLabel, hostField])
110 | hostRow.orientation = .horizontal
111 | hostRow.spacing = 12
112 | hostRow.alignment = .centerY
113 |
114 | let highPingRow = NSStackView(views: [highPingLabel, highPingField])
115 | highPingRow.orientation = .horizontal
116 | highPingRow.spacing = 12
117 | highPingRow.alignment = .centerY
118 |
119 | let customDNSRow = NSStackView(views: [customDNSLabel, customDNSField])
120 | customDNSRow.orientation = .horizontal
121 | customDNSRow.spacing = 12
122 | customDNSRow.alignment = .centerY
123 |
124 | networkStack.addArrangedSubview(networkSectionLabel)
125 | networkStack.addArrangedSubview(intervalRow)
126 | networkStack.addArrangedSubview(hostRow)
127 | networkStack.addArrangedSubview(highPingRow)
128 | networkStack.addArrangedSubview(customDNSRow)
129 |
130 | // DNS settings section
131 | let dnsStack = NSStackView()
132 | dnsStack.orientation = .vertical
133 | dnsStack.spacing = 8
134 | dnsStack.translatesAutoresizingMaskIntoConstraints = false
135 | dnsStack.addArrangedSubview(dnsSectionLabel)
136 | dnsStack.addArrangedSubview(revertDNSCheckbox)
137 | dnsStack.addArrangedSubview(restoreDNSCheckbox)
138 |
139 | // System settings section
140 | let systemStack = NSStackView()
141 | systemStack.orientation = .vertical
142 | systemStack.spacing = 8
143 | systemStack.translatesAutoresizingMaskIntoConstraints = false
144 | systemStack.addArrangedSubview(systemSectionLabel)
145 | systemStack.addArrangedSubview(launchAtLoginCheckbox)
146 |
147 | // Main form stack
148 | let formStack = NSStackView()
149 | formStack.orientation = .vertical
150 | formStack.spacing = 20
151 | formStack.translatesAutoresizingMaskIntoConstraints = false
152 | formStack.addArrangedSubview(networkStack)
153 | formStack.addArrangedSubview(self.createSeparator())
154 | formStack.addArrangedSubview(dnsStack)
155 | formStack.addArrangedSubview(self.createSeparator())
156 | formStack.addArrangedSubview(systemStack)
157 |
158 | // Button stack
159 | let buttonStack = NSStackView(views: [saveButton, cancelButton])
160 | buttonStack.orientation = .horizontal
161 | buttonStack.spacing = 12
162 | buttonStack.alignment = .centerY
163 | buttonStack.translatesAutoresizingMaskIntoConstraints = false
164 |
165 | // Main vertical stack with better spacing
166 | let mainStack = NSStackView(views: [headerStack, formStack, buttonStack])
167 | mainStack.orientation = .vertical
168 | mainStack.spacing = 24
169 | mainStack.edgeInsets = NSEdgeInsets(top: 30, left: 30, bottom: 30, right: 30)
170 | mainStack.translatesAutoresizingMaskIntoConstraints = false
171 |
172 | view.addSubview(mainStack)
173 |
174 | NSLayoutConstraint.activate([
175 | mainStack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
176 | mainStack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
177 | mainStack.topAnchor.constraint(equalTo: view.topAnchor),
178 | mainStack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
179 | intervalField.widthAnchor.constraint(equalToConstant: 220),
180 | hostField.widthAnchor.constraint(equalToConstant: 220),
181 | highPingField.widthAnchor.constraint(equalToConstant: 220),
182 | customDNSField.widthAnchor.constraint(equalToConstant: 220),
183 | intervalLabel.widthAnchor.constraint(equalToConstant: 180),
184 | hostLabel.widthAnchor.constraint(equalToConstant: 180),
185 | highPingLabel.widthAnchor.constraint(equalToConstant: 180),
186 | customDNSLabel.widthAnchor.constraint(equalToConstant: 180)
187 | ])
188 |
189 | revertDNSCheckbox.state = UserDefaults.standard.bool(forKey: "RevertDNSOnCaptivePortal") ? .on : .off
190 | restoreDNSCheckbox.state = UserDefaults.standard.bool(forKey: "RestoreCustomDNSAfterCaptive") ? .on : .off
191 | launchAtLoginCheckbox.state = UserDefaults.standard.bool(forKey: "LaunchAtLogin") ? .on : .off
192 | }
193 |
194 | @objc func saveClicked() {
195 | let interval = Double(intervalField.stringValue) ?? 5.0
196 | let host = hostField.stringValue
197 | let highPing = Int(highPingField.stringValue) ?? 200
198 | let customDNS = customDNSField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
199 |
200 | // Validate custom DNS if provided
201 | if !customDNS.isEmpty {
202 | let components = customDNS.components(separatedBy: " ")
203 | let ipAddress = components[0]
204 |
205 | // Basic IP address validation
206 | if !isValidIPAddress(ipAddress) {
207 | let alert = NSAlert()
208 | alert.messageText = "Invalid DNS Server"
209 | alert.informativeText = "Please enter a valid IP address for the custom DNS server. Examples:\n• 1.1.1.1\n• 8.8.8.8 Google\n• 192.168.1.1 Home Router"
210 | alert.alertStyle = .warning
211 | alert.runModal()
212 | return
213 | }
214 | }
215 |
216 | let revertDNS = (revertDNSCheckbox.state == .on)
217 | let restoreDNS = (restoreDNSCheckbox.state == .on)
218 | let launchAtLogin = (launchAtLoginCheckbox.state == .on)
219 | UserDefaults.standard.set(highPing, forKey: "HighPingThreshold")
220 | UserDefaults.standard.set(customDNS, forKey: "CustomDNSServer")
221 | UserDefaults.standard.set(revertDNS, forKey: "RevertDNSOnCaptivePortal")
222 | UserDefaults.standard.set(restoreDNS, forKey: "RestoreCustomDNSAfterCaptive")
223 | UserDefaults.standard.set(launchAtLogin, forKey: "LaunchAtLogin")
224 | onSave?(host, interval, highPing, customDNS, revertDNS, restoreDNS, launchAtLogin)
225 | self.view.window?.close()
226 | }
227 |
228 | private func isValidIPAddress(_ ip: String) -> Bool {
229 | let parts = ip.components(separatedBy: ".")
230 | guard parts.count == 4 else { return false }
231 |
232 | for part in parts {
233 | guard let number = Int(part), number >= 0 && number <= 255 else {
234 | return false
235 | }
236 | }
237 | return true
238 | }
239 |
240 | @objc func cancelClicked() {
241 | self.view.window?.close()
242 | }
243 |
244 | // MARK: - UI Styling Helper Methods
245 |
246 | private func styleTextField(_ textField: NSTextField) {
247 | textField.font = NSFont.systemFont(ofSize: 13)
248 | textField.isEditable = true
249 | textField.isBezeled = true
250 | textField.drawsBackground = true
251 | textField.translatesAutoresizingMaskIntoConstraints = false
252 | textField.alignment = .left
253 | textField.controlSize = .regular
254 | textField.preferredMaxLayoutWidth = 220
255 | textField.wantsLayer = true
256 | textField.layer?.cornerRadius = 4
257 | textField.layer?.borderWidth = 1
258 | textField.layer?.borderColor = NSColor.separatorColor.cgColor
259 | }
260 |
261 | private func styleCheckbox(_ checkbox: NSButton) {
262 | checkbox.font = NSFont.systemFont(ofSize: 13)
263 | checkbox.controlSize = .regular
264 | }
265 |
266 | private func styleButton(_ button: NSButton, isPrimary: Bool) {
267 | button.bezelStyle = .rounded
268 | button.font = NSFont.systemFont(ofSize: 13, weight: isPrimary ? .semibold : .regular)
269 | button.controlSize = .regular
270 |
271 | if isPrimary {
272 | button.keyEquivalent = "\\r"
273 | }
274 |
275 | button.wantsLayer = true
276 | button.layer?.cornerRadius = 6
277 |
278 | if isPrimary {
279 | button.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
280 | button.contentTintColor = NSColor.white
281 | }
282 | }
283 |
284 | private func createSeparator() -> NSView {
285 | let separator = NSView()
286 | separator.wantsLayer = true
287 | separator.layer?.backgroundColor = NSColor.separatorColor.cgColor
288 | separator.translatesAutoresizingMaskIntoConstraints = false
289 | separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
290 | return separator
291 | }
292 | }
293 |
294 | class PreferencesWindowController: NSWindowController {
295 | convenience init(onSave: @escaping (String, Double, Int, String, Bool, Bool, Bool) -> Void) {
296 | let vc = PreferencesViewController()
297 | vc.onSave = onSave
298 | let window = NSWindow(contentViewController: vc)
299 | window.title = "Preferences"
300 | window.styleMask = [.titled, .closable]
301 | window.setContentSize(NSSize(width: 480, height: 340))
302 | window.center()
303 | self.init(window: window)
304 | }
305 | }
--------------------------------------------------------------------------------
/Sources/PingBarApp.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import Foundation
3 | import Security
4 |
5 | @MainActor
6 | public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
7 | private var statusItem: NSStatusItem?
8 | private var pingManager: PingManager!
9 | private var pingMenuItem: NSMenuItem?
10 | private var preferencesWindow: PreferencesWindowController?
11 | private var ipMenuItems: [NSMenuItem] = []
12 | private var dnsMenuItem: NSMenuItem?
13 | private var dnsRevertedForOutage = false
14 | private var customDNSBeforeCaptive: String?
15 | private var graphMenuItem: NSMenuItem?
16 |
17 | nonisolated public override init() {
18 | super.init()
19 | }
20 |
21 | public func applicationDidFinishLaunching(_ notification: Notification) {
22 | pingManager = PingManager()
23 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
24 | setupStatusButton()
25 | updateStatusIndicator(.bad)
26 |
27 | let menu = NSMenu()
28 | menu.delegate = self
29 |
30 | let pingItem = NSMenuItem(title: "Checking...", action: nil, keyEquivalent: "")
31 | self.stylePingMenuItem(pingItem)
32 | menu.addItem(pingItem)
33 |
34 | let graphItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
35 | self.styleGraphMenuItem(graphItem)
36 | menu.addItem(graphItem)
37 |
38 | menu.addItem(.separator())
39 |
40 | let prefsItem = NSMenuItem(title: "⚙ Preferences…", action: #selector(showPreferences), keyEquivalent: ",")
41 | self.styleSystemMenuItem(prefsItem)
42 | menu.addItem(prefsItem)
43 |
44 | menu.addItem(.separator())
45 |
46 | let quitItem = NSMenuItem(title: "⏻ Quit PingBar", action: #selector(quit), keyEquivalent: "q")
47 | self.styleSystemMenuItem(quitItem)
48 | menu.addItem(quitItem)
49 | statusItem?.menu = menu
50 | self.pingMenuItem = pingItem
51 | self.graphMenuItem = graphItem
52 |
53 | pingManager.onPingResult = { [weak self] status, result in
54 | guard let self else { return }
55 | let revertDNS = UserDefaults.standard.bool(forKey: "RevertDNSOnCaptivePortal")
56 | let restoreDNS = UserDefaults.standard.bool(forKey: "RestoreCustomDNSAfterCaptive")
57 | let pings = self.pingManager.recentPings
58 | if !pings.isEmpty {
59 | let spark = SparklineRenderer.renderSparkline(pings: pings)
60 | let avg = pings.reduce(0, +) / pings.count
61 | let minPing = pings.min() ?? 0
62 | let maxPing = pings.max() ?? 0
63 | self.graphMenuItem?.title = "📊 \(spark) ⌀ \(avg)ms ↓ \(minPing)ms ↑ \(maxPing)ms"
64 | if let graphItem = self.graphMenuItem {
65 | self.styleGraphMenuItem(graphItem)
66 | }
67 | self.graphMenuItem?.isHidden = false
68 | } else {
69 | self.graphMenuItem?.title = ""
70 | self.graphMenuItem?.isHidden = true
71 | }
72 | switch status {
73 | case .good, .warning:
74 | self.updateStatusIndicator(status)
75 | if self.dnsRevertedForOutage, restoreDNS, let custom = self.customDNSBeforeCaptive, custom != "Empty" {
76 | if let iface = NetworkUtilities.defaultInterface(), let service = NetworkUtilities.networkServiceName(for: iface) {
77 | _ = DNSManager.setDNSWithOsascript(service: service, dnsArg: custom)
78 | self.dnsRevertedForOutage = false
79 | self.customDNSBeforeCaptive = nil
80 | }
81 | } else {
82 | self.dnsRevertedForOutage = false
83 | }
84 | case .bad:
85 | self.updateStatusIndicator(.bad)
86 | if revertDNS, !self.dnsRevertedForOutage {
87 | if let iface = NetworkUtilities.defaultInterface(), let service = NetworkUtilities.networkServiceName(for: iface) {
88 | let lastCustom = UserDefaults.standard.string(forKey: "LastCustomDNS")
89 | self.customDNSBeforeCaptive = (lastCustom != "Empty") ? lastCustom : nil
90 | _ = DNSManager.setDNSWithOsascript(service: service, dnsArg: "Empty")
91 | self.dnsRevertedForOutage = true
92 | }
93 | }
94 | case .captivePortal:
95 | self.updateStatusIndicator(.captivePortal)
96 | }
97 | self.pingMenuItem?.title = result
98 | if let pingItem = self.pingMenuItem {
99 | self.stylePingMenuItem(pingItem)
100 | }
101 | }
102 | pingManager.start()
103 |
104 | NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(systemWillSleep), name: NSWorkspace.willSleepNotification, object: nil)
105 | NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(systemDidWake), name: NSWorkspace.didWakeNotification, object: nil)
106 | }
107 |
108 | public func menuWillOpen(_ menu: NSMenu) {
109 | ipMenuItems.forEach(menu.removeItem)
110 | ipMenuItems.removeAll()
111 | if let dnsMenu = dnsMenuItem { menu.removeItem(dnsMenu) }
112 | if let graphItem = graphMenuItem, menu.items.contains(graphItem) { menu.removeItem(graphItem) }
113 | var insertIndex = 0
114 | if let pingItem = pingMenuItem, let pingIdx = menu.items.firstIndex(of: pingItem) {
115 | insertIndex = pingIdx + 1
116 | }
117 | if let graphItem = graphMenuItem {
118 | menu.insertItem(graphItem, at: insertIndex)
119 | insertIndex += 1
120 | let sep = NSMenuItem.separator()
121 | menu.insertItem(sep, at: insertIndex)
122 | insertIndex += 1
123 | }
124 | let ifaces = NetworkUtilities.localInterfaceAddresses()
125 | if !ifaces.isEmpty {
126 | for (idx, (iface, ip)) in ifaces.enumerated() {
127 | let ipItem = NSMenuItem(title: "🌐 \(iface): \(ip)", action: nil, keyEquivalent: "")
128 | self.styleInfoMenuItem(ipItem)
129 | menu.insertItem(ipItem, at: insertIndex + idx)
130 | ipMenuItems.append(ipItem)
131 | }
132 | let sep = NSMenuItem.separator()
133 | menu.insertItem(sep, at: insertIndex + ifaces.count)
134 | ipMenuItems.append(sep)
135 | insertIndex += ifaces.count + 1
136 | }
137 | let dnsResolvers = NetworkUtilities.currentDNSResolvers()
138 | if !dnsResolvers.isEmpty {
139 | let sep = NSMenuItem.separator()
140 | menu.insertItem(sep, at: insertIndex)
141 | ipMenuItems.append(sep)
142 | insertIndex += 1
143 | for (idx, dns) in dnsResolvers.enumerated() {
144 | let display = DNSManager.displayName(for: dns)
145 | let dnsItem = NSMenuItem(title: "🔍 DNS: \(display)", action: nil, keyEquivalent: "")
146 | self.styleInfoMenuItem(dnsItem)
147 | menu.insertItem(dnsItem, at: insertIndex + idx)
148 | ipMenuItems.append(dnsItem)
149 | }
150 | let sepAfter = NSMenuItem.separator()
151 | menu.insertItem(sepAfter, at: insertIndex + dnsResolvers.count)
152 | ipMenuItems.append(sepAfter)
153 | insertIndex += dnsResolvers.count + 1
154 | }
155 | let dnsMenu = NSMenu(title: "Set DNS for Default Interface")
156 | var dnsOptions: [(String, String?)] = [
157 | ("🏠 System Default", nil),
158 | ("🔒 dnscrypt-proxy (127.0.0.1)", "127.0.0.1"),
159 | ("☁️ Cloudflare (1.1.1.1)", "1.1.1.1"),
160 | ("🔍 Google (8.8.8.8)", "8.8.8.8"),
161 | ("🛡 Quad9 (9.9.9.9)", "9.9.9.9"),
162 | ("🌏 114DNS (114.114.114.114)", "114.114.114.114")
163 | ]
164 |
165 | // Add custom DNS if configured
166 | if let customDNSIP = DNSManager.getCustomDNSIP() {
167 | let displayName = DNSManager.displayName(for: customDNSIP)
168 | let menuTitle = "⚙️ \(displayName) (\(customDNSIP))"
169 | dnsOptions.append((menuTitle, customDNSIP))
170 | }
171 |
172 | let systemDefault = dnsOptions.removeFirst()
173 | let dnscryptProxy = dnsOptions.removeFirst()
174 | dnsOptions.sort { $0.0.localizedCaseInsensitiveCompare($1.0) == .orderedAscending }
175 | dnsOptions.insert(dnscryptProxy, at: 0)
176 | dnsOptions.insert(systemDefault, at: 0)
177 | for (label, ip) in dnsOptions {
178 | let item = NSMenuItem(title: label, action: #selector(setDNS(_:)), keyEquivalent: "")
179 | item.target = self
180 | item.representedObject = ip as AnyObject?
181 | self.styleDNSMenuItem(item)
182 | dnsMenu.addItem(item)
183 | }
184 | let dnsMenuItem = NSMenuItem(title: "🔧 Set DNS for Default Interface", action: nil, keyEquivalent: "")
185 | dnsMenuItem.submenu = dnsMenu
186 | self.styleSystemMenuItem(dnsMenuItem)
187 | menu.insertItem(dnsMenuItem, at: insertIndex)
188 | self.dnsMenuItem = dnsMenuItem
189 | }
190 |
191 |
192 | @MainActor
193 | @objc private func showPreferences(_ sender: Any?) {
194 | if preferencesWindow == nil {
195 | preferencesWindow = PreferencesWindowController { [weak self] host, interval, highPing, customDNS, revertDNS, restoreDNS, launchAtLogin in
196 | UserDefaults.standard.set(interval, forKey: "PingInterval")
197 | UserDefaults.standard.set(host, forKey: "PingHost")
198 | UserDefaults.standard.set(highPing, forKey: "HighPingThreshold")
199 | UserDefaults.standard.set(customDNS, forKey: "CustomDNSServer")
200 | UserDefaults.standard.set(revertDNS, forKey: "RevertDNSOnCaptivePortal")
201 | UserDefaults.standard.set(restoreDNS, forKey: "RestoreCustomDNSAfterCaptive")
202 | UserDefaults.standard.set(launchAtLogin, forKey: "LaunchAtLogin")
203 | self?.pingManager.updateSettings(host: host, interval: interval)
204 | self?.pingManager.highPingThreshold = highPing
205 | self?.preferencesWindow = nil
206 | LaunchAgentManager.setLaunchAtLogin(enabled: launchAtLogin)
207 | }
208 | }
209 | preferencesWindow?.showWindow(nil)
210 | preferencesWindow?.window?.makeKeyAndOrderFront(nil)
211 | NSApp.activate(ignoringOtherApps: true)
212 | }
213 |
214 | @objc private func systemWillSleep(_ notification: Notification) {
215 | pingManager.stop()
216 | }
217 |
218 | @objc private func systemDidWake(_ notification: Notification) {
219 | pingManager.start()
220 | }
221 |
222 | @MainActor
223 | @objc private func quit() {
224 | NSApplication.shared.terminate(self)
225 | }
226 |
227 |
228 | @MainActor
229 | @objc private func setDNS(_ sender: NSMenuItem) {
230 | guard let ip = sender.representedObject as? String? else { return }
231 | guard let iface = NetworkUtilities.defaultInterface(), let service = NetworkUtilities.networkServiceName(for: iface) else { return }
232 | let dnsArg = ip ?? "Empty"
233 | if dnsArg != "Empty" {
234 | UserDefaults.standard.set(dnsArg, forKey: "LastCustomDNS")
235 | } else {
236 | UserDefaults.standard.removeObject(forKey: "LastCustomDNS")
237 | }
238 | let status = DNSManager.setDNSWithOsascript(service: service, dnsArg: dnsArg)
239 | if !status.success {
240 | let alert = NSAlert()
241 | alert.messageText = "Failed to change DNS settings"
242 | alert.informativeText = status.message
243 | alert.runModal()
244 | }
245 | }
246 |
247 | // MARK: - UI Styling Methods
248 |
249 | private func setupStatusButton() {
250 | guard let button = statusItem?.button else { return }
251 | button.font = NSFont.systemFont(ofSize: 16, weight: .medium)
252 | button.imagePosition = .noImage
253 | }
254 |
255 | private func updateStatusIndicator(_ status: PingManager.PingStatus) {
256 | guard let button = statusItem?.button else { return }
257 |
258 | let attributes: [NSAttributedString.Key: Any]
259 |
260 | switch status {
261 | case .good:
262 | attributes = [
263 | .font: NSFont.systemFont(ofSize: 14, weight: .bold),
264 | .foregroundColor: NSColor.systemGreen
265 | ]
266 | let attributedTitle = NSAttributedString(string: "●", attributes: attributes)
267 | button.attributedTitle = attributedTitle
268 | case .warning:
269 | attributes = [
270 | .font: NSFont.systemFont(ofSize: 14, weight: .bold),
271 | .foregroundColor: NSColor.systemYellow
272 | ]
273 | let attributedTitle = NSAttributedString(string: "●", attributes: attributes)
274 | button.attributedTitle = attributedTitle
275 | case .bad:
276 | attributes = [
277 | .font: NSFont.systemFont(ofSize: 14, weight: .bold),
278 | .foregroundColor: NSColor.systemRed
279 | ]
280 | let attributedTitle = NSAttributedString(string: "●", attributes: attributes)
281 | button.attributedTitle = attributedTitle
282 | case .captivePortal:
283 | attributes = [
284 | .font: NSFont.systemFont(ofSize: 14, weight: .bold),
285 | .foregroundColor: NSColor.systemOrange
286 | ]
287 | let attributedTitle = NSAttributedString(string: "●", attributes: attributes)
288 | button.attributedTitle = attributedTitle
289 | }
290 | }
291 |
292 | private func stylePingMenuItem(_ item: NSMenuItem) {
293 | let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .medium)
294 | let attributes: [NSAttributedString.Key: Any] = [
295 | .font: font,
296 | .foregroundColor: NSColor.labelColor
297 | ]
298 | let attributedTitle = NSAttributedString(string: item.title, attributes: attributes)
299 | item.attributedTitle = attributedTitle
300 | }
301 |
302 | private func styleGraphMenuItem(_ item: NSMenuItem) {
303 | let font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
304 | let attributes: [NSAttributedString.Key: Any] = [
305 | .font: font,
306 | .foregroundColor: NSColor.secondaryLabelColor
307 | ]
308 | let attributedTitle = NSAttributedString(string: item.title, attributes: attributes)
309 | item.attributedTitle = attributedTitle
310 | }
311 |
312 | private func styleInfoMenuItem(_ item: NSMenuItem) {
313 | let font = NSFont.systemFont(ofSize: 12, weight: .regular)
314 | let attributes: [NSAttributedString.Key: Any] = [
315 | .font: font,
316 | .foregroundColor: NSColor.tertiaryLabelColor
317 | ]
318 | let attributedTitle = NSAttributedString(string: item.title, attributes: attributes)
319 | item.attributedTitle = attributedTitle
320 | }
321 |
322 | private func styleSystemMenuItem(_ item: NSMenuItem) {
323 | let font = NSFont.systemFont(ofSize: 13, weight: .medium)
324 | let attributes: [NSAttributedString.Key: Any] = [
325 | .font: font,
326 | .foregroundColor: NSColor.labelColor
327 | ]
328 | let attributedTitle = NSAttributedString(string: item.title, attributes: attributes)
329 | item.attributedTitle = attributedTitle
330 | }
331 |
332 | private func styleDNSMenuItem(_ item: NSMenuItem) {
333 | let font = NSFont.systemFont(ofSize: 12, weight: .regular)
334 | let attributes: [NSAttributedString.Key: Any] = [
335 | .font: font,
336 | .foregroundColor: NSColor.labelColor
337 | ]
338 | let attributedTitle = NSAttributedString(string: item.title, attributes: attributes)
339 | item.attributedTitle = attributedTitle
340 | }
341 |
342 | }
343 |
--------------------------------------------------------------------------------