diff --git a/LidAngleSensor.xcodeproj/project.pbxproj b/LidAngleSensor.xcodeproj/project.pbxproj index 6110b1f..c1c3403 100644 --- a/LidAngleSensor.xcodeproj/project.pbxproj +++ b/LidAngleSensor.xcodeproj/project.pbxproj @@ -141,7 +141,9 @@ EA5683D52F704307005813E9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALLOW_TARGET_PLATFORM_SPECIALIZATION = NO; ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -192,7 +194,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -205,7 +207,9 @@ EA5683D62F704307005813E9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALLOW_TARGET_PLATFORM_SPECIALIZATION = NO; ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -250,7 +254,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -262,10 +266,12 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = SM75355Y6R; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -278,10 +284,11 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -296,10 +303,12 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = SM75355Y6R; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -312,10 +321,11 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = gold.samhenri.LidAngleSensor; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/LidAngleSensor.xcodeproj/project.xcworkspace/xcuserdata/sd.xcuserdatad/UserInterfaceState.xcuserstate b/LidAngleSensor.xcodeproj/project.xcworkspace/xcuserdata/sd.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..2aab096 Binary files /dev/null and b/LidAngleSensor.xcodeproj/project.xcworkspace/xcuserdata/sd.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/LidAngleSensor.xcodeproj/xcuserdata/sd.xcuserdatad/xcschemes/xcschememanagement.plist b/LidAngleSensor.xcodeproj/xcuserdata/sd.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..4f4f967 --- /dev/null +++ b/LidAngleSensor.xcodeproj/xcuserdata/sd.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + LidAngleSensor.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/LidAngleSensor/Info.plist b/LidAngleSensor/Info.plist index 894bbe2..4047fb4 100644 --- a/LidAngleSensor/Info.plist +++ b/LidAngleSensor/Info.plist @@ -2,6 +2,8 @@ + LSUIElement + CFBundleIcons ISGraphicIconConfiguration diff --git a/LidAngleSensor/LidAngleSensorApp.swift b/LidAngleSensor/LidAngleSensorApp.swift index 9441617..3a89ead 100644 --- a/LidAngleSensor/LidAngleSensorApp.swift +++ b/LidAngleSensor/LidAngleSensorApp.swift @@ -1,46 +1,240 @@ -// -// LidAngleSensorApp.swift -// LidAngleSensor -// -// Created by Sam on 2026-03-22. -// - import SwiftUI @main struct LidAngleSensorApp: App { - @State private var sensor = LidAngleSensor() - @State private var audioController = AudioController() - + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + var body: some Scene { - Window("Lid Angle Sensor", id: "main") { - ContentView() - .environment(\.lidAngleSensor, sensor) - .environment(\.audioController, audioController) - .onAppear { - NSWindow.allowsAutomaticWindowTabbing = false - } - } - .windowResizability(.contentSize) - .commands { - CommandGroup(after: .appInfo) { - Link(destination: URL(string: "https://github.com/samhenrigold/LidAngleSensor")!) { - Label("View Source", systemImage: "swift") - } + Settings { + EmptyView() + } + } +} + +@MainActor +private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + private static let warningAngle = 119.0 + private static let statusItemSidePadding = 1.0 + private static let statusItemIconTitleSpacing = 1.0 + private static let statusItemClippingAllowance = 3.0 + private static let statusItemImageSize = NSSize(width: 14, height: 14) + + private let sensor = LidAngleSensor() + private let audioController = AudioController() + private let statusMenu = NSMenu() + + private var statusItem: NSStatusItem? + private var updateTimer: Timer? + private var didShowWarning = false + + func applicationDidFinishLaunching(_ notification: Notification) { + NSWindow.allowsAutomaticWindowTabbing = false + NSApplication.shared.setActivationPolicy(.accessory) + + createStatusItem() + sensor.start() + + updateTimer = .scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleSensorUpdate() } } + } + + func applicationWillTerminate(_ notification: Notification) { + updateTimer?.invalidate() + updateTimer = nil + sensor.stop() + } + + private func createStatusItem() { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusMenu.delegate = self + statusItem.menu = statusMenu + + if let button = statusItem.button { + let image = NSImage(systemSymbolName: "angle", accessibilityDescription: "Lid angle") + image?.isTemplate = true + image?.size = Self.statusItemImageSize + + button.image = image + button.imagePosition = .imageLeading + button.imageHugsTitle = true + button.toolTip = "Lid angle" + } + + self.statusItem = statusItem + updateStatusItem() + rebuildMenu(statusMenu) + } + + private func handleSensorUpdate() { + audioController.feed(angle: sensor.angle, velocity: sensor.velocity) + updateStatusItem() + + guard sensor.isAvailable else { return } + + if sensor.angle >= Self.warningAngle { + guard !didShowWarning else { return } + didShowWarning = true + showLidAngleWarning() + } else { + didShowWarning = false + } + } + + private func updateStatusItem() { + guard let button = statusItem?.button else { return } + + let angleText = sensor.isAvailable + ? "\(sensor.angle.formatted(.number.precision(.fractionLength(0))))°" + : "" + + let attributedTitle = NSAttributedString( + string: angleText, + attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium), + .foregroundColor: NSColor.labelColor + ] + ) + + button.attributedTitle = attributedTitle + button.imagePosition = sensor.isAvailable ? .imageLeading : .imageOnly + button.imageHugsTitle = true + updateStatusItemLength(title: attributedTitle) + } + + private func updateStatusItemLength(title: NSAttributedString) { + let imageWidth = Self.statusItemImageSize.width + let titleWidth = sensor.isAvailable ? title.size().width : 0 + let spacing = sensor.isAvailable ? Self.statusItemIconTitleSpacing : 0 + let sidePadding = Self.statusItemSidePadding * 2 + let clippingAllowance = sensor.isAvailable ? Self.statusItemClippingAllowance : 0 + + statusItem?.length = ceil(imageWidth + titleWidth + spacing + sidePadding + clippingAllowance) + } + + func menuWillOpen(_ menu: NSMenu) { + rebuildMenu(menu) + } + + private func rebuildMenu(_ menu: NSMenu) { + menu.removeAllItems() - MenuBarExtra { - MenuBarView() - .environment(\.lidAngleSensor, sensor) - .environment(\.audioController, audioController) - } label: { - Image(systemName: "angle") - - if sensor.isAvailable { - Text("\(sensor.angle, format: .number.precision(.fractionLength(0)))°") - .monospacedDigit() - } + if !sensor.isAvailable { + let item = NSMenuItem(title: "Sensor Not Available", action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + } + + if sensor.angle >= Self.warningAngle { + let item = NSMenuItem(title: "Warning: 119° reached", action: nil, keyEquivalent: "") + item.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: nil) + item.isEnabled = false + menu.addItem(item) + } + + let soundModeItem = NSMenuItem(title: "Sound Mode", action: nil, keyEquivalent: "") + let soundModeMenu = NSMenu() + for mode in AudioMode.allCases { + let item = NSMenuItem(title: mode.rawValue, action: #selector(selectSoundMode(_:)), keyEquivalent: "") + item.target = self + item.representedObject = mode + item.state = audioController.mode == mode ? .on : .off + soundModeMenu.addItem(item) + } + soundModeItem.submenu = soundModeMenu + menu.addItem(soundModeItem) + + let startStopItem = NSMenuItem( + title: audioController.isPlaying ? "🖐Stop" : "✅Start", + action: #selector(toggleAudio), + keyEquivalent: "" + ) + startStopItem.target = self + startStopItem.isEnabled = sensor.isAvailable + menu.addItem(startStopItem) + + menu.addItem(.separator()) + + let showItem = NSMenuItem(title: "👁Show", action: #selector(showApplication), keyEquivalent: "") + showItem.target = self + menu.addItem(showItem) + + let hideItem = NSMenuItem(title: "🔼Hide", action: #selector(hideApplication), keyEquivalent: "") + hideItem.target = self + menu.addItem(hideItem) + + let quitItem = NSMenuItem(title: "❌Quit", action: #selector(quit), keyEquivalent: "") + quitItem.target = self + menu.addItem(quitItem) + } + + @objc private func selectSoundMode(_ sender: NSMenuItem) { + guard let mode = sender.representedObject as? AudioMode else { return } + audioController.mode = mode + } + + @objc private func toggleAudio() { + audioController.toggle() + } + + @objc private func showApplication() { + AppWindowPresenter.show(sensor: sensor, audioController: audioController) + } + + @objc private func hideApplication() { + NSApplication.shared.windows.forEach { $0.orderOut(nil) } + NSApplication.shared.setActivationPolicy(.accessory) + } + + @objc private func quit() { + NSApplication.shared.terminate(nil) + } + + private func showLidAngleWarning() { + NSSound.beep() + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Lid angle warning" + alert.informativeText = "The lid sensor has reached 119 degrees (Fully Open)." + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +@MainActor +private enum AppWindowPresenter { + private static var window: NSWindow? + + static func show(sensor: LidAngleSensor, audioController: AudioController) { + NSApplication.shared.setActivationPolicy(.regular) + + if let window = Self.window { + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + return } + + let contentView = ContentView() + .environment(\.lidAngleSensor, sensor) + .environment(\.audioController, audioController) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 667), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Lid Angle Sensor" + window.contentViewController = NSHostingController(rootView: contentView) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + + Self.window = window + NSApplication.shared.activate(ignoringOtherApps: true) } } diff --git a/LidAngleSensor/MenuBarView.swift b/LidAngleSensor/MenuBarView.swift index 29ca43f..c9876eb 100644 --- a/LidAngleSensor/MenuBarView.swift +++ b/LidAngleSensor/MenuBarView.swift @@ -17,6 +17,11 @@ struct MenuBarView: View { if !sensor.isAvailable { Text("Sensor Not Available") } + + if sensor.angle >= 119 { + Label("Warning: 119° reached", systemImage: "exclamationmark.triangle.fill") + + } Section { Picker("Sound Mode", selection: $controller.mode) { @@ -26,14 +31,57 @@ struct MenuBarView: View { } .pickerStyle(.inline) - Button(audioController.isPlaying ? "Stop" : "Start") { + Button(audioController.isPlaying ? "🖐Stop" : "✅Start") { audioController.toggle() } } .disabled(!sensor.isAvailable) + + Button("👁Show") { + AppWindowPresenter.show(sensor: sensor, audioController: audioController) + } - Button("Quit") { + Button("❌Quit") { NSApplication.shared.terminate(nil) } + Button("🔼Hide") { + NSApplication.shared.windows.forEach { $0.orderOut(nil) } + NSApplication.shared.setActivationPolicy(.accessory) + } + } +} + +@MainActor +private enum AppWindowPresenter { + private static var window: NSWindow? + + static func show(sensor: LidAngleSensor, audioController: AudioController) { + NSApplication.shared.setActivationPolicy(.regular) + + if let window = Self.window { + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + return + } + + let contentView = ContentView() + .environment(\.lidAngleSensor, sensor) + .environment(\.audioController, audioController) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 667), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Lid Angle Sensor" + window.contentViewController = NSHostingController(rootView: contentView) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + + Self.window = window + NSApplication.shared.activate(ignoringOtherApps: true) } }