Initial commit: KPopBird - macOS sleep prevention menu bar app

Swift macOS menu bar app with Samjokoo (삼족오) icon.
Features: display/system sleep prevention, timer mode, battery awareness, auto-start on login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
charleskwon
2026-04-12 12:52:01 +09:00
commit 8c08d92613
7 changed files with 422 additions and 0 deletions

View File

@@ -0,0 +1,275 @@
import AppKit
import Foundation
final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private let sleepManager = SleepManager()
private var timer: Timer?
private var expireTimer: Timer?
private var expireDate: Date?
private var batteryAwareDisable = false
private lazy var defaults = UserDefaults.standard
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
defaults.register(defaults: [
"activateOnLaunch": true,
"preventDisplaySleep": true,
"preventSystemSleep": true
])
loadPreferences()
setupStatusItem()
startTickingTimer()
if defaults.bool(forKey: "activateOnLaunch") {
activateIndefinitely()
}
}
func applicationWillTerminate(_ notification: Notification) {
sleepManager.deactivate()
savePreferences()
}
// MARK: - Preferences
private func loadPreferences() {
if defaults.object(forKey: "preventDisplaySleep") != nil {
sleepManager.preventDisplaySleep = defaults.bool(forKey: "preventDisplaySleep")
}
if defaults.object(forKey: "preventSystemSleep") != nil {
sleepManager.preventSystemSleep = defaults.bool(forKey: "preventSystemSleep")
}
batteryAwareDisable = defaults.bool(forKey: "batteryAwareDisable")
}
private func savePreferences() {
defaults.set(sleepManager.preventDisplaySleep, forKey: "preventDisplaySleep")
defaults.set(sleepManager.preventSystemSleep, forKey: "preventSystemSleep")
defaults.set(batteryAwareDisable, forKey: "batteryAwareDisable")
}
// MARK: - Status Item
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
updateStatusIcon()
rebuildMenu()
}
private func updateStatusIcon() {
guard let button = statusItem.button else { return }
button.image = Self.makeIcon(active: sleepManager.isActive)
}
private static let activeIcon: NSImage = loadIcon(active: true)
private static let inactiveIcon: NSImage = loadIcon(active: false)
private static func makeIcon(active: Bool) -> NSImage {
active ? activeIcon : inactiveIcon
}
private static func loadIcon(active: Bool) -> NSImage {
let url = Bundle.main.url(forResource: "samjokoo", withExtension: "pdf")
?? URL(fileURLWithPath: "/Users/charleskwon/Applications/KPopBird.app/Contents/Resources/samjokoo.pdf")
guard let base = NSImage(contentsOf: url) else {
return NSImage(systemSymbolName: "bird.fill", accessibilityDescription: "KPopBird") ?? NSImage()
}
let target = NSSize(width: 20, height: 18)
let rendered = NSImage(size: target, flipped: false) { _ in
let rect = NSRect(origin: .zero, size: target)
if active {
base.draw(in: rect)
} else {
base.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 0.45)
}
return true
}
rendered.isTemplate = true
return rendered
}
private func rebuildMenu() {
let menu = NSMenu()
let statusTitle: String
if sleepManager.isActive {
if let expire = expireDate {
let remaining = Int(expire.timeIntervalSinceNow)
statusTitle = "활성: \(formatDuration(remaining)) 남음"
} else if let started = sleepManager.activatedAt {
let elapsed = Int(Date().timeIntervalSince(started))
statusTitle = "활성: \(formatDuration(elapsed)) 경과"
} else {
statusTitle = "활성"
}
} else {
statusTitle = "비활성"
}
let statusItemMenu = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "")
statusItemMenu.isEnabled = false
menu.addItem(statusItemMenu)
menu.addItem(.separator())
let toggleTitle = sleepManager.isActive ? "비활성화" : "활성화 (무제한)"
let toggleItem = NSMenuItem(title: toggleTitle, action: #selector(toggle), keyEquivalent: "t")
toggleItem.target = self
menu.addItem(toggleItem)
let timerMenu = NSMenu()
let durations: [(String, TimeInterval)] = [
("15분", 15 * 60),
("30분", 30 * 60),
("1시간", 60 * 60),
("2시간", 2 * 60 * 60),
("4시간", 4 * 60 * 60)
]
for (title, duration) in durations {
let item = NSMenuItem(title: title, action: #selector(activateTimer(_:)), keyEquivalent: "")
item.target = self
item.representedObject = duration
timerMenu.addItem(item)
}
let timerRoot = NSMenuItem(title: "타이머로 활성화", action: nil, keyEquivalent: "")
timerRoot.submenu = timerMenu
menu.addItem(timerRoot)
menu.addItem(.separator())
let displayItem = NSMenuItem(title: "디스플레이 절전 방지", action: #selector(toggleDisplay), keyEquivalent: "")
displayItem.target = self
displayItem.state = sleepManager.preventDisplaySleep ? .on : .off
menu.addItem(displayItem)
let systemItem = NSMenuItem(title: "시스템 절전 방지", action: #selector(toggleSystem), keyEquivalent: "")
systemItem.target = self
systemItem.state = sleepManager.preventSystemSleep ? .on : .off
menu.addItem(systemItem)
let batteryItem = NSMenuItem(title: "배터리 사용 시 자동 해제", action: #selector(toggleBatteryAware), keyEquivalent: "")
batteryItem.target = self
batteryItem.state = batteryAwareDisable ? .on : .off
menu.addItem(batteryItem)
let launchItem = NSMenuItem(title: "로그인 시 자동 활성화", action: #selector(toggleActivateOnLaunch), keyEquivalent: "")
launchItem.target = self
launchItem.state = defaults.bool(forKey: "activateOnLaunch") ? .on : .off
menu.addItem(launchItem)
menu.addItem(.separator())
let powerInfo = SleepManager.isOnACPower() ? "전원 어댑터 연결됨" : "배터리 사용 중"
let powerItem = NSMenuItem(title: powerInfo, action: nil, keyEquivalent: "")
powerItem.isEnabled = false
menu.addItem(powerItem)
menu.addItem(.separator())
let quitItem = NSMenuItem(title: "KPopBird 종료", action: #selector(quit), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem.menu = menu
}
// MARK: - Actions
@objc private func toggle() {
if sleepManager.isActive {
deactivate()
} else {
activateIndefinitely()
}
}
@objc private func activateTimer(_ sender: NSMenuItem) {
guard let duration = sender.representedObject as? TimeInterval else { return }
activate(for: duration)
}
@objc private func toggleDisplay() {
sleepManager.preventDisplaySleep.toggle()
savePreferences()
if sleepManager.isActive { activateIndefinitely() }
rebuildMenu()
}
@objc private func toggleSystem() {
sleepManager.preventSystemSleep.toggle()
savePreferences()
if sleepManager.isActive { activateIndefinitely() }
rebuildMenu()
}
@objc private func toggleBatteryAware() {
batteryAwareDisable.toggle()
savePreferences()
rebuildMenu()
}
@objc private func toggleActivateOnLaunch() {
let current = defaults.bool(forKey: "activateOnLaunch")
defaults.set(!current, forKey: "activateOnLaunch")
rebuildMenu()
}
@objc private func quit() {
NSApp.terminate(nil)
}
// MARK: - Activation
private func activateIndefinitely() {
expireTimer?.invalidate()
expireTimer = nil
expireDate = nil
sleepManager.activate()
updateStatusIcon()
rebuildMenu()
}
private func activate(for duration: TimeInterval) {
sleepManager.activate()
expireDate = Date().addingTimeInterval(duration)
expireTimer?.invalidate()
expireTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
self?.deactivate()
}
updateStatusIcon()
rebuildMenu()
}
private func deactivate() {
sleepManager.deactivate()
expireTimer?.invalidate()
expireTimer = nil
expireDate = nil
updateStatusIcon()
rebuildMenu()
}
// MARK: - Ticking
private func startTickingTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
guard let self = self else { return }
if self.batteryAwareDisable, !SleepManager.isOnACPower(), self.sleepManager.isActive {
self.deactivate()
}
self.rebuildMenu()
}
}
private func formatDuration(_ seconds: Int) -> String {
let s = max(0, seconds)
let h = s / 3600
let m = (s % 3600) / 60
let sec = s % 60
if h > 0 { return String(format: "%d시간 %d분", h, m) }
if m > 0 { return String(format: "%d분 %d초", m, sec) }
return String(format: "%d초", sec)
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
import IOKit
import IOKit.pwr_mgt
import IOKit.ps
final class SleepManager {
private var displayAssertionID: IOPMAssertionID = 0
private var systemAssertionID: IOPMAssertionID = 0
private(set) var isDisplayActive = false
private(set) var isSystemActive = false
private(set) var activatedAt: Date?
var preventDisplaySleep = true
var preventSystemSleep = true
func activate() {
deactivate()
if preventDisplaySleep {
let reason = "KPopBird: preventing display sleep" as CFString
let result = IOPMAssertionCreateWithName(
kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason,
&displayAssertionID
)
isDisplayActive = (result == kIOReturnSuccess)
}
if preventSystemSleep {
let reason = "KPopBird: preventing system sleep" as CFString
let result = IOPMAssertionCreateWithName(
kIOPMAssertionTypePreventUserIdleSystemSleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason,
&systemAssertionID
)
isSystemActive = (result == kIOReturnSuccess)
}
activatedAt = Date()
}
func deactivate() {
if isDisplayActive {
IOPMAssertionRelease(displayAssertionID)
isDisplayActive = false
displayAssertionID = 0
}
if isSystemActive {
IOPMAssertionRelease(systemAssertionID)
isSystemActive = false
systemAssertionID = 0
}
activatedAt = nil
}
var isActive: Bool {
isDisplayActive || isSystemActive
}
static func isOnACPower() -> Bool {
guard let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let providing = IOPSGetProvidingPowerSourceType(info)?.takeUnretainedValue()
else { return true }
return (providing as String) == kIOPMACPowerKey
}
}

View File

@@ -0,0 +1,6 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()