From 8c08d92613d59fa674e4593c91de413d283979db Mon Sep 17 00:00:00 2001 From: charleskwon Date: Sun, 12 Apr 2026 12:52:01 +0900 Subject: [PATCH] Initial commit: KPopBird - macOS sleep prevention menu bar app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 6 + Package.swift | 13 ++ Resources/samjokoo.pdf | Bin 0 -> 3116 bytes Resources/samjokoo.svg | 54 ++++++ Sources/KPopBird/AppDelegate.swift | 275 ++++++++++++++++++++++++++++ Sources/KPopBird/SleepManager.swift | 68 +++++++ Sources/KPopBird/main.swift | 6 + 7 files changed, 422 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Resources/samjokoo.pdf create mode 100644 Resources/samjokoo.svg create mode 100644 Sources/KPopBird/AppDelegate.swift create mode 100644 Sources/KPopBird/SleepManager.swift create mode 100644 Sources/KPopBird/main.swift 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 0000000000000000000000000000000000000000..503600adee47e2b43e49c3437aa45a7652824a26 GIT binary patch literal 3116 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 + + + +]> + + + + + + + + + + + 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()