Repository navigation
How it works
This is a tour of the internals for curious users and contributors.
| Piece | File |
|---|---|
| Process sampling, real-time CPU limiter, crash safety (C) | Sources/ProcKit/ProcKit.c |
| Groups helpers into apps, computes rates | Sources/AppWranglerKit/Sampler.swift |
| Rules, conditions, persistence, file watching, migration | Sources/AppWranglerKit/Rules.swift |
Applies rules (behind a mockable ProcessController) |
Sources/AppWranglerKit/Enforcer.swift |
| Battery / Low Power / thermal / memory-pressure monitor | Sources/AppWranglerKit/SystemState.swift |
| Runaway detection, history |
Runaway.swift, History.swift
|
| "What is this?" descriptions | Sources/AppWranglerKit/Catalog.swift |
| Command line | Sources/AppWranglerKit/CLI.swift |
| MCP server (stdio JSON-RPC) for AI assistants | Sources/AppWranglerKit/MCP.swift |
| JSON reports shared by CLI and MCP | Sources/AppWranglerKit/Reports.swift |
| Impact statistics (savings, cost, accuracy) | Sources/AppWranglerKit/Stats.swift |
| Sampling schedule, instant re-apply on any change | Sources/AppWranglerKit/AppModel.swift |
| Notifications, login item, global shortcut | Sources/AppWranglerKit/Services.swift |
UI (the menu bar panel in an NSPopover, the main window, Settings and Help windows) |
Sources/AppWranglerKit/Views/ |
appwrangler:// links (widget buttons, Shortcuts) |
Sources/AppWranglerKit/AppURL.swift |
App-wide settings for the CLI and MCP (prefs) |
Sources/AppWranglerKit/Preferences.swift |
| Auto mode (focus, E-cores, fair share, idle-app freezing) | Sources/AppWranglerKit/AutoPilot.swift |
| Suggestions, per-app settings, undo journal |
Suggestions.swift, AppSettings.swift
|
appwrangler mcp install (Claude / Codex config files) |
Sources/AppWranglerKit/MCPInstaller.swift |
| In-app Help (Markdown → HTML in a web view) |
Markdown.swift, Views/HelpView.swift
|
Desktop widget (sandboxed WidgetKit extension; reads widget.json) |
Widget/AppWranglerWidget.swift + WidgetSnapshot.swift
|
| Watchdog helper (restores apps if AppWrangler dies; maps the shared tables from an inherited fd) |
Sources/AppWranglerWatchdog/main.c → pk_watchdog_main
|
| Entry point (app or CLI) |
Sources/AppWrangler/main.swift → AppWranglerMain.run()
|
Almost everything lives in the AppWranglerKit library, so tests can @testable import it; the executable is two lines.
proc_listallpids lists processes. For each new PID, the sampler caches:
- the executable path and name;
- the parent PID;
- the responsible PID: the app macOS considers the owner of an XPC service or helper.
One proc_pid_rusage call per process returns CPU time, physical footprint, disk I/O and energy together. On Apple Silicon, task CPU times are in Mach ticks (24 MHz, timebase 125/3), not nanoseconds, so everything is converted. The original AppPolice missed this and under-measured about 42×.
Grouping (Grouping.owner): a process joins an app if the app is responsible for it, or is an ancestor of it, and the process ships inside the app's bundle or is an XPC service. So Chrome renderers and Safari's WebContent join their app, while a shell started from Terminal stays separate.
Adaptive cadence (AppModel):
- Panel open: everything, every second.
- Panel closed: apps with rules or freezes, plus every app while Auto mode is on, every 2 s; plus the hot command-line processes Auto is following; plus a full scan every 5 s if runaway detection is on, and every 10 s to find hot processes while Auto manages processes.
- Nothing to do: no timer at all.
CPU limit: the C limiter duty-cycles a whole process group with SIGSTOP/SIGCONT.
- Each period (50 ms by default), it measures the group's real CPU use over the real elapsed time.
- It estimates the group's demand (its usage while running) and sets the share of the period the group may run to
limit / demand. - A slow integral term corrects for signal latency.
- The limiter thread is a Mach time-constraint (real-time) thread. When a throttled app saturates every P-core, even top-QoS threads wake about 6 ms late on Apple Silicon, which made a 0.5 ms run slice into 6 ms and low limits overshoot 4×. Measured on an M1 against a multi-threaded load, it holds within about 2%.
Efficiency cores: setpriority(PRIO_DARWIN_PROCESS, pid, PRIO_DARWIN_BG) puts the process in the background band: E-cores only, with throttled disk and network I/O. This is undone when the rule goes away.
Freeze: a limiter group flagged frozen stays stopped, including any newly added helpers. Pausing limits doesn't thaw it.
The Enforcer works out which groups should be limited, frozen or in efficiency mode for the current snapshot, rules and SystemState:
- It only calls the limiter when a group's parameters change, so steady state never interrupts a duty cycle.
- Memory checks only count new samples. Re-applying on a focus change doesn't add a strike.
- All side effects go through the
ProcessControllerprotocol, which tests replace with a fake.
Instant re-apply: any of these triggers a pass straight away:
- rule edits from the UI;
-
rules.jsonchanges (the data folder is watched with aDispatchSource; the store ignores its own writes and never overwrites a pending local edit); - CLI commands, sent as distributed notifications;
- app launch or quit, and focus changes;
- power-source changes (IOKit), Low Power Mode, thermal state, and memory pressure (
DispatchSource.makeMemoryPressureSource).
The C module keeps a lock-free table of every PID it has stopped. Several things all resume them:
- a handler for
SIGTERM,SIGINT,SIGHUP,SIGQUIT(SIGTSTPonly resumes paused apps; the limiter carries on afterSIGCONT); - the crash signals (
SIGSEGV,SIGBUS,SIGILL,SIGFPE,SIGABRT,SIGTRAP); -
atexit; - normal termination.
The handler is async-signal-safe: atomics, kill and setpriority only.
The tables live in an unnamed shared-memory object, shared with a watchdog. At launch AppWrangler forks, and the child execs the small AppWranglerWatchdog helper, which maps the same tables from the inherited file descriptor and waits on a pipe. It's a separate executable so it has its own process name: killall AppWrangler can't take it down too. Development builds without the helper fall back to a plain fork. When AppWrangler dies in any way, including SIGKILL, the pipe closes. The watchdog then resumes every stopped pid and restores efficiency-core pids and their children, then exits. Release is terminal: it sets a shutdown flag that the limiter checks before and after each stop, so a stop racing with the exit can't leave an app suspended.
Session-critical processes are on a protected list. An flock on the data folder ensures one instance.
| Suite | What it covers |
|---|---|
Tests/AppWranglerKitTests (Swift Testing, ./test.sh) |
Enforcer logic against a fake controller; rules, precedence, patterns, conditions and schedules; import/merge; migration; grouping; runaway detection; CLI parsing; the catalog; localization coverage; and real limiter integration tests on /usr/bin/yes burners |
Tests/e2e/run.sh |
Runs the built .app with an isolated APPWRANGLER_DATA_DIR and drives it through the CLI. It checks: on-the-fly rule changes (time to enforcement), new processes picking up rules, pause/freeze interplay, the memory limit, persistence across restart, release on quit and crash, single instance, and overhead |
With only the Command Line Tools, swift test can't find the Swift Testing macro plugin; test.sh passes its path. Likewise SwiftUI's @State macro isn't available, so the views use a small @Local property wrapper built on @StateObject (Views/Local.swift).
These pages are generated from the docs in the repository. To change one, edit it there and send a pull request; edits made here are overwritten. AppWrangler is free software under the GPL-2.0.
Use it
Go further
Project