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:
275
Sources/KPopBird/AppDelegate.swift
Normal file
275
Sources/KPopBird/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
68
Sources/KPopBird/SleepManager.swift
Normal file
68
Sources/KPopBird/SleepManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
6
Sources/KPopBird/main.swift
Normal file
6
Sources/KPopBird/main.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import AppKit
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
Reference in New Issue
Block a user