diff --git a/BLEUnlock/AppDelegate.swift b/BLEUnlock/AppDelegate.swift index 4ee9c91..213e9fa 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 4e75488..8af7528 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 0000000..1dc0068 --- /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`