├── .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 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 4 | [![macOS](https://img.shields.io/badge/macOS-12.0+-green.svg)](https://www.apple.com/macos/) 5 | [![Swift](https://img.shields.io/badge/Swift-6.1-orange.svg)](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 | ![PingBar Main Menu](.media/screenshot.png) 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 | ![PingBar Preferences](.media/screenshot2.png) 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 | --------------------------------------------------------------------------------