commit 8c08d92613d59fa674e4593c91de413d283979db Author: charleskwon Date: Sun Apr 12 12:52:01 2026 +0900 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab7f987 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +*.xcodeproj/ +*.xcworkspace/ +DerivedData/ +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..615475e --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/Resources/samjokoo.pdf b/Resources/samjokoo.pdf new file mode 100644 index 0000000..503600a Binary files /dev/null and b/Resources/samjokoo.pdf differ diff --git a/Resources/samjokoo.svg b/Resources/samjokoo.svg new file mode 100644 index 0000000..e679289 --- /dev/null +++ b/Resources/samjokoo.svg @@ -0,0 +1,54 @@ + + + + +]> + + + + + + + + + + + diff --git a/Sources/KPopBird/AppDelegate.swift b/Sources/KPopBird/AppDelegate.swift new file mode 100644 index 0000000..df87afd --- /dev/null +++ b/Sources/KPopBird/AppDelegate.swift @@ -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) + } +} diff --git a/Sources/KPopBird/SleepManager.swift b/Sources/KPopBird/SleepManager.swift new file mode 100644 index 0000000..0d349a6 --- /dev/null +++ b/Sources/KPopBird/SleepManager.swift @@ -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 + } +} diff --git a/Sources/KPopBird/main.swift b/Sources/KPopBird/main.swift new file mode 100644 index 0000000..b57e54a --- /dev/null +++ b/Sources/KPopBird/main.swift @@ -0,0 +1,6 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run()