Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions LidAngleSensor.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>LidAngleSensor.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
2 changes: 2 additions & 0 deletions LidAngleSensor/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
<key>CFBundleIcons</key>
<dict>
<key>ISGraphicIconConfiguration</key>
Expand Down
264 changes: 229 additions & 35 deletions LidAngleSensor/LidAngleSensorApp.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading