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)
}
}