MacAPLock is an experimental macOS menu-bar utility that connects to one specific Wi-Fi access point by BSSID (the AP's MAC address) and watches for macOS roaming to another AP with the same SSID.
It uses a public SSID scan with a local exact-BSSID match, then tries several ABI-validated exact-BSSID association paths. These include CoreWLAN's extended forceBSSID method and a direct CoreWiFi call using the exact scan result. If every private call fails, it tries the public CoreWLAN association API once. After every association it polls CoreWLAN asynchronously to verify the actual BSSID. While the lock is active, coalesced CoreWLAN events and a three-second watchdog detect roaming and reconnect to the selected AP.
The most recently verified association strategy is learned per macOS version. The last exact scan result is retained and always tried first so a roam can normally be corrected without waiting for another scan. If that direct call fails, MacAPLock performs one fresh scan and retries it before moving to the fallback strategies.
Three departures within 30 seconds are treated as a roam storm. During a storm, the menu reports that the target is unstable instead of treating a momentary return as a healthy lock. The warning clears after 30 seconds without another departure.
Each visible AP also receives a persistent five-segment connectivity-history bar. The score learns from time observed on that BSSID, unwanted departures, roam storms, association reliability, enforcement failures, and recovery speed. New APs are labeled Learning until the app has enough evidence; RSSI remains separate because a strong signal does not guarantee a stable connection.
An optional Automatic AP Selection mode can use that history to choose among BSSIDs on the selected SSID. You can allow only 2.4, 5, or 6 GHz and exclude individual channels. The app filters its own scan results, ranks eligible APs using 70% connectivity health and 30% normalized RSSI, and changes targets only when an alternative is at least 12 points better. This hysteresis avoids bouncing between nearly equal APs. The rules are scoped to MacAPLock; they do not reconfigure the macOS radio or prevent another application from initiating a connection.
CoreWLAN and CoreWiFi objects never leave a dedicated serial queue. Menu state contains immutable snapshots rather than live framework objects; this is important because the framework's XPC-backed objects are not safe to use concurrently.
Download the latest app from GitHub Releases. The prebuilt release currently targets Apple silicon and requires macOS 13 or later.
The release is ad-hoc signed but not Apple-notarized. On first launch, macOS may block it. Open System Settings → Privacy & Security, review the warning, and choose Open Anyway only if you obtained the app from this repository and accept the experimental private-API behavior.
MacAPLock does not require disabling SIP, reducing startup security, installing a privileged helper, or claiming Apple's restricted Wi-Fi entitlements.
Requirements:
- macOS 13 or later
- Xcode command-line tools or Xcode with Swift 5.10 or later
Build the signed application bundle:
./Scripts/build-app.sh
open build/MacAPLock.appThe build is ad-hoc signed for local use. It is not intended for the Mac App Store because it optionally calls an undocumented API.
- Launch the app and find its Wi-Fi/lock icon in the menu bar.
- Approve Location Services access. macOS treats SSIDs and BSSIDs as location data.
- Choose Scan for Access Points.
- Open Visible Access Points and select the exact BSSID you want. History bars appear before each AP; hover over one for the underlying observations.
- Enter credentials if needed. Leaving the fields empty asks macOS to associate without explicit credentials. Credentials supplied to MacAPLock can be stored in the login keychain.
- Confirm that the status says Locked to … and that Keep Lock Active is checked.
To let MacAPLock choose a stable allowed AP instead, open Automatic AP Selection after selecting the first AP. Configure Allowed Bands and Excluded Channels, then enable Use Automatic Selection. The initial target defines the SSID and security boundary; automatic mode never selects a different SSID or security type. Re-evaluate Now performs an immediate scan. A regular access-point scan also re-evaluates while automatic mode and the lock are active.
The selected target is remembered across launches, but the lock deliberately starts disabled after a relaunch. Enable Keep Lock Active when you want it again. This avoids surprising network changes immediately after login.
The Diagnostics menu provides:
- Copy Diagnostic Snapshot — copies OS, permission, interface, current connection, private-API, target, last-error, and last-scan information.
- Export Diagnostic Snapshot… — saves the same information to a text file.
- Open Debug Log — opens the persistent detailed log.
- Reveal Debug Log in Finder — reveals the log file.
The menu and diagnostic snapshot also report target dwell time, total and recent unwanted roams, roam-storm state, successful recoveries, failed attempts, last recovery duration, the last verified association strategy, persistent per-BSSID connectivity history, and a read-only probe of historical BSSID records already stored in matching macOS network profiles. Private nested records are reduced to BSSID values; location and timestamp metadata are not logged.
The log is stored at:
~/Library/Logs/MacAPLock/MacAPLock.log
For a live view of the same subsystem in Terminal:
log stream --level debug --predicate 'subsystem == "com.macaplock.app"'It rotates at approximately 5 MB. Logs contain SSIDs and BSSIDs because those values are essential to diagnosing roaming. Password and passphrase values are never intentionally logged, and an additional redaction pass is applied before every log write.
Every scan and association records:
- Location authorization and interface availability
- All usable scan candidates, channels, RSSI, noise, and security modes
- Whether each private scan/association strategy exists and its runtime type encoding
- Every association strategy attempted and which one was ultimately used
- CoreWLAN error domain, numeric code, description, and non-secret user info
- Asynchronous post-association BSSID verification samples
- Coalesced BSSID, SSID, link, and power change events
- Watchdog mismatches, cached-first recovery, targeted rescans, retry delay, and consecutive failure count
- Roam-storm detection and 30-second stable-window clearing
- Cached CoreWiFi roam-status details when the watchdog detects a departure
- ABI availability and sanitized BSSID-only observations from the read-only private
CWNetworkProfile.bssidListgetter - Read-only ABI and authorization diagnostics for private band/channel controls; the app does not change blocked bands
- Periodic lock-health summaries, dwell time, roam/recovery counts, and the learned working strategy
- Automatic-selection policy, candidate health/signal/combined scores, switch-threshold decisions, and every band/channel rejection reason
- No active Wi-Fi interface: Wi-Fi is off or its Network service is inactive. Enable Wi-Fi in System Settings.
- Every BSSID was redacted: Location permission was not granted to MacAPLock, or Location Services is globally disabled. Grant access and scan again.
- Private force-BSSID attempt failed: macOS rejected or changed that undocumented method. MacAPLock logs the full error and proceeds to the next exact-BSSID strategy before considering the public fallback.
- Target AP is not currently visible: the AP is out of range, no longer broadcasting, or the scan was incomplete. The watchdog retries with exponential backoff up to 60 seconds.
- Verification failed: macOS reported association success but connected to a different BSSID. This is the most important result for investigating whether
forceBSSIDsemantics changed. - Associated; BSSID verification unavailable: association may have worked, but macOS redacted the current BSSID, so enforcement cannot be trusted.
- Repeated enterprise association failure: username/password enterprise networks are supported, but certificate-identity selection is not yet implemented.
This is a client-side enforcement utility, not a firmware-level roaming lock. macOS may briefly roam before the app receives the event and reconnects. If the network requires a truly interruption-free result, the reliable fix is on the Wi-Fi infrastructure: repair the bad AP, assign a separate SSID, or configure the controller to reject the Mac specifically on that AP.
MacAPLock relies on undocumented CoreWLAN/CoreWiFi behavior. Apple may change or remove it in any macOS update. The runtime validates private method signatures and verifies every claimed association by BSSID, but no compatibility guarantee is possible. The association behavior was verified on an Apple silicon Mac running macOS 26.6.1.
Runtime inspection on macOS 26.6.1 found that the undocumented CWNetworkProfile.bssidList value contains historical association records for multiple APs, not an allow-list or BSSID lock. MacAPLock reads and sanitizes this field for diagnostics only; it never writes the field or commits a modified Wi-Fi configuration.
The same runtime exposes a private CoreWiFi blocked-band setter, but the normal MacAPLock XPC client is denied its required request authorization. Private channel lists constrain scans, not later roaming, and association band preference is a hint rather than a demonstrated lock. The public channel setter cannot change channels while the interface is associated. MacAPLock therefore does not claim to block a band or channel system-wide. Its allowed-band and excluded-channel rules choose an exact BSSID from filtered scan results, then the existing watchdog enforces that BSSID and re-evaluates if the target disappears or changes to a disallowed channel.
swift build
swift testScripts/build-entitlement-probe.sh creates a separate
build/MacAPLockProbe.app containing the private Wi-Fi entitlements observed
on Apple's WiFiAgent. It also logs the process's effective entitlement values
at startup. On a normal SIP-enabled installation, AMFI is expected to reject
this ad-hoc-signed probe before launch with “restricted entitlements.” The
probe does not disable SIP or modify startup security.
Private association calls are isolated in PrivateAssociation.swift; read-only cached roam and saved-profile diagnostics are isolated in PrivateRoamDiagnostics.swift and PrivateNetworkProfileDiagnostics.swift. Private selectors and method ABIs are validated before they are called. The saved-profile probe never creates a mutable configuration or calls commitConfiguration. Changes in a future macOS release should therefore produce an explicit diagnostic and safe failure instead of blindly calling an incompatible signature.
MacAPLock is available under the MIT License.