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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand All @@ -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?
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`.

---

Expand Down
66 changes: 66 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 4 additions & 4 deletions PortBar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
31 changes: 30 additions & 1 deletion PortBar/App/StatusBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
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() }
Expand Down Expand Up @@ -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]) {
Expand Down
7 changes: 7 additions & 0 deletions PortBar/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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?
}
Loading
Loading