These are the exact bytes from lidcreak-app-2.2.0.zip, checksums included —
read them here or download any one of them. Back to the build log.
what it is, install, the three gates, tests
# Lid Creak 2.2.0
A small macOS app that makes your MacBook's lid creak as you open and close it. It reads the
lid-angle sensor 20 times a second and plays a recorded creak when the lid actually moves —
loud and quick when you flop it shut, a single quiet creak when you drag it slowly.
It exists because FreeSlapMac 1.1.0 ships a **"Lid Creaking Sound"** switch — the label, the
description, the `_creakingEnabled` preference in its binary, and the unused
`creaking.mp3` in its own bundle — and no code that ever reads the lid sensor. The switch is
dead. This app is the missing half.
```
Lid Creak.app → reads the lid-angle sensor → plays FreeSlapMac's own creak sample
```
No admin password, no kernel extension, no private API, no network. The sensor is an
unprivileged HID feature report; the sound is read from your own FreeSlapMac installation.
---
## Requirements
| | |
|---|---|
| macOS | 13 or newer |
| CPU | Apple silicon (the shipped binary is arm64) |
| Sound | FreeSlapMac in `/Applications` — the creak sample belongs to its author, so it is **not** redistributed here. The app looks for `FreeSlapMac.app`, `FreeSlapMac Silent.app`, `SlapMac.app` or `slapmac.app`. |
Without FreeSlapMac the app still runs and still reads the sensor, but there is no sound to
play; the menu says **Test creak (no sound file)** so you are not left guessing.
## Install
1. Unzip → you get `Lid Creak.app`.
2. Drag it to `/Applications`.
3. First launch: **right-click → Open** (it is ad-hoc signed, not notarised, so a plain
double-click from the internet is refused by Gatekeeper). Or, in Terminal:
```sh
xattr -dr com.apple.quarantine "/Applications/Lid Creak.app"
open "/Applications/Lid Creak.app"
```
4. The window opens with the live lid angle, a speed readout and an on/off switch.
## What it does
| control | effect |
|---|---|
| **Creak sound** switch | the on/off switch for the whole feature |
| **Test creak** | plays one at full level — the fastest way to check audio |
| live readout | `Lid: 102° · moving 34 °/s · 12 creaks`, 20×/s |
| **Sensitivity** | **Low 35 · Normal 22 · High 14 °/s** |
| **Start at login** | writes/removes `~/Library/LaunchAgents/com.isaac.lidcreak.plist` |
| **Reveal sound file** | shows the prepared clip in Finder |
| **Open log** | `~/Library/Logs/LidCreak.log` |
| menu bar item | same switch, reachable when the window is closed |
Settings live in `defaults` under `com.isaac.lidcreak` (`enabled`, `threshold`), so they
survive a restart and can be scripted:
```sh
defaults write com.isaac.lidcreak enabled -bool false # off
defaults write com.isaac.lidcreak threshold -float 35 # Low
```
## How it decides to creak
The sensor is HID usage page `0x20` / usage `0x8A`, feature report 1, a `uint16` little-endian
angle in degrees — sampled at 20 Hz. A creak has to pass three gates:
| gate | rule | what it kills |
|---|---|---|
| travel | ≥ **3°** across a 0.35 s window | 1–2° jitter, knocks, taps |
| speed | measured across a **0.15 s** span, not one sample | a 1° blip reads **6.7 °/s**, not 20 |
| sustain | 2 consecutive qualifying ticks (~100 ms) | single-sample spikes |
Then a 1.4 s retrigger cooldown, so a long drag creaks once instead of machine-gunning.
Playback volume rides the measured speed (0.55 → 1.0). The clip is trimmed to its first 1.8 s
and peak-normalised to −1 dBFS once, into `~/Library/Application Support/LidCreak/creak.caf`.
## Why 2.1.0's sensitivity setting did nothing
2.1.0 measured speed between two consecutive 50 ms samples. The sensor reports **whole
degrees**, so one 1° step in one sample is `1 / 0.05 = 20 °/s` — which beat *every* threshold,
including Low at 15 °/s. The least sensitive setting was already more sensitive than a single
quantisation step. Reported by the user as: *"the sensitivity didnt matter i did low and i angle
changed 1 degree still creaked"*. 2.2.0 changes what is measured (span-averaged speed plus a
travel gate) and rescales the thresholds.
## Tests
The app is built around a pure `Detector` that can be fed a scripted angle timeline, so the
decision logic is testable without moving the machine.
```sh
"Lid Creak.app/Contents/MacOS/LidCreak" --simulate # 8 scripted lid timelines
"Lid Creak.app/Contents/MacOS/LidCreak" --trigger-test # 10 boundary assertions
"Lid Creak.app/Contents/MacOS/LidCreak" --selftest # sensor + audio + system volume
"Lid Creak.app/Contents/MacOS/LidCreak" --once 4 # print a few real angle samples
"Lid Creak.app/Contents/MacOS/LidCreak" --record 10 # log a real sensor trace
```
`--simulate` output on the machine this was built on:
```
1° blip then held still fires=0 peak=6.7 °/s PASS
1° every 300 ms (slow creep) fires=0 peak=3.3 °/s PASS
12 °/s opening, High (needs 14) fires=0 peak=12.0 °/s PASS
3° tap in 150 ms, High (a tap, not a slide) fires=0 peak=20.0 °/s PASS
16 °/s sustained 2 s, High (gentle slide) fires=2 peak=16.0 °/s PASS
22 °/s opening, Normal fires=1 peak=22.0 °/s PASS
80 °/s slam, Low fires=1 peak=80.0 °/s PASS
20 °/s opening, Low (needs 35) fires=0 peak=20.0 °/s PASS
```
`--selftest` exits 2 and prints a warning when the system output is muted or at volume 0 —
the failure that made an earlier build look like it did nothing at all.
## Build from source
```sh
bash src/build.sh # needs the Xcode command line tools (swiftc)
```
`src/build.sh` generates the icon, compiles `LidCreak.swift` (one file, ~1,160 lines, no Xcode
project), assembles the bundle, ad-hoc signs it, and runs the detector tests. On this machine
the shell runs under Rosetta, so the script prefixes `swiftc` with `arch -arm64`.
## Honest limits
- **The sound is FreeSlapMac's.** Without it installed, there is nothing to play.
- **arm64 only.** No universal binary was built; an Intel Mac needs a rebuild.
- **Not notarised.** Ad-hoc signature; first launch needs right-click → Open, or
`xattr -dr com.apple.quarantine`.
- **The 1.4 s cooldown is a taste decision**, not a measurement: a slow drag creaks once.
- **Screen-recording permission is not used and not needed** — the app only reads a sensor.
- Verified on one machine (MacBook, macOS 26.6), by driving the sensor and the detector, not
by listening to every creak.
## Files
```
Lid Creak.app/ prebuilt, ad-hoc signed, arm64
src/LidCreak.swift the whole app (sensor + detector + audio + UI)
src/mkicon.swift draws the icon with CoreGraphics
src/Info.plist bundle id com.isaac.lidcreak, LSUIElement = false
src/build.sh build + sign + tests
README.md this file
INSTALL.txt the two-step version
LICENSE MIT
SHA256SUMS.txt checksums of the files above
```
## Licence
MIT for the code — see `LICENSE`. The creak sample is FreeSlapMac's and is not included.
the 30-second version
LID CREAK 2.2.0 — INSTALL (30 seconds)
======================================
1. Drag "Lid Creak.app" into /Applications.
2. Right-click the app → Open (once). Gatekeeper refuses a plain double-click because
the app is ad-hoc signed, not notarised.
Terminal people can do the same thing with:
xattr -dr com.apple.quarantine "/Applications/Lid Creak.app"
open "/Applications/Lid Creak.app"
3. The window opens. Press "Test creak" to hear it.
REQUIREMENTS
macOS 13+ on Apple silicon.
FreeSlapMac in /Applications — the creak sample is that app's, so it is not included here.
Without it the menu reads "Test creak (no sound file)".
IF YOU HEAR NOTHING
- Check the system output volume is not muted / not at 0.
- Press "Test creak" and read the log (menu → Open log).
- Run: "/Applications/Lid Creak.app/Contents/MacOS/LidCreak" --selftest
It prints the sensor, the sound source and the system volume, and exits 2 when muted.
TURN IT OFF
Switch "Creak sound" off, or: defaults write com.isaac.lidcreak enabled -bool false
Quit: ⌘Q.
Full details: README.md. Licence: MIT (see LICENSE). The creak audio belongs to FreeSlapMac.
the whole app: sensor, detector, audio, UI
// Lid Creak — a macOS app for the MacBook lid-angle sensor.
//
// Reads IOHID usage page 0x20 / usage 0x8A (feature report 1 -> uint16 LE degrees) and plays
// FreeSlapMac's own creaking.mp3 while the lid moves. Supersedes the launchd + Python helper:
// a real .app with a window (live angle, measured speed), a menu-bar item, a Creak sound
// switch, a three-level sensitivity control, start-at-login, and a real Quit.
//
// The decision logic lives in Detector, a pure object fed (time, angle) samples, so the sensor
// is only one caller. That is what makes the sensitivity testable without moving the machine:
//
// speed is measured across a >= 0.15 s span, not between two 50 ms samples, because the
// sensor reports WHOLE DEGREES and 1 deg in 50 ms reads as 20 deg/s of "speed" — pure
// quantisation. A 1 deg blip must not creak at the least sensitive setting.
//
// LidCreak the app
// LidCreak --selftest sensor + audio diagnostics, plays one creak, exit 0/2
// LidCreak --trigger-test 10 detector boundary assertions, exit 0/1
// LidCreak --simulate 8 scripted lid timelines through the same Detector, exit 0/1
// LidCreak --record N log N seconds of real sensor samples
// LidCreak --once N print N angle samples and exit (debugging)
// LidCreak --snapshot FILE render the window's content into a PNG (no screen recording)
// LidCreak --login-item add/remove the LaunchAgent
//
// No root, no kext, no private APIs: feature-report reads are unprivileged.
import AppKit
import AVFoundation
import CoreFoundation
import IOKit.hid
import QuartzCore
// MARK: - constants
let kPage: Int32 = 0x20
let kUsage: Int32 = 0x8A
let kVersion = "2.2.0"
let kBundleID = "com.isaac.lidcreak"
let kRetrigger: TimeInterval = 1.4
let kSampleHz: Double = 20
let kSleepJumpDeg = 40.0
let kMaxDT: TimeInterval = 0.5
let kClipSeconds = 1.8
// Speed is measured across a sliding window, not between two consecutive
// samples. One 50 ms sample of 1° is 20 °/s of "speed" — pure quantisation,
// not a creak — and that is why every sensitivity setting used to fire.
let kWindow: TimeInterval = 0.35 // look back this far to measure how far it moved
let kMinWindow: TimeInterval = 0.18 // don't trust a span shorter than this
let kSpeedWindow: TimeInterval = 0.15 // shorter span for speed: follows a quick push
let kSpeedMin: TimeInterval = 0.10
let kMinTravel: Double = 3.0 // a lid must actually move 3° to creak
let kConfirmTicks = 2 // and keep it up for ~100 ms
enum Threshold: Double, CaseIterable {
case low = 35, normal = 22, high = 14
var label: String {
switch self {
case .low: return "Low — 35 °/s"
case .normal: return "Normal — 22 °/s"
case .high: return "High — 14 °/s"
}
}
}
// MARK: - log
let logURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/LidCreak.log")
func logLine(_ s: String) {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
let line = "\(f.string(from: Date())) \(s)\n"
if let h = try? FileHandle(forWritingTo: logURL) {
h.seekToEndOfFile()
h.write(line.data(using: .utf8)!)
try? h.close()
} else {
try? line.write(to: logURL, atomically: true, encoding: .utf8)
}
FileHandle.standardError.write(line.data(using: .utf8)!)
}
// MARK: - sound
/// Finds FreeSlapMac's creaking.mp3, renders a trimmed + peak-normalised copy once, and plays it.
final class CreakSound {
private(set) var sourceURL: URL?
private(set) var preparedURL: URL?
private(set) var peakBefore: Float = 0 // linear peak of the source
private(set) var gainApplied: Float = 1
private var players: [AVAudioPlayer] = []
private var next = 0
private static let candidates = [
"/Applications/FreeSlapMac.app/Contents/Resources/Sounds/creaking.mp3",
"/Applications/FreeSlapMac Silent.app/Contents/Resources/Sounds/creaking.mp3",
"/Applications/SlapMac.app/Contents/Resources/Sounds/creaking.mp3",
"/Applications/slapmac.app/Contents/Resources/Sounds/creaking.mp3",
]
var supportDir: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("LidCreak", isDirectory: true)
}
func locate() {
for path in Self.candidates where FileManager.default.fileExists(atPath: path) {
sourceURL = URL(fileURLWithPath: path)
return
}
sourceURL = nil
}
/// Render <support>/creak.caf: first 1.8 s, normalised to -1 dBFS. Idempotent.
@discardableResult
func prepare() throws -> URL? {
if sourceURL == nil { locate() }
guard let src = sourceURL else { return nil }
try FileManager.default.createDirectory(at: supportDir, withIntermediateDirectories: true)
let out = supportDir.appendingPathComponent("creak.wav")
let stale = supportDir.appendingPathComponent("creak.caf")
try? FileManager.default.removeItem(at: stale)
let outAttrs = (try? FileManager.default.attributesOfItem(atPath: out.path))?[.size] as? Int ?? 0
if outAttrs > 0 {
// cache hit: recover the measured numbers so --selftest reports facts, not blanks
let side = supportDir.appendingPathComponent("prepared.json")
if let d = try? Data(contentsOf: side),
let j = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
peakBefore = Float(j["peak"] as? Double ?? 0)
gainApplied = Float(j["gain"] as? Double ?? 1)
}
preparedURL = out
return out
}
let file = try AVAudioFile(forReading: src)
let fmt = file.processingFormat
let maxFrames = AVAudioFrameCount(min(Double(file.length), fmt.sampleRate * Double(kClipSeconds)))
guard maxFrames > 0, let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: maxFrames) else {
return nil
}
try file.read(into: buf)
guard let src_data = buf.floatChannelData else { return nil }
let ch = Int(fmt.channelCount)
let n = Int(buf.frameLength)
guard n > 0, ch > 0 else { return nil }
// find the peak so the gain is measured, not guessed
var peak: Float = 0
for c in 0..<ch {
let p = src_data[c]
for i in 0..<n { peak = max(peak, abs(p[i])) }
}
peakBefore = peak
guard peak > 0.0001 else { return nil }
let target: Float = 0.891 // -1 dBFS
let gain = min(20, target / peak)
gainApplied = gain
// interleave to 16-bit PCM and write the WAV container by hand: no format negotiation,
// nothing for CoreAudio to reject, and the gain maths is right here instead of implied.
var pcm = [Int16](repeating: 0, count: n * ch)
for i in 0..<n {
for c in 0..<ch {
let v = max(-1, min(1, src_data[c][i] * gain))
pcm[i * ch + c] = Int16(v * 32767.0)
}
}
var blob = Data()
let byteRate = UInt32(fmt.sampleRate) * UInt32(ch * 2)
let dataBytes = UInt32(pcm.count * 2)
func le32(_ v: UInt32) -> Data { withUnsafeBytes(of: v.littleEndian) { Data($0) } }
func le16(_ v: UInt16) -> Data { withUnsafeBytes(of: v.littleEndian) { Data($0) } }
blob.append("RIFF".data(using: .ascii)!)
blob.append(le32(36 + dataBytes))
blob.append("WAVEfmt ".data(using: .ascii)!)
blob.append(le32(16)) // PCM chunk size
blob.append(le16(1)) // format = PCM
blob.append(le16(UInt16(ch)))
blob.append(le32(UInt32(fmt.sampleRate)))
blob.append(le32(byteRate))
blob.append(le16(UInt16(ch * 2))) // block align
blob.append(le16(16)) // bits per sample
blob.append("data".data(using: .ascii)!)
blob.append(le32(dataBytes))
pcm.withUnsafeBufferPointer { blob.append(Data(buffer: $0)) }
try blob.write(to: out)
let side = supportDir.appendingPathComponent("prepared.json")
let j: [String: Any] = ["peak": Double(peak), "gain": Double(gain),
"source": src.path, "seconds": Double(n) / fmt.sampleRate]
if let d = try? JSONSerialization.data(withJSONObject: j, options: [.prettyPrinted]) {
try? d.write(to: side)
}
preparedURL = out
return out
}
func loadPlayers() {
players.removeAll()
guard let url = preparedURL ?? (try? prepare()) else { return }
for _ in 0..<3 {
if let p = try? AVAudioPlayer(contentsOf: url) {
p.prepareToPlay()
players.append(p)
}
}
next = 0
}
var isReady: Bool { !players.isEmpty }
/// Play once with a per-move volume (0.55 quiet push .. 1.0 fast flop).
func play(volume: Float) {
guard !players.isEmpty else { return }
let p = players[next % players.count]
next += 1
p.stop()
p.currentTime = 0
p.volume = max(0.05, min(1, volume))
p.play()
}
}
// MARK: - sensor
/// The decision core, separated from I/O so a scripted angle timeline
/// (--simulate) can drive the exact code the lid drives at runtime.
final class Detector {
var threshold: Double = Threshold.normal.rawValue
var enabled = true
private(set) var lastSpeed: Double = 0
private var hist: [(t: CFTimeInterval, a: Double)] = []
private var last: Double?
private var lastPlay: CFTimeInterval = -99
private var confirm = 0
func reset() {
hist.removeAll(); last = nil; confirm = 0; lastSpeed = 0
}
/// One sample in; measured speed out when a creak should fire.
func feed(t now: CFTimeInterval, angle a: Double) -> Double? {
if let prev = last, abs(a - prev) > Double(kSleepJumpDeg) { reset() }
hist.append((now, a))
while let f = hist.first, now - f.t > kWindow * 2 { hist.removeFirst() }
// travel (did it really move?) uses the long span; speed (how fast?)
// uses a short span so a quick push is not averaged away — but the short
// span is still 0.15 s, so one 50 ms 1° sample reads 6.7 °/s, not 20.
var speed = 0.0
var travel = 0.0
if let ref = hist.first(where: { now - $0.t <= kWindow }) ?? hist.first {
let dt = now - ref.t
if dt >= kMinWindow { travel = abs(a - ref.a) }
}
if let ref = hist.first(where: { now - $0.t <= kSpeedWindow }) ?? hist.first {
let dt = now - ref.t
if dt >= kSpeedMin { speed = abs(a - ref.a) / dt }
}
lastSpeed = speed
last = a
let ok = LidSensor.shouldPlay(speed: speed, travel: travel, threshold: threshold,
enabled: enabled, sinceLast: now - lastPlay)
confirm = ok ? confirm + 1 : 0
guard confirm >= kConfirmTicks else { return nil }
confirm = 0
lastPlay = now
return speed
}
}
final class LidSensor {
private var manager: IOHIDManager?
private var device: IOHIDDevice?
private var timer: DispatchSourceTimer?
private var lastTime: CFTimeInterval = 0
let detector = Detector()
var onCreak: ((Double) -> Void)? // fired on the main queue
var onUpdate: ((Double?, Double) -> Void)? // (angle, speed °/s) on the main queue
var threshold: Double = Threshold.normal.rawValue
var enabled = true
private(set) var lastSpeed: Double = 0
private(set) var lastAngleValue: Double?
func start() {
open()
let t = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
t.schedule(deadline: .now(), repeating: 1.0 / kSampleHz, leeway: .milliseconds(20))
t.setEventHandler { [weak self] in self?.tick() }
t.resume()
timer = t
}
func stop() {
timer?.cancel(); timer = nil
close()
}
private func open() {
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
let matching: [String: Any] = [
kIOHIDDeviceUsagePageKey: kPage,
kIOHIDDeviceUsageKey: kUsage,
]
IOHIDManagerSetDeviceMatching(mgr, matching as CFDictionary)
IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone))
manager = mgr
attach()
}
private func attach() {
guard device == nil, let mgr = manager else { return }
guard let set = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice>, !set.isEmpty else { return }
for dev in set {
if IOHIDDeviceOpen(dev, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess {
device = dev
logLine("sensor attached")
return
}
}
}
private func close() {
if let d = device {
IOHIDDeviceClose(d, IOOptionBits(kIOHIDOptionsTypeNone))
device = nil
}
if let m = manager {
IOHIDManagerClose(m, IOOptionBits(kIOHIDOptionsTypeNone))
manager = nil
}
}
private func readAngle() -> Double? {
if device == nil { attach() }
guard let d = device else { return nil }
var buf = [UInt8](repeating: 0, count: 8)
var len = 8
let r = buf.withUnsafeMutableBufferPointer { ptr in
IOHIDDeviceGetReport(d, kIOHIDReportTypeFeature, 1, ptr.baseAddress!, &len)
}
guard r == kIOReturnSuccess, len >= 3 else {
if r != kIOReturnSuccess { close() } // lid closed: re-enumerate later
return nil
}
return Double(buf[1]) + Double(buf[2]) * 256
}
/// Pure decision function — also exercised by --trigger-test.
/// Three gates: the lid has to travel far enough (kills 1–2° jitter), fast
/// enough for the chosen sensitivity, and not have creaked in the last 1.4 s.
static func shouldPlay(speed: Double, travel: Double, threshold: Double,
enabled: Bool, sinceLast: TimeInterval) -> Bool {
guard enabled, sinceLast >= kRetrigger else { return false }
guard travel >= kMinTravel else { return false }
return speed >= threshold
}
private func tick() {
let now = CACurrentMediaTime()
let angle = readAngle()
if let a = angle {
lastAngleValue = a
detector.threshold = threshold
detector.enabled = enabled
if let speed = detector.feed(t: now, angle: a) {
let cb = onCreak
DispatchQueue.main.async { cb?(speed) }
}
lastSpeed = detector.lastSpeed
} else {
detector.reset()
lastSpeed = 0
}
lastTime = now
let cb = onUpdate
let a2 = lastAngleValue
let s2 = lastSpeed
DispatchQueue.main.async { cb?(a2, s2) }
}
}
// MARK: - login item
enum LoginItem {
static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(kBundleID).menubar.plist")
}
static var isEnabled: Bool { FileManager.default.fileExists(atPath: plistURL.path) }
static func setEnabled(_ on: Bool) {
if on {
guard let exe = Bundle.main.executablePath else { return }
let plist: [String: Any] = [
"Label": "\(kBundleID).menubar",
"ProgramArguments": [exe],
"RunAtLoad": true,
"ProcessType": "Interactive",
]
try? FileManager.default.createDirectory(
at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true)
if let data = try? PropertyListSerialization.data(fromPropertyList: plist,
format: .xml, options: 0) {
try? data.write(to: plistURL)
}
} else {
try? FileManager.default.removeItem(at: plistURL)
}
}
}
// MARK: - window
// A real window. The first version was a menu-bar-only accessory app, which
// double-clicked into nothing visible — from the outside it looked broken.
final class MainWindowController: NSObject, NSWindowDelegate {
private(set) var window: NSWindow
private let angleField = NSTextField(labelWithString: "—")
private let speedField = NSTextField(labelWithString: "reading lid sensor…")
private let creaksField = NSTextField(labelWithString: "no creaks yet")
private let switchControl = NSSwitch()
private let switchLabel = NSTextField(labelWithString: "Creak sound")
private let testButton = NSButton(title: "Test creak", target: nil, action: nil)
private let sensControl: NSSegmentedControl
private let loginCheck = NSButton(checkboxWithTitle: "Start at login", target: nil, action: nil)
private let revealButton = NSButton(title: "Sound file", target: nil, action: nil)
private let footerField = NSTextField(labelWithString: "")
var onToggle: ((Bool) -> Void)?
var onTest: (() -> Void)?
var onSensitivity: ((Double) -> Void)?
var onLogin: ((Bool) -> Void)?
var onReveal: (() -> Void)?
override init() {
sensControl = NSSegmentedControl(labels: Threshold.allCases.map { $0.label },
trackingMode: .selectOne, target: nil, action: nil)
window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 372),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
super.init()
window.title = "Lid Creak"
window.isReleasedWhenClosed = false
window.delegate = self
build()
window.center()
}
// MARK: build
private func spacer() -> NSView {
let v = NSView()
v.translatesAutoresizingMaskIntoConstraints = false
v.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal)
v.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).isActive = true
return v
}
private func row(_ views: [NSView], spacing: CGFloat = 10) -> NSStackView {
let s = NSStackView(views: views)
s.orientation = .horizontal
s.alignment = .centerY
s.spacing = spacing
return s
}
private func build() {
guard let content = window.contentView else { return }
content.wantsLayer = true
angleField.font = .monospacedDigitSystemFont(ofSize: 52, weight: .semibold)
angleField.textColor = .labelColor
angleField.setContentHuggingPriority(.init(999), for: .horizontal)
speedField.font = .systemFont(ofSize: 13, weight: .regular)
speedField.textColor = .secondaryLabelColor
creaksField.font = .systemFont(ofSize: 13, weight: .regular)
creaksField.textColor = .secondaryLabelColor
let metrics = NSStackView(views: [speedField, creaksField])
metrics.orientation = .vertical
metrics.alignment = .leading
metrics.spacing = 3
let header = row([angleField, metrics], spacing: 20)
switchLabel.font = .systemFont(ofSize: 14, weight: .medium)
switchControl.target = self
switchControl.action = #selector(toggled(_:))
switchControl.controlSize = .large
testButton.target = self
testButton.action = #selector(testPressed)
testButton.bezelStyle = .rounded
testButton.controlSize = .large
testButton.keyEquivalent = "t"
testButton.keyEquivalentModifierMask = []
testButton.bezelColor = .controlAccentColor
testButton.contentTintColor = .white
let toggleRow = row([switchControl, switchLabel, spacer(), testButton])
let sensLabel = NSTextField(labelWithString: "Sensitivity")
sensLabel.font = .systemFont(ofSize: 14, weight: .medium)
sensControl.target = self
sensControl.action = #selector(sensChanged(_:))
sensControl.segmentStyle = .rounded
let sensRow = row([sensLabel, spacer(), sensControl])
loginCheck.font = .systemFont(ofSize: 13)
loginCheck.target = self
loginCheck.action = #selector(loginToggled(_:))
revealButton.target = self
revealButton.action = #selector(revealPressed)
revealButton.bezelStyle = .rounded
revealButton.controlSize = .small
revealButton.font = .systemFont(ofSize: 11)
let bottomRow = row([loginCheck, spacer(), revealButton])
footerField.font = .systemFont(ofSize: 11)
footerField.textColor = .tertiaryLabelColor
footerField.lineBreakMode = .byTruncatingMiddle
let root = NSStackView(views: [header, toggleRow, sensRow, bottomRow, footerField])
root.orientation = .vertical
root.alignment = .leading
root.spacing = 18
root.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 20, right: 24)
root.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(root)
NSLayoutConstraint.activate([
root.leadingAnchor.constraint(equalTo: content.leadingAnchor),
root.trailingAnchor.constraint(equalTo: content.trailingAnchor),
root.topAnchor.constraint(equalTo: content.topAnchor),
root.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor),
header.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
toggleRow.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
sensRow.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
bottomRow.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
footerField.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
])
update(angle: nil, speed: 0, creaks: 0, enabled: true,
threshold: Threshold.normal.rawValue, soundReady: false, soundPath: nil)
content.layoutSubtreeIfNeeded()
let fit = root.fittingSize
window.setContentSize(NSSize(width: 480, height: max(fit.height, 240)))
}
var debugSwitchOn: Bool { switchControl.state == .on }
var debugFit: String { String(format: "%.0fx%.0f", window.contentView?.fittingSize.width ?? 0, window.contentView?.fittingSize.height ?? 0) }
// MARK: state
func update(angle: Double?, speed: Double, creaks: Int, enabled: Bool,
threshold: Double, soundReady: Bool, soundPath: String?) {
if let a = angle {
angleField.stringValue = String(format: "%.0f°", a)
let moving = speed >= threshold
speedField.stringValue = moving
? String(format: "lid moving %.0f °/s", speed)
: String(format: "lid %.0f ° · still", a)
} else {
angleField.stringValue = "—"
speedField.stringValue = "lid closed or not readable"
}
creaksField.stringValue = creaks == 1 ? "1 creak" : "\(creaks) creaks"
switchControl.state = enabled ? .on : .off
switchControl.needsDisplay = true
switchLabel.textColor = enabled ? .labelColor : .secondaryLabelColor
testButton.isEnabled = soundReady
testButton.title = soundReady ? "Test creak" : "No sound file"
for (i, t) in Threshold.allCases.enumerated() {
sensControl.setSelected(abs(threshold - t.rawValue) < 0.01, forSegment: i)
}
loginCheck.state = LoginItem.isEnabled ? .on : .off
revealButton.isEnabled = soundPath != nil
footerField.stringValue = soundReady
? "creak: \(soundPath ?? "")\nv\(kVersion)"
: "creak sound not found — install FreeSlapMac, then reopen\nv\(kVersion)"
footerField.maximumNumberOfLines = 2
}
// MARK: actions
@objc private func toggled(_ sender: NSSwitch) {
onToggle?(sender.state == .on)
}
@objc private func testPressed() { onTest?() }
@objc private func revealPressed() { onReveal?() }
@objc private func loginToggled(_ sender: NSButton) {
onLogin?(sender.state == .on)
}
@objc private func sensChanged(_ sender: NSSegmentedControl) {
let i = sender.selectedSegment
guard i >= 0 && i < Threshold.allCases.count else { return }
onSensitivity?(Threshold.allCases[i].rawValue)
}
// MARK: show
func show() {
window.collectionBehavior.insert(.moveToActiveSpace)
NSApp.activate(ignoringOtherApps: true)
// a background launch can be denied focus; ask the running app directly too
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
logLine("window shown: visible=\(window.isVisible) "
+ "frame=\(Int(window.frame.width))x\(Int(window.frame.height)) "
+ "policy=\(NSApp.activationPolicy().rawValue)")
// occlusionState is the honest answer to "can the user actually see it?"
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
guard let self else { return }
let w = self.window
let occ = w.occlusionState.contains(.visible) ? "visible" : "NOT visible"
let scr = w.screen.map { "\(Int($0.frame.width))x\(Int($0.frame.height))" } ?? "no screen"
if !w.isOnActiveSpace || !w.occlusionState.contains(.visible) {
w.collectionBehavior.insert(.moveToActiveSpace)
w.orderFrontRegardless()
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
logLine("window states: occlusion=\(occ) onScreen=\(w.isOnActiveSpace) "
+ "key=\(w.isKeyWindow) screen=\(scr) origin=\(Int(w.frame.origin.x)),\(Int(w.frame.origin.y))")
}
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
// closing hides the window; the app keeps creaking from the menu bar
sender.orderOut(nil)
logLine("window closed (still running in menu bar)")
return false
}
}
// MARK: - app
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem!
private var windowController: MainWindowController!
private let sensor = LidSensor()
private let sound = CreakSound()
private var creakCount = 0
private var enabled: Bool {
get { UserDefaults.standard.object(forKey: "enabled") as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: "enabled") }
}
private var threshold: Double {
get { UserDefaults.standard.object(forKey: "threshold") as? Double ?? Threshold.normal.rawValue }
set { UserDefaults.standard.set(newValue, forKey: "threshold") }
}
func applicationDidFinishLaunching(_ n: Notification) {
NSApp.setActivationPolicy(.regular)
buildMainMenu()
windowController = MainWindowController()
windowController.onToggle = { [weak self] on in self?.setEnabled(on) }
windowController.onTest = { [weak self] in self?.fireCreak(speed: 40) }
windowController.onSensitivity = { [weak self] v in self?.setThresholdValue(v) }
windowController.onLogin = { [weak self] on in
LoginItem.setEnabled(on)
logLine("start at login=\(LoginItem.isEnabled)")
self?.refreshWindow()
}
windowController.onReveal = { [weak self] in self?.revealSound() }
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
if let img = menuIcon(active: true) {
button.image = img
button.image?.isTemplate = true
} else {
button.title = "Lid" // never leave an invisible item in the bar
}
button.toolTip = "Lid Creak"
}
let menu = NSMenu()
menu.delegate = self
statusItem.menu = menu
rebuild(menu)
do {
try sound.prepare()
} catch {
logLine("sound prepare failed: \(error)")
}
sound.loadPlayers()
if !sound.isReady { logLine("no creak sound available — is FreeSlapMac installed?") }
sensor.threshold = threshold
sensor.enabled = enabled
sensor.onCreak = { [weak self] speed in self?.fireCreak(speed: speed) }
sensor.onUpdate = { [weak self] _, _ in
self?.refreshLiveItem()
self?.refreshWindow()
}
sensor.start()
logLine("started v\(kVersion) (enabled=\(enabled) threshold=\(Int(threshold)) "
+ "sound=\(sound.sourceURL?.path ?? "none"))")
refreshWindow()
windowController.show()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self else { return }
let f = self.statusItem.button?.window?.frame ?? .zero
logLine("menu bar item: icon=\(self.statusItem.button?.image != nil ? "yes" : "text") "
+ "frame=\(Int(f.width))x\(Int(f.height)) at x=\(Int(f.origin.x)) "
+ "visible=\(self.statusItem.isVisible)")
}
let out = systemAudio()
if out.volume == 0 || out.muted {
logLine("WARNING: system output volume=\(out.volume) muted=\(out.muted) — creaks will be inaudible")
}
NotificationCenter.default.addObserver(
forName: NSWorkspace.willSleepNotification, object: nil, queue: .main
) { [weak self] _ in
self?.sensor.stop()
logLine("sleep: sensor closed")
}
NotificationCenter.default.addObserver(
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
) { [weak self] _ in
self?.sensor.start()
logLine("wake: sensor reopened")
}
}
func applicationWillTerminate(_ n: Notification) {
sensor.stop()
logLine("quit")
}
// double-clicking the app while it is already running must re-open the window
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
windowController?.show()
logLine("reopen: window brought back (hadVisible=\(flag))")
return true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
private func buildMainMenu() {
let main = NSMenu()
let appItem = NSMenuItem()
main.addItem(appItem)
let appMenu = NSMenu()
let about = NSMenuItem(title: "About Lid Creak", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
appMenu.addItem(about)
appMenu.addItem(.separator())
let hide = NSMenuItem(title: "Hide Lid Creak", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
appMenu.addItem(hide)
appMenu.addItem(.separator())
let quitItem = NSMenuItem(title: "Quit Lid Creak", action: #selector(quit), keyEquivalent: "q")
quitItem.target = self
appMenu.addItem(quitItem)
appItem.submenu = appMenu
let windowItem = NSMenuItem()
main.addItem(windowItem)
let windowMenu = NSMenu(title: "Window")
let min = NSMenuItem(title: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
windowMenu.addItem(min)
windowItem.submenu = windowMenu
NSApp.mainMenu = main
NSApp.windowsMenu = windowMenu
}
private func refreshWindow() {
guard windowController != nil else { return }
windowController.update(angle: sensor.lastAngleValue,
speed: sensor.lastSpeed,
creaks: creakCount,
enabled: enabled,
threshold: threshold,
soundReady: sound.isReady,
soundPath: sound.sourceURL?.path)
}
private func setEnabled(_ on: Bool) {
enabled = on
sensor.enabled = on
statusItem.button?.image = menuIcon(active: on)
logLine("enabled=\(on)")
refreshWindow()
}
private func setThresholdValue(_ v: Double) {
threshold = v
sensor.threshold = v
logLine("threshold=\(Int(v)) °/s")
refreshWindow()
}
// MARK: menu
private func menuIcon(active: Bool) -> NSImage? {
let name = active ? "speaker.wave.2" : "speaker.slash"
let img = NSImage(systemSymbolName: name, accessibilityDescription: "Lid Creak")
img?.isTemplate = true
return img
}
private func rebuild(_ menu: NSMenu) {
menu.removeAllItems()
let open = NSMenuItem(title: "Open Lid Creak", action: #selector(openWindow), keyEquivalent: "o")
open.target = self
menu.addItem(open)
menu.addItem(.separator())
let toggle = NSMenuItem(title: "Creak sound", action: #selector(toggleEnabled), keyEquivalent: "")
toggle.target = self
toggle.state = enabled ? .on : .off
menu.addItem(toggle)
let test = NSMenuItem(title: sound.isReady ? "Test creak" : "Test creak (no sound file)",
action: #selector(testCreak), keyEquivalent: "")
test.target = self
test.isEnabled = sound.isReady
menu.addItem(test)
menu.addItem(.separator())
let live = NSMenuItem(title: liveTitle(), action: nil, keyEquivalent: "")
live.isEnabled = false
live.tag = 100
menu.addItem(live)
let sens = NSMenuItem(title: "Sensitivity", action: nil, keyEquivalent: "")
let sub = NSMenu()
for t in Threshold.allCases {
let it = NSMenuItem(title: t.label, action: #selector(setThreshold(_:)), keyEquivalent: "")
it.target = self
it.representedObject = t.rawValue
it.state = (abs(threshold - t.rawValue) < 0.01) ? .on : .off
sub.addItem(it)
}
sens.submenu = sub
menu.addItem(sens)
menu.addItem(.separator())
let login = NSMenuItem(title: "Start at login", action: #selector(toggleLogin), keyEquivalent: "")
login.target = self
login.state = LoginItem.isEnabled ? .on : .off
menu.addItem(login)
let reveal = NSMenuItem(title: "Reveal sound file", action: #selector(revealSound), keyEquivalent: "")
reveal.target = self
reveal.isEnabled = sound.preparedURL != nil
menu.addItem(reveal)
let logItem = NSMenuItem(title: "Open log", action: #selector(openLog), keyEquivalent: "")
logItem.target = self
menu.addItem(logItem)
menu.addItem(.separator())
let quit = NSMenuItem(title: "Quit Lid Creak", action: #selector(quit), keyEquivalent: "q")
quit.target = self
menu.addItem(quit)
}
func menuNeedsUpdate(_ menu: NSMenu) {
rebuild(menu)
}
private func liveTitle() -> String {
guard sensor.lastAngleValue != nil else { return "Lid: closed / not found" }
let a = sensor.lastAngleValue!
let s = sensor.lastSpeed
let state = s >= threshold ? "moving \(Int(s.rounded())) °/s" : "still"
return String(format: "Lid: %.0f° · %@ · %d creaks", a, state, creakCount)
}
// MARK: actions
@objc private func toggleEnabled() {
setEnabled(!enabled)
statusItem.menu?.item(withTag: 100)?.title = liveTitle()
}
@objc private func testCreak() {
fireCreak(speed: 40)
}
@objc private func setThreshold(_ item: NSMenuItem) {
guard let v = item.representedObject as? Double else { return }
setThresholdValue(v)
}
@objc private func toggleLogin() {
LoginItem.setEnabled(!LoginItem.isEnabled)
logLine("start at login=\(LoginItem.isEnabled)")
refreshWindow()
}
@objc private func openWindow() {
windowController.show()
}
@objc private func revealSound() {
if let u = sound.preparedURL { NSWorkspace.shared.activateFileViewerSelecting([u]) }
}
@objc private func openLog() {
NSWorkspace.shared.open(logURL)
}
@objc private func quit() {
NSApp.terminate(nil)
}
// called from the sensor queue's main-hop
func fireCreak(speed: Double) {
guard enabled else { return }
let vol = Float(min(1.0, 0.55 + speed / 120.0))
sound.play(volume: vol)
creakCount += 1
logLine(String(format: "creak vol=%.2f speed=%.0f deg/s", vol, speed))
if let m = statusItem.menu?.item(withTag: 100) { m.title = liveTitle() }
refreshWindow()
}
func refreshLiveItem() {
if let m = statusItem.menu?.item(withTag: 100) { m.title = liveTitle() }
}
}
// MARK: - audio state
func systemAudio() -> (volume: Int, muted: Bool) {
var vol = -1, muted = false
let script = NSAppleScript(source: "output volume of (get volume settings)")
var err: NSDictionary?
if let r = script?.executeAndReturnError(&err) { vol = Int(r.int32Value) }
let m = NSAppleScript(source: "output muted of (get volume settings)")
if let r = m?.executeAndReturnError(&err) { muted = r.booleanValue }
return (vol, muted)
}
// MARK: - entry
let args = CommandLine.arguments
if let li = args.firstIndex(of: "--login-item"), let v = args[safe: li + 1] {
LoginItem.setEnabled(v.lowercased() == "on")
print("start at login: \(LoginItem.isEnabled ? "on" : "off") (\(LoginItem.plistURL.path))")
exit(LoginItem.isEnabled == (v.lowercased() == "on") ? 0 : 1)
}
if args.contains("--trigger-test") {
var fails = 0
func expect(_ label: String, _ got: Bool, _ want: Bool) {
let ok = got == want
if !ok { fails += 1 }
print(String(format: "%-52@ %@", label as NSString, ok ? "PASS" : "FAIL (got \(got))"))
}
let low = Threshold.low.rawValue, norm = Threshold.normal.rawValue, high = Threshold.high.rawValue
// the exact complaint: a 1° nudge must never creak, at any sensitivity
expect("1° travel @ 20 °/s, High (the old bug) -> no",
LidSensor.shouldPlay(speed: 20, travel: 1, threshold: high, enabled: true, sinceLast: 5), false)
expect("2.9° travel @ 80 °/s, High -> no",
LidSensor.shouldPlay(speed: 80, travel: 2.9, threshold: high, enabled: true, sinceLast: 5), false)
expect("3.5° travel @ 45 °/s, Low -> yes",
LidSensor.shouldPlay(speed: 45, travel: 3.5, threshold: low, enabled: true, sinceLast: 5), true)
expect("3.5° travel @ 20 °/s, Normal (too slow) -> no",
LidSensor.shouldPlay(speed: 20, travel: 3.5, threshold: norm, enabled: true, sinceLast: 5), false)
expect("3.5° travel @ 25 °/s, Normal -> yes",
LidSensor.shouldPlay(speed: 25, travel: 3.5, threshold: norm, enabled: true, sinceLast: 5), true)
expect("12° travel @ 80 °/s, Low -> yes",
LidSensor.shouldPlay(speed: 80, travel: 12, threshold: low, enabled: true, sinceLast: 5), true)
expect("8° travel @ 12 °/s, High (gentle, too slow) -> no",
LidSensor.shouldPlay(speed: 12, travel: 8, threshold: high, enabled: true, sinceLast: 5), false)
expect("8° travel @ 15 °/s, High -> yes",
LidSensor.shouldPlay(speed: 15, travel: 8, threshold: high, enabled: true, sinceLast: 5), true)
expect("3.5° travel @ 45 °/s, disabled -> no",
LidSensor.shouldPlay(speed: 45, travel: 3.5, threshold: low, enabled: false, sinceLast: 5), false)
expect("3.5° travel @ 45 °/s, 0.5 s since last -> no",
LidSensor.shouldPlay(speed: 45, travel: 3.5, threshold: low, enabled: true, sinceLast: 0.5), false)
print(fails == 0 ? "10/10 PASS" : "\(fails) FAILED")
exit(fails == 0 ? 0 : 1)
}
if args.contains("--simulate") {
// Drive the real Detector with scripted lid timelines: the same code path
// the IOHID samples go through, without needing a human to move the lid.
func timeline(_ segs: [(Double, Double, Double)]) -> [(Double, Double)] {
var out: [(Double, Double)] = []
var t = 0.0
let step = 1.0 / kSampleHz
for (secs, from, to) in segs {
var elapsed = 0.0
while elapsed <= secs + 1e-9 {
let f = secs <= 0 ? 1 : min(1, elapsed / secs)
out.append((t, from + (to - from) * f))
t += step
elapsed += step
}
}
return out
}
func run(_ label: String, threshold: Threshold, _ segs: [(Double, Double, Double)],
expectFire: Bool) -> Bool {
let d = Detector()
d.threshold = threshold.rawValue
d.enabled = true
var fires = 0
var peak = 0.0
for (t, a) in timeline(segs) {
if let sp = d.feed(t: t, angle: a) { fires += 1; peak = max(peak, sp) }
peak = max(peak, d.lastSpeed)
}
let ok = (fires > 0) == expectFire
print(String(format: "%-44@ fires=%d peak=%.1f °/s %@",
label as NSString, fires, peak, ok ? "PASS" : "FAIL"))
return ok
}
var ok = true
ok = run("1° blip then held still", threshold: .high,
[(2, 115, 115), (0, 115, 116), (1.5, 116, 116)], expectFire: false) && ok
ok = run("1° every 300 ms (slow creep)", threshold: .high,
[(1, 115, 115), (0.3, 115, 116), (0.3, 116, 117), (0.3, 117, 118),
(1.5, 118, 118)], expectFire: false) && ok
ok = run("12 °/s opening, High (needs 14)", threshold: .high,
[(1, 115, 115), (1.5, 115, 133), (1, 133, 133)], expectFire: false) && ok
ok = run("3° tap in 150 ms, High (a tap, not a slide)", threshold: .high,
[(1, 115, 115), (0.15, 115, 118), (1, 118, 118)], expectFire: false) && ok
ok = run("16 °/s sustained 2 s, High (gentle slide)", threshold: .high,
[(1, 115, 115), (2, 115, 147), (1, 147, 147)], expectFire: true) && ok
ok = run("22 °/s opening, Normal", threshold: .normal,
[(1, 115, 115), (1.5, 115, 148), (1, 148, 148)], expectFire: true) && ok
ok = run("80 °/s slam, Low", threshold: .low,
[(1, 115, 115), (0.5, 115, 155), (1, 155, 155)], expectFire: true) && ok
ok = run("20 °/s opening, Low (needs 35)", threshold: .low,
[(1, 115, 115), (1.5, 115, 145), (1, 145, 145)], expectFire: false) && ok
print(ok ? "SIMULATE PASS" : "SIMULATE FAIL")
exit(ok ? 0 : 1)
}
if let ri = args.firstIndex(of: "--record") {
// raw sensor timeline, for measuring real noise and real lid motion
let secs = Double(args[safe: ri + 1] ?? "10") ?? 10
let s = LidSensor()
s.start()
let t0 = CACurrentMediaTime()
var rows: [String] = []
while CACurrentMediaTime() - t0 < secs {
RunLoop.current.run(until: Date().addingTimeInterval(1.0 / kSampleHz))
let t = CACurrentMediaTime() - t0
rows.append(String(format: "%.3f %@", t,
s.lastAngleValue.map { String(format: "%.0f", $0) } ?? "nil"))
}
s.stop()
print(rows.joined(separator: "\n"))
exit(0)
}
if let oi = args.firstIndex(of: "--once") {
let n = Int(args[safe: oi + 1] ?? "5") ?? 5
let s = LidSensor()
s.start()
var seen: [Double] = []
let deadline = Date().addingTimeInterval(3)
while seen.count < n && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
if let a = s.lastAngleValue, seen.last != a { seen.append(a) }
}
s.stop()
print("samples: " + seen.map { String(format: "%.0f", $0) }.joined(separator: " "))
exit(seen.isEmpty ? 2 : 0)
}
if args.contains("--selftest") {
setvbuf(stdout, nil, _IONBF, 0)
func note(_ s: String) { print(" · \(s)"); fflush(stdout) }
print("LidCreak \(kVersion)")
let sound = CreakSound()
sound.locate()
print("sound source: \(sound.sourceURL?.path ?? "NOT FOUND (install FreeSlapMac)")")
note("preparing sound…")
do {
if let u = try sound.prepare() {
let a = try FileManager.default.attributesOfItem(atPath: u.path)
print("prepared: \(u.path) (\(a[.size] ?? 0) bytes, peak \(String(format: "%.3f", sound.peakBefore)) × gain \(String(format: "%.2f", sound.gainApplied)))")
}
} catch {
print("prepare failed: \(error)")
}
note("loading players…")
sound.loadPlayers()
print("players: \(sound.isReady ? "ready" : "none")")
let au = systemAudio()
print("system output: volume=\(au.volume) muted=\(au.muted)")
note("reading lid sensor…")
let s = LidSensor()
s.start()
var angle: Double?
let deadline = Date().addingTimeInterval(3)
while angle == nil && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
angle = s.lastAngleValue
}
s.stop()
print("lid angle: \(angle.map { String(format: "%.0f°", $0) } ?? "unavailable (lid closed?)")")
var ok = angle != nil && sound.isReady
if sound.isReady {
note("playing…")
sound.play(volume: 1.0)
print("played test creak")
let until = Date().addingTimeInterval(2.0)
while Date() < until { RunLoop.current.run(until: Date().addingTimeInterval(0.05)) }
}
if au.volume == 0 || au.muted {
print("WARNING: output is muted — nothing will be audible")
ok = false
}
print(ok ? "SELFTEST PASS" : "SELFTEST FAIL")
exit(ok ? 0 : 2)
}
if let si = args.firstIndex(of: "--snapshot") {
let out = args[safe: si + 1] ?? "/tmp/lidcreak-window.png"
let nsapp = NSApplication.shared
nsapp.setActivationPolicy(.prohibited)
let wc = MainWindowController()
wc.update(angle: 102, speed: 34, creaks: 12, enabled: !args.contains("--off"),
threshold: Threshold.normal.rawValue, soundReady: true,
soundPath: "/Applications/FreeSlapMac.app/Contents/Resources/Sounds/creaking.mp3")
wc.window.orderFront(nil)
wc.window.contentView?.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
guard let view = wc.window.contentView else { exit(3) }
view.layoutSubtreeIfNeeded()
let bounds = view.bounds
guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
print("snapshot failed: no bitmap rep"); exit(4)
}
view.cacheDisplay(in: bounds, to: rep)
guard let png = rep.representation(using: .png, properties: [:]) else {
print("snapshot failed: no png"); exit(5)
}
do { try png.write(to: URL(fileURLWithPath: out)) }
catch { print("snapshot write failed: \(error)"); exit(6) }
print("snapshot: switch=\(wc.debugSwitchOn) fit=\(wc.debugFit) windowKey=\(wc.window.isKeyWindow) \(out) pixels=\(rep.pixelsWide)x\(rep.pixelsHigh) "
+ "content=\(Int(bounds.width))x\(Int(bounds.height))")
exit(0)
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
extension Array {
subscript(safe i: Int) -> Element? { indices.contains(i) ? self[i] : nil }
}
icon → compile → bundle → sign → 10 tests
#!/bin/bash
# Build Lid Creak.app from a single Swift file. No Xcode project, no dependencies.
set -euo pipefail
cd "$(dirname "$0")"
APP="Lid Creak.app"
BIN="$APP/Contents/MacOS/LidCreak"
echo "==> clean"
rm -rf "$APP" LidCreak.iconset
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
echo "==> icon"
arch -arm64 swiftc -O -swift-version 5 mkicon.swift -o /tmp/lidcreak-mkicon -framework AppKit
/tmp/lidcreak-mkicon LidCreak.iconset | sed 's/^/ /'
iconutil -c icns LidCreak.iconset -o "$APP/Contents/Resources/LidCreak.icns"
ls -lh "$APP/Contents/Resources/LidCreak.icns" | awk '{print " icns: "$5}'
echo "==> compile (Swift $(swiftc --version | awk '{print $4}'))"
arch -arm64 swiftc -O -swift-version 5 \
-target arm64-apple-macos13.0 \
LidCreak.swift \
-o "$BIN" \
-framework AppKit -framework AVFoundation -framework IOKit -framework QuartzCore
echo "==> bundle"
cp Info.plist "$APP/Contents/Info.plist"
printf 'APPL????' > "$APP/Contents/PkgInfo"
echo "==> sign (ad-hoc)"
codesign --force --deep --sign - "$APP" 2>&1 | sed 's/^/ /'
codesign --verify --deep --strict "$APP" && echo " signature valid"
echo "==> smoke tests"
"$BIN" --trigger-test | sed 's/^/ /'
echo "==> built: $(pwd)/$APP"
ls -lh "$BIN" | awk '{print " binary: "$5}'
draws the icon with CoreGraphics
// mkicon.swift — draws Lid Creak.app's icon with CoreGraphics. No assets needed.
import AppKit
let canvas: CGFloat = 1024
func drawIcon(size: CGFloat) -> NSBitmapImageRep {
let rep = NSBitmapImageRep(bitmapDataPlanes: nil,
pixelsWide: Int(size), pixelsHigh: Int(size),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
isPlanar: false, colorSpaceName: .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0)!
rep.size = NSSize(width: size, height: size)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
guard let ctx = NSGraphicsContext.current?.cgContext else { return rep }
let s = size / canvas
ctx.scaleBy(x: s, y: s)
ctx.setAllowsAntialiasing(true)
// rounded-square background
let bg = CGPath(roundedRect: CGRect(x: 112, y: 112, width: 800, height: 800),
cornerWidth: 190, cornerHeight: 190, transform: nil)
ctx.saveGState()
ctx.addPath(bg)
ctx.clip()
let top = CGColor(red: 0.22, green: 0.26, blue: 0.34, alpha: 1)
let bottom = CGColor(red: 0.07, green: 0.09, blue: 0.13, alpha: 1)
if let g = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: [top, bottom] as CFArray, locations: [0, 1]) {
ctx.drawLinearGradient(g, start: CGPoint(x: 512, y: 912),
end: CGPoint(x: 512, y: 112), options: [])
}
ctx.restoreGState()
// faint lid line so the icon says "lid", not just "speaker"
ctx.saveGState()
ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.16))
ctx.setLineWidth(16)
ctx.setLineCap(.round)
ctx.move(to: CGPoint(x: 262, y: 300))
ctx.addLine(to: CGPoint(x: 762, y: 300))
ctx.strokePath()
ctx.restoreGState()
// speaker
let ink = CGColor(red: 0.95, green: 0.96, blue: 0.99, alpha: 1)
ctx.setFillColor(ink)
let body = CGPath(roundedRect: CGRect(x: 250, y: 434, width: 92, height: 156),
cornerWidth: 16, cornerHeight: 16, transform: nil)
ctx.addPath(body)
ctx.fillPath()
ctx.beginPath()
ctx.move(to: CGPoint(x: 342, y: 512))
ctx.addLine(to: CGPoint(x: 512, y: 660))
ctx.addLine(to: CGPoint(x: 512, y: 364))
ctx.closePath()
ctx.setLineJoin(.round)
ctx.fillPath()
// sound waves
let center = CGPoint(x: 540, y: 512)
let waves: [(CGFloat, CGFloat, CGColor)] = [
(96, 0.95, CGColor(red: 0.95, green: 0.96, blue: 0.99, alpha: 0.95)),
(170, 0.62, CGColor(red: 0.95, green: 0.96, blue: 0.99, alpha: 0.62)),
(244, 0.34, CGColor(red: 0.95, green: 0.96, blue: 0.99, alpha: 0.34)),
]
ctx.setLineCap(.round)
ctx.setLineWidth(34)
for (r, _, c) in waves {
ctx.setStrokeColor(c)
ctx.beginPath()
ctx.addArc(center: center, radius: r,
startAngle: -58 * .pi / 180, endAngle: 58 * .pi / 180, clockwise: false)
ctx.strokePath()
}
_ = waves.map { $0.1 }
NSGraphicsContext.restoreGraphicsState()
return rep
}
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "LidCreak.iconset"
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
let entries: [(String, CGFloat)] = [
("icon_16x16.png", 16), ("[email protected]", 32),
("icon_32x32.png", 32), ("[email protected]", 64),
("icon_128x128.png", 128), ("[email protected]", 256),
("icon_256x256.png", 256), ("[email protected]", 512),
("icon_512x512.png", 512), ("[email protected]", 1024),
]
var written = 0
for (name, px) in entries {
let rep = drawIcon(size: px)
guard let png = rep.representation(using: .png, properties: [:]) else { continue }
let path = outDir + "/" + name
do {
try png.write(to: URL(fileURLWithPath: path))
written += 1
} catch {
print("failed \(name): \(error)")
exit(1)
}
}
print("wrote \(written)/\(entries.count) icon sizes into \(outDir)")
bundle id com.isaac.lidcreak, macOS 13+
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>LidCreak</string>
<key>CFBundleIdentifier</key>
<string>com.isaac.lidcreak</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Lid Creak</string>
<key>CFBundleDisplayName</key>
<string>Lid Creak</string>
<key>CFBundleIconFile</key>
<string>LidCreak</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.2.0</string>
<key>CFBundleVersion</key>
<string>4</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>MIT. Unofficial companion for FreeSlapMac; the creak audio is read from your own installation and is not redistributed.</string>
</dict>
</plist>
checksums of the shipped files
b6254e69478ecf697624372ecac9daea49216b0ab22a8cb8e9b70bf331bed44c Lid Creak.app/Contents/MacOS/LidCreak
e14c8de53ff951e0240271a5aa523287e7058360de85f4411909c51169f463e9 src/LidCreak.swift
56a2f1abdc903212e644206c16ac2650f9771c59422e362d0b73e9e62a947096 src/mkicon.swift
3c6f27adeb8ccdd597a60d69f9a4a7a54215e116b6117288393b271f24771b7b src/Info.plist
7f49ae179576c7c4340a3ac269a4362822a0ed6d000f279410a230f1dc90a7b5 src/build.sh
a629c0691f8bcbd5469af3c32beed916cca816fbbf38eb9eea1e52dc8979381a README.md
54cc6924492cba71776d383d6dbaf27ecd0b5f9c5b3e2e887be21cb63e29999b INSTALL.txt
5d963dad33771425d4e62016aff764d49296198333f3c104ace6ca710208feb4 LICENSE
MIT
MIT License
Copyright (c) 2026 Isaac Chan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.