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>
276 lines
9.3 KiB
Swift
276 lines
9.3 KiB
Swift
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)
|
|
}
|
|
}
|