A macOS menu bar app that shows what is listening on your local ports,
which project it belongs to, and lets you stop it.
Discovery works from processes, not from port numbers, so custom ports need no configuration.
brew install --cask xergic/tap/portfoxOr download the DMG from Releases. Every release is signed with a Developer ID and notarized by Apple, so it opens without a Gatekeeper warning.
Portfox needs macOS 15 or later on Apple Silicon.
Remove it with brew uninstall --cask portfox. Add --zap to delete its preferences too.
- macOS 15 or later
- Apple Silicon
- Xcode 26 or later
- XcodeGen and SwiftLint, both via
brew install xcodegen swiftlint
make run # build and launch the menu bar app
make test # run the core test suite
make scan # resolve services on this machine, no GUI
make lint # SwiftLint
make archive # package an arm64 build to hand to someonePortfox.xcodeproj is generated from project.yml and is not in version control. Run make gen after changing the project definition.
make archive # version from the latest git tag
make archive VERSION=0.2.0This writes dist/Portfox.app and dist/Portfox-<version>.dmg, both arm64 only, and fails if the binary carries any other architecture. dist/ is wiped on each run and is not in version control.
The build is ad hoc signed, not notarized, so macOS blocks it on another machine and offers no right-click Open. Whoever receives it clears the quarantine flag once:
xattr -dr com.apple.quarantine /Applications/Portfox.appLaunch at login also needs a real signature, so it fails on this build. Push a vMAJOR.MINOR.PATCH tag instead when either matters. .github/workflows/release.yml signs with a Developer ID, notarizes, staples and publishes a DMG that installs with no warnings.
PortfoxKit is a SwiftPM library holding the whole pipeline. The app target is a thin SwiftUI shell.
| Path | Holds |
|---|---|
Sources/PortfoxKit/ |
The pipeline. No SwiftUI and no UI state. |
Sources/portfox-scan/ |
The command line tool over the same pipeline. |
App/Portfox/ |
The SwiftUI app: state, views, theme, snapshot renderer. |
Tests/PortfoxKitTests/ |
Tests for the kit. |
Tools/ |
Icon fetching, app icon generation, and release packaging. |
One scan runs one pipeline. Each stage narrows raw sockets into something the UI can name.
ListenerScanner → ProcessInspector → ProcessTree → ProjectResolver
→ DetectionEngine → ListenerClassifier → IconResolver → ServiceRepository → SwiftUI
- ListenerScanner asks
lsoffor TCP sockets in LISTEN state. It needs no privileges and only ever returns the current user's processes. - ProcessInspector reads every process's parent, owner and start time from
sysctl, then reads the expensive fields (executable, argv, working directory) for listeners and their ancestors only. - ProcessTree answers ancestors, descendants and the logical root, stopping at a shell, a terminal or an app bundle.
- ProjectResolver walks up from the working directory looking for a manifest such as
package.jsonorpyproject.toml. - DetectionEngine scores every detector and takes the winner, breaking ties toward the more specific service type.
- ListenerClassifier sorts each service into a development service, infrastructure, a probable developer process, or system noise.
- ServiceRepository is an actor that owns the pipeline and its caches. A tick whose socket set has not moved returns the previous result instead of rebuilding it.
Every stage takes and returns plain value types, and no stage imports SwiftUI. portfox-scan drives the identical pipeline from the command line, so detection can be developed and verified without launching the GUI.
The popover footer opens a dashboard window.
A searchable, filterable service list sits on the left. The right pane shows everything known about the selected service: process metadata (PID, executable, working directory, resident memory), an HTTP inspection panel, and the process ancestry with its child workers, one row per process, each labelled by its role:
- Parent Shell for a boundary process (a shell, terminal or supervisor), never walked through or signalled.
- Runner for a wrapper such as
pnpmornodemon. - Listener for the service itself.
The row Portfox would actually signal on Stop is badged "stops here".
A dev server running a second time from the same folder gets a Duplicate badge. That usually means an agent started pnpm dev without noticing it was already up, and the copy took the next port. The oldest process is the original and keeps its row clean. The dashboard names the port the original is on.
Two packages of one monorepo are not duplicates, because each runs from its own folder. Databases and daemons are never marked.
A server running from a linked git worktree gets its own project heading, with the branch beside the name, or the worktree's name on a detached HEAD. This holds even when the worktree sits inside the main checkout, as Claude Code's .claude/worktrees/ do. The main checkout carries no label, and a submodule is never mistaken for a worktree.
A server Localfox started carries LOCALFOX_HOST in its environment. Portfox reads it from the same kernel buffer as the command line, so it costs nothing extra. The row gets a Localfox badge, and Open and Copy URL use the domain. The HTTP status check still talks to localhost and the port, because it never leaves loopback. A value that is not a .localhost host is ignored.
Settings live in a sheet inside the dashboard, replacing the old Settings window. The dashboard's toolbar opens it, and so does the popover footer, which opens the window and the sheet together.
- Automatic refresh loop: poll listening TCP sockets while a window is open. Off stops the timer entirely. With every window closed the loop drops to one scan a minute, which is only there to keep the menu bar count current.
- Polling interval: 1s, 2s or 5s, used while a window is open. Disabled while automatic refresh is off.
- Show count in menu bar: draw the number of active services next to the icon. Off leaves the glyph alone.
- Service list layout: Service or Project, which fact leads each row. Daemons and databases always stay service-first, since their resolved project is an accident of where they run.
- Show uptime: how long each service has been running, beside its folder. Read at scan time, so it ages in jumps rather than counting seconds.
- Show CPU usage: percent of one core, listener and workers together, as Activity Monitor counts it. It is measured between two scans, so it stays blank with the refresh loop off.
- Show all listeners: include background system ports such as CUPS or mDNSResponder.
- Hide infrastructure and daemons: leave out databases, daemons and anything without a project, and drop them from the counts. Hides the whole Infrastructure & Daemons section and the dashboard's DB chip along with it. Like ignoring, it is a preference of the app, so
portfox-scanstill lists them. - Group related services by project: fold sibling repositories under their shared parent directory.
- Ignored services: the list of services you have hidden, each with a remove button. Right-click any service and choose Ignore Service to add one. Ignored services leave the popover, the dashboard, the menu bar count and the memory total. An ignored service that is running can also be un-ignored from the Ignored chip in the dashboard sidebar.
- Warn about memory use: one banner when a dev server passes a limit (2, 4 or 8 GB), or all of them together pass one (8 to 24 GB). A server counts its workers. On by default, but it never asks for notification permission by itself, so it posts only once banners are allowed. It keeps measuring with every window closed, once a minute, so it needs the automatic refresh loop.
- Warn about idle agent servers: one banner with a Stop button when a server Claude Code or Codex started has been idle for an hour. Idle means under 1% CPU, workers included, and no new connection to its port. Sleep does not count. Portfox never stops it on its own. Same notification rules as the memory warning.
- Editor and Terminal: which app Open in… uses. Only apps installed on this Mac are listed. The editor opens the repository, the terminal opens the directory the service actually runs in.
- Launch at login: start Portfox automatically when you log in.
- Share anonymous usage data: send anonymous usage counts to TelemetryDeck. On by default. See Privacy.
Right-click a service for Restart, or press it in the dashboard header. Portfox stops the service, then hands its own command line to your terminal, which opens a window running it again in the same directory.
The command is rebuilt from the service's logical root, the same process Stop
signals, so pnpm dev comes back as pnpm dev rather than as the inner node
server. It runs through zsh -l
8000
, so nvm, direnv, asdf and mise are all back in
place. A plain respawn would inherit Portfox's own environment, and a
version-managed runtime would simply be missing.
Relaunching in a terminal rather than in the background is deliberate. The logs stay visible, Ctrl-C still stops it, and the server does not die with Portfox.
Portfox refuses to restart rather than guess:
- Databases and daemons. Homebrew, DBngin and Docker bring their own back, so a hand-relaunched copy would end up unmanaged beside the supervised one.
- Anything whose working directory or command line could not be read, whose directory has been deleted, or whose binary is gone after an upgrade.
- A shell or terminal session, which Stop already refuses to signal.
Restart needs a terminal that runs a script handed to it. Terminal, iTerm2 and Warp were tested doing so. For any other terminal the menu offers Stop and Copy Command instead, which stops the service and leaves the command on the pasteboard.
Stop All sits in the popover footer once two or more services are listed. It
arms on the first press and fires on the second, because a MenuBarExtra popover
closes the moment focus leaves it and a modal confirmation would arrive after the
list it was asking about had gone.
swift run portfox-scan # grouped service list, the popover's view
swift run portfox-scan --raw # every listener with pid, port, exe, argv, cwd
swift run portfox-scan --diagnose # explain how each listener was resolved
swift run portfox-scan --all # include listeners classified as system noise
swift run portfox-scan --json # machine readable
swift run portfox-scan --stop 3000 # SIGTERM the service on a portDetectors are declarative rows, not code. Adding one costs three edits:
- A case in
ServiceType, with its display name, category and default ports. - A row in the relevant file under
Sources/PortfoxKit/Detection/Detectors/. - An entry in
Tools/fetch-icons.py, either inICONSor inNO_BRAND_ICONwith a reason, thenpython3 Tools/fetch-icons.py.
CatalogueGuardTests fails the build if a type has no detector, no icon decision, or a threshold no combination of its signals can reach.
A row looks like this:
RuleBasedDetector(type: .nextJS, threshold: standardThreshold, signals: [
.binPath("next", 100),
.commandContains("next-server", 90),
.configFile("next.config", 70),
.dependency("next", 60),
.defaultPort(3000)
])Signals sharing a group are alternatives, so only the strongest match scores and the group counts once toward confidence. Command-ish helpers default to the command group, port helpers to port.
Command matching is word-boundary aware. A command line is mostly filesystem paths, so plain substring search labels ngrok as Angular (/bin/ng) and vitepress as Vite. String.containsToken requires a non-word character on each side of the match.
defaultPorts is ordered by usefulness, not numerically. When a service binds several of its own defaults, the first one wins the port pill, so Mailpit shows its web UI on 8025 rather than SMTP on 1025.
Every detector must produce at least one match in its requiredGroup, which defaults to command. Project evidence alone never identifies a service. Without that rule, adb running inside an Expo project was reported as Expo.
Stop sends SIGTERM to the service's logical root, not to the process holding the socket. For pnpm dev that is the pnpm process, so the task runner does not survive its child.
The walk up the process tree stops hard at an interactive shell, a terminal emulator or any application bundle. Signals are never sent to a process group, because a dev server started from a terminal shares its group with your shell.
Children that outlive their parent are swept with the same SIGTERM. Anything that ignores that is reported by pid rather than escalated automatically. SIGKILL only ever happens when you ask for it.
Inspecting a service sends one GET request to it and reads back the status, page title, Server header and latency. It only runs when you press Inspect. It never runs on the refresh tick, so opening the dashboard never touches a service that happens to be listening.
The request is refused unless the URL's host is localhost, 127.0.0.1 or ::1, and it never follows a redirect. A 3xx response shows its Location header instead. Together those two rules mean a dev server cannot walk the probe off the machine or trap it in a redirect loop.
Three flags render a surface to a file and exit, without a pointer or Screen Recording permission:
Portfox --snapshot out.png [--hover] # the popover
Portfox --snapshot-dashboard out.png # the dashboard window
Portfox --snapshot-prefs out.png # the preferences sheetAdd --light for the light appearance, and --demo to render a fixed set of made-up services instead of this machine. The README screenshot is --demo, so it never shows a real project.
Preferences needs its own flag rather than sharing the dashboard's, because it is a sheet, and a sheet is a separate window that never renders inside its parent.
ImageRenderer lays out in a single pass and never draws scroll content, so every snapshot renders without its scroll container: the popover's list, the dashboard's panes, and the preferences sheet all carry the same scrolls escape hatch. That is also why the popover measures its own list height rather than letting the ScrollView size itself: inside a MenuBarExtra window a ScrollView has no intrinsic height and collapses to nothing. A TextField is NSTextField-backed and renders as a placeholder block under ImageRenderer, so the dashboard's search box draws its contents as flat text in snapshots instead.
Portfox uses TelemetryDeck for anonymous usage counts. It is on by default.
What is sent:
- A session start, with the app and macOS version.
- One signal each time you stop, force stop, restart, inspect or ignore a service, or open the dashboard. Service signals carry the service type and category, such as
nextjsandweb.
What is never sent: service or project names, paths, ports, process ids, command lines, hostnames, URLs or container names.
To turn it off, clear Share anonymous usage data in Settings. It takes effect at once. Snapshot renders never send anything.
Only the official release builds report. The TelemetryDeck app ID is injected by the release workflow, so a build from source or a fork sends nothing.
- Only processes owned by the current user are visible. A database installed as a root daemon will not appear.
- Working directory and resident memory lookups fail for hardened-runtime processes, along with the other libproc fields. Those services still appear, without a project or a memory reading.
- A service whose type is
.unknownnever gets alocalURL, so it can never be HTTP-inspected even when it obviously serves HTTP. - Dynamic
app.config.jsandapp.config.tsare not evaluated, only their static JSON equivalents. - Ignoring is a preference of the menu bar app, so
portfox-scanstill lists and can still stop an ignored service. That is deliberate: the CLI reports the machine, not your filters. - An ignore is matched by port plus project directory, or port plus executable path for a service with no project. Move a project or change its port and the ignore no longer applies.
- A service whose ports are all ephemeral is hidden by default. That is what orphaned
workerdchildren look like. Turn on Show all listeners to see them. - Launch at login needs a signed build. It fails on a local ad hoc one.
- Restart only works with a terminal that runs a script handed to it. Terminal, iTerm2 and Warp do. Anything else gets Stop and Copy Command.
- CPU is measured between two scans, so it reads blank while automatic refresh is off.
- macOS withholds the environment of an Apple platform binary, such as
/usr/bin/python3, so a server running one never shows as started by Localfox. - An idle server is judged on samples a minute apart with every window closed. A short request that opens and closes between two samples counts only through the CPU it cost.
Portfox will be open sourced. Issues and pull requests are welcome now.
brew install xcodegen swiftlint
make test # confirm the toolchain works
make run # build and launchmake scan runs the whole detection pipeline in the terminal. Use it while working on detection, because it is far faster than launching the GUI.
make lint # must be clean
make test # must pass
make snapshot # after any UI change, then look at snapshots/New behaviour in PortfoxKit needs a test. The kit is pure value types with injectable dependencies, so nearly everything is testable without a running machine. Tests/PortfoxKitTests/Fixtures/live-machine-scan.txt is a recorded lsof sweep used by the regression tests, so detection changes can be checked against a real machine.
The app target has no tests. Verify UI changes with make snapshot.
- Swift 6 language mode with complete strict concurrency. Do not weaken either.
PortfoxKitnever imports SwiftUI or AppKit. If a change needs UI types in the kit, the change belongs in the app.- Preferences belong to the app, not the kit. The kit reports the machine as it is, which is why
portfox-scanstill lists a service you ignored in the app. - Nothing touches a user's dev server on the refresh tick. The HTTP probe only runs when the user presses Inspect.
- Comments explain a non-obvious why, usually naming the bug that forced the decision. Do not add comments that restate the code.
Portfox.xcodeprojis generated and not in version control. Changeproject.ymland runmake gen.
Commit messages use Conventional Commits, one short sentence, no body:
feat(dashboard): add a process tree card
fix(detection): stop labelling ngrok as Angular
A pull request should say what the change does and why, not how. Mention any trade-off you made.
The easiest first contribution is a new service detector. See Adding a service detector above.
MIT. Copyright (c) 2026 Ondra Kandera.
Framework logos come from simple-icons, whose icon paths are CC0. The trademarks belong to their owners and are used only to identify the software each service is running. Regenerate with python3 Tools/fetch-icons.py.