diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index fc93dfb2..3ee0adcc 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -138,6 +138,105 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { } reply(false) } + + // MARK: - Bluetooth Device Info + // Maps to the root object containing the SPBluetoothDataType + private struct SPBluetoothDataRoot: Decodable { + let bluetoothData: [SPBluetoothData]? + + private enum CodingKeys: String, CodingKey { + case bluetoothData = "SPBluetoothDataType" + } + } + + private struct SPBluetoothData: Decodable { + let deviceConnected: [SPBluetoothDataDevice]? + let deviceNotconnected: [SPBluetoothDataDevice]? + + enum CodingKeys: String, CodingKey { + case deviceConnected = "device_connected" + case deviceNotconnected = "device_not_connected" + } + } + + private struct SPBluetoothDataDevice: Decodable { + let name: String? + let info: SPBluetoothDataDeviceInfo? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let dict = try container.decode([String: SPBluetoothDataDeviceInfo].self) + + guard let (key, value) = dict.first else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Expected dictionary with a single key") + ) + } + + self.name = key + self.info = value + } + } + + private struct SPBluetoothDataDeviceInfo: Decodable { + let adress: String? + // let deviceBatteryLevelMain: String // - not used + let minorType: String? + let productID: String? + let services: String? + let vendorID: String? + + enum CodingKeys: String, CodingKey { + case adress = "device_adress" + case minorType = "device_minorType" + case productID = "device_productID" + case services = "device_services" + case vendorID = "device_vendorID" + } + } + + @objc func getBluetoothDeviceMinorClass(with deviceName: String, with reply: @escaping (String?) -> Void) { + let task = Process() + let pipe = Pipe() + + task.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler") + task.arguments = ["-json", "SPBluetoothDataType"] + task.standardOutput = pipe + + let fileHandle = pipe.fileHandleForReading + + var data: Data? + do { + try task.run() + task.waitUntilExit() // Block until the shell exits + data = try fileHandle.readToEnd() + } catch { + reply(nil) + } + + guard let data, data.isEmpty == false else { + reply(nil) + return + } + + // Continue with your decoding logic + do { + let rootInfo = try JSONDecoder().decode(SPBluetoothDataRoot.self, from: data) + + guard let deviceContainer = rootInfo.bluetoothData?.first, + let devices = deviceContainer.deviceConnected, + let device = devices.first(where: {$0.name == deviceName}) + else { + reply(nil) + return + } + + reply(device.info?.minorType?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)) + } catch { + reply(nil) + } + } // MARK: - Private helpers for DisplayServices / IOKit access private func displayServicesGetBrightness(displayID: CGDirectDisplayID, out: inout Float) -> Bool { diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift index 01eccf69..fb01960e 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift @@ -20,6 +20,7 @@ import Foundation func isScreenBrightnessAvailable(with reply: @escaping (Bool) -> Void) func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) func setScreenBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) + func getBluetoothDeviceMinorClass(with deviceName: String, with reply: @escaping (String?) -> Void) } /* diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index a2489cd7..476f489f 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -142,6 +142,10 @@ B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0A0012E60000100000001 /* BrightnessManager.swift */; }; B1F747F92EC7E94000F841DB /* LottieView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F747F82EC7E94000F841DB /* LottieView.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; + D9958EED2ED3BA190021100C /* SymbolPicker in Frameworks */ = {isa = PBXBuildFile; productRef = D9958EEC2ED3BA190021100C /* SymbolPicker */; }; + D9958EEE2ED468050021100C /* IOBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D9B69FAF2ECFC09E009BDE40 /* IOBluetooth.framework */; }; + D9958EF02ED4680A0021100C /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D9958EEF2ED4680A0021100C /* CoreBluetooth.framework */; }; + D9B69FA32ECE8074009BDE40 /* BluetoothManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B69FA22ECE8074009BDE40 /* BluetoothManager.swift */; }; F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; }; /* End PBXBuildFile section */ @@ -308,6 +312,9 @@ B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; B1F747F82EC7E94000F841DB /* LottieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieView.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; + D9958EEF2ED4680A0021100C /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; + D9B69FA22ECE8074009BDE40 /* BluetoothManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothManager.swift; sourceTree = ""; }; + D9B69FAF2ECFC09E009BDE40 /* IOBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOBluetooth.framework; path = System/Library/Frameworks/IOBluetooth.framework; sourceTree = SDKROOT; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -340,8 +347,10 @@ files = ( 111BEA5F2ED07A340079DD4E /* MacroVisionKit in Frameworks */, 9A987A102C73CA8D005CA465 /* Collections in Frameworks */, + D9958EF02ED4680A0021100C /* CoreBluetooth.framework in Frameworks */, 1194E8852EA57D23009C82D6 /* SkyLightWindow in Frameworks */, 112B0EBB2E30DD5000562D6C /* MediaRemoteAdapter.framework in Frameworks */, + D9958EEE2ED468050021100C /* IOBluetooth.framework in Frameworks */, 14D0321D2C68F3350096E6A1 /* Sparkle in Frameworks */, 111BEA6F2ED166E20079DD4E /* MacroVisionKit in Frameworks */, 11F748732EC9DA9300F841DB /* Lottie in Frameworks */, @@ -351,6 +360,7 @@ B19016222CC15B3D00E3F12E /* Defaults in Frameworks */, 111BE95D2ECD71E10079DD4E /* AsyncXPCConnection in Frameworks */, B1628B922CC260C0003D8DF3 /* SwiftUIIntrospect in Frameworks */, + D9958EED2ED3BA190021100C /* SymbolPicker in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -518,6 +528,7 @@ 14C08BB52C8DE42D000F8AA0 /* CalendarManager.swift */, B1F0A0012E60000100000001 /* BrightnessManager.swift */, 59D8C23B2E589FAA00147B33 /* VolumeManager.swift */, + D9B69FA22ECE8074009BDE40 /* BluetoothManager.swift */, ); path = managers; sourceTree = ""; @@ -599,6 +610,8 @@ 14D031EC2C689DB70096E6A1 /* Frameworks */ = { isa = PBXGroup; children = ( + D9958EEF2ED4680A0021100C /* CoreBluetooth.framework */, + D9B69FAF2ECFC09E009BDE40 /* IOBluetooth.framework */, 14D031EF2C689DC00096E6A1 /* ApplicationServices.framework */, 14D031ED2C689DB70096E6A1 /* IOKit.framework */, 112B0EBA2E30DD5000562D6C /* MediaRemoteAdapter.framework */, @@ -814,6 +827,7 @@ 111BEA502ECFBF7F0079DD4E /* MacroVisionKit */, 111BEA5E2ED07A340079DD4E /* MacroVisionKit */, 111BEA6E2ED166E20079DD4E /* MacroVisionKit */, + D9958EEC2ED3BA190021100C /* SymbolPicker */, ); productName = dynamicNotch; productReference = 14CEF4122C5CAED300855D72 /* boringNotch.app */; @@ -859,6 +873,7 @@ 11F748712EC9DA9300F841DB /* XCRemoteSwiftPackageReference "lottie-spm" */, 111BE95B2ECD71E10079DD4E /* XCRemoteSwiftPackageReference "AsyncXPCConnection" */, 111BEA6D2ED166E20079DD4E /* XCRemoteSwiftPackageReference "MacroVisionKit" */, + D9958EEB2ED3BA190021100C /* XCRemoteSwiftPackageReference "SymbolPicker" */, ); productRefGroup = 14CEF4132C5CAED300855D72 /* Products */; projectDirPath = ""; @@ -941,6 +956,7 @@ B1C974342C642B6D0000E707 /* MarqueeTextView.swift in Sources */, 14288DE82C6E01C800B9F80C /* ProgressIndicator.swift in Sources */, 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */, + D9B69FA32ECE8074009BDE40 /* BluetoothManager.swift in Sources */, 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */, 1113ABC82E80E27000EC13B2 /* ShelfItem.swift in Sources */, 1113ABCA2E80E27000EC13B2 /* ShelfSelectionModel.swift in Sources */, @@ -1239,6 +1255,8 @@ INFOPLIST_KEY_LSBackgroundOnly = NO; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSAppleEventsUsageDescription = "This app uses AppleEvents to control music"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "This app monitors Bluetooth device connections"; + INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "This app monitors Bluetooth device connections"; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -1292,6 +1310,8 @@ INFOPLIST_KEY_LSBackgroundOnly = NO; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSAppleEventsUsageDescription = "This app uses AppleEvents to control music"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "This app monitors Bluetooth device connections"; + INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "This app monitors Bluetooth device connections"; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -1436,6 +1456,14 @@ minimumVersion = 9.0.2; }; }; + D9958EEB2ED3BA190021100C /* XCRemoteSwiftPackageReference "SymbolPicker" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/xnth97/SymbolPicker.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.6.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1497,6 +1525,11 @@ package = B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; + D9958EEC2ED3BA190021100C /* SymbolPicker */ = { + isa = XCSwiftPackageProductDependency; + package = D9958EEB2ED3BA190021100C /* XCRemoteSwiftPackageReference "SymbolPicker" */; + productName = SymbolPicker; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 14CEF40A2C5CAED200855D72 /* Project object */; diff --git a/boringNotch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/boringNotch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 40d9939d..727de124 100644 --- a/boringNotch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/boringNotch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ab961f5de25797b1a82bd0f8c39b561332a3dfe10de0a118e1252262fbd45864", + "originHash" : "c2019725100d260cd60a8eaa0bc7eb4d80fec44a51dce6da9ba422d72aa7c49b", "pins" : [ { "identity" : "asyncxpcconnection", @@ -108,6 +108,15 @@ "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", "version" : "1.3.0" } + }, + { + "identity" : "symbolpicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/xnth97/SymbolPicker.git", + "state" : { + "revision" : "8bb08c982235b8e601bc500a41e770d7e198759b", + "version" : "1.6.2" + } } ], "version" : 3 diff --git a/boringNotch/Assets.xcassets/bluetooth.imageset/Contents.json b/boringNotch/Assets.xcassets/bluetooth.imageset/Contents.json new file mode 100644 index 00000000..3747cccc --- /dev/null +++ b/boringNotch/Assets.xcassets/bluetooth.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "baseline_bluetooth_black_36pt_1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baseline_bluetooth_black_36pt_2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "baseline_bluetooth_black_36pt_3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_1x.png b/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_1x.png new file mode 100644 index 00000000..05239e96 Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_1x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_2x.png b/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_2x.png new file mode 100644 index 00000000..19c1db27 Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_2x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_3x.png b/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_3x.png new file mode 100644 index 00000000..e1c562f3 Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.imageset/baseline_bluetooth_black_36pt_3x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.settings.imageset/Contents.json b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/Contents.json new file mode 100644 index 00000000..a63ca891 --- /dev/null +++ b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "baseline_settings_bluetooth_black_36pt_1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baseline_settings_bluetooth_black_36pt_2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "baseline_settings_bluetooth_black_36pt_3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_1x.png b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_1x.png new file mode 100644 index 00000000..d4c1b494 Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_1x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_2x.png b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_2x.png new file mode 100644 index 00000000..ce613323 Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_2x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_3x.png b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_3x.png new file mode 100644 index 00000000..c0a8aeb7 Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.settings.imageset/baseline_settings_bluetooth_black_36pt_3x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.slash.imageset/Contents.json b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/Contents.json new file mode 100644 index 00000000..a2d797c2 --- /dev/null +++ b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "baseline_bluetooth_disabled_black_36pt_1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baseline_bluetooth_disabled_black_36pt_2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "baseline_bluetooth_disabled_black_36pt_3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_1x.png b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_1x.png new file mode 100644 index 00000000..7d37139d Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_1x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_2x.png b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_2x.png new file mode 100644 index 00000000..3d09695c Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_2x.png differ diff --git a/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_3x.png b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_3x.png new file mode 100644 index 00000000..725e79ec Binary files /dev/null and b/boringNotch/Assets.xcassets/bluetooth.slash.imageset/baseline_bluetooth_disabled_black_36pt_3x.png differ diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 59728b87..8446882e 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -18,6 +18,7 @@ enum SneakContentType { case mic case battery case download + case bluetooth } struct sneakPeek { @@ -59,6 +60,7 @@ class BoringViewCoordinator: ObservableObject { @AppStorage("firstLaunch") var firstLaunch: Bool = true @AppStorage("showWhatsNew") var showWhatsNew: Bool = true @AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true + @AppStorage("bluetoothLiveActivityEnabled") var bluetoothLiveActivityEnabled: Bool = false @AppStorage("currentMicStatus") var currentMicStatus: Bool = true @AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true { diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index d95af616..9d826304 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -23,6 +23,7 @@ struct ContentView: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var brightnessManager = BrightnessManager.shared @ObservedObject var volumeManager = VolumeManager.shared + @ObservedObject var bluetoothManager = BluetoothManager.shared @State private var hoverTask: Task? @State private var isHovering: Bool = false @State private var anyDropDebounceTask: Task? @@ -285,6 +286,8 @@ struct ContentView: View { } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && vm.notchState == .closed { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.opacity) + } else if coordinator.expandingView.show && coordinator.expandingView.type == .bluetooth && vm.notchState == .closed && coordinator.bluetoothLiveActivityEnabled && !vm.hideOnClosed { + BluetoothLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { MusicLiveActivity() .frame(alignment: .center) @@ -484,6 +487,118 @@ struct ContentView: View { ) } + @ViewBuilder + func BluetoothLiveActivity() -> some View { + VStack { + HStack { + let iconName: String = bluetoothManager.getDeviceIcon(for: bluetoothManager.lastBluetoothDevice) + + Image(systemName: iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(bluetoothManager.lastBluetoothDevice?.isConnected() == true ? Color.effectiveAccent : .gray) + .symbolRenderingMode(.monochrome) + .frame( + width: max(0, vm.effectiveClosedNotchHeight - 12), + height: max(0, vm.effectiveClosedNotchHeight - 12) + ) + + Rectangle() + .fill(.black) + .overlay( + HStack(alignment: .top) { + if coordinator.expandingView.show + && coordinator.expandingView.type == .bluetooth + && Defaults[.enableBluetoothSneakPeek] + && Defaults[.bluetoothSneakPeekStyle] == .inline + { + MarqueeText( + .constant("\(bluetoothManager.lastBluetoothDevice?.name ?? "") - \(bluetoothManager.lastBluetoothDevice?.isConnected() == true ? "Connected" : "Disconnected")"), + textColor: .gray, + minDuration: 0.4, + frameWidth: 100 + ) + Spacer(minLength: vm.closedNotchSize.width) + } + } + ) + .frame( + width: (coordinator.expandingView.show + && coordinator.expandingView.type == .bluetooth + && Defaults[.enableBluetoothSneakPeek] + && Defaults[.bluetoothSneakPeekStyle] == .inline) + ? 380 + : vm.closedNotchSize.width + + (isHovering ? 8 : -cornerRadiusInsets.closed.top) + ) // RECTANGLE + if bluetoothManager.lastBluetoothDevice?.isConnected() == true { + if let battery = bluetoothManager.batteryPercentage { + HStack { + BatteryRing(percentage: Double(battery)) + } + .frame( + width: max( + 0, + vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + + gestureProgress / 2 + ), + height: max( + 0, + vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ), + alignment: .center + ) + } else { + Image(systemName: "circle.slash") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.gray) + .symbolRenderingMode(.hierarchical) + .frame( + width: max(0, vm.effectiveClosedNotchHeight - 12), + height: max(0, vm.effectiveClosedNotchHeight - 12) + ) + } + } else { + Image("bluetooth.slash") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .foregroundStyle(.gray) + .frame( + width: max(0, vm.effectiveClosedNotchHeight - 12), + height: max(0, vm.effectiveClosedNotchHeight - 12) + ) + } + } // HSTACK + .frame( + height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), + alignment: .center + ) + + if coordinator.expandingView.type == .bluetooth && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.enableBluetoothSneakPeek] && Defaults[.bluetoothSneakPeekStyle] == .standard { + HStack(alignment: .center) { + GeometryReader { geo in + MarqueeText( + .constant("\(bluetoothManager.lastBluetoothDevice?.name ?? "") - \(bluetoothManager.lastBluetoothDevice?.isConnected() == true ? "Connected" : "Disconnected")"), + textColor: .gray, + minDuration: 1, + frameWidth: geo.size.width, + infiniteText: true + ) + } + } + .foregroundStyle(.gray) + .padding(.bottom, 10) + } + } // VSTACK + .conditionalModifier((coordinator.expandingView.show && (coordinator.expandingView.type == .bluetooth) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.bluetoothSneakPeekStyle] == .standard) || (coordinator.expandingView.show && (coordinator.expandingView.type != .bluetooth) && (vm.notchState == .closed))) { view in + view + .fixedSize() + } + .zIndex(2) + } + @ViewBuilder var dragDetector: some View { if Defaults[.boringShelf] && vm.notchState == .closed { diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index fa343990..daa1e53e 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -1800,6 +1800,9 @@ } } } + }, + "Add Device Icon" : { + }, "Add manually" : { "extractionState" : "stale", @@ -3601,6 +3604,18 @@ } } } + }, + "Bluetooth" : { + + }, + "Bluetooth Access" : { + + }, + "Bluetooth access is required to detect connected devices and display their battery status." : { + + }, + "Bluetooth is currently turned off." : { + }, "Boost your productivity with Clipboard Manager" : { "localizations" : { @@ -4801,6 +4816,9 @@ } } } + }, + "Choose an SF Symbol to represent this device." : { + }, "Choose any color" : { "localizations" : { @@ -6201,6 +6219,9 @@ } } } + }, + "Create custom icon mappings for Bluetooth devices. When a device name contains your keyword, the specified SF Symbol will be used." : { + }, "Currently selected: %@" : { "localizations" : { @@ -6401,6 +6422,9 @@ } } } + }, + "Custom Device Icons" : { + }, "Custom height" : { "localizations" : { @@ -7202,6 +7226,9 @@ } } } + }, + "Device Name" : { + }, "Disable" : { "extractionState" : "stale", @@ -7404,6 +7431,9 @@ } } } + }, + "Displays connected Bluetooth devices and their battery status inside the notch." : { + }, "Download" : { "localizations" : { @@ -7905,6 +7935,9 @@ } } } + }, + "e.g., AirPods Pro, Magic Mouse" : { + }, "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : { "localizations" : { @@ -8005,6 +8038,9 @@ } } } + }, + "Edit Device Icon" : { + }, "Edit layout" : { "localizations" : { @@ -9106,6 +9142,9 @@ } } } + }, + "Enter a keyword or part of the device name. Matching is case-insensitive." : { + }, "Expanded drag detection area" : { "localizations" : { @@ -12008,6 +12047,9 @@ } } } + }, + "Live activity" : { + }, "Lottie JSON URL" : { "localizations" : { @@ -14608,6 +14650,9 @@ } } } + }, + "No custom device icons" : { + }, "No custom visualizer" : { "localizations" : { @@ -15509,6 +15554,9 @@ } } } + }, + "Open Bluetooth Settings" : { + }, "Open Calendar Settings" : { "localizations" : { @@ -18111,6 +18159,12 @@ } } } + }, + "Save" : { + + }, + "Select SF Symbol" : { + }, "Select the music source you want to use. You can change this later in the app settings." : { "localizations" : { @@ -18511,6 +18565,9 @@ } } } + }, + "SF Symbol" : { + }, "Shelf" : { "localizations" : { @@ -18911,6 +18968,9 @@ } } } + }, + "Show Bluetooth live activity" : { + }, "Show calendar" : { "localizations" : { @@ -20111,6 +20171,9 @@ } } } + }, + "Show sneak peek on device changes" : { + }, "Show sneak peek on playback changes" : { "localizations" : { @@ -20311,6 +20374,12 @@ } } } + }, + "Sneak peek" : { + + }, + "Sneak peek shows the Bluetooth device name under the notch for a few seconds when a device connects or disconnects." : { + }, "Sneak Peek shows the media title and artist under the notch for a few seconds." : { "localizations" : { diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift index ae08673c..7494e5a6 100644 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift @@ -20,5 +20,6 @@ import Foundation func isScreenBrightnessAvailable(with reply: @escaping (Bool) -> Void) func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) func setScreenBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) + func getBluetoothDeviceMinorClass(with deviceName: String, with reply: @escaping (String?) -> Void) } diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index f8c6e232..78335cc0 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -241,6 +241,22 @@ final class XPCHelperClient: NSObject { return false } } + + // MARK: - Bluetooth Device Info + nonisolated func getBluetoothDeviceMinorClass(with deviceName: String) async -> String? { + do { + let service = await MainActor.run { + ensureRemoteService() + } + return try await service.withContinuation { service, continuation in + service.getBluetoothDeviceMinorClass(with: deviceName) { minorClass in + continuation.resume(returning: minorClass) + } + } + } catch { + return nil + } + } } extension Notification.Name { diff --git a/boringNotch/boringNotch.entitlements b/boringNotch/boringNotch.entitlements index 7f3881d6..da0ef1d5 100644 --- a/boringNotch/boringNotch.entitlements +++ b/boringNotch/boringNotch.entitlements @@ -20,6 +20,8 @@ com.apple.security.network.server + com.apple.security.device.bluetooth + com.apple.security.temporary-exception.apple-events com.spotify.client diff --git a/boringNotch/components/Live activities/BoringBattery.swift b/boringNotch/components/Live activities/BoringBattery.swift index 7486c7ec..0e2d2ddd 100644 --- a/boringNotch/components/Live activities/BoringBattery.swift +++ b/boringNotch/components/Live activities/BoringBattery.swift @@ -253,15 +253,75 @@ struct BoringBatteryView: View { } } +/// A view that displays the battery percentage with a ring shaped view +struct BatteryRing: View { + @Environment(\.self) private var env + var percentage: Double // 0–100 + var displayPercentage: Bool = false + + var body: some View { + ZStack { + // Background ring + Circle() + .stroke(batteryColor(for: percentage).opacity(0.2), lineWidth: 2) + + // Foreground ring (progress) + Circle() + .trim(from: 0, to: percentage / 102) + .stroke( + batteryColor(for: percentage), + style: StrokeStyle(lineWidth: 2, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) // Start at top + + if displayPercentage { + // Percentage label (optional) + Text("\(Int(percentage))%") + .font(.system(size: 4)) + .fontWeight(.bold) + .foregroundStyle(.white) + } + } + .frame(width: 16, height: 16) + .padding(4) + } + + private func batteryColor(for percentage: Double) -> Color { + let pct = max(0, min(100, percentage)) + + if pct <= 50 { + return Color.interpolate(from: .red, to: .yellow, percent: pct / 50, in: env) + } else { + return Color.interpolate(from: .yellow, to: .green, percent: (pct - 50) / 50, in: env) + } + } +} + #Preview { - BoringBatteryView( - batteryWidth: 30, - isCharging: false, - isInLowPowerMode: false, - isPluggedIn: true, - levelBattery: 80, - maxCapacity: 100, - timeToFullCharge: 10, - isForNotification: false - ).frame(width: 200, height: 200) + Group { + BoringBatteryView( + batteryWidth: 30, + isCharging: false, + isInLowPowerMode: false, + isPluggedIn: true, + levelBattery: 80, + maxCapacity: 100, + timeToFullCharge: 10, + isForNotification: false + ).frame(width: 200, height: 200) + + HStack { + BatteryRing(percentage: 96, displayPercentage: true) + .padding() + BatteryRing(percentage: 97, displayPercentage: true) + .padding() + BatteryRing(percentage: 98, displayPercentage: true) + .padding() + BatteryRing(percentage: 99, displayPercentage: true) + .padding() + BatteryRing(percentage: 100, displayPercentage: true) + .padding() + } + } + .background(.black) } diff --git a/boringNotch/components/Live activities/MarqueeTextView.swift b/boringNotch/components/Live activities/MarqueeTextView.swift index 4bb7bf32..1db117d7 100644 --- a/boringNotch/components/Live activities/MarqueeTextView.swift +++ b/boringNotch/components/Live activities/MarqueeTextView.swift @@ -23,7 +23,8 @@ struct MeasureSizeModifier: ViewModifier { } struct MarqueeText: View { - @Binding var text: String + @Binding var bindingText: String + @State private var text: String let font: Font let nsFont: NSFont.TextStyle let textColor: Color @@ -31,18 +32,24 @@ struct MarqueeText: View { let minDuration: Double let frameWidth: CGFloat + /// Used to repeat the text until it no longer fits the given *frameWidth*. + /// When *true* will **always** scroll the text + let infiniteText: Bool + @State private var animate = false @State private var textSize: CGSize = .zero @State private var offset: CGFloat = 0 - init(_ text: Binding, font: Font = .body, nsFont: NSFont.TextStyle = .body, textColor: Color = .primary, backgroundColor: Color = .clear, minDuration: Double = 3.0, frameWidth: CGFloat = 200) { - _text = text + init(_ text: Binding, font: Font = .body, nsFont: NSFont.TextStyle = .body, textColor: Color = .primary, backgroundColor: Color = .clear, minDuration: Double = 3.0, frameWidth: CGFloat = 200, infiniteText: Bool = false) { + self.text = text.wrappedValue + _bindingText = text self.font = font self.nsFont = nsFont self.textColor = textColor self.backgroundColor = backgroundColor self.minDuration = minDuration self.frameWidth = frameWidth + self.infiniteText = infiniteText } private var needsScrolling: Bool { @@ -73,6 +80,10 @@ struct MarqueeText: View { .modifier(MeasureSizeModifier()) .onPreferenceChange(SizePreferenceKey.self) { size in self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize) + if infiniteText && !needsScrolling { + text = text + " " + _bindingText.wrappedValue + return + } self.animate = false self.offset = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.01){ diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index cff23f33..065a4a63 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -13,6 +13,7 @@ import LaunchAtLogin import Sparkle import SwiftUI import SwiftUIIntrospect +import SymbolPicker struct SettingsView: View { @State private var selectedTab = "General" @@ -45,6 +46,17 @@ struct SettingsView: View { NavigationLink(value: "Battery") { Label("Battery", systemImage: "battery.100.bolt") } + NavigationLink(value: "Bluetooth") { + Label( + title: { Text("Bluetooth") }, + icon: { + Image("bluetooth.settings") + .resizable() + .renderingMode(.template) + .frame(width: 16, height: 16) + } + ) + } // NavigationLink(value: "Downloads") { // Label("Downloads", systemImage: "square.and.arrow.down") // } @@ -83,6 +95,8 @@ struct SettingsView: View { HUD() case "Battery": Charge() + case "Bluetooth": + BluetoothSettings() case "Shelf": Shelf() case "Shortcuts": @@ -382,6 +396,327 @@ struct Charge: View { } } +struct BluetoothSettings: View { + @ObservedObject var coordinator = BoringViewCoordinator.shared + @ObservedObject private var bluetoothManager = BluetoothManager.shared + @Default(.bluetoothDeviceIconMappings) var deviceIconMappings + @Default(.enableBluetoothSneakPeek) var enableBluetoothSneakPeek + @Default(.bluetoothSneakPeekStyle) var bluetoothSneakPeekStyle + + @State private var selectedMapping: BluetoothDeviceIconMapping? = nil + @State private var isPresented: Bool = false + @State private var deviceName: String = "" + @State private var sfSymbolName: String = "" + @State private var iconPickerPresented: Bool = false + + var body: some View { + Form { + if bluetoothManager.bluetoothState == .unauthorized { + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Bluetooth access is required to detect connected devices and display their battery status.") + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button("Open Bluetooth Settings") { + if let settingsURL = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth" + ) { + NSWorkspace.shared.open(settingsURL) + } + } + .buttonStyle(.bordered) + } + } + .padding(.top, 6) + } header: { + Text("Bluetooth Access") + } + } else if bluetoothManager.bluetoothState == .poweredOff { + Text("Bluetooth is currently turned off.") + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding(4) + } + + Section { + Toggle( + "Show Bluetooth live activity", + isOn: $coordinator.bluetoothLiveActivityEnabled.animation() + ) + } header: { + Text("Live activity") + } footer: { + Text("Displays connected Bluetooth devices and their battery status inside the notch.") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(bluetoothManager.bluetoothState == .unauthorized) + + Section { + Toggle("Show sneak peek on device changes", isOn: $enableBluetoothSneakPeek) + Picker("Sneak Peek Style", selection: $bluetoothSneakPeekStyle) { + ForEach(SneakPeekStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(!enableBluetoothSneakPeek) + } header: { + Text("Sneak peek") + } footer: { + Text("Sneak peek shows the Bluetooth device name under the notch for a few seconds when a device connects or disconnects.") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(!coordinator.bluetoothLiveActivityEnabled || bluetoothManager.bluetoothState == .unauthorized) + + Section { + List { + ForEach(deviceIconMappings, id: \.UUID) { mapping in + HStack { + Image(systemName: mapping.sfSymbolName) + .frame(width: 20, height: 20) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(mapping.deviceName) + .font(.body) + Text(mapping.sfSymbolName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 2) + .background( + selectedMapping != nil && selectedMapping?.UUID == mapping.UUID + ? Color.effectiveAccent.opacity(0.1) : Color.clear, + in: RoundedRectangle(cornerRadius: 5) + ) + .contentShape(Rectangle()) + .onTapGesture { + if selectedMapping?.UUID == mapping.UUID { + selectedMapping = nil + } else { + selectedMapping = mapping + } + } + } + } + .frame(minHeight: 120) + .actionBar { + HStack(spacing: 5) { + Button { + deviceName = "" + sfSymbolName = "" + selectedMapping = nil + isPresented.toggle() + } label: { + Image(systemName: "plus") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .controlSize(.large) + Divider() + Button { + if let mapping = selectedMapping { + deviceName = mapping.deviceName + sfSymbolName = mapping.sfSymbolName + isPresented.toggle() + } + } label: { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .disabled(selectedMapping == nil) + .controlSize(.large) + Divider() + Button { + if let mapping = selectedMapping { + deviceIconMappings.removeAll { $0.UUID == mapping.UUID } + selectedMapping = nil + } + } label: { + Image(systemName: "minus") + .foregroundStyle(.secondary) + .padding(.horizontal, 2) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .controlSize(.large) + .disabled(selectedMapping == nil) + } + } + .controlSize(.small) + .buttonStyle(PlainButtonStyle()) + .overlay { + if deviceIconMappings.isEmpty { + Text("No custom device icons") + .foregroundStyle(Color(.secondaryLabelColor)) + .padding(.bottom, 22) + } + } + .sheet(isPresented: $iconPickerPresented) { + SymbolPicker(symbol: $sfSymbolName) + } + .sheet(isPresented: $isPresented) { + VStack(alignment: .leading, spacing: 16) { + Text(selectedMapping == nil ? "Add Device Icon" : "Edit Device Icon") + .font(.largeTitle.bold()) + .padding(.vertical) + + VStack(alignment: .leading, spacing: 8) { + Text("Device Name") + .font(.headline) + TextField("e.g., AirPods Pro, Magic Mouse", text: $deviceName) + Text("Enter a keyword or part of the device name. Matching is case-insensitive.") + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("SF Symbol") + .font(.headline) + Button { + isPresented = false + iconPickerPresented = true + } label: { + HStack { + if !sfSymbolName.isEmpty { + Image(systemName: sfSymbolName) + .foregroundStyle(.primary) + .frame(width: 24, height: 24) + Text(sfSymbolName) + .foregroundStyle(.primary) + } else { + Text("Select SF Symbol") + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + .font(.caption) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(8) + } + .buttonStyle(.plain) + Text("Choose an SF Symbol to represent this device.") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + Button { + isPresented.toggle() + } label: { + Text("Cancel") + .frame(maxWidth: .infinity, alignment: .center) + } + + Button { + if !deviceName.isEmpty && !sfSymbolName.isEmpty { + if let existing = selectedMapping { + // Editing existing mapping + let trimmedDeviceName = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if device name changed to one that already exists (different UUID) + if let conflictingIndex = deviceIconMappings.firstIndex(where: { + $0.UUID != existing.UUID && + $0.deviceName.localizedCaseInsensitiveCompare(trimmedDeviceName) == .orderedSame + }) { + // Update the existing mapping with the same device name and remove the one being edited + let conflictingMapping = deviceIconMappings[conflictingIndex] + deviceIconMappings[conflictingIndex] = BluetoothDeviceIconMapping( + UUID: conflictingMapping.UUID, + deviceName: conflictingMapping.deviceName, + sfSymbolName: sfSymbolName + ) + // Remove the mapping being edited + deviceIconMappings.removeAll { $0.UUID == existing.UUID } + } else { + // Normal update - device name is unique or unchanged + if let index = deviceIconMappings.firstIndex(where: { $0.UUID == existing.UUID }) { + deviceIconMappings[index] = BluetoothDeviceIconMapping( + UUID: existing.UUID, + deviceName: deviceName, + sfSymbolName: sfSymbolName + ) + } + } + } else { + // Adding new mapping - check if device name already exists + let trimmedDeviceName = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + if let existingIndex = deviceIconMappings.firstIndex(where: { + $0.deviceName.localizedCaseInsensitiveCompare(trimmedDeviceName) == .orderedSame + }) { + // Update existing mapping with same device name + let existingMapping = deviceIconMappings[existingIndex] + deviceIconMappings[existingIndex] = BluetoothDeviceIconMapping( + UUID: existingMapping.UUID, + deviceName: existingMapping.deviceName, + sfSymbolName: sfSymbolName + ) + } else { + // Add new mapping + let mapping = BluetoothDeviceIconMapping( + deviceName: deviceName, + sfSymbolName: sfSymbolName + ) + deviceIconMappings.append(mapping) + } + } + } + isPresented.toggle() + } label: { + Text(selectedMapping == nil ? "Add" : "Save") + .frame(maxWidth: .infinity, alignment: .center) + } + .buttonStyle(BorderedProminentButtonStyle()) + .disabled(deviceName.isEmpty || sfSymbolName.isEmpty) + } + } // VSTACK + .textFieldStyle(RoundedBorderTextFieldStyle()) + .controlSize(.extraLarge) + .padding() + .background(.regularMaterial) + } + } header: { + HStack(spacing: 0) { + Text("Custom Device Icons") + if !deviceIconMappings.isEmpty { + Text(" – \(deviceIconMappings.count)") + .foregroundStyle(.secondary) + } + } + } footer: { + Text("Create custom icon mappings for Bluetooth devices. When a device name contains your keyword, the specified SF Symbol will be used.") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(!coordinator.bluetoothLiveActivityEnabled || bluetoothManager.bluetoothState == .unauthorized) + } + .accentColor(.effectiveAccent) + .navigationTitle("Bluetooth") + .onAppear { + Task { @MainActor in + // Check if Bluetooth is already initialized + if bluetoothManager.isInitialized == false { + // Initialize Bluetooth when user opens settings (this will trigger permission prompt) + bluetoothManager.initializeBluetooth() + } + } + } + .onChange(of: sfSymbolName) { _, _ in + iconPickerPresented = false + isPresented = true + } + } +} + //struct Downloads: View { // @Default(.selectedDownloadIndicatorStyle) var selectedDownloadIndicatorStyle // @Default(.selectedDownloadIconStyle) var selectedDownloadIconStyle @@ -1795,5 +2130,6 @@ func warningBadge(_ text: String, _ description: String) -> some View { } #Preview { - HUD() + //HUD() + SettingsView() } diff --git a/boringNotch/extensions/Color+AccentColor.swift b/boringNotch/extensions/Color+AccentColor.swift index 8b4e26f1..48f83490 100644 --- a/boringNotch/extensions/Color+AccentColor.swift +++ b/boringNotch/extensions/Color+AccentColor.swift @@ -27,6 +27,25 @@ extension Color { } return Color.effectiveAccent.opacity(0.25) } + + static func interpolate( + from: Color, + to: Color, + percent t: Double, + in env: EnvironmentValues + ) -> Color { + let t = max(0, min(t, 1)) // clamp + + let c1 = from.resolve(in: env) + let c2 = to.resolve(in: env) + + let r = c1.red + (c2.red - c1.red) * Float(t) + let g = c1.green + (c2.green - c1.green) * Float(t) + let b = c1.blue + (c2.blue - c1.blue) * Float(t) + let a = c1.opacity + (c2.opacity - c1.opacity) * Float(t) + + return Color(red: Double(r), green: Double(g), blue: Double(b), opacity: Double(a)) + } } extension NSColor { diff --git a/boringNotch/managers/BluetoothManager.swift b/boringNotch/managers/BluetoothManager.swift new file mode 100644 index 00000000..596e9f85 --- /dev/null +++ b/boringNotch/managers/BluetoothManager.swift @@ -0,0 +1,315 @@ +// +// BluetoothManager.swift +// boringNotch +// +// Created by Murat ŞENOL on 20.11.2025. +// + +import Foundation +import SwiftUI +import IOBluetooth +import CoreBluetooth +import Defaults + +final class BluetoothManager: NSObject, ObservableObject { + + static let shared = BluetoothManager() + + @ObservedObject var coordinator = BoringViewCoordinator.shared + @Published private(set) var batteryPercentage: Int? = nil + @Published private(set) var lastBluetoothDevice: IOBluetoothDevice? + @Published private(set) var isInitialized: Bool = false + @Published private(set) var bluetoothState: CBManagerState = .unknown + + private var notificationCenter: IOBluetoothUserNotification? + private var batteryFetchTask: Task? + private var lastBluetoothDeviceMinorClass: String? + private var manager: CBCentralManager? + + private override init() { + super.init() + // Don't initialize Bluetooth on init - wait for user to open settings + } + + deinit { + notificationCenter?.unregister() + notificationCenter = nil + batteryFetchTask?.cancel() + manager?.stopScan() + manager?.delegate = nil + manager = nil + } + + func initializeBluetooth() { + guard !isInitialized else { return } + registerForConnect() + isInitialized = true + } + + private func registerForConnect() { + // Register for Bluetooth notifications + notificationCenter = IOBluetoothDevice.register( + forConnectNotifications: self, + selector: #selector(deviceConnected(_:device:)) + ) + manager = CBCentralManager(delegate: self, queue: nil) + + // Initial check + checkDevices() + print("Listening for bluetooth devices...") + } + + private func checkDevices() { + guard let devices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { + return + } + + let currentConnected = Set(devices.filter { $0.isConnected() }.compactMap { $0.addressString }) + + // Find connected devices + for address in currentConnected { + if let device = devices.first(where: { $0.addressString == address }) { + device.register(forDisconnectNotification: self, selector: #selector(deviceDisconnected(_:device:))) + } + } + } + + // MARK: - Device Connection/Disconnection + @objc private func deviceConnected(_ notification: IOBluetoothUserNotification, device: IOBluetoothDevice) { + handleDeviceConnected(device) + } + + @objc private func deviceDisconnected(_ notification: IOBluetoothUserNotification, device: IOBluetoothDevice) { + handleDeviceDisconnected(device) + } + + private func handleDeviceConnected(_ device: IOBluetoothDevice) { + Task { @MainActor in + device.register(forDisconnectNotification: self, selector: #selector(deviceDisconnected(_:device:))) + lastBluetoothDevice = device + } + startBatteryPolling(for: device) + } + + private func handleDeviceDisconnected(_ device: IOBluetoothDevice) { + batteryFetchTask?.cancel() + Task { @MainActor in + lastBluetoothDevice = device + batteryPercentage = nil + coordinator.toggleExpandingView(status: true, type: .bluetooth) + } + } + + // MARK: - Battery Info + private func startBatteryPolling(for device: IOBluetoothDevice?) { + batteryFetchTask?.cancel() + + guard let device, + let deviceName = device.name, + let deviceAddress = device.addressString else { return } + + batteryFetchTask = Task.detached { [weak self] in + guard let self else { return } + let maxDuration: TimeInterval = 2.0 + let pollingInterval: UInt64 = 100_000_000 // 100ms + let deadline = Date().addingTimeInterval(maxDuration) + + while !Task.isCancelled && Date() < deadline { + if let percentage = await self.getBatteryPercentageViaPmset( + deviceName: deviceName, + deviceAddress: deviceAddress + ) { + let minorClass = await getBluetoothDeviceMinorClass(device) + await MainActor.run { + guard self.lastBluetoothDevice?.addressString == deviceAddress else { return } + self.lastBluetoothDeviceMinorClass = minorClass + self.batteryPercentage = percentage + self.coordinator.toggleExpandingView(status: true, type: .bluetooth) + } + return + } + try? await Task.sleep(nanoseconds: pollingInterval) + } + + let minorClass = await getBluetoothDeviceMinorClass(device) + await MainActor.run { + guard self.lastBluetoothDevice?.addressString == deviceAddress else { return } + self.lastBluetoothDeviceMinorClass = minorClass + self.batteryPercentage = nil + self.coordinator.toggleExpandingView(status: true, type: .bluetooth) + } + } + } + + private func getBatteryPercentageViaPmset(deviceName: String, deviceAddress: String?) async -> Int? { + let trimmedName = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return nil } + + let task = Process() + let pipe = Pipe() + + // Command to list all attached power sources (including peripherals) + task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset") + task.arguments = ["-g", "accps"] + task.standardOutput = pipe + + let fileHandle = pipe.fileHandleForReading + let data: Data? + do { + try task.run() + task.waitUntilExit() + data = try fileHandle.readToEnd() + } catch { + return nil + } + + guard let data, let output = String(data: data, encoding: .utf8) else { return nil } + // Example line to look for: " -External-0 (id=XXXXX) 85%; discharging;" + // We need to parse this output specifically for the device name. + + let lines = output + .components(separatedBy: .newlines) + .filter { $0.localizedCaseInsensitiveContains(trimmedName) } + + for line in lines { + // Use a regex to find the percentage number followed by "%" + let pattern = "(\\d+)\\%;" // Capture one or more digits before the %; + if let regex = try? NSRegularExpression(pattern: pattern) { + let range = NSRange(line.startIndex..., in: line) + if let match = regex.firstMatch(in: line, options: [], range: range) { + let percentRange = Range(match.range(at: 1), in: line)! + return Int(line[percentRange]) + } + } + } + return nil + } + + // MARK: - Device Icon & Info + private func getBluetoothDeviceMinorClass(_ device: IOBluetoothDevice?) async -> String? { + guard let deviceName = device?.name else { return nil } + return await XPCHelperClient.shared.getBluetoothDeviceMinorClass(with: deviceName) + } + + func getDeviceIcon(for device: IOBluetoothDevice?) -> String { + guard let device, let deviceName = device.name else { + return "circle.badge.questionmark" + } + // Check custom mappings first + let customMappings = Defaults[.bluetoothDeviceIconMappings] + for mapping in customMappings { + if deviceName.localizedCaseInsensitiveContains(mapping.deviceName) { + return mapping.sfSymbolName + } + } + + // Fall back to name matching + if let iconName = sfSymbolForDevice(deviceName) { + return iconName + } + + // Fall back to device minor type + if let lastBluetoothDeviceMinorClass, + let iconName = getIconByBluetoothDeviceMinorType(lastBluetoothDeviceMinorClass) { + return iconName + } + + return "circle.badge.questionmark" + } + + private func getIconByBluetoothDeviceMinorType(_ type: String?) -> String? { + // just to be sure + let lowercasedType = type?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + switch lowercasedType { + // ---------------------------------------------------------------------- + // 1. PERIPHERAL / HID (Major Class: 0x05) + // ---------------------------------------------------------------------- + case "keyboard": + return "keyboard.fill" + case "mouse", "pointing device": + return "computermouse.fill" + case "gamepad", "joystick", "remote control", "gaming controller": + return "gamecontroller.fill" + // ---------------------------------------------------------------------- + // 2. AUDIO/VIDEO (Major Class: 0x04) + // ---------------------------------------------------------------------- + case "headset", "hands-free device", "headphones": + return "headphones" + case "loudspeaker", "portable audio device", "car audio": + return "hifispeaker.fill" + case "microphone", "camcorder", "video camera", "video conferencing": + return "speaker.wave.3.fill" + // ---------------------------------------------------------------------- + // 3. PHONE (Major Class: 0x02) + // ---------------------------------------------------------------------- + case "cellular", "smart phone", "cordless phone", "modem": + return "smartphone" + // ---------------------------------------------------------------------- + // 4. COMPUTER (Major Class: 0x01) + // ---------------------------------------------------------------------- + case "desktop workstation", "server-class computer", "laptop", "handheld pc/pda", "palm sized pc/pda", "tablet": + return "desktopcomputer" + // ---------------------------------------------------------------------- + // 5. WEARABLE (Major Class: 0x07) + // ---------------------------------------------------------------------- + case "wristwatch", "pager", "jacket", "helmet", "glasses": + return "watch.analog" + // ---------------------------------------------------------------------- + // 6. HEALTH / MEDICAL (Major Class: 0x09) + // ---------------------------------------------------------------------- + case "blood pressure monitor", "thermometer", "weighing scale", "glucose meter", "pulse oximeter", "heart/pulse rate monitor": + return "circle.badge.questionmark" + default: + return nil + } + } + + private func sfSymbolForDevice(_ deviceName: String) -> String? { + let name = deviceName.lowercased() + + // ---- Apple AirPods ---- + if name.contains("airpods max") { return "airpodsmax" } + else if name.contains("airpods pro") { return "airpodspro" } + else if name.contains("airpods case") { return "airpodschargingcase" } + else if name.contains("airpods") { return "airpods" } + // ---- Beats ---- + if name.contains("beats studio buds") { return "beats.studiobuds" } + else if name.contains("beats solo buds") { return "beats.solobuds" } + else if name.contains("beats solo") { return "beats.headphones" } + else if name.contains("beats studio") { return "beats.headphones" } + else if name.contains("powerbeats pro") { return "beats.powerbeats.pro" } + else if name.contains("beats fit pro") { return "beats.fitpro" } + else if name.contains("beats flex") { return "beats.earphones" } + // ---- General fallback for audio devices ---- + if name.contains("buds") { return "earbuds" } + if name.contains("headphone") || name.contains("headset") { return "headphones" } + if name.contains("speaker") { return "hifispeaker.fill" } + // --- Keyboard & Mouse --- + if name.contains("keyboard") { return "keyboard.fill" } + if name.contains("mouse") && name.contains("magic") { return "magicmouse.fill" } + else if name.contains("mouse") { return "computermouse.fill" } + // ---- Gamepads ---- + if name.contains("gamepad") || name.contains("controller") || name.contains("joy-con") { return "gamecontroller.fill" } + // ---- Phones ---- + if name.contains("phone") { return "smartphone"} + + return nil + } +} + +extension BluetoothManager: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + bluetoothState = central.state + switch central.state { + case .poweredOn: + print("Bluetooth usable (permission granted)") + case .unauthorized: + print("Bluetooth permission denied") + case .poweredOff: + print("Bluetooth off") + default: + break + } + } +} diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 8477434c..7af8d7d3 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -25,6 +25,18 @@ struct CustomVisualizer: Codable, Hashable, Equatable, Defaults.Serializable { var speed: CGFloat = 1.0 } +struct BluetoothDeviceIconMapping: Codable, Hashable, Equatable, Defaults.Serializable { + let UUID: UUID + var deviceName: String + var sfSymbolName: String + + init(UUID: UUID = Foundation.UUID(), deviceName: String, sfSymbolName: String) { + self.UUID = UUID + self.deviceName = deviceName + self.sfSymbolName = sfSymbolName + } +} + enum CalendarSelectionState: Codable, Defaults.Serializable { case all case selected(Set) @@ -189,6 +201,11 @@ extension Defaults.Keys { // Show or hide the title bar static let hideTitleBar = Key("hideTitleBar", default: true) + // MARK: Bluetooth + static let bluetoothDeviceIconMappings = Key<[BluetoothDeviceIconMapping]>("bluetoothDeviceIconMappings", default: []) + static let enableBluetoothSneakPeek = Key("enableBluetoothSneakPeek", default: false) + static let bluetoothSneakPeekStyle = Key("bluetoothSneakPeekStyle", default: .standard) + // Helper to determine the default media controller based on NowPlaying deprecation status static var defaultMediaController: MediaControllerType { if MusicManager.shared.isNowPlayingDeprecated {