#!/bin/bash
PomodoroSync Daemon — 滴答番茄同步守护进程
监控 TickTick 番茄钟状态,自动驱动 TickMato 浮窗
用法:nohup bash ~/Scripts/pomodoro-sync-daemon.sh > /tmp/pomodoro-sync.log 2>&1 &
WAS_RUNNING=false
echo "🍅 PomodoroSync 已启动,正在后台监控 TickTick 番茄钟..."
while true; do STATE=$(osascript -e ' tell application "System Events" if not (exists process "TickTick") then return "no_ticktick" tell process "TickTick" set allTexts to every static text of window 1 set output to "" repeat with txt in allTexts try set output to output & (value of txt as text) & "|" end try end repeat return output end tell end tell' 2>&1)
if echo "$STATE" | grep -q "番茄专注"; then
TIMER=$(echo "$STATE" | sed 's/.*番茄专注|//' | cut -d'|' -f1)
if [ -n "$TIMER" ] && [ "$TIMER" != "25:00" ] && [ "$WAS_RUNNING" = "false" ]; then
# TickTick 开始计时 → 启动 TickMato
echo "$(date +%H:%M:%S) ⏰ 检测到番茄钟启动 ($TIMER)"
WAS_RUNNING=true
/Users/shiny/Scripts/TickMato --autostart &
elif [ "$TIMER" = "25:00" ] && [ "$WAS_RUNNING" = "true" ]; then
# TickTick 回到 25:00 → 用户手动结束了
echo "$(date +%H:%M:%S) ⏹ 检测到番茄钟手动结束"
WAS_RUNNING=false
/Users/shiny/Scripts/TickMato --stop &
fi
fi
sleep 2done
// TickMato.swift — 滴答番茄 macOS 浮窗番茄钟 // Compile: swiftc -o TickMato TickMato.swift -framework AppKit // Usage: ./TickMato → IDLE, wait for Wind Up // ./TickMato --autostart → start 25min immediately // ./TickMato --break → start 5min break // ./TickMato --stop → reset to IDLE
import AppKit import Foundation
// ═══════════════════════════════════════════ // MARK: - Configuration // ═══════════════════════════════════════════
let kNotification = "com.tickmato.command" let kPidPath = (NSHomeDirectory() as NSString).appendingPathComponent(".tickmato.pid") let kWorkDuration = 1500 // 25 min let kBreakDuration = 300 // 5 min let kWechatBlockDuration = 600 // 10 min reminder block
struct Phase { let name: String let label: String let duration: Int }
let kPhases: [Phase] = [ Phase(name: "Initial Review", label: "r", duration: 120), Phase(name: "Work I", label: "w", duration: 630), Phase(name: "Work II", label: "w", duration: 630), Phase(name: "Final Review", label: "r", duration: 60), Phase(name: "Tracking", label: "t", duration: 60), ]
struct C { static let tomato = NSColor(srgbRed: 232/255, green: 57/255, blue: 42/255, alpha: 1) static let workBg = NSColor(srgbRed: 92/255, green: 15/255, blue: 8/255, alpha: 0.96) static let breakBg = NSColor(srgbRed: 27/255, green: 94/255, blue: 32/255, alpha: 0.96) static let idleBg = NSColor(srgbRed: 245/255, green: 245/255, blue: 245/255, alpha: 0.96) static let green = NSColor(srgbRed: 76/255, green: 175/255, blue: 80/255, alpha: 1) static let done = NSColor(srgbRed: 160/255, green: 160/255, blue: 160/255, alpha: 1) static let pending = NSColor(srgbRed: 224/255, green: 224/255, blue: 224/255, alpha: 1) }
enum AppState { case idle, work, brk }
struct PerfGrade: Equatable { let emoji: String let label: String let color: NSColor
static func == (lhs: PerfGrade, rhs: PerfGrade) -> Bool { lhs.emoji == rhs.emoji }
static let beyond = PerfGrade(emoji: "🟪", label: "突破上限!绝啦!!", color: NSColor(srgbRed: 155/255, green: 89/255, blue: 182/255, alpha: 1))
static let great = PerfGrade(emoji: "🟦", label: "超预期,太牛了!", color: NSColor(srgbRed: 52/255, green: 152/255, blue: 219/255, alpha: 1))
static let good = PerfGrade(emoji: "🟩", label: "完成啦很不错!", color: NSColor(srgbRed: 46/255, green: 204/255, blue: 113/255, alpha: 1))
static let almost = PerfGrade(emoji: "🟧", label: "差一点加油喔!", color: NSColor(srgbRed: 230/255, green: 126/255, blue: 34/255, alpha: 1))
static let low = PerfGrade(emoji: "🟥", label: "状态不佳休息吧~", color: NSColor(srgbRed: 231/255, green: 76/255, blue: 60/255, alpha: 1))
static let none = PerfGrade(emoji: "⬜", label: "", color: NSColor.tertiaryLabelColor)
/// Grade by per-working-day average: 🟥<5 🟧5-6 🟩7 🟦8-10 🟪≥11
static func grade(_ count: Int, workDays: Int = 1) -> PerfGrade {
if count == 0 { return .none }
let avg = Double(count) / Double(max(workDays, 1))
if avg >= 11 { return .beyond }
if avg >= 8 { return .great }
if avg >= 7 { return .good }
if avg >= 5 { return .almost }
return .low
}}
// ═══════════════════════════════════════════ // MARK: - Sound Preferences // ═══════════════════════════════════════════
struct SoundEvent { let key: String let label: String let defaultSound: String let defaultRepeat: Int }
let kSoundEvents: [SoundEvent] = [ SoundEvent(key: "windUp", label: "上发条 Wind Up", defaultSound: "Tink", defaultRepeat: 10), SoundEvent(key: "phaseTransition", label: "阶段切换 Phase Transition", defaultSound: "Tink", defaultRepeat: 1), SoundEvent(key: "workComplete", label: "番茄完成 Work Complete", defaultSound: "Hero", defaultRepeat: 3), SoundEvent(key: "breakEnd", label: "休息结束 Break End", defaultSound: "Funk", defaultRepeat: 2), SoundEvent(key: "manualStop", label: "手动停止 Manual Stop", defaultSound: "Pop", defaultRepeat: 1), SoundEvent(key: "wechatRemind", label: "微信提醒 WeChat Remind", defaultSound: "Glass", defaultRepeat: 2), ]
class SoundPrefs: NSObject, NSSoundDelegate { static let shared = SoundPrefs() private var activeSounds: [NSSound] = [] private var playGeneration = 0
func sound(_ sound: NSSound, didFinishPlaying aBool: Bool) {
activeSounds.removeAll { $0 === sound }
}
func stopAll() {
playGeneration += 1
for s in activeSounds { s.stop() }
activeSounds.removeAll()
}
// Scan directories for sound files, return (displayName, fullPath) pairs
static let allSoundFiles: [(name: String, path: String)] = {
let searchDirs: [(prefix: String, dir: String)] = [
("", "/System/Library/Sounds"),
("🔔 ", "/System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones"),
("🔊 ", "/System/Library/PrivateFrameworks/ScreenReader.framework/Versions/A/Resources/Sounds"),
("🎵 ", "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds"),
("✨ ", "/System/Library/PrivateFrameworks/AXMediaUtilities.framework/Versions/A/Resources/sounds"),
("📱 ", "/System/Library/CoreServices"),
("🎤 ", "/System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Resources"),
("🔍 ", "/System/Library/PrivateFrameworks/FindMyDevice.framework/Versions/A/Resources"),
("📩 ", "/System/Library/PrivateFrameworks/IMDaemonCore.framework/Versions/A/Resources"),
("🗺️ ", "/System/Library/PrivateFrameworks/Navigation.framework/Versions/A/Resources"),
("📤 ", "/System/Library/PrivateFrameworks/Sharing.framework/Versions/A/Resources"),
("🎮 ", "/System/Library/UserNotifications/Bundles"),
]
let exts = Set(["aiff", "caf", "wav"])
let skipWords = Set(["keyboard", "key_press", "dictation", "dtmf", "telephony",
"SiriTTS", "VoicePreview", "gryphon", "ttsbundle",
"MobileAsset", "TTSAXResource", "SecureCharacter",
"welcomeMessage", "VOTraining", "Siri+", "SpeakerRecog",
"VoiceTrigger", "screenshot"])
var results: [(String, String)] = []
func scan(prefix: String, dir: String) {
let fm = FileManager.default
guard let enumerator = fm.enumerator(atPath: dir) else { return }
while let file = enumerator.nextObject() as? String {
let ext = (file as NSString).pathExtension.lowercased()
guard exts.contains(ext) else { continue }
let full = (dir as NSString).appendingPathComponent(file)
// Skip unwanted files
if skipWords.contains(where: { full.contains($0) }) { continue }
// Generate display name
let base = ((file as NSString).lastPathComponent as NSString).deletingPathExtension
let display = prefix + base
.replacingOccurrences(of: "-EncoreInfinitum", with: "")
.replacingOccurrences(of: "Text-Message-Acknowledgement-", with: "Msg-")
results.append((display, full))
}
}
for sd in searchDirs { scan(prefix: sd.prefix, dir: sd.dir) }
results.sort { $0.0.localizedCaseInsensitiveCompare($1.0) == .orderedAscending }
return results
}()
lazy var availableSounds: [String] = {
SoundPrefs.allSoundFiles.map { $0.name }
}()
func soundName(for key: String) -> String {
UserDefaults.standard.string(forKey: "sound.\(key)")
?? kSoundEvents.first { $0.key == key }?.defaultSound ?? "Tink"
}
func repeatCount(for key: String) -> Int {
let val = UserDefaults.standard.integer(forKey: "repeat.\(key)")
return val > 0 ? val : kSoundEvents.first { $0.key == key }?.defaultRepeat ?? 1
}
func set(sound: String, for key: String) {
UserDefaults.standard.set(sound, forKey: "sound.\(key)")
}
func set(repeatCount: Int, for key: String) {
UserDefaults.standard.set(repeatCount, forKey: "repeat.\(key)")
}
static func resolvePath(_ name: String) -> String? {
if name == "None" { return nil }
if let entry = allSoundFiles.first(where: { $0.name == name }) {
return entry.path
}
// Fallback: try as system sound
let path = "/System/Library/Sounds/\(name).aiff"
return FileManager.default.fileExists(atPath: path) ? path : nil
}
private func playFile(_ path: String) {
guard let s = NSSound(contentsOfFile: path, byReference: false) else { return }
activeSounds.append(s)
s.delegate = SoundPrefs.shared
s.play()
}
func play(_ key: String) {
let name = soundName(for: key)
guard let path = SoundPrefs.resolvePath(name) else { return }
let n = repeatCount(for: key)
let gen = playGeneration
if key == "windUp" {
let interval = 0.18
for i in 0..<n {
DispatchQueue.main.asyncAfter(deadline: .now() + interval * Double(i)) { [self] in
guard self.playGeneration == gen else { return }
self.playFile(path)
}
}
} else {
playFile(path)
if n > 1 {
for i in 1..<n {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5 * Double(i)) { [self] in
guard self.playGeneration == gen else { return }
self.playFile(path)
}
}
}
}
}
static func preview(_ name: String) {
guard let path = resolvePath(name) else { return }
shared.playFile(path)
}}
// ═══════════════════════════════════════════ // MARK: - Sound Settings Window // ═══════════════════════════════════════════
class SoundSettingsController: NSObject, NSWindowDelegate { var window: NSWindow? var onClose: (() -> Void)? var popups: [String: NSPopUpButton] = [:] var steppers: [String: NSStepper] = [:] var stepperLabels: [String: NSTextField] = [:]
func show() {
if let w = window { w.makeKeyAndOrderFront(nil); return }
let W: CGFloat = 360
let rowH: CGFloat = 56
let H: CGFloat = CGFloat(kSoundEvents.count) * rowH + 116
NSApp.setActivationPolicy(.regular)
let w = NSWindow(
contentRect: NSRect(x: 200, y: 200, width: W, height: H),
styleMask: [.titled, .closable],
backing: .buffered, defer: false
)
w.title = "⚙ Settings 设置"
w.level = .floating
w.delegate = self
let content = w.contentView!
// ── WeChat pause toggle ──
let toggleY = H - 36
let wechatCheck = NSButton(checkboxWithTitle: "切微信时暂停计时 Pause timer on WeChat",
target: self, action: #selector(wechatPauseToggled(_:)))
wechatCheck.frame = NSRect(x: 16, y: toggleY, width: W - 32, height: 20)
wechatCheck.state = UserDefaults.standard.object(forKey: "pauseOnWechat") as? Bool ?? true ? .on : .off
wechatCheck.font = NSFont.systemFont(ofSize: 12, weight: .medium)
content.addSubview(wechatCheck)
let sep = NSBox(frame: NSRect(x: 16, y: toggleY - 12, width: W - 32, height: 1))
sep.boxType = .separator
content.addSubview(sep)
let sounds = ["None"] + SoundPrefs.shared.availableSounds
for (i, ev) in kSoundEvents.enumerated() {
let y = H - CGFloat(i + 1) * rowH - 56
// Label
let lbl = NSTextField(labelWithString: ev.label)
lbl.font = NSFont.systemFont(ofSize: 11, weight: .medium)
lbl.textColor = .secondaryLabelColor
lbl.frame = NSRect(x: 16, y: y + 24, width: W - 32, height: 16)
content.addSubview(lbl)
// Sound picker
let popup = NSPopUpButton(frame: NSRect(x: 16, y: y, width: 180, height: 24))
popup.addItems(withTitles: sounds)
popup.selectItem(withTitle: SoundPrefs.shared.soundName(for: ev.key))
popup.tag = i
popup.target = self
popup.action = #selector(soundChanged(_:))
content.addSubview(popup)
popups[ev.key] = popup
// Repeat count label
let rLbl = NSTextField(labelWithString: "×\(SoundPrefs.shared.repeatCount(for: ev.key))")
rLbl.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
rLbl.alignment = .center
rLbl.frame = NSRect(x: 210, y: y + 2, width: 30, height: 20)
content.addSubview(rLbl)
stepperLabels[ev.key] = rLbl
// Stepper for repeat count
let stepper = NSStepper(frame: NSRect(x: 242, y: y, width: 20, height: 24))
stepper.minValue = 1
stepper.maxValue = 15
stepper.integerValue = SoundPrefs.shared.repeatCount(for: ev.key)
stepper.tag = i
stepper.target = self
stepper.action = #selector(repeatChanged(_:))
content.addSubview(stepper)
steppers[ev.key] = stepper
// Preview button
let preview = NSButton(frame: NSRect(x: 275, y: y, width: 70, height: 24))
preview.title = "▶ 试听"
preview.bezelStyle = .rounded
preview.tag = i
preview.target = self
preview.action = #selector(previewSound(_:))
content.addSubview(preview)
}
// Done button
let doneBtn = NSButton(frame: NSRect(x: W / 2 - 50, y: 12, width: 100, height: 30))
doneBtn.title = "Done 完成"
doneBtn.bezelStyle = .rounded
doneBtn.target = self
doneBtn.action = #selector(close)
content.addSubview(doneBtn)
w.center()
NSApp.activate(ignoringOtherApps: true)
w.makeKeyAndOrderFront(nil)
window = w
}
@objc func wechatPauseToggled(_ sender: NSButton) {
UserDefaults.standard.set(sender.state == .on, forKey: "pauseOnWechat")
}
@objc func soundChanged(_ sender: NSPopUpButton) {
let ev = kSoundEvents[sender.tag]
let name = sender.titleOfSelectedItem ?? "Tink"
SoundPrefs.shared.set(sound: name, for: ev.key)
SoundPrefs.preview(name)
}
@objc func repeatChanged(_ sender: NSStepper) {
let ev = kSoundEvents[sender.tag]
SoundPrefs.shared.set(repeatCount: sender.integerValue, for: ev.key)
stepperLabels[ev.key]?.stringValue = "×\(sender.integerValue)"
}
@objc func previewSound(_ sender: NSButton) {
let ev = kSoundEvents[sender.tag]
SoundPrefs.shared.play(ev.key)
}
@objc func close() {
window?.close()
}
func windowWillClose(_ notification: Notification) {
window = nil
NSApp.setActivationPolicy(.accessory)
onClose?()
}}
// ═══════════════════════════════════════════ // MARK: - Stats Store // ═══════════════════════════════════════════
struct DayStats: Codable { var pomodoros: Int = 0 var abandoned: Int = 0 var focusSecs: Int = 0 var breakSecs: Int = 0 var wechatSecs: Int = 0 }
class StatsStore { static let shared = StatsStore() private let path = (NSHomeDirectory() as NSString).appendingPathComponent(".tickmato-stats.json") private var data: [String: DayStats] = [:]
private init() { load() }
private static let dayFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()
static func dayKey(_ date: Date = Date()) -> String { dayFmt.string(from: date) }
private func load() {
guard let d = FileManager.default.contents(atPath: path),
let obj = try? JSONDecoder().decode([String: DayStats].self, from: d)
else { return }
data = obj
}
func save() {
guard let d = try? JSONEncoder().encode(data) else { return }
try? d.write(to: URL(fileURLWithPath: path))
}
func today() -> DayStats { data[StatsStore.dayKey()] ?? DayStats() }
func stats(for key: String) -> DayStats { data[key] ?? DayStats() }
func addPomodoro() {
var s = data[StatsStore.dayKey()] ?? DayStats(); s.pomodoros += 1
data[StatsStore.dayKey()] = s; save()
}
func addAbandoned() {
var s = data[StatsStore.dayKey()] ?? DayStats(); s.abandoned += 1
data[StatsStore.dayKey()] = s; save()
}
func addSeconds(_ type: String, _ secs: Int) {
guard secs > 0 else { return }
var s = data[StatsStore.dayKey()] ?? DayStats()
switch type {
case "focus": s.focusSecs += secs
case "break": s.breakSecs += secs
case "wechat": s.wechatSecs += secs
default: break
}
data[StatsStore.dayKey()] = s; save()
}
private func aggregate(_ keys: [String]) -> DayStats {
var r = DayStats()
for k in keys {
let s = data[k] ?? DayStats()
r.pomodoros += s.pomodoros; r.abandoned += s.abandoned
r.focusSecs += s.focusSecs; r.breakSecs += s.breakSecs
r.wechatSecs += s.wechatSecs
}
return r
}
private func dayKeys(from start: Date, count: Int) -> [String] {
(0..<count).compactMap { Calendar.current.date(byAdding: .day, value: -$0, to: start) }
.map { StatsStore.dayKey($0) }
}
func yesterday() -> DayStats {
guard let d = Calendar.current.date(byAdding: .day, value: -1, to: Date()) else { return DayStats() }
return stats(for: StatsStore.dayKey(d))
}
func thisWeek() -> DayStats {
let wd = Calendar.current.component(.weekday, from: Date())
return aggregate(dayKeys(from: Date(), count: (wd + 5) % 7 + 1))
}
func lastWeek() -> DayStats {
let wd = Calendar.current.component(.weekday, from: Date())
let off = (wd + 5) % 7 + 1
guard let end = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return DayStats() }
return aggregate(dayKeys(from: end, count: 7))
}
func thisMonth() -> DayStats {
aggregate(dayKeys(from: Date(), count: Calendar.current.component(.day, from: Date())))
}
func currentStreak() -> Int {
let cal = Calendar.current
var streak = 0; var date = Date()
if stats(for: StatsStore.dayKey(date)).pomodoros == 0 {
guard let y = cal.date(byAdding: .day, value: -1, to: date) else { return 0 }
date = y
}
while true {
if stats(for: StatsStore.dayKey(date)).pomodoros == 0 { break }
streak += 1
guard let prev = cal.date(byAdding: .day, value: -1, to: date) else { break }
date = prev
}
let best = UserDefaults.standard.integer(forKey: "bestStreak")
if streak > best { UserDefaults.standard.set(streak, forKey: "bestStreak") }
return streak
}
var bestStreak: Int { max(currentStreak(), UserDefaults.standard.integer(forKey: "bestStreak")) }
func lastMonth() -> DayStats {
let day = Calendar.current.component(.day, from: Date())
guard let end = Calendar.current.date(byAdding: .day, value: -day, to: Date()) else { return DayStats() }
let n = Calendar.current.range(of: .day, in: .month, for: end)?.count ?? 30
return aggregate(dayKeys(from: end, count: n))
}}
// ═══════════════════════════════════════════ // MARK: - Stats Window // ═══════════════════════════════════════════
class StatsWindowController: NSObject, NSWindowDelegate { var window: NSWindow? var onClose: (() -> Void)? var textView: NSTextView? var tabControl: NSSegmentedControl? var navLabel: NSTextField? var pageOffset = 0 var currentTab = 0
static let wdShort = ["日","一","二","三","四","五","六"]
func show() {
if let w = window { refresh(); w.makeKeyAndOrderFront(nil); return }
NSApp.setActivationPolicy(.regular)
let W: CGFloat = 420, H: CGFloat = 640
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: W, height: H),
styleMask: [.titled, .closable], backing: .buffered, defer: false)
w.title = "📊 TickMato Stats"; w.level = .floating; w.delegate = self
let cv = w.contentView!
let tabs = NSSegmentedControl(frame: NSRect(x: 16, y: H - 36, width: W - 32, height: 24))
tabs.segmentCount = 2
tabs.setLabel("🍅 番茄专注", forSegment: 0)
tabs.setLabel("📱 微信 / 摸鱼", forSegment: 1)
tabs.selectedSegment = 0; tabs.target = self; tabs.action = #selector(tabChanged(_:))
cv.addSubview(tabs); tabControl = tabs
let prev = NSButton(frame: NSRect(x: 16, y: H - 62, width: 28, height: 22))
prev.title = "◀"; prev.bezelStyle = .rounded; prev.target = self; prev.action = #selector(goPrev)
cv.addSubview(prev)
let nl = NSTextField(labelWithString: "")
nl.frame = NSRect(x: 48, y: H - 60, width: W - 96, height: 16)
nl.alignment = .center; nl.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .medium)
nl.textColor = .secondaryLabelColor; cv.addSubview(nl); navLabel = nl
let next = NSButton(frame: NSRect(x: W - 44, y: H - 62, width: 28, height: 22))
next.title = "▶"; next.bezelStyle = .rounded; next.target = self; next.action = #selector(goNext)
cv.addSubview(next)
let scroll = NSScrollView(frame: NSRect(x: 0, y: 40, width: W, height: H - 108))
scroll.hasVerticalScroller = true; scroll.autohidesScrollers = true
let tv = NSTextView(frame: NSRect(x: 0, y: 0, width: W, height: 3000))
tv.isEditable = false; tv.isSelectable = true
tv.textContainerInset = NSSize(width: 14, height: 8)
tv.backgroundColor = .windowBackgroundColor
tv.isVerticallyResizable = true; tv.autoresizingMask = [.width]
tv.textContainer?.widthTracksTextView = true
scroll.documentView = tv; cv.addSubview(scroll); textView = tv
let done = NSButton(frame: NSRect(x: W / 2 - 40, y: 8, width: 80, height: 24))
done.title = "完成"; done.bezelStyle = .rounded; done.target = self; done.action = #selector(close)
cv.addSubview(done)
refresh(); w.center(); NSApp.activate(ignoringOtherApps: true); w.makeKeyAndOrderFront(nil)
window = w
}
@objc func tabChanged(_ s: NSSegmentedControl) { currentTab = s.selectedSegment; pageOffset = 0; refresh() }
@objc func goPrev() { pageOffset -= 1; refresh() }
@objc func goNext() { if pageOffset < 0 { pageOffset += 1; refresh() } }
@objc func close() { window?.close() }
func windowWillClose(_ notification: Notification) {
window = nil
NSApp.setActivationPolicy(.accessory)
onClose?()
}
func refresh() {
let end = endDate(); let start = Calendar.current.date(byAdding: .day, value: -6, to: end)!
let fmt = DateFormatter(); fmt.dateFormat = "M/d"
navLabel?.stringValue = "\(fmt.string(from: start)) – \(fmt.string(from: end))"
textView?.textStorage?.setAttributedString(currentTab == 0 ? buildPomodoro() : buildWechat())
// scroll to top
textView?.scrollToBeginningOfDocument(nil)
}
// ── Shared Helpers ──
private func endDate() -> Date { Calendar.current.date(byAdding: .day, value: pageOffset * 7, to: Date())! }
private func fmtT(_ s: Int) -> String {
if s < 60 { return s > 0 ? "<1m" : "0m" }
let h = s/3600, m = (s%3600)/60; return h > 0 ? "\(h)h\(m)m" : "\(m)m"
}
private func sparkline(_ values: [Int]) -> String {
let bars: [Character] = ["▁","▂","▃","▄","▅","▆","▇","█"]
let mx = max(values.max() ?? 1, 1)
return String(values.map { v in
if v == 0 { return Character(" ") }
let i = min(Int(Double(v) / Double(mx) * 7), 7)
return bars[i]
})
}
private func progressBar(_ cur: Int, _ target: Int, w: Int = 20) -> String {
let pct = min(Double(cur) / Double(max(target, 1)), 1.0)
let filled = Int(pct * Double(w))
return String(repeating: "█", count: filled) + String(repeating: "░", count: w - filled)
}
private func weekAgg(offset: Int) -> (wn: Int, ds: DayStats) {
let cal = Calendar.current; let now = Date()
let wd = cal.component(.weekday, from: now)
let toMon = (wd + 5) % 7
guard let mon = cal.date(byAdding: .day, value: -toMon, to: now),
let ws = cal.date(byAdding: .day, value: -offset * 7, to: mon) else { return (0, DayStats()) }
var r = DayStats()
for d in 0..<7 {
guard let dt = cal.date(byAdding: .day, value: d, to: ws), dt <= now else { continue }
let s = StatsStore.shared.stats(for: StatsStore.dayKey(dt))
r.pomodoros += s.pomodoros; r.focusSecs += s.focusSecs
r.breakSecs += s.breakSecs; r.wechatSecs += s.wechatSecs
}
return (cal.component(.weekOfYear, from: ws), r)
}
private func monthAgg(offset: Int) -> (label: String, ds: DayStats, wd: Int) {
let cal = Calendar.current
guard let t = cal.date(byAdding: .month, value: -offset, to: Date()) else { return ("", DayStats(), 1) }
let y = cal.component(.year, from: t), m = cal.component(.month, from: t)
let first = cal.date(from: DateComponents(year: y, month: m, day: 1))!
let nd = cal.range(of: .day, in: .month, for: first)!.count
let upto = offset == 0 ? cal.component(.day, from: Date()) : nd
var r = DayStats()
for d in 1...upto {
guard let dt = cal.date(from: DateComponents(year: y, month: m, day: d)) else { continue }
let s = StatsStore.shared.stats(for: StatsStore.dayKey(dt))
r.pomodoros += s.pomodoros; r.focusSecs += s.focusSecs
r.breakSecs += s.breakSecs; r.wechatSecs += s.wechatSecs
}
return ("\(m)月", r, max(upto * 5 / 7, 1))
}
// ════════════════════════════════
// Pomodoro Page
// ════════════════════════════════
private func buildPomodoro() -> NSAttributedString {
let r = NSMutableAttributedString()
let cal = Calendar.current; let now = Date()
let store = StatsStore.shared
// Fonts
let big = NSFont.monospacedDigitSystemFont(ofSize: 28, weight: .bold)
let med = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
let body = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
let small = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular)
let tiny = NSFont.systemFont(ofSize: 9)
let dim = NSColor.secondaryLabelColor; let lbl = NSColor.labelColor
let sep = NSColor.separatorColor
func add(_ s: String, f: NSFont = body, c: NSColor = lbl) {
r.append(NSAttributedString(string: s, attributes: [.font: f, .foregroundColor: c]))
}
// ── Hero Cards ──
let td = store.today(); let tdG = PerfGrade.grade(td.pomodoros)
let tw = store.thisWeek(); let twG = PerfGrade.grade(tw.pomodoros, workDays: 5)
add("\n")
// Today
add(" TODAY 今日\n", f: tiny, c: dim)
add(" \(td.pomodoros)", f: big, c: tdG == .none ? dim : tdG.color)
add(" 🍅 ", f: med)
add(fmtT(td.focusSecs), f: med, c: dim)
add("\n")
add(" \(progressBar(td.pomodoros, 7, w: 15))", f: small, c: tdG == .none ? dim : tdG.color)
add(" \(td.pomodoros)/7", f: small, c: dim)
add("\n")
if td.pomodoros > 0 {
add(" \(tdG.emoji) \(tdG.label)\n", f: tiny, c: tdG.color)
}
add("\n")
// This Week
add(" WEEK 本周\n", f: tiny, c: dim)
add(" \(tw.pomodoros)", f: big, c: twG == .none ? dim : twG.color)
add(" 🍅 ", f: med)
add(fmtT(tw.focusSecs), f: med, c: dim)
add("\n")
add(" \(progressBar(tw.pomodoros, 35, w: 15))", f: small, c: twG == .none ? dim : twG.color)
add(" \(tw.pomodoros)/35", f: small, c: dim)
add("\n")
// ── Streak ──
let streak = store.currentStreak()
let best = store.bestStreak
let tm = store.thisMonth()
add("\n")
add(" 🔥 连续 \(streak)天", f: med)
if best > streak { add(" · 最长 \(best)天", f: small, c: dim) }
add(" · 本月 \(tm.pomodoros)🍅", f: small, c: dim)
add("\n")
// ── Separator ──
add(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
// ── 7-Day Detail ──
add("\n")
let end = endDate()
for i in (0..<7).reversed() {
guard let date = cal.date(byAdding: .day, value: -i, to: end) else { continue }
let ds = store.stats(for: StatsStore.dayKey(date))
let g = PerfGrade.grade(ds.pomodoros)
let dn = cal.component(.day, from: date)
let wd = cal.component(.weekday, from: date)
let isToday = cal.isDateInToday(date)
let isFuture = date > now
add(" ", f: small)
add(String(format: "%2d", dn), f: small, c: isToday ? lbl : dim)
add(" \(Self.wdShort[wd - 1]) ", f: small, c: isToday ? lbl : dim)
if isFuture {
add(" ·\n", f: small, c: NSColor.tertiaryLabelColor)
continue
}
let count = min(ds.pomodoros, 15)
if count > 0 {
// Each ■ = 1 pomodoro, colored by grade
let block = String(repeating: "■ ", count: count).trimmingCharacters(in: .whitespaces)
add(block, f: small, c: g.color)
add(String(format: " %d", ds.pomodoros), f: small, c: dim)
} else {
add(" ·", f: small, c: NSColor.tertiaryLabelColor)
}
if isToday { add(" ◂", f: tiny, c: dim) }
add("\n")
}
// Week summary
var wkTotal = 0; var wkFocus = 0
for i in 0..<7 {
guard let d = cal.date(byAdding: .day, value: -i, to: end), d <= now else { continue }
let ds = store.stats(for: StatsStore.dayKey(d))
wkTotal += ds.pomodoros; wkFocus += ds.focusSecs
}
let wkG = PerfGrade.grade(wkTotal, workDays: 5)
add("\n 合计 \(wkTotal)🍅 \(wkG.emoji) ⏱\(fmtT(wkFocus))\n", f: small, c: dim)
// ── Separator ──
add(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
// ── 30-Day Sparkline ──
var last30: [Int] = []
for i in (0..<30).reversed() {
guard let d = cal.date(byAdding: .day, value: -i, to: now) else { continue }
last30.append(store.stats(for: StatsStore.dayKey(d)).pomodoros)
}
add("\n 近30天 ", f: small, c: dim)
// Color each spark char by its value's grade
let bars: [Character] = ["▁","▂","▃","▄","▅","▆","▇","█"]
let mx = max(last30.max() ?? 1, 1)
for v in last30 {
if v == 0 {
add(" ", f: small, c: NSColor.tertiaryLabelColor)
} else {
let idx = min(Int(Double(v) / Double(mx) * 7), 7)
let g = PerfGrade.grade(v)
add(String(bars[idx]), f: small, c: g.color)
}
}
add("\n", f: small)
// ── Separator ──
add(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
// ── Weekly Bars (8 weeks) ──
add("\n 近8周\n", f: med)
for i in (0..<8).reversed() {
let (wn, ws) = weekAgg(offset: i)
let g = PerfGrade.grade(ws.pomodoros, workDays: 5)
let isCur = i == 0
add(" ", f: small)
add(String(format: "W%02d ", wn), f: small, c: isCur ? lbl : dim)
let bLen = max(min(ws.pomodoros * 2 / 5, 20), 0) // scale: 50 pomodoros = 20 chars
if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: g.color) }
add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
add(String(format: " %3d ", ws.pomodoros), f: small)
add(g.emoji, f: tiny)
if isCur { add(" ◂", f: tiny, c: dim) }
add("\n")
}
// ── Monthly Heatmap ──
add("\n", f: small)
add(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
let year = cal.component(.year, from: now)
let month = cal.component(.month, from: now)
let today = cal.component(.day, from: now)
let firstOfMonth = cal.date(from: DateComponents(year: year, month: month, day: 1))!
let firstWd = cal.component(.weekday, from: firstOfMonth)
let numDays = cal.range(of: .day, in: .month, for: firstOfMonth)!.count
let startOff = (firstWd + 5) % 7
add("\n \(month)月热力图\n", f: med)
// Build week columns
let totalCells = startOff + numDays
let numWeeks = (totalCells + 6) / 7
// Header: week numbers
add(" ", f: small, c: dim)
for w in 0..<numWeeks {
add(String(format: "W%d ", w + 1), f: small, c: dim)
}
add("\n")
// Rows: Mon(0) through Sun(6)
for row in 0..<7 {
add(" \(Self.wdShort[(row + 1) % 7]) ", f: small, c: dim)
for col in 0..<numWeeks {
let dayIdx = col * 7 + row - startOff + 1
if dayIdx >= 1 && dayIdx <= numDays {
if dayIdx <= today {
let dt = cal.date(from: DateComponents(year: year, month: month, day: dayIdx))!
let ds = store.stats(for: StatsStore.dayKey(dt))
let g = PerfGrade.grade(ds.pomodoros)
add(" ■ ", f: small, c: g == .none ? NSColor.quaternaryLabelColor : g.color)
} else {
add(" · ", f: small, c: NSColor.tertiaryLabelColor)
}
} else {
add(" ", f: small)
}
}
add("\n")
}
// ── Monthly Bars (6 months) ──
add("\n 近6月\n", f: med)
for i in (0..<6).reversed() {
let (label, ms, wd) = monthAgg(offset: i)
let g = PerfGrade.grade(ms.pomodoros, workDays: wd)
let isCur = i == 0
add(" ", f: small)
add(String(format: "%3@ ", label), f: small, c: isCur ? lbl : dim)
let bLen = max(min(ms.pomodoros / 5, 20), 0)
if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: g.color) }
add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
add(String(format: "%4d ", ms.pomodoros), f: small)
add(g.emoji, f: tiny)
if isCur { add(" ◂", f: tiny, c: dim) }
add("\n")
}
add("\n")
return r
}
// ════════════════════════════════
// WeChat / 摸鱼 Page
// ════════════════════════════════
private func buildWechat() -> NSAttributedString {
let r = NSMutableAttributedString()
let cal = Calendar.current; let now = Date()
let store = StatsStore.shared
let big = NSFont.monospacedDigitSystemFont(ofSize: 28, weight: .bold)
let med = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
let body = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
let small = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular)
let tiny = NSFont.systemFont(ofSize: 9)
let dim = NSColor.secondaryLabelColor; let lbl = NSColor.labelColor
let sep = NSColor.separatorColor
let bar1 = NSColor(srgbRed: 76/255, green: 175/255, blue: 80/255, alpha: 1) // green
let bar2 = NSColor(srgbRed: 230/255, green: 126/255, blue: 34/255, alpha: 1) // orange for high
func add(_ s: String, f: NSFont = body, c: NSColor = lbl) {
r.append(NSAttributedString(string: s, attributes: [.font: f, .foregroundColor: c]))
}
func barColor(_ secs: Int) -> NSColor { secs > 3600 ? bar2 : bar1 }
// ── Hero ──
let td = store.today(); let yd = store.yesterday()
let tw = store.thisWeek(); let lw = store.lastWeek()
add("\n")
add(" TODAY 今日\n", f: tiny, c: dim)
add(" \(fmtT(td.wechatSecs))", f: big, c: barColor(td.wechatSecs))
add(" 📱\n", f: med)
let ydDiff = td.wechatSecs - yd.wechatSecs
if yd.wechatSecs > 0 {
let arrow = ydDiff >= 0 ? "▲" : "▼"
let ac: NSColor = ydDiff >= 0 ? bar2 : bar1
add(" \(arrow) \(ydDiff >= 0 ? "+" : "")\(fmtT(abs(ydDiff))) vs 昨日\n", f: small, c: ac)
}
add("\n")
add(" WEEK 本周\n", f: tiny, c: dim)
add(" \(fmtT(tw.wechatSecs))", f: big, c: barColor(tw.wechatSecs))
add(" 📱\n", f: med)
let lwDiff = tw.wechatSecs - lw.wechatSecs
if lw.wechatSecs > 0 {
let arrow = lwDiff >= 0 ? "▲" : "▼"
let ac: NSColor = lwDiff >= 0 ? bar2 : bar1
add(" \(arrow) \(lwDiff >= 0 ? "+" : "")\(fmtT(abs(lwDiff))) vs 上周\n", f: small, c: ac)
}
add(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
// ── 7-Day Detail ──
let end = endDate()
add("\n")
var daySecs: [(Date, Int)] = []
for i in (0..<7).reversed() {
guard let d = cal.date(byAdding: .day, value: -i, to: end) else { continue }
daySecs.append((d, store.stats(for: StatsStore.dayKey(d)).wechatSecs))
}
let maxS = max(daySecs.map({ $0.1 }).max() ?? 1, 300)
for (date, secs) in daySecs {
let dn = cal.component(.day, from: date)
let wd = cal.component(.weekday, from: date)
let isToday = cal.isDateInToday(date)
let isFuture = date > now
add(" ", f: small)
add(String(format: "%2d", dn), f: small, c: isToday ? lbl : dim)
add(" \(Self.wdShort[wd - 1]) ", f: small, c: isToday ? lbl : dim)
if isFuture { add("·\n", f: small, c: NSColor.tertiaryLabelColor); continue }
let bLen = max(Int(Double(secs) / Double(maxS) * 20), secs > 0 ? 1 : 0)
if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: barColor(secs)) }
add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
add(" \(fmtT(secs))", f: small, c: dim)
if isToday { add(" ◂", f: tiny, c: dim) }
add("\n")
}
let wkTotal = daySecs.filter { $0.0 <= now }.reduce(0) { $0 + $1.1 }
add("\n 合计 \(fmtT(wkTotal))\n", f: small, c: dim)
add(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
// ── 30-Day Sparkline ──var last30: [Int] = [] for i in (0..<30).reversed() { guard let d = cal.date(byAdding: .day, value: -i, to: now) else { continue } last30.append(store.stats(for: StatsStore.dayKey(d)).wechatSecs) } add("\n 近30天 ", f: small, c: dim) let bars: [Character] = ["▁","▂","▃","▄","▅","▆","▇","█"] let mx = max(last30.max() ?? 1, 1) for v in last30 { if v == 0 { add(" ", f: small, c: NSColor.tertiaryLabelColor) } else { let idx = min(Int(Double(v) / Double(mx) * 7), 7) add(String(bars[idx]), f: small, c: barColor(v)) } } add("\n")
add(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
// ── Weekly Bars ──
add("\n 近8周\n", f: med)
var weekSecs: [(Int, Int)] = []
for i in (0..<8).reversed() { let (wn, ws) = weekAgg(offset: i); weekSecs.append((wn, ws.wechatSecs)) }
let maxWS = max(weekSecs.map({ $0.1 }).max() ?? 1, 1800)
for (idx, (wn, secs)) in weekSecs.enumerated() {
let isCur = idx == weekSecs.count - 1
add(" ", f: small)
add(String(format: "W%02d ", wn), f: small, c: isCur ? lbl : dim)
let bLen = Int(Double(secs) / Double(maxWS) * 20)
if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: barColor(secs)) }
add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
add(" \(fmtT(secs))", f: small, c: dim)
if isCur { add(" ◂", f: tiny, c: dim) }
add("\n")
}
// ── Monthly Bars ──
add("\n 近6月\n", f: med)
var monthS: [(String, Int)] = []
for i in (0..<6).reversed() { let (l, ms, _) = monthAgg(offset: i); monthS.append((l, ms.wechatSecs)) }
let maxMS = max(monthS.map({ $0.1 }).max() ?? 1, 3600)
for (idx, (label, secs)) in monthS.enumerated() {
let isCur = idx == monthS.count - 1
add(" ", f: small)
add(String(format: "%3@ ", label), f: small, c: isCur ? lbl : dim)
let bLen = Int(Double(secs) / Double(maxMS) * 20)
if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: barColor(secs)) }
add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
add(" \(fmtT(secs))", f: small, c: dim)
if isCur { add(" ◂", f: tiny, c: dim) }
add("\n")
}
add("\n")
return r
}}
// MARK: - Clickable Panel // ═══════════════════════════════════════════
class FloatingPanel: NSPanel { override var canBecomeKey: Bool { true } }
// ═══════════════════════════════════════════ // MARK: - Segment Progress View // ═══════════════════════════════════════════
class SegmentBar: NSView { var elapsed = 0 var appState: AppState = .idle var wechatBlockProgress: CGFloat = 0 var onSeek: ((Int) -> Void)? private var isDragging = false
override func resetCursorRects() {
if appState != .idle {
addCursorRect(bounds, cursor: .resizeLeftRight)
}
}
private func elapsedFromMouse(_ event: NSEvent) -> Int {
let loc = convert(event.locationInWindow, from: nil)
let pct = max(0, min(1, loc.x / bounds.width))
let total = appState == .brk ? kBreakDuration : kWorkDuration
return Int(pct * CGFloat(total))
}
override func mouseDown(with event: NSEvent) {
guard appState != .idle else { super.mouseDown(with: event); return }
isDragging = true
onSeek?(elapsedFromMouse(event))
}
override func mouseDragged(with event: NSEvent) {
guard isDragging else { return }
onSeek?(elapsedFromMouse(event))
}
override func mouseUp(with event: NSEvent) {
isDragging = false
}
override func draw(_ dirtyRect: NSRect) {
let w = bounds.width, h = bounds.height
if appState == .idle {
C.pending.setFill()
NSBezierPath(roundedRect: bounds, xRadius: 3, yRadius: 3).fill()
if wechatBlockProgress > 0 {
C.green.setFill()
NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: w * min(wechatBlockProgress, 1), height: h),
xRadius: 3, yRadius: 3).fill()
}
return
}
if appState == .brk {
C.green.withAlphaComponent(0.25).setFill()
NSBezierPath(roundedRect: bounds, xRadius: 3, yRadius: 3).fill()
let pct = CGFloat(elapsed) / CGFloat(kBreakDuration)
C.green.setFill()
NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: w * min(pct, 1), height: h),
xRadius: 3, yRadius: 3).fill()
return
}
var x: CGFloat = 0
var accum = 0
for (i, ph) in kPhases.enumerated() {
let segW = w * CGFloat(ph.duration) / CGFloat(kWorkDuration)
C.pending.setFill()
NSBezierPath(rect: NSRect(x: x, y: 0, width: segW, height: h)).fill()
if appState == .work {
let segEnd = accum + ph.duration
let fill: CGFloat
if elapsed >= segEnd { fill = 1 }
else if elapsed > accum { fill = CGFloat(elapsed - accum) / CGFloat(ph.duration) }
else { fill = 0 }
if fill > 0 {
(elapsed >= segEnd ? C.done : C.tomato).setFill()
NSBezierPath(rect: NSRect(x: x, y: 0, width: segW * fill, height: h)).fill()
}
}
let textColor: NSColor = appState == .work && elapsed > accum
? .white.withAlphaComponent(0.9)
: (appState == .idle ? .darkGray.withAlphaComponent(0.5) : .white.withAlphaComponent(0.35))
let s = NSAttributedString(string: ph.label, attributes: [
.font: NSFont.systemFont(ofSize: 7, weight: .semibold),
.foregroundColor: textColor,
])
let sz = s.size()
if segW > sz.width + 2 {
s.draw(at: NSPoint(x: x + (segW - sz.width) / 2, y: (h - sz.height) / 2))
}
if i < kPhases.count - 1 {
(appState == .idle ? NSColor.gray.withAlphaComponent(0.2) : .white.withAlphaComponent(0.15)).setFill()
NSBezierPath(rect: NSRect(x: x + segW - 0.5, y: 0, width: 1, height: h)).fill()
}
accum += ph.duration
x += segW
}
}}
// ═══════════════════════════════════════════ // MARK: - App Delegate // ═══════════════════════════════════════════
class TickMatoDelegate: NSObject, NSApplicationDelegate { var panel: FloatingPanel! var bg: NSView! var statusLabel: NSTextField! var timerLabel: NSTextField! var segmentBar: SegmentBar! var statsLabel: NSTextField! var actionBtn: NSButton! var statsBtn: NSButton! var gearBtn: NSButton!
let soundSettings = SoundSettingsController()
let statsWindow = StatsWindowController()
var timer: Timer?
var idleTimer: Timer?
var alarmTimer: Timer?
var alarmElapsed = 0
var workDoneRinging = false
var workStatsAlreadyFlushed = false
var state: AppState = .idle
var elapsed = 0
var curPhase = -1
var initialCmd: String?
var isWechatFront = false
var wechatIdleSeconds = 0
var wechatBlockSeconds = 0
var wechatSessionSeconds = 0
var stateEnteredAt = Date()
var wechatActiveAt: Date?
var lastStatsFlush = 0
var pausedElapsed: Int? = nil // non-nil = paused by WeChat, stores saved elapsed seconds
var pausedPhase: Int = -1
var dimWindow: NSWindow?
let W: CGFloat = 280
let H: CGFloat = 178
func applicationDidFinishLaunching(_ n: Notification) {
NSApp.setActivationPolicy(.accessory)
writePID()
buildUI()
listenIPC()
observeAppSwitch()
applyState(animate: false)
let reshow = { [weak self] in
DispatchQueue.main.async { self?.panel.orderFront(nil) }
}
soundSettings.onClose = reshow
statsWindow.onClose = reshow
if let c = initialCmd { handleCmd(c) }
else { startIdleTick() }
}
func applicationWillTerminate(_ n: Notification) {
flushStateTime(); flushWechatTime(); removePID()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
// ── Build UI ──
func buildUI() {
let screen = NSScreen.main!.frame
let x = screen.maxX - W - 16
let y = screen.maxY - H - 36
panel = FloatingPanel(
contentRect: NSRect(x: x, y: y, width: W, height: H),
styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel],
backing: .buffered, defer: false
)
panel.level = .floating
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
panel.titlebarAppearsTransparent = true
panel.titleVisibility = .hidden
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = true
panel.isMovableByWindowBackground = true
bg = panel.contentView!
bg.wantsLayer = true
bg.layer?.cornerRadius = 12
bg.layer?.masksToBounds = true
statusLabel = makeLabel("", size: 11, weight: .medium)
statusLabel.frame = NSRect(x: 10, y: H - 40, width: W - 20, height: 16)
statusLabel.alignment = .center
statusLabel.lineBreakMode = .byTruncatingTail
bg.addSubview(statusLabel)
timerLabel = makeLabel("25:00", size: 42, weight: .bold, mono: true)
timerLabel.frame = NSRect(x: 0, y: H - 90, width: W, height: 48)
timerLabel.alignment = .center
bg.addSubview(timerLabel)
segmentBar = SegmentBar()
segmentBar.frame = NSRect(x: 12, y: H - 102, width: W - 24, height: 8)
segmentBar.wantsLayer = true
segmentBar.layer?.cornerRadius = 4
segmentBar.layer?.masksToBounds = true
bg.addSubview(segmentBar)
statsLabel = makeLabel("", size: 10, weight: .medium)
statsLabel.frame = NSRect(x: 12, y: H - 118, width: W - 24, height: 14)
statsLabel.alignment = .center
statsLabel.lineBreakMode = .byTruncatingTail
bg.addSubview(statsLabel)
// Action button
actionBtn = NSButton(frame: NSRect(x: 12, y: 10, width: W - 100, height: 34))
actionBtn.wantsLayer = true
actionBtn.isBordered = false
actionBtn.layer?.cornerRadius = 8
actionBtn.target = self
actionBtn.action = #selector(onAction)
bg.addSubview(actionBtn)
// Stats button
statsBtn = NSButton(frame: NSRect(x: W - 82, y: 10, width: 34, height: 34))
statsBtn.wantsLayer = true
statsBtn.isBordered = false
statsBtn.layer?.cornerRadius = 8
statsBtn.target = self
statsBtn.action = #selector(openStats)
bg.addSubview(statsBtn)
// Gear button
gearBtn = NSButton(frame: NSRect(x: W - 44, y: 10, width: 34, height: 34))
gearBtn.wantsLayer = true
gearBtn.isBordered = false
gearBtn.layer?.cornerRadius = 8
gearBtn.target = self
gearBtn.action = #selector(openSettings)
let gearAttr = NSAttributedString(string: "⚙", attributes: [
.font: NSFont.systemFont(ofSize: 18),
.foregroundColor: NSColor.white.withAlphaComponent(0.8),
])
gearBtn.attributedTitle = gearAttr
gearBtn.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.1).cgColor
bg.addSubview(gearBtn)
segmentBar.onSeek = { [weak self] newElapsed in
self?.seekTo(newElapsed)
}
panel.orderFront(nil)
}
func makeLabel(_ text: String, size: CGFloat, weight: NSFont.Weight, mono: Bool = false) -> NSTextField {
let l = NSTextField(labelWithString: text)
l.font = mono ? NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
: NSFont.systemFont(ofSize: size, weight: weight)
return l
}
func setButtonTitle(_ title: String) {
actionBtn.attributedTitle = NSAttributedString(string: title, attributes: [
.font: NSFont.systemFont(ofSize: 13, weight: .semibold),
.foregroundColor: NSColor.white,
])
}
// ── Break Dim Overlay ──
func showDimOverlay() {
guard dimWindow == nil else { return }
let frame = NSScreen.screens.reduce(NSRect.zero) { $0.union($1.frame) }
let w = NSWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false)
w.level = NSWindow.Level(rawValue: Int(NSWindow.Level.floating.rawValue) - 1)
w.backgroundColor = NSColor.black.withAlphaComponent(0.65)
w.isOpaque = false
w.hasShadow = false
w.ignoresMouseEvents = true
w.collectionBehavior = [.canJoinAllSpaces, .stationary]
let label = NSTextField(labelWithString: "🌱 休息一下")
label.font = NSFont.systemFont(ofSize: 48, weight: .light)
label.textColor = .white.withAlphaComponent(0.5)
label.alignment = .center
label.sizeToFit()
if let mainScreen = NSScreen.main {
label.frame.origin = NSPoint(
x: mainScreen.frame.midX - label.frame.width / 2,
y: mainScreen.frame.midY - label.frame.height / 2
)
}
w.contentView?.addSubview(label)
w.orderFront(nil)
panel.orderFront(nil) // keep TickMato above overlay
dimWindow = w
}
func hideDimOverlay() {
dimWindow?.close()
dimWindow = nil
}
// ── State Machine ──
func transitionTo(_ newState: AppState) {
let old = state
let fromRinging = workStatsAlreadyFlushed
workDoneRinging = false // Bug fix: always clear ringing flag on any transition
timer?.invalidate(); timer = nil
idleTimer?.invalidate(); idleTimer = nil
stopAlarm()
// Flush stats for the state we're leaving
if fromRinging {
workStatsAlreadyFlushed = false
} else {
flushStateTime()
if old == .work {
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastFocusEnd")
if newState == .brk { StatsStore.shared.addPomodoro() }
else if pausedElapsed == nil { StatsStore.shared.addAbandoned() }
// If pausedElapsed != nil, it's a WeChat pause — not an abandonment
}
}
flushWechatTime()
// Clear pause state when entering work or break (fresh start)
if newState != .idle { pausedElapsed = nil; pausedPhase = -1 }
elapsed = 0; curPhase = -1
isWechatFront = false; wechatIdleSeconds = 0; wechatBlockSeconds = 0; wechatSessionSeconds = 0
stateEnteredAt = Date(); lastStatsFlush = 0
state = newState
segmentBar.appState = newState
segmentBar.elapsed = 0
segmentBar.wechatBlockProgress = 0
switch newState {
case .idle:
hideDimOverlay()
if old == .brk { SoundPrefs.shared.play("breakEnd") }
checkCurrentFrontApp()
startIdleTick()
case .work:
hideDimOverlay()
SoundPrefs.shared.play("windUp")
startTick()
case .brk:
if old == .work && !fromRinging { startAlarm("workComplete") }
showDimOverlay()
startTick()
}
applyState(animate: old != newState)
}
func applyState(animate: Bool) {
let bgColor: CGColor
switch state {
case .idle:
bgColor = C.idleBg.cgColor
if let saved = pausedElapsed {
let rem = kWorkDuration - saved
statusLabel.stringValue = "⏸ 已暂停 Paused"
timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
setButtonTitle("Continue 继续")
} else {
let ts = UserDefaults.standard.double(forKey: "lastFocusEnd")
if ts > 0 {
statusLabel.stringValue = "距上次专注 Since Last Focus"
timerLabel.stringValue = formatIdleElapsed(since: ts)
} else {
statusLabel.stringValue = "Ready to Pomodoro! 准备好开始番茄工作法了!"
timerLabel.stringValue = "25:00"
}
setButtonTitle("Focus 去专注")
}
statusLabel.textColor = .darkGray
timerLabel.textColor = NSColor.darkGray.withAlphaComponent(0.6)
actionBtn.layer?.backgroundColor = C.tomato.cgColor
updateStatsLabel()
let idleBtnColor = NSColor.darkGray.withAlphaComponent(0.1).cgColor
gearBtn.layer?.backgroundColor = idleBtnColor
statsBtn.layer?.backgroundColor = idleBtnColor
let idleIconColor = NSColor.darkGray.withAlphaComponent(0.6)
gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
.font: NSFont.systemFont(ofSize: 18), .foregroundColor: idleIconColor])
statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
.font: NSFont.systemFont(ofSize: 16), .foregroundColor: idleIconColor])
case .work:
bgColor = C.workBg.cgColor
statusLabel.stringValue = "Initial Review 专注中"
statusLabel.textColor = .white.withAlphaComponent(0.7)
timerLabel.stringValue = "25:00"
timerLabel.textColor = .white
setButtonTitle("Squash 停止")
updateStatsLabel()
let workBtnColor = NSColor.white.withAlphaComponent(0.1).cgColor
actionBtn.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.15).cgColor
gearBtn.layer?.backgroundColor = workBtnColor
statsBtn.layer?.backgroundColor = workBtnColor
let workIconColor = NSColor.white.withAlphaComponent(0.5)
gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
.font: NSFont.systemFont(ofSize: 18), .foregroundColor: workIconColor])
statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
.font: NSFont.systemFont(ofSize: 16), .foregroundColor: workIconColor])
case .brk:
bgColor = C.breakBg.cgColor
statusLabel.stringValue = "Take a break! 休息一下 🌱"
statusLabel.textColor = .white.withAlphaComponent(0.8)
timerLabel.stringValue = "05:00"
timerLabel.textColor = .white
setButtonTitle("Skip Break 跳过")
updateStatsLabel()
let brkBtnColor = NSColor.white.withAlphaComponent(0.1).cgColor
actionBtn.layer?.backgroundColor = C.green.withAlphaComponent(0.4).cgColor
gearBtn.layer?.backgroundColor = brkBtnColor
statsBtn.layer?.backgroundColor = brkBtnColor
let brkIconColor = NSColor.white.withAlphaComponent(0.5)
gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
.font: NSFont.systemFont(ofSize: 18), .foregroundColor: brkIconColor])
statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
.font: NSFont.systemFont(ofSize: 16), .foregroundColor: brkIconColor])
}
segmentBar.needsDisplay = true
if animate {
let anim = CABasicAnimation(keyPath: "backgroundColor")
anim.fromValue = bg.layer?.backgroundColor
anim.toValue = bgColor
anim.duration = 0.4
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
bg.layer?.add(anim, forKey: "bg")
}
bg.layer?.backgroundColor = bgColor
}
// ── Timer ──
func startTick() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
}
func tick() {
elapsed += 1
let total = state == .work ? kWorkDuration : kBreakDuration
let rem = total - elapsed
if rem <= 0 {
timer?.invalidate(); timer = nil
if state == .work {
// Don't auto-transition — ring and wait for user to click
workDoneRinging = true
workStatsAlreadyFlushed = true
timerLabel.stringValue = "00:00"
statusLabel.stringValue = "🍅 番茄完成!点击进入休息"
setButtonTitle("Break 休息")
startAlarm("workComplete")
showDimOverlay()
// Flush stats now
flushStateTime()
StatsStore.shared.addPomodoro()
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastFocusEnd")
return
}
else { transitionTo(.idle) }
return
}
timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
segmentBar.elapsed = elapsed
segmentBar.needsDisplay = true
// Periodic stats update (every 30s)
if elapsed % 30 == 0 { updateStatsLabel() }
if state == .work {
var acc = 0
for (i, ph) in kPhases.enumerated() {
if elapsed <= acc + ph.duration {
if i != curPhase {
curPhase = i
statusLabel.stringValue = "\(ph.name) 专注中"
if i > 0 { SoundPrefs.shared.play("phaseTransition") }
}
break
}
acc += ph.duration
}
}
}
// ── Idle Timer ──
func startIdleTick() {
idleTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.idleTick()
}
}
func idleTick() {
if isWechatFront {
wechatIdleSeconds += 1
wechatSessionSeconds += 1
wechatBlockSeconds += 1
if wechatBlockSeconds >= kWechatBlockDuration {
SoundPrefs.shared.play("wechatRemind")
wechatBlockSeconds = 0
}
}
// Big timer: show paused remaining or "距上次专注"
if let saved = pausedElapsed, !isWechatFront {
let rem = kWorkDuration - saved
timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
} else {
let ts = UserDefaults.standard.double(forKey: "lastFocusEnd")
if ts > 0 {
timerLabel.stringValue = formatIdleElapsed(since: ts)
}
}
// Status label: context-aware
updateIdleAppearance()
// Periodic stats flush & label update (every 30s)
lastStatsFlush += 1
if lastStatsFlush >= 30 {
lastStatsFlush = 0
if let wa = wechatActiveAt {
let secs = Int(Date().timeIntervalSince(wa))
StatsStore.shared.addSeconds("wechat", secs)
wechatActiveAt = Date()
}
updateStatsLabel()
}
}
func formatSeconds(_ secs: Int) -> String {
if secs < 0 { return "00:00" }
let h = secs / 3600, m = (secs % 3600) / 60, s = secs % 60
return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%02d:%02d", m, s)
}
func formatIdleElapsed(since ts: Double) -> String {
formatSeconds(Int(Date().timeIntervalSince1970 - ts))
}
func updateIdleAppearance() {
guard state == .idle else { return }
let wechatMinutes = CGFloat(wechatIdleSeconds) / 60.0
let darkText = wechatMinutes < 25
let baseColor: NSColor = darkText ? .darkGray : .white.withAlphaComponent(0.8)
let monoFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .bold)
let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)
let text = NSMutableAttributedString()
if isWechatFront {
// "📱 刷微信中 本次 03:25 · 今日共 45m"
text.append(NSAttributedString(string: "📱 刷微信中 本次 ", attributes: [
.foregroundColor: baseColor, .font: textFont]))
text.append(NSAttributedString(string: formatSeconds(wechatSessionSeconds), attributes: [
.foregroundColor: C.green, .font: monoFont]))
let todayTotal = StatsStore.shared.today().wechatSecs + (wechatActiveAt != nil
? Int(Date().timeIntervalSince(wechatActiveAt!)) : 0)
if todayTotal > 60 {
let h = todayTotal / 3600, m = (todayTotal % 3600) / 60
let totalStr = h > 0 ? "\(h)h\(m)m" : "\(m)m"
text.append(NSAttributedString(string: " · 今日共 \(totalStr)", attributes: [
.foregroundColor: baseColor.withAlphaComponent(0.6), .font: textFont]))
}
} else if pausedElapsed != nil {
// Paused by WeChat — show paused status
text.append(NSAttributedString(string: "⏸ 已暂停 Paused", attributes: [
.foregroundColor: baseColor, .font: textFont]))
} else if wechatIdleSeconds > 0 {
// "距上次专注 · 📱今日 45m"
text.append(NSAttributedString(string: "距上次专注", attributes: [
.foregroundColor: baseColor, .font: textFont]))
let todayTotal = StatsStore.shared.today().wechatSecs
if todayTotal > 60 {
let h = todayTotal / 3600, m = (todayTotal % 3600) / 60
let totalStr = h > 0 ? "\(h)h\(m)m" : "\(m)m"
text.append(NSAttributedString(string: " · 📱今日 \(totalStr)", attributes: [
.foregroundColor: C.green, .font: textFont]))
}
} else {
text.append(NSAttributedString(string: "距上次专注", attributes: [
.foregroundColor: baseColor, .font: textFont]))
}
statusLabel.attributedStringValue = text
timerLabel.textColor = darkText ? NSColor.darkGray.withAlphaComponent(0.6) : .white
segmentBar.wechatBlockProgress = CGFloat(wechatBlockSeconds) / CGFloat(kWechatBlockDuration)
segmentBar.needsDisplay = true
let bgColor = wechatBgColor(totalSeconds: wechatIdleSeconds)
bg.layer?.backgroundColor = bgColor.cgColor
if !darkText {
let wBtnColor = NSColor.white.withAlphaComponent(0.1).cgColor
let wIconColor = NSColor.white.withAlphaComponent(0.5)
gearBtn.layer?.backgroundColor = wBtnColor
statsBtn.layer?.backgroundColor = wBtnColor
gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
.font: NSFont.systemFont(ofSize: 18), .foregroundColor: wIconColor])
statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
.font: NSFont.systemFont(ofSize: 16), .foregroundColor: wIconColor])
statsLabel.textColor = .white.withAlphaComponent(0.4)
}
}
func wechatBgColor(totalSeconds: Int) -> NSColor {
let minutes = CGFloat(totalSeconds) / 60.0
let stops: [(m: CGFloat, r: CGFloat, g: CGFloat, b: CGFloat)] = [
(0, 245, 245, 245),
(10, 255, 243, 224),
(20, 255, 204, 128),
(30, 245, 140, 60),
(40, 232, 57, 42),
]
var i = 0
while i < stops.count - 2 && minutes > stops[i + 1].m { i += 1 }
let lo = stops[i], hi = stops[i + 1]
let range = hi.m - lo.m
let t: CGFloat = range > 0 ? min((minutes - lo.m) / range, 1) : 1
return NSColor(
srgbRed: (lo.r + t * (hi.r - lo.r)) / 255,
green: (lo.g + t * (hi.g - lo.g)) / 255,
blue: (lo.b + t * (hi.b - lo.b)) / 255,
alpha: 0.96
)
}
func checkCurrentFrontApp() {
if let app = NSWorkspace.shared.frontmostApplication,
app.bundleIdentifier == "com.tencent.xinWeChat" {
isWechatFront = true
if wechatActiveAt == nil { wechatActiveAt = Date() }
}
}
// ── Stats Helpers ──
func flushStateTime() {
let secs = Int(Date().timeIntervalSince(stateEnteredAt))
guard secs > 0 else { return }
switch state {
case .work: StatsStore.shared.addSeconds("focus", secs)
case .brk: StatsStore.shared.addSeconds("break", secs)
case .idle: break
}
}
func flushWechatTime() {
guard let start = wechatActiveAt else { return }
let secs = Int(Date().timeIntervalSince(start))
StatsStore.shared.addSeconds("wechat", secs)
wechatActiveAt = nil
}
func updateStatsLabel() {
guard let label = statsLabel else { return }
let s = StatsStore.shared.today()
// Add live unflushed time
var focusLive = s.focusSecs
var wechatLive = s.wechatSecs
if state == .work { focusLive += Int(Date().timeIntervalSince(stateEnteredAt)) }
if let wa = wechatActiveAt { wechatLive += Int(Date().timeIntervalSince(wa)) }
let fmtT = { (secs: Int) -> String in
if secs < 60 { return "<1m" }
let h = secs / 3600, m = (secs % 3600) / 60
return h > 0 ? "\(h)h\(m)m" : "\(m)m"
}
let g = PerfGrade.grade(s.pomodoros)
let text = "今日 \(g.emoji)\(s.pomodoros) ⏱\(fmtT(focusLive)) 📱\(fmtT(wechatLive))"
let color: NSColor = state == .idle ? .darkGray.withAlphaComponent(0.5) : .white.withAlphaComponent(0.4)
label.stringValue = text
label.textColor = color
}
@objc func openStats() {
// Flush current data so stats window shows latest
if state == .work || state == .brk {
let secs = Int(Date().timeIntervalSince(stateEnteredAt))
if state == .work { StatsStore.shared.addSeconds("focus", secs) }
else { StatsStore.shared.addSeconds("break", secs) }
stateEnteredAt = Date()
}
if let wa = wechatActiveAt {
let secs = Int(Date().timeIntervalSince(wa))
StatsStore.shared.addSeconds("wechat", secs)
wechatActiveAt = Date()
}
statsWindow.show()
}
// ── Seek ──
func seekTo(_ newElapsed: Int) {
guard (state == .work || state == .brk) && !workDoneRinging else { return }
let total = state == .work ? kWorkDuration : kBreakDuration
elapsed = max(0, min(newElapsed, total - 1))
let rem = total - elapsed
timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
segmentBar.elapsed = elapsed
segmentBar.needsDisplay = true
if state == .work {
var acc = 0
for (i, ph) in kPhases.enumerated() {
if elapsed <= acc + ph.duration {
if i != curPhase {
curPhase = i
statusLabel.stringValue = "\(ph.name) 专注中"
}
break
}
acc += ph.duration
}
}
}
// ── Alarm (persistent ringing) ──
func startAlarm(_ key: String) {
stopAlarm()
alarmElapsed = 0
SoundPrefs.shared.play(key)
alarmTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.alarmElapsed += 5
if self.alarmElapsed >= 60 {
self.stopAlarm()
if self.workDoneRinging { self.workDoneRinging = false; self.transitionTo(.brk) }
return
}
SoundPrefs.shared.play(key)
}
}
func stopAlarm() {
alarmTimer?.invalidate(); alarmTimer = nil; alarmElapsed = 0
SoundPrefs.shared.stopAll()
}
// ── Actions ──
@objc func onAction() {
stopAlarm()
if workDoneRinging {
workDoneRinging = false
transitionTo(.brk)
return
}
switch state {
case .idle:
if let saved = pausedElapsed {
resumeWork(from: saved)
} else {
transitionTo(.work)
}
case .work: SoundPrefs.shared.play("manualStop"); transitionTo(.idle)
case .brk: transitionTo(.idle)
}
}
func resumeWork(from savedElapsed: Int) {
pausedElapsed = nil; pausedPhase = -1
idleTimer?.invalidate(); idleTimer = nil
isWechatFront = false
wechatIdleSeconds = 0; wechatBlockSeconds = 0; wechatSessionSeconds = 0
flushWechatTime()
state = .work
elapsed = savedElapsed
stateEnteredAt = Date()
lastStatsFlush = 0
segmentBar.appState = .work
segmentBar.elapsed = savedElapsed
segmentBar.wechatBlockProgress = 0
SoundPrefs.shared.play("windUp")
startTick()
applyState(animate: true)
// Restore correct remaining time & phase
let rem = kWorkDuration - elapsed
timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
var acc = 0
for (i, ph) in kPhases.enumerated() {
if elapsed <= acc + ph.duration {
curPhase = i
statusLabel.stringValue = "\(ph.name) 专注中"
break
}
acc += ph.duration
}
}
@objc func openSettings() {
soundSettings.show()
}
// ── App Switch Detection ──
func observeAppSwitch() {
NSWorkspace.shared.notificationCenter.addObserver(
self, selector: #selector(appDidActivate(_:)),
name: NSWorkspace.didActivateApplicationNotification,
object: nil
)
}
@objc func appDidActivate(_ note: Notification) {
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
let isWeChat = app.bundleIdentifier == "com.tencent.xinWeChat"
// Global WeChat time tracking
if isWeChat && wechatActiveAt == nil {
wechatActiveAt = Date()
} else if !isWeChat && wechatActiveAt != nil {
flushWechatTime()
}
let pauseOnWechat = UserDefaults.standard.object(forKey: "pauseOnWechat") as? Bool ?? true
if isWeChat && state == .work && pauseOnWechat {
// Pause: save progress so user can resume later
pausedElapsed = elapsed
pausedPhase = curPhase
stopTickTickTimer()
transitionTo(.idle)
isWechatFront = true
wechatActiveAt = Date() // re-set after transition cleared it
updateIdleAppearance()
return
}
if state == .idle {
if isWeChat && !isWechatFront { wechatSessionSeconds = 0 }
isWechatFront = isWeChat
updateIdleAppearance()
}
}
func stopTickTickTimer() {
DispatchQueue.global(qos: .userInitiated).async {
let script = """
tell application "System Events"
if not (exists process "TickTick") then return "no_ticktick"
tell process "TickTick"
set hasPomo to false
set txts to every static text of window 1
repeat with t in txts
try
if (value of t as text) contains "番茄专注" then
set hasPomo to true
exit repeat
end if
end try
end repeat
if not hasPomo then return "no_pomo"
set btns to every button of window 1
set maxArea to 0
set targetBtn to missing value
repeat with b in btns
try
set r to role description of b
if r is "关闭按钮" or r is "全屏幕按钮" or r is "最小化按钮" then
else
set s to size of b
set area to (item 1 of s) * (item 2 of s)
if area > maxArea then
set maxArea to area
set targetBtn to b
end if
end if
end try
end repeat
if targetBtn is not missing value and maxArea > 3000 then
perform action "AXPress" of targetBtn
return "stopped"
end if
return "no_button"
end tell
end tell
"""
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
task.arguments = ["-e", script]
try? task.run()
task.waitUntilExit()
}
}
// ── IPC ──
func listenIPC() {
DistributedNotificationCenter.default().addObserver(
self, selector: #selector(onIPC(_:)),
name: Notification.Name(kNotification),
object: nil, suspensionBehavior: .deliverImmediately
)
}
@objc func onIPC(_ note: Notification) {
if let cmd = note.object as? String {
DispatchQueue.main.async { self.handleCmd(cmd) }
}
}
func handleCmd(_ raw: String) {
let cmd = raw.replacingOccurrences(of: "--", with: "")
panel?.orderFront(nil) // always show window on any command
switch cmd {
case "autostart": pausedElapsed = nil; transitionTo(.work)
case "break": pausedElapsed = nil; transitionTo(.brk)
case "stop": pausedElapsed = nil; SoundPrefs.shared.play("manualStop"); transitionTo(.idle)
case "settings": openSettings()
case "show": break // panel?.orderFront already called above
default: break
}
}
// ── PID ──
func writePID() {
try? "\(ProcessInfo.processInfo.processIdentifier)".write(
toFile: kPidPath, atomically: true, encoding: .utf8)
}
func removePID() {
try? FileManager.default.removeItem(atPath: kPidPath)
}}
// ═══════════════════════════════════════════ // MARK: - Entry Point // ═══════════════════════════════════════════
let args = CommandLine.arguments let cmd = args.count > 1 ? args[1] : nil
if let raw = try? String(contentsOfFile: kPidPath, encoding: .utf8), let pid = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)), kill(pid, 0) == 0 { let ipcCmd = cmd?.replacingOccurrences(of: "--", with: "") ?? "show" DistributedNotificationCenter.default().postNotificationName( Notification.Name(kNotification), object: ipcCmd, userInfo: nil, deliverImmediately: true ) exit(0) }
let app = NSApplication.shared let delegate = TickMatoDelegate() delegate.initialCmd = cmd app.delegate = delegate app.run()