diff --git a/CLAUDE.md b/CLAUDE.md index 53e55c3..0f23347 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,12 @@ Each `PortPopoverRow` shows: - TYPE label: `entry.framework.rawValue` if known, otherwise `entry.projectName ?? entry.processName` - PROJECT (truncated middle, `.infinity` width) - Uptime (`formatUptime()`) -- Action buttons: `🌐` (HTTP only), `πŸ“‹`, `βœ•` +- Action buttons: `πŸ“‘` (orange, only when `bindScope == .exposed`), `🌐`, `πŸ“‹`, `βœ•` + +The `🌐` button and the legacy menu's "Open in Browser" share one source of truth +in `PortScanner.swift`: `shouldOfferBrowser(entry)` = `isHTTPPort(port)` (80, 443, +3xxx, 4xxx, **5xxx**, 8xxx) OR the framework is a web framework. `localhostURL(port:)` +builds the URL and uses `https` only for `:443`. Never duplicate these ranges in the UI. ### NSPopover trigger @@ -177,6 +182,11 @@ enum HealthStatus { case zombie // process in Z state } +enum BindScope { + case localOnly // bound to 127.0.0.1 / ::1 β€” only this machine + case exposed // bound to 0.0.0.0 / * / :: / a specific IP β€” reachable from the LAN +} + enum Framework: String { case nextjs = "Next.js", vite = "Vite", express = "Express" case remix = "Remix", astro = "Astro", angular = "Angular", nuxt = "Nuxt" @@ -199,6 +209,7 @@ struct PortEntry: Identifiable { let framework: Framework let uptime: TimeInterval let health: HealthStatus + let bindScope: BindScope let isDockerContainer: Bool let dockerContainerName: String? } @@ -240,6 +251,21 @@ Skip known system processes: `Spotify`, `Raycast`, `com.apple.*`, `UserEventAgen - `ppid == 1` AND dev runtime β†’ `.orphaned` - otherwise β†’ `.healthy` +### Bind scope (LAN exposure) +`parseLsofListen` keeps the host portion of the lsof NAME field (everything before +the last `:`), not just the port. Classify via `bindScope(forHost:)`: +- host ∈ `{127.0.0.1, ::1, [::1], localhost}` β†’ `.localOnly` +- anything else (`*`, `0.0.0.0`, `::`, `[::]`, a specific LAN IP) β†’ `.exposed` + +A pid+port can appear on multiple bindings (IPv4 loopback + IPv6 wildcard). Merge +with **exposed-wins**: if any binding is exposed the entry is `.exposed`. Docker +entries take scope from the publish host in `docker ps` (`parseDocker`). + +### Missing-PID fallback +If `ps` races and returns no row for a listening pid, still emit the entry from +lsof data (`processName` from lsof, `health = .healthy`, `uptime = 0`) rather than +dropping a live port. + --- ## FrameworkDetector (FrameworkDetector.swift) @@ -288,14 +314,19 @@ Observes `watchService.$ports`, `watchService.$isWatching`, and `PortBarSettings ```swift struct ProcessKiller { - static func kill(entry: PortEntry) async + static func kill(entry: PortEntry, watchService: WatchService) async } ``` - Confirm with `NSAlert` first -- `Darwin.kill(pid, SIGTERM)` β†’ wait 3s β†’ `Darwin.kill(pid, SIGKILL)` if still alive +- Kills the **process group** (`kill(-pgid, …)`) so dev-server child workers die + too and can't hold the port or respawn. Falls back to single-pid kill when the + target shares our own group (`pgid == getpgid(0)`) β€” never nuke PortBar's session. +- `SIGTERM` β†’ wait 3s β†’ `SIGKILL` if still alive - No `sudo` β€” only kills user-owned processes -- Triggers `WatchService.refresh()` after kill +- Takes the `WatchService` and calls `await watchService.refresh()` after the kill + so the row disappears immediately. Popover passes it via `PortPopoverRow`; the + legacy NSMenu path sets `KillTarget.shared.watchService` in `MenuBuilder.build`. --- diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..05ae319 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +# PortBar β€” dev / test shortcuts +# Usage: `make run`, `make smoke`, `make test-ports`, `make stop` + +PROJECT := PortBar.xcodeproj +SCHEME := PortBar +DERIVED := build +APP := $(DERIVED)/Build/Products/Debug/PortBar.app + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' + +.PHONY: check-xcode +check-xcode: ## Verify full Xcode is selected (not just CLT) + @xcodebuild -version >/dev/null 2>&1 || { \ + echo "βœ— Full Xcode required. Run:"; \ + echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; \ + exit 1; } + @echo "βœ“ $$(xcodebuild -version | head -1)" + +.PHONY: build +build: check-xcode ## Build Debug into ./build + xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration Debug \ + -derivedDataPath $(DERIVED) build + +.PHONY: run +run: build stop ## Build, launch, confirm alive + open $(APP) + @sleep 1; pgrep -x PortBar >/dev/null && echo "βœ“ PortBar running (⚑ in menu bar)" || echo "βœ— not running" + +.PHONY: stop +stop: ## Kill any running PortBar instance + @pkill -x PortBar 2>/dev/null || true + +.PHONY: test-ports +test-ports: ## Spawn 3 test servers (local, LAN-exposed, Vite range) + @python3 -m http.server 8888 --bind 127.0.0.1 >/dev/null 2>&1 & echo " :8888 local-only β†’ expect NO antenna, globe present" + @python3 -m http.server 8890 --bind 0.0.0.0 >/dev/null 2>&1 & echo " :8890 LAN-exposed β†’ expect ORANGE antenna + globe" + @python3 -m http.server 5173 >/dev/null 2>&1 & echo " :5173 Vite range β†’ expect globe present (bug #3)" + @echo "Click the ⚑ menu bar icon and verify. Then: make kill-ports" + +.PHONY: kill-ports +kill-ports: ## Kill the test servers + @pkill -f "http.server" 2>/dev/null || true + @echo "test servers killed" + +.PHONY: smoke +smoke: run test-ports ## Full smoke: build, run, spawn test ports + @echo "" + @echo "Manual checks:" + @echo " 1. :8890 shows orange antenna πŸ“‘, :8888 does not" + @echo " 2. :5173 shows globe button" + @echo " 3. Turn Watch OFF, click βœ• on a row β†’ row vanishes <1s (bug #1)" + @echo " 4. ⚑ count drops when you run 'make kill-ports'" + +.PHONY: release +release: check-xcode ## Build Release configuration + xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration Release \ + -derivedDataPath $(DERIVED) build + +.PHONY: clean +clean: ## Remove build output + rm -rf $(DERIVED) diff --git a/PortBar.xcodeproj/project.pbxproj b/PortBar.xcodeproj/project.pbxproj index 3950d85..d6a29d7 100644 --- a/PortBar.xcodeproj/project.pbxproj +++ b/PortBar.xcodeproj/project.pbxproj @@ -337,13 +337,13 @@ CODE_SIGN_ENTITLEMENTS = PortBar/Resources/PortBar.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; INFOPLIST_FILE = PortBar/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.1; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = com.portbar.PortBar; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -358,13 +358,13 @@ CODE_SIGN_ENTITLEMENTS = PortBar/Resources/PortBar.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; INFOPLIST_FILE = PortBar/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.1; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = com.portbar.PortBar; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/PortBar/App/StatusBarController.swift b/PortBar/App/StatusBarController.swift index 59779a7..3e0abf6 100644 --- a/PortBar/App/StatusBarController.swift +++ b/PortBar/App/StatusBarController.swift @@ -3,15 +3,20 @@ import Combine import SwiftUI @MainActor -class StatusBarController { +class StatusBarController: NSObject, NSPopoverDelegate { private var statusItem: NSStatusItem private var watchService: WatchService private var cancellables = Set() private var popover: NSPopover? + // ponytail: .transient is unreliable to dismiss for an LSUIElement accessory app, + // so we close the popover ourselves on any outside click or app deactivation. + private var outsideClickMonitor: Any? + private var resignObserver: Any? init(watchService: WatchService) { self.watchService = watchService statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() setupObservers() updateTitle(ports: []) Task { await watchService.refresh() } @@ -61,16 +66,40 @@ class StatusBarController { if popover == nil { let p = NSPopover() p.behavior = .transient + p.delegate = self p.contentViewController = NSHostingController( rootView: PortListPopoverView(watchService: watchService) ) popover = p } popover?.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + installDismissMonitors() Task { await watchService.refresh() } } } + private func installDismissMonitors() { + removeDismissMonitors() + outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in + Task { @MainActor in self?.popover?.close() } + } + resignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.popover?.close() } + } + } + + private func removeDismissMonitors() { + if let m = outsideClickMonitor { NSEvent.removeMonitor(m); outsideClickMonitor = nil } + if let o = resignObserver { NotificationCenter.default.removeObserver(o); resignObserver = nil } + } + + // NSPopoverDelegate β€” clean up whenever the popover closes (transient or manual). + nonisolated func popoverDidClose(_ notification: Notification) { + MainActor.assumeIsolated { removeDismissMonitors() } + } + // MARK: - Title private func updateTitle(ports: [PortEntry]) { diff --git a/PortBar/Core/Models.swift b/PortBar/Core/Models.swift index d5310c2..337be46 100644 --- a/PortBar/Core/Models.swift +++ b/PortBar/Core/Models.swift @@ -6,6 +6,12 @@ enum HealthStatus { case zombie } +// Whether the listening socket is reachable only from this machine or from the LAN. +enum BindScope { + case localOnly // bound to 127.0.0.1 / ::1 + case exposed // bound to 0.0.0.0 / * / :: / a specific interface β€” other devices can reach it +} + enum Framework: String { // Node / JS case nextjs = "Next.js" @@ -46,6 +52,7 @@ struct PortEntry: Identifiable { let framework: Framework let uptime: TimeInterval let health: HealthStatus + let bindScope: BindScope let isDockerContainer: Bool let dockerContainerName: String? } diff --git a/PortBar/Core/PortScanner.swift b/PortBar/Core/PortScanner.swift index e23e979..406df94 100644 --- a/PortBar/Core/PortScanner.swift +++ b/PortBar/Core/PortScanner.swift @@ -24,16 +24,28 @@ actor PortScanner { let processInfos = parsePs(psOutput) let cwdMap = parseCwd(cwdOutput) - // Call 3b: Docker (conditional) + // Call 3b: Docker (conditional). lsof truncates COMMAND to 9 chars, so + // "com.docker.backend" arrives as "com.docke" β€” match the prefix too. let hasDocker = processInfos.values.contains { - $0.name.lowercased().contains("docker") || $0.name == "com.docker" + let n = $0.name.lowercased() + return n.hasPrefix("docker") || n.hasPrefix("com.docke") || n.contains("docker") } - var dockerPortMap: [Int: (name: String, image: String)] = [:] + var dockerPortMap: [Int: (name: String, image: String, scope: BindScope)] = [:] if hasDocker { let dockerOutput = (try? await shell("docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}' 2>/dev/null")) ?? "" dockerPortMap = parseDocker(dockerOutput) } + // A pid+port can appear twice (e.g. IPv4 loopback + IPv6 wildcard) β€” the + // most-exposed binding wins so we never mislabel a LAN-reachable port as local. + var scopeByKey: [String: BindScope] = [:] + for pair in filteredPairs { + let key = "\(pair.pid)-\(pair.port)" + if scopeByKey[key] == nil || pair.scope == .exposed { + scopeByKey[key] = pair.scope + } + } + // Build entries var seen = Set() var entries: [PortEntry] = [] @@ -43,35 +55,41 @@ actor PortScanner { guard !seen.contains(key) else { continue } seen.insert(key) - guard let info = processInfos[pair.pid] else { continue } - + let info = processInfos[pair.pid] let cwd = cwdMap[pair.pid] + let scope = scopeByKey[key] ?? pair.scope let framework: Framework let isDocker: Bool let dockerName: String? + let bindScope: BindScope if let dockerInfo = dockerPortMap[pair.port] { framework = detectDockerFramework(image: dockerInfo.image) isDocker = true dockerName = dockerInfo.name + bindScope = dockerInfo.scope } else { - framework = FrameworkDetector.detect(cwd: cwd, cmdline: nil, processName: info.name) + framework = FrameworkDetector.detect(cwd: cwd, cmdline: nil, processName: info?.name ?? pair.name) isDocker = false dockerName = nil + bindScope = scope } - let health = determineHealth(stat: info.stat, ppid: info.ppid, processName: info.name) + // #5: ps can race and drop a PID β€” still surface the port with lsof data + // rather than hiding a live listener. + let health = info.map { determineHealth(stat: $0.stat, ppid: $0.ppid, processName: $0.name) } ?? .healthy let projectName = cwd.map { URL(fileURLWithPath: $0).lastPathComponent } entries.append(PortEntry( port: pair.port, - processName: pair.name, + processName: info?.name ?? pair.name, pid: pair.pid, projectName: projectName, projectPath: cwd, framework: framework, - uptime: parseEtime(info.etime), + uptime: parseEtime(info?.etime ?? ""), health: health, + bindScope: bindScope, isDockerContainer: isDocker, dockerContainerName: dockerName )) @@ -82,8 +100,8 @@ actor PortScanner { // MARK: - Parsers - private func parseLsofListen(_ output: String) -> [(pid: Int, port: Int, name: String)] { - var results: [(pid: Int, port: Int, name: String)] = [] + private func parseLsofListen(_ output: String) -> [(pid: Int, port: Int, name: String, scope: BindScope)] { + var results: [(pid: Int, port: Int, name: String, scope: BindScope)] = [] let lines = output.components(separatedBy: "\n").dropFirst() // skip header for line in lines { let parts = line.split(separator: " ", omittingEmptySubsequences: true) @@ -92,11 +110,18 @@ actor PortScanner { // Use lastIndex to handle IPv6 addresses like [::1]:3000 guard let colonIdx = nameField.lastIndex(of: ":"), let port = Int(nameField[nameField.index(after: colonIdx)...]) else { continue } - results.append((pid: pid, port: port, name: String(parts[0]))) + let host = String(nameField[.. BindScope { + let localHosts: Set = ["127.0.0.1", "::1", "[::1]", "localhost"] + return localHosts.contains(host) ? .localOnly : .exposed + } + private struct ProcessInfo { let name: String let ppid: Int @@ -136,8 +161,8 @@ actor PortScanner { return result } - private func parseDocker(_ output: String) -> [Int: (name: String, image: String)] { - var result: [Int: (name: String, image: String)] = [:] + private func parseDocker(_ output: String) -> [Int: (name: String, image: String, scope: BindScope)] { + var result: [Int: (name: String, image: String, scope: BindScope)] = [:] for line in output.components(separatedBy: "\n") { let parts = line.components(separatedBy: "\t") guard parts.count >= 3 else { continue } @@ -151,8 +176,9 @@ actor PortScanner { let arrowIdx = trimmed.range(of: "->")?.lowerBound, colonIdx < arrowIdx { let portStr = String(trimmed[trimmed.index(after: colonIdx).. TimeInterval { return total } +// Single source of truth for "does this port speak HTTP?" β€” used by popover + menu. +// Frameworks that serve HTTP always get a browser button regardless of port range. +private let webFrameworks: Set = [ + .nextjs, .vite, .express, .remix, .astro, .angular, .nuxt, + .django, .fastapi, .flask, .rails, +] + +func shouldOfferBrowser(_ entry: PortEntry) -> Bool { + isHTTPPort(entry.port) || webFrameworks.contains(entry.framework) +} + +func isHTTPPort(_ port: Int) -> Bool { + port == 80 || port == 443 + || (3000...3999).contains(port) + || (4000...4999).contains(port) + || (5000...5999).contains(port) // Vite (5173), CRA, Flask default + || (8000...8999).contains(port) +} + +// Localhost URL for a port β€” https only for :443. +func localhostURL(port: Int) -> URL? { + let scheme = port == 443 ? "https" : "http" + return URL(string: "\(scheme)://localhost:\(port)") +} + func formatUptime(_ seconds: TimeInterval) -> String { let s = Int(seconds) if s < 60 { return "\(s)s" } diff --git a/PortBar/Core/ProcessKiller.swift b/PortBar/Core/ProcessKiller.swift index 2ed5926..101fc82 100644 --- a/PortBar/Core/ProcessKiller.swift +++ b/PortBar/Core/ProcessKiller.swift @@ -2,7 +2,7 @@ import AppKit import Darwin struct ProcessKiller { - static func kill(entry: PortEntry) async { + static func kill(entry: PortEntry, watchService: WatchService) async { let confirmed = await MainActor.run { () -> Bool in let alert = NSAlert() alert.messageText = "Kill \(entry.processName) on :\(entry.port)?" @@ -16,11 +16,21 @@ struct ProcessKiller { guard confirmed else { return } let pid = pid_t(entry.pid) - Darwin.kill(pid, SIGTERM) + // ponytail: kill the whole process group so dev-server child workers die + // too and can't hold the port or respawn. Fall back to single-pid if the + // process shares our own group (never nuke PortBar's terminal/session). + let pgid = getpgid(pid) + let useGroup = pgid > 0 && pgid != getpgid(0) + let target: pid_t = useGroup ? -pgid : pid + + Darwin.kill(target, SIGTERM) try? await Task.sleep(nanoseconds: 3_000_000_000) if Darwin.kill(pid, 0) == 0 { - Darwin.kill(pid, SIGKILL) + Darwin.kill(target, SIGKILL) } + + // #1: reflect the kill immediately instead of waiting for the next poll. + await watchService.refresh() } } diff --git a/PortBar/Core/Settings.swift b/PortBar/Core/Settings.swift index b0b4b27..ce8ba3f 100644 --- a/PortBar/Core/Settings.swift +++ b/PortBar/Core/Settings.swift @@ -1,10 +1,29 @@ import Foundation import Combine +import CoreGraphics final class PortBarSettings: ObservableObject { static let shared = PortBarSettings() private init() {} + // Popover size β€” user-resizable via the footer grip, persisted across launches. + static let widthRange: ClosedRange = 460...1000 + static let heightRange: ClosedRange = 240...760 + + @Published var popoverWidth: CGFloat = { + let v = CGFloat(UserDefaults.standard.double(forKey: "pb.popoverWidth")) + return v > 0 ? v : 520 + }() { + didSet { UserDefaults.standard.set(Double(popoverWidth), forKey: "pb.popoverWidth") } + } + + @Published var popoverListHeight: CGFloat = { + let v = CGFloat(UserDefaults.standard.double(forKey: "pb.popoverListHeight")) + return v > 0 ? v : 400 + }() { + didSet { UserDefaults.standard.set(Double(popoverListHeight), forKey: "pb.popoverListHeight") } + } + enum DisplayMode: String, CaseIterable { case grouped = "grouped" case flat = "flat" diff --git a/PortBar/Core/WatchService.swift b/PortBar/Core/WatchService.swift index 25001ee..85f46a0 100644 --- a/PortBar/Core/WatchService.swift +++ b/PortBar/Core/WatchService.swift @@ -46,10 +46,13 @@ class WatchService: ObservableObject { func refresh() async { guard let newPorts = try? await scanner.scan(includeAll: showAll) else { return } let oldPorts = ports - let oldPortNumbers = Set(oldPorts.map { $0.port }) - let newPortNumbers = Set(newPorts.map { $0.port }) - let added = newPorts.filter { !oldPortNumbers.contains($0.port) } - let removed = oldPorts.filter { !newPortNumbers.contains($0.port) } + // Key on pid+port so a rebind (same port, new PID) counts as a change and + // the stale PID isn't carried forward. + let key: (PortEntry) -> String = { "\($0.pid)-\($0.port)" } + let oldKeys = Set(oldPorts.map(key)) + let newKeys = Set(newPorts.map(key)) + let added = newPorts.filter { !oldKeys.contains(key($0)) } + let removed = oldPorts.filter { !newKeys.contains(key($0)) } ports = newPorts if !added.isEmpty || !removed.isEmpty { onPortsChanged?(added, removed) diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png index 40b9f06..dafc5f9 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png index 3114973..2559c65 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png index 7cf9ff7..316564f 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png index da4ffbb..6dc0d86 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png index 3114973..2559c65 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png index 6ad5da6..545c663 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png index da4ffbb..6dc0d86 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png index a638cb9..79e8278 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png index 6ad5da6..545c663 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png index a39efd8..bc83eba 100644 Binary files a/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and b/PortBar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/PortBar/UI/MenuBuilder.swift b/PortBar/UI/MenuBuilder.swift index 72daa92..10bfe9a 100644 --- a/PortBar/UI/MenuBuilder.swift +++ b/PortBar/UI/MenuBuilder.swift @@ -43,6 +43,9 @@ struct MenuBuilder { ) -> NSMenu { let menu = NSMenu() + // Legacy menu path uses the singleton KillTarget β€” give it the ws to refresh after a kill. + KillTarget.shared.watchService = watchService + // Watch toggle let watchTitle = watchService.isWatching ? "β—‰ Watch Mode" : "Watch Mode" let watchItem = NSMenuItem(title: watchTitle, action: #selector(WatchToggleTarget.toggle(_:)), keyEquivalent: "") @@ -237,7 +240,7 @@ struct MenuBuilder { killItem.target = KillTarget.shared sub.addItem(killItem) - if isHTTPPort(entry.port) { + if shouldOfferBrowser(entry) { let openItem = NSMenuItem(title: "Open in Browser", action: #selector(OpenBrowserTarget.open(_:)), keyEquivalent: "") openItem.representedObject = entry as AnyObject openItem.target = OpenBrowserTarget.shared @@ -277,6 +280,9 @@ struct MenuBuilder { string: ":\(entry.port)", attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .semibold)] )) + if entry.bindScope == .exposed { + result.append(NSAttributedString(string: " πŸ“‘")) + } let label = frameworkLabel(entry) result.append(NSAttributedString(string: " \(label)")) if let project = entry.projectName, !project.isEmpty { @@ -296,12 +302,6 @@ struct MenuBuilder { } } - private static func isHTTPPort(_ port: Int) -> Bool { - port == 80 || port == 443 - || (3000...3999).contains(port) - || (4000...4999).contains(port) - || (8000...8999).contains(port) - } } // MARK: - Action targets @@ -335,9 +335,11 @@ class RefreshTarget: NSObject { class KillTarget: NSObject { static let shared = KillTarget() + weak var watchService: WatchService? @objc func kill(_ sender: NSMenuItem) { - guard let entry = sender.representedObject as? PortEntry else { return } - Task { await ProcessKiller.kill(entry: entry) } + guard let entry = sender.representedObject as? PortEntry, + let ws = watchService else { return } + Task { await ProcessKiller.kill(entry: entry, watchService: ws) } } } @@ -345,7 +347,7 @@ class OpenBrowserTarget: NSObject { static let shared = OpenBrowserTarget() @objc func open(_ sender: NSMenuItem) { guard let entry = sender.representedObject as? PortEntry, - let url = URL(string: "http://localhost:\(entry.port)") else { return } + let url = localhostURL(port: entry.port) else { return } NSWorkspace.shared.open(url) } } diff --git a/PortBar/UI/PortListPopoverView.swift b/PortBar/UI/PortListPopoverView.swift index 5c4fdf1..e269253 100644 --- a/PortBar/UI/PortListPopoverView.swift +++ b/PortBar/UI/PortListPopoverView.swift @@ -5,10 +5,11 @@ import AppKit private enum Col { static let health: CGFloat = 20 // dot static let port: CGFloat = 58 // :3000 - static let process: CGFloat = 72 // node, python3 … + // process: .infinity β€” grows when the panel is widened, so full paths show static let type: CGFloat = 90 // Next.js, Vite … - // project: .infinity + static let project: CGFloat = 120 // project folder name static let uptime: CGFloat = 56 // 2h 4m + static let processMin: CGFloat = 90 } // MARK: - Root @@ -18,6 +19,8 @@ struct PortListPopoverView: View { @ObservedObject private var settings = PortBarSettings.shared @ObservedObject private var updater = UpdateChecker.shared @State private var showSettings = false + @State private var dragBaseWidth: CGFloat? + @State private var dragBaseHeight: CGFloat? var body: some View { VStack(spacing: 0) { @@ -30,7 +33,7 @@ struct PortListPopoverView: View { Divider() footer } - .frame(width: 520) + .frame(width: settings.popoverWidth) .background(Color(NSColor.windowBackgroundColor)) } @@ -187,20 +190,20 @@ struct PortListPopoverView: View { .frame(width: Col.port, alignment: .center) Text("PROCESS") - .frame(width: Col.process, alignment: .leading) + .frame(minWidth: Col.processMin, maxWidth: .infinity, alignment: .leading) Text("TYPE") .frame(width: Col.type, alignment: .leading) Text("PROJECT") - .frame(maxWidth: .infinity, alignment: .center) + .frame(width: Col.project, alignment: .leading) Text("UPTIME") .frame(width: Col.uptime, alignment: .trailing) .padding(.trailing, 10) Text("TOOLS") - .frame(width: 86, alignment: .center) + .frame(width: 104, alignment: .center) } .font(.caption.weight(.semibold)) .foregroundStyle(Color.secondary) @@ -222,13 +225,13 @@ struct PortListPopoverView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(watchService.ports) { entry in - PortPopoverRow(entry: entry) + PortPopoverRow(entry: entry, watchService: watchService) Divider().padding(.leading, 14) } } .animation(.easeInOut(duration: 0.18), value: watchService.ports.map { $0.id }) } - .frame(maxHeight: 400) + .frame(maxHeight: settings.popoverListHeight) } } } @@ -247,17 +250,55 @@ struct PortListPopoverView: View { .buttonStyle(.plain) .font(.caption) .foregroundStyle(Color.secondary) + + resizeGrip } .padding(.horizontal, 14) .padding(.vertical, 7) } + + // Drag to resize the panel (width + list height), persisted in settings. + private var resizeGrip: some View { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + .padding(.leading, 8) + .contentShape(Rectangle()) + .help("Drag to resize") + .gesture( + DragGesture() + .onChanged { g in + let baseW = dragBaseWidth ?? settings.popoverWidth + let baseH = dragBaseHeight ?? settings.popoverListHeight + dragBaseWidth = baseW + dragBaseHeight = baseH + settings.popoverWidth = (baseW + g.translation.width) + .clamped(to: PortBarSettings.widthRange) + settings.popoverListHeight = (baseH + g.translation.height) + .clamped(to: PortBarSettings.heightRange) + } + .onEnded { _ in + dragBaseWidth = nil + dragBaseHeight = nil + } + ) + } +} + +private extension CGFloat { + func clamped(to range: ClosedRange) -> CGFloat { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } } // MARK: - Row struct PortPopoverRow: View { let entry: PortEntry + @ObservedObject var watchService: WatchService @State private var hovered = false + @State private var hoverProcess = false + @State private var hoverProject = false var body: some View { HStack(spacing: 0) { @@ -274,13 +315,17 @@ struct PortPopoverRow: View { .frame(width: Col.port, alignment: .leading) .padding(.leading, 8) - // PROCESS + // PROCESS β€” full path; column grows with the panel width. Hover = tooltip. Text(entry.processName) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) - .truncationMode(.tail) - .frame(width: Col.process, alignment: .leading) + .truncationMode(.middle) + .frame(minWidth: Col.processMin, maxWidth: .infinity, alignment: .leading) + .onHover { hoverProcess = $0 } + .overlay(alignment: .topLeading) { + if hoverProcess { HoverPathBubble(text: entry.processName) } + } // TYPE Text(label) @@ -292,7 +337,13 @@ struct PortPopoverRow: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(width: Col.project, alignment: .leading) + .onHover { hoverProject = $0 } + .overlay(alignment: .topLeading) { + if hoverProject, let p = entry.projectPath ?? entry.projectName { + HoverPathBubble(text: p) + } + } // UP Text(formatUptime(entry.uptime)) @@ -303,17 +354,27 @@ struct PortPopoverRow: View { // Actions HStack(spacing: 4) { - if isHTTP { + // LAN-exposure marker: other devices can reach this port. + if entry.bindScope == .exposed { + Image(systemName: "dot.radiowaves.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.orange) + .frame(width: 18) + .help("Reachable from other devices on your network") + } else { + Spacer().frame(width: 18) // keep actions aligned + } + if shouldOfferBrowser(entry) { PortActionButton(icon: "globe", tint: .blue) { openBrowser() } } else { Spacer().frame(width: 30) // keep kill/copy aligned } PortActionButton(icon: "doc.on.doc", tint: Color(NSColor.secondaryLabelColor)) { copyPort() } PortActionButton(icon: "xmark.circle.fill", tint: .red) { - Task { await ProcessKiller.kill(entry: entry) } + Task { await ProcessKiller.kill(entry: entry, watchService: watchService) } } } - .frame(width: 86, alignment: .trailing) + .frame(width: 104, alignment: .trailing) } .padding(.horizontal, 14) .padding(.vertical, 6) @@ -336,15 +397,8 @@ struct PortPopoverRow: View { } } - private var isHTTP: Bool { - entry.port == 80 || entry.port == 443 - || (3000...3999).contains(entry.port) - || (4000...4999).contains(entry.port) - || (8000...8999).contains(entry.port) - } - private func openBrowser() { - guard let url = URL(string: "http://localhost:\(entry.port)") else { return } + guard let url = localhostURL(port: entry.port) else { return } NSWorkspace.shared.open(url) } @@ -354,6 +408,29 @@ struct PortPopoverRow: View { } } +// MARK: - Hover reveal bubble (SwiftUI .help / native toolTip are unreliable in NSPopover) + +struct HoverPathBubble: View { + let text: String + var body: some View { + Text(text) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 460, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.12))) + .shadow(color: .black.opacity(0.28), radius: 5, y: 2) + .offset(y: 22) + .zIndex(100) + .allowsHitTesting(false) + .transition(.opacity) + } +} + // MARK: - Action button struct PortActionButton: View { diff --git a/README.md b/README.md index 827bbb9..717dac3 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ Click the icon β†’ a panel drops down listing all your ports. Kill a runaway pro - **Process column** β€” shows the exact binary name running on each port (e.g. `node`, `python3`, `ARDAgent`) - **Framework detection** β€” recognizes Next.js, Vite, Express, Django, FastAPI, Flask, Rails, PostgreSQL, Redis, MongoDB, nginx, and more - **Health status** β€” color-coded dot per port: 🟒 healthy Β· 🟑 orphaned Β· πŸ”΄ zombie +- **LAN-exposure marker** β€” an orange πŸ“‘ antenna flags ports bound to all interfaces (`0.0.0.0`/`*`), meaning other devices on your network can reach them; ports bound to `127.0.0.1` (local-only) have no marker - **Filtered & All modes** β€” default view hides system/tool processes (matches `ports`); toggle **All** to show everything (matches `ports --all`) - **Watch mode** β€” polls every 3 seconds, menu bar title updates automatically - **Kill process** β€” confirms with a dialog, sends SIGTERM β†’ SIGKILL after 3 seconds -- **Open in browser** β€” one click for HTTP ports (80, 443, 3xxx, 4xxx, 8xxx) +- **Open in browser** β€” one click for HTTP ports (80, 443, 3xxx, 4xxx, 5xxx, 8xxx) and any detected web framework (Vite, Next.js, Django, …) - **Copy port** β€” copies `:3000` style to clipboard - **Settings panel** β€” auto-watch on launch, default to All mode, and update checker - **Update checker** β€” notifies you in-app when a new version is available on GitHub @@ -28,6 +29,14 @@ Click the icon β†’ a panel drops down listing all your ports. Kill a runaway pro ## Install +### Quick install (curl) + +```bash +curl -fsSL https://raw.githubusercontent.com/mulhamna/portbar/main/scripts/install.sh | bash +``` + +Downloads the latest release DMG, installs to `/Applications`, clears quarantine, and launches it. + ### Homebrew ```bash @@ -63,10 +72,45 @@ brew update && brew upgrade --cask portbar | Manual refresh | Click the **β†Ί** button | | Open settings | Click the **βš™οΈ** button β€” auto-watch, default mode, update status | | Kill a process | Click the red **βœ•** button on a port row | -| Open in browser | Click the **🌐** button (HTTP ports only) | +| Open in browser | Click the **🌐** button (HTTP ports & web frameworks) | +| Spot LAN-exposed | Orange **πŸ“‘** on a row = reachable by other devices on your network | | Copy port | Click the **πŸ“‹** button | +| Resize the panel | Drag the **‑** grip in the footer (bottom-right); size is remembered | | Quit | Footer β†’ Quit | +### Icon reference + +**Toolbar (top of the panel)** + +| Icon | Meaning | +| ---- | ------- | +| filter | Toggle **All** β€” include system & tool processes | +| πŸ‘ eye | Toggle **Watch** β€” auto-refresh every 3s | +| ↻ | Manual refresh | +| βš™οΈ gear | Settings (orange dot = update available) | +| ⚑ N | Number of listening ports | + +**Per-port row** + +| Icon | Meaning | +| ---- | ------- | +| 🟒 / 🟑 / πŸ”΄ dot | Health β€” healthy / orphaned / zombie | +| 🟠 `(Β·))` waves | **LAN-exposed** β€” bound to all interfaces, other devices can reach it. Absent = local-only (`127.0.0.1`) | +| 🌐 globe | Open in browser (HTTP ports & web frameworks) | +| πŸ“„ copy | Copy `:PORT` to clipboard | +| βŠ— red | Kill the process | + +> Hover a truncated **PROCESS** or **PROJECT** cell to see its full path. + +--- + +## What's new in v3.0 + +- **LAN-exposure marker** β€” an orange πŸ“‘ antenna now flags any port bound to all interfaces (`0.0.0.0`/`*`/`::`), so you can tell at a glance which ports other devices on your network can reach vs. local-only (`127.0.0.1`) ports +- **Reliable kill** β€” killing a process now refreshes the list immediately (row disappears at once instead of lingering until the next poll), and kills the whole process group so dev-server child workers can't keep the port alive or respawn +- **Wider browser button** β€” the 🌐 button now covers the `5xxx` range (Vite's `5173`, Flask, CRA) and any detected web framework, not just `3xxx`/`4xxx`/`8xxx` +- **Sturdier scanner** β€” ports whose `ps` lookup races are still shown (built from `lsof` data) instead of vanishing; Docker container names resolve even when `lsof` truncates the process name; port `443` opens over `https` + --- ## What's new in v2.0 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..ad20cc7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# PortBar installer β€” downloads the latest release DMG and installs to /Applications. +# Usage: curl -fsSL https://raw.githubusercontent.com/mulhamna/portbar/main/scripts/install.sh | bash +set -euo pipefail + +REPO="mulhamna/portbar" +APP="PortBar" +DMG_NAME="PortBar.dmg" + +echo "⚑ Installing $APP…" + +# Resolve the latest release DMG asset URL from the GitHub API. +URL=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \ + | grep -o "https://github.com/$REPO/releases/download/[^\"]*\.dmg" \ + | head -1) + +if [ -z "${URL:-}" ]; then + echo "βœ— Could not find a release DMG. Check https://github.com/$REPO/releases" >&2 + exit 1 +fi + +TMP=$(mktemp -d) +trap 'hdiutil detach "$TMP/mnt" >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT + +echo "β†’ Downloading $URL" +curl -fsSL "$URL" -o "$TMP/$DMG_NAME" + +echo "β†’ Mounting" +hdiutil attach "$TMP/$DMG_NAME" -nobrowse -quiet -mountpoint "$TMP/mnt" + +echo "β†’ Copying to /Applications" +rm -rf "/Applications/$APP.app" +cp -R "$TMP/mnt/$APP.app" /Applications/ + +echo "β†’ Clearing quarantine" +xattr -dr com.apple.quarantine "/Applications/$APP.app" || true + +echo "β†’ Launching" +open "/Applications/$APP.app" + +echo "βœ“ Done β€” look for the ⚑ icon in your menu bar." diff --git a/scripts/make_icon.swift b/scripts/make_icon.swift new file mode 100755 index 0000000..435ec3c --- /dev/null +++ b/scripts/make_icon.swift @@ -0,0 +1,104 @@ +#!/usr/bin/env swift +// Generates the PortBar app icon (indigo squircle + glowing lightning bolt). +// Zero deps β€” pure AppKit/CoreGraphics. Renders every AppIcon size natively. +// +// Usage: swift scripts/make_icon.swift +import AppKit + +let outDir = "PortBar/Resources/Assets.xcassets/AppIcon.appiconset" + +// (filename, pixel size) +let targets: [(String, Int)] = [ + ("icon_16x16.png", 16), ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), ("icon_512x512@2x.png", 1024), +] + +func color(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) -> CGColor { + CGColor(red: r/255, green: g/255, blue: b/255, alpha: 1) +} + +func render(size s: Int) -> Data { + let px = CGFloat(s) + let cs = CGColorSpaceCreateDeviceRGB() + let ctx = CGContext(data: nil, width: s, height: s, bitsPerComponent: 8, + bytesPerRow: 0, space: cs, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + + // --- Squircle background with vertical indigo gradient --- + let margin = px * 0.085 + let rect = CGRect(x: margin, y: margin, width: px - 2*margin, height: px - 2*margin) + let radius = rect.width * 0.2237 + let squircle = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) + + ctx.saveGState() + ctx.addPath(squircle) + ctx.clip() + let bgGrad = CGGradient(colorsSpace: cs, + colors: [color(124, 92, 255), color(67, 56, 202), color(30, 20, 90)] as CFArray, + locations: [0.0, 0.55, 1.0])! + ctx.drawLinearGradient(bgGrad, + start: CGPoint(x: rect.midX, y: rect.maxY), + end: CGPoint(x: rect.midX, y: rect.minY), options: []) + // subtle top sheen + let sheen = CGGradient(colorsSpace: cs, + colors: [CGColor(red: 1, green: 1, blue: 1, alpha: 0.18), CGColor(red: 1, green: 1, blue: 1, alpha: 0)] as CFArray, + locations: [0.0, 1.0])! + ctx.drawLinearGradient(sheen, + start: CGPoint(x: rect.midX, y: rect.maxY), + end: CGPoint(x: rect.midX, y: rect.midY), options: []) + ctx.restoreGState() + + // --- Lightning bolt (unit coords, y-up), centered --- + let bolt: [CGPoint] = [ + CGPoint(x: 0.60, y: 0.98), + CGPoint(x: 0.24, y: 0.46), + CGPoint(x: 0.46, y: 0.46), + CGPoint(x: 0.38, y: 0.02), + CGPoint(x: 0.78, y: 0.58), + CGPoint(x: 0.55, y: 0.58), + ] + let boltSize = px * 0.46 + let ox = (px - boltSize) / 2 + let oy = (px - boltSize) / 2 + let path = CGMutablePath() + for (i, p) in bolt.enumerated() { + let pt = CGPoint(x: ox + p.x * boltSize, y: oy + p.y * boltSize) + if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) } + } + path.closeSubpath() + + // glow + ctx.saveGState() + ctx.setShadow(offset: .zero, blur: px * 0.05, color: color(253, 224, 71).copy(alpha: 0.9)) + ctx.addPath(path) + ctx.setFillColor(color(253, 224, 71)) + ctx.fillPath() + ctx.restoreGState() + + // bolt with yellowβ†’orange gradient + ctx.saveGState() + ctx.addPath(path) + ctx.clip() + let boltGrad = CGGradient(colorsSpace: cs, + colors: [color(255, 240, 150), color(253, 224, 71), color(251, 146, 60)] as CFArray, + locations: [0.0, 0.5, 1.0])! + ctx.drawLinearGradient(boltGrad, + start: CGPoint(x: px/2, y: oy + boltSize), + end: CGPoint(x: px/2, y: oy), options: []) + ctx.restoreGState() + + let img = ctx.makeImage()! + let rep = NSBitmapImageRep(cgImage: img) + return rep.representation(using: .png, properties: [:])! +} + +for (name, s) in targets { + let data = render(size: s) + let url = URL(fileURLWithPath: "\(outDir)/\(name)") + try! data.write(to: url) + print("βœ“ \(name) (\(s)px)") +} +print("done")