Skip to content
Open
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
73 changes: 67 additions & 6 deletions BLEUnlock/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
var inScreensaver = false
var lastRSSI: Int? = nil

var customNames: [String: String] {
get { prefs.dictionary(forKey: "customDeviceNames") as? [String: String] ?? [:] }
set { prefs.set(newValue, forKey: "customDeviceNames") }
}

func updateMonitorMenuTitle() {
let prefix: String
if let uuid = ble.monitoredUUID, let name = customNames[uuid.uuidString], !name.isEmpty {
prefix = name + ": "
} else {
prefix = ""
}
if connected, let r = lastRSSI {
monitorMenuItem?.title = prefix + String(format: "%ddBm", r)
} else {
monitorMenuItem?.title = prefix.isEmpty ? t("not_detected") : prefix + t("not_detected")
}
}

func menuWillOpen(_ menu: NSMenu) {
if menu == deviceMenu {
ble.startScanning()
Expand Down Expand Up @@ -74,9 +93,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
return menuItem.tag <= ble.unlockRSSI
} else if menuItem.menu == unlockRSSIMenu {
return menuItem.tag >= ble.lockRSSI
} else if menuItem.action == #selector(renameDevice) {
return ble.monitoredUUID != nil
}
return true
}

@objc func renameDevice() {
guard let uuid = ble.monitoredUUID else { return }

let msg = NSAlert()
msg.addButton(withTitle: t("ok"))
msg.addButton(withTitle: t("cancel"))
msg.messageText = t("rename_device")
msg.informativeText = t("rename_device_info")
msg.window.title = "BLEUnlock"

let txt = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 20))
txt.placeholderString = t("rename_device_placeholder")
if let existing = customNames[uuid.uuidString] {
txt.stringValue = existing
}
msg.accessoryView = txt
txt.becomeFirstResponder()
NSApp.activate(ignoringOtherApps: true)
let response = msg.runModal()

if response == .alertFirstButtonReturn {
var names = customNames
let name = txt.stringValue.trimmingCharacters(in: .whitespaces)
if name.isEmpty {
names.removeValue(forKey: uuid.uuidString)
} else {
names[uuid.uuidString] = name
}
customNames = names
updateMonitorMenuTitle()
}
}

func menuDidClose(_ menu: NSMenu) {
if menu == deviceMenu {
Expand All @@ -85,12 +139,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
}

func menuItemTitle(device: Device) -> String {
var desc : String!
let customName = customNames[device.uuid.uuidString]
let label = customName.flatMap { $0.isEmpty ? nil : $0 } ?? device.description
let desc: String
if let mac = device.macAddr {
let prettifiedMac = mac.replacingOccurrences(of: "-", with: ":").uppercased()
desc = String(format: "%@ (%@)", device.description, prettifiedMac)
desc = "\(label) (\(prettifiedMac))"
} else {
desc = device.description
desc = label
}
return String(format: "%@ (%ddBm)", desc, device.rssi)
}
Expand Down Expand Up @@ -119,18 +175,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
func updateRSSI(rssi: Int?, active: Bool) {
if let r = rssi {
lastRSSI = r
monitorMenuItem?.title = String(format:"%ddBm", r) + (active ? " (Active)" : "")
if (!connected) {
connected = true
statusItem.button?.image = NSImage(named: "StatusBarConnected")
}
} else {
monitorMenuItem?.title = t("not_detected")
if (connected) {
connected = false
statusItem.button?.image = NSImage(named: "StatusBarDisconnected")
}
}
updateMonitorMenuTitle()
if let r = rssi, active {
monitorMenuItem?.title += " (Active)"
}
}

func bluetoothPowerWarn() {
Expand Down Expand Up @@ -379,9 +437,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa

func monitorDevice(uuid: UUID) {
connected = false
lastRSSI = nil
statusItem.button?.image = NSImage(named: "StatusBarDisconnected")
monitorMenuItem?.title = t("not_detected")
ble.startMonitor(uuid: uuid)
updateMonitorMenuTitle()
}

func errorModal(_ msg: String, info: String? = nil) {
Expand Down Expand Up @@ -586,6 +645,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
deviceMenu.delegate = self
deviceMenu.addItem(withTitle: t("scanning"), action: nil, keyEquivalent: "")

mainMenu.addItem(withTitle: t("rename_device"), action: #selector(renameDevice), keyEquivalent: "")

let unlockRSSIItem = mainMenu.addItem(withTitle: t("unlock_rssi"), action: nil, keyEquivalent: "")
unlockRSSIItem.submenu = unlockRSSIMenu
item = unlockRSSIMenu.addItem(withTitle: t("disabled"), action: #selector(setUnlockRSSI), keyEquivalent: "")
Expand Down
3 changes: 3 additions & 0 deletions BLEUnlock/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"password_not_set" = "Password is not set.";
"pause_now_playing" = "Pause \"Now Playing\" while Locked";
"quit" = "Quit BLEUnlock";
"rename_device" = "Rename Device…";
"rename_device_info" = "Enter a custom name for this device. Leave blank to remove the custom name.";
"rename_device_placeholder" = "e.g. My iPhone";
"scanning" = "Scanning…";
"seconds" = "seconds";
"set_password" = "Set Password…";
Expand Down
64 changes: 64 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

BLEUnlock is a macOS menu bar app (Swift + Obj-C) that locks/unlocks a Mac based on proximity of a Bluetooth Low Energy device. It requires macOS 10.13+ and must run on a Mac (not cross-platform). Bundle ID: `jp.sone.BLEUnlock`.

## Building

Build and run via Xcode (open `BLEUnlock.xcodeproj`). There are no Swift Package Manager or CocoaPods dependencies — the project uses only system frameworks.

From the command line:
```bash
# Debug build
xcodebuild -scheme BLEUnlock -configuration Debug build

# Archive + export for release (requires Apple Developer credentials and PASSWORD env var)
PASSWORD=<app-specific-password> ./release
```

The `release` script archives, exports, notarizes both the main app and the embedded Launcher login item, then zips for distribution.

## Architecture

### Core Components

**`BLEUnlock/BLE.swift`** — The BLE engine. `BLE` (a `CBCentralManager` delegate) manages two modes:
- **Passive mode**: relies on advertisement packets for RSSI
- **Active mode**: connects to the peripheral and polls `readRSSI()` every 2s; falls back to passive after 10s of no reads

`BLE` tracks a single "monitored" device by UUID. Presence is determined by comparing a moving average of RSSI values (using `vDSP_normalizeD`) against configurable lock/unlock RSSI thresholds. Timers handle `proximityTimeout` (delay-to-lock) and `signalTimeout` (no-signal timeout).

**`BLEUnlock/AppDelegate.swift`** — The UI and system event hub. Handles:
- Menu bar construction and all user actions
- System/display sleep+wake via `NSWorkspace` notifications
- Screen lock/unlock via `DistributedNotificationCenter` (`com.apple.screenIsUnlocked`)
- Screensaver start/stop notifications
- Unlocking: calls `fakeKeyStrokes()` to type the password via `CGEvent` into the lock screen
- Locking: calls `SACLockScreenImmediate()` (private API via `lowlevel.c`) or launches `ScreenSaverEngine`
- Media control via private `MediaRemote.framework` (see `MediaRemote.h`)
- Password storage/retrieval via Keychain (`SecItemAdd`/`SecItemCopyMatching`)
- Runs `~/Library/Application Scripts/jp.sone.BLEUnlock/event` with args: `away`, `lost`, `unlocked`, `intruded`

**`BLEUnlock/lowlevel.c`** — C wrappers for IOKit: `wakeDisplay()`, `sleepDisplay()`, and `SACLockScreenImmediate()` (private SAC framework symbol declared via bridging header).

**`BLEUnlock/LEDeviceInfo.swift`** — Resolves BLE device UUID → MAC address + name. Uses two strategies:
1. (macOS Monterey+) Direct SQLite queries to `/Library/Bluetooth/com.apple.MobileBluetooth.ledevices.paired.db` and `.other.db`
2. (Older macOS) Reads `/Library/Preferences/com.apple.Bluetooth.plist` CoreBluetoothCache

**`BLEUnlock/appleDeviceNames.swift`** — Static lookup table mapping Apple model identifiers (e.g. `"iPhone14,5"`) to human-readable names.

**`Launcher/`** — A minimal Obj-C login item (`SMLoginItemSetEnabled`) that launches the main app at login.

### Key Behavioral Details

- The app cannot use `LSUIElement = true` in Info.plist (which would hide the Dock icon) because `CBCentralManager.scanForPeripherals` won't work in that mode. Instead, it calls `NSApp.setActivationPolicy(.accessory)` after launch and `.regular` during system sleep so BLE scanning resumes after wake.
- `manualLock`: set when the user triggers "Lock Screen Now"; prevents auto-unlock until the device leaves and returns.
- `unlockedAt`: timestamp of the last BLEUnlock-initiated unlock; used by `onUnlock()` to detect manual (intruded) unlocks — if the screen is unlocked more than 10 seconds after BLEUnlock typed the password, it fires the `intruded` event.
- RSSI moving average uses `latestN = 5` samples via `vDSP_normalizeD` (returns mean).

### UserDefaults Keys

`device`, `lockRSSI`, `unlockRSSI`, `timeout`, `lockDelay`, `passiveMode`, `thresholdRSSI`, `wakeOnProximity`, `wakeWithoutUnlocking`, `pauseItunes`, `screensaver`, `sleepDisplay`, `launchAtLogin`