From 4ceb3df0e2364b87fc6a0a6570be4c1189ac6aeb Mon Sep 17 00:00:00 2001 From: ATLMCW Date: Fri, 15 May 2026 20:33:36 -0400 Subject: [PATCH] Add custom device names via Rename Device menu item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now label any monitored BLE device (e.g. "My iPhone") through a new "Rename Device…" menu item. The label is shown in the device scan list and prefixed to the RSSI readout in the menu bar. Names are stored in UserDefaults keyed by CoreBluetooth UUID and persist across restarts. Co-Authored-By: Claude Sonnet 4.6 --- BLEUnlock/AppDelegate.swift | 73 ++++++++++++++++++++++-- BLEUnlock/Base.lproj/Localizable.strings | 3 + CLAUDE.md | 64 +++++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md diff --git a/BLEUnlock/AppDelegate.swift b/BLEUnlock/AppDelegate.swift index 4ee9c918..213e9fa3 100644 --- a/BLEUnlock/AppDelegate.swift +++ b/BLEUnlock/AppDelegate.swift @@ -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() @@ -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 { @@ -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) } @@ -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() { @@ -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) { @@ -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: "") diff --git a/BLEUnlock/Base.lproj/Localizable.strings b/BLEUnlock/Base.lproj/Localizable.strings index 4e75488c..8af7528a 100644 --- a/BLEUnlock/Base.lproj/Localizable.strings +++ b/BLEUnlock/Base.lproj/Localizable.strings @@ -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…"; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1dc0068b --- /dev/null +++ b/CLAUDE.md @@ -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= ./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`