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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.build/
.swiftpm/
*.xcodeproj/
*.xcworkspace/
DerivedData/
.DS_Store

13
Package.swift Normal file
View File

@@ -0,0 +1,13 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "KPopBird",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "KPopBird",
path: "Sources/KPopBird"
)
]
)

BIN
Resources/samjokoo.pdf Normal file

Binary file not shown.

54
Resources/samjokoo.svg Normal file
View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 12.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 51448) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_svg "http://www.w3.org/2000/svg">
<!ENTITY ns_xlink "http://www.w3.org/1999/xlink">
]>
<svg version="1.1" id="Layer_1" xmlns="&ns_svg;" xmlns:xlink="&ns_xlink;" width="500" height="460" viewBox="0 0 500 460"
style="overflow:visible;enable-background:new 0 0 500 460;" xml:space="preserve">
<style type="text/css">
<![CDATA[
.st0{fill-rule:evenodd;clip-rule:evenodd;}
.st1{fill:none;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#000000;}
]]>
</style>
<path class="st2" d="M259.671,318c4.255,0.712-5.295,8.543-6,11c-0.86,3,1.269,9.854,0,16c-1.105,5.353-6.372,9.959-7,13
c-1.013,4.899,2.025,8.452,0,10c-3.141,2.398-15.412-1.614-21-1c-7.167,0.786-3.856,0.022-3-7c0.66-5.416-0.699-12.623,0-15
c0.712-2.421,3.79-3.116,6-6c1.736-2.266,1.688-6.518,4-8c1.484-0.952,8.828-1.187,11-2
C250.568,326.415,256.254,317.428,259.671,318z"/>
<path class="st2" d="M278.671,319c5.49,8.939-6.158,27.4-6,35c0.087,4.2,7.173,10.737,5,14c-1.836,2.755-11.129,0.585-14,0
c-4.852-0.989-9.148,1.092-10,0c-0.469-0.601-0.556-9.62,0-12c0.376-1.611,5.021-6.449,7-12c1.798-5.045,1.667-13.379,3-15
c1.109-1.351,5.688-1.419,7-2C274.416,325.34,276.528,323.492,278.671,319z"/>
<g>
<g>
<path class="st0" d="M388.671,254c-13.233,23.05-35.762,36.475-66,45c-3.237,0.912-21.35,4.236-22,5
c-1.313,1.541,8.384,12.646,10,15c7.567,11.021,11.606,18.713,25,23c-16.342,4.211-32.363-5.416-41-16
c-5.62-6.889-3.176,9.273-5,15c-1.956,6.14-7.55,12.092-8,15c-0.386,2.497,0.337,10.319,1,11c1.747,1.792,8.929-2.188,14-2
c-0.786-0.029,11.557,3.098,13,4c0.119,0.074,2.768,2.428,3,2c-1.574,2.902-21.896,1.586-31,2c-3.587,0.162-13.943,2.136-17,2
c-7.656-0.342-16.387-3.266-27-4c0.233,0.016-0.422,1.577-1,1c-0.117-0.117,0.098-1.997,0-2c-2.463-0.085-5.22,0.941-8,1
c-8.309,0.173-16.016-1.163-25-1c0.881-4.443,6.881,0.122,10-2c4.725-3.215,1.534-16.363,3-24c0.733-3.822,7.005-8.967,5-12
c-0.735-1.112-6.617,1.871-7,2c-13.336,4.465-30.386,5.691-50,5c26.413-7.141,41.822-13.103,66-23c8.53-3.492-2.806-1.999-10-3
c-31.338-4.362-55.012-19.218-72-35c-18.942-17.6-31.463-36.518-41-67c-0.707-2.26-4.535-10.465-3-12c0.675-0.675,7.951,7.794,4,3
c29.772,36.127,57.773,68.089,112,81c12.193,2.902-9.467-7.307-12-9c-11.936-7.981-15.776-14.186-26-25
c-4.771-5.047-19.127-18.604-10-14c5.037,2.54,17.975,14.539,23,18c4.363,3.003,8.252,6.297,14,9c1.941,0.912,16.153,6.847,18,5
c1.111-1.112-5.383-6.456-7-8c-15.717-15.011-34.139-34.362-39-59c-2.125-10.773-1.536-19.645-4-29c-1.638-6.217-5.078-13.53,1-6
c0.567,0.703,3.876,6.124,4,6c2.512-2.512,0.116-11.912,0-13c-0.685-6.424-6.729-16.324,1-10c12.732,10.417-4.997-27.458,5-20
c18.703,13.953,15.191,48.101,29,66c4.547,5.894,10.551,10.367,16,15c8.498,7.226,17.526,12.205,22,23c0.718,1.73,1.123,6.551,2,7
c5.688,2.908,16.494-21.346,17-25c2.617-18.903-9.566-39.435-6-56c1.383-6.425,14.401-19.07,9-27c-4.435-6.511-47.828-1.407-56-3
c-7.658-1.492-17.97-10.516-11-21c2.648-3.982,16.5-10.718,18-4c1.404,6.292-13.102,6.874-14,10c-1.702,5.921,3.389,8.073,10,9
c17.853,2.503,69.597-16.014,66,13c-0.061,0.486-1.071,6.929-2,6c2.206,2.206,10.995-0.438,15,0c5.598,0.613,14.604,4.784,20,7
c10.687,4.389,25.524,6.003,11,10c-10.661,2.934-26.078,5.874-33,11c-9.344,6.919-7.355,9.393-5,21c1.554,7.653,2.588,15.333,3,24
c0.705,14.833-6.435,35.15,9,37c5.476,0.656,13.352-2.878,17-6c2.812-2.408,3.729-8.663,6-10c3.612-2.128,13.58-1.155,19-3
c3.53-1.202,10.804-4.669,13-7c1.78-1.889,3.782-6.748,4-1c0.02,0.511-1.95,2.752-1,4c1.658,2.177,12.366-13.502,15-20
c1.521-3.75,3.845-14.247,6-22c0.625-2.249,2.561-11.265,4-3c1.273,7.308,0.767,11.693,1,17c0.571,12.967-1.814,39.675-7,51
C382.968,256.904,383.73,256.902,388.671,254z M243.671,329c-2.172,0.813-9.516,1.048-11,2c-2.312,1.482-2.264,5.734-4,8
c-2.21,2.884-5.288,3.579-6,6c-0.699,2.377,0.66,9.584,0,15c-0.856,7.022-4.167,7.786,3,7c5.588-0.614,17.859,3.398,21,1
c2.025-1.548-1.013-5.101,0-10c0.628-3.041,5.895-7.647,7-13c1.269-6.146-0.86-13,0-16c0.705-2.457,10.255-10.288,6-11
C256.254,317.428,250.568,326.415,243.671,329z M270.671,327c-1.312,0.581-5.891,0.649-7,2c-1.333,1.621-1.202,9.955-3,15
c-1.979,5.551-6.624,10.389-7,12c-0.556,2.38-0.469,11.399,0,12c0.852,1.092,5.148-0.989,10,0c2.871,0.585,12.164,2.755,14,0
c2.173-3.263-4.913-9.8-5-14c-0.158-7.6,11.49-26.061,6-35C276.528,323.492,274.416,325.34,270.671,327z"/>
</g>
<rect class="st1" width="500" height="460"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

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