504 lines
19 KiB
Swift
504 lines
19 KiB
Swift
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import AppKit
|
|
import Darwin
|
|
import Foundation
|
|
import Localization
|
|
import Observation
|
|
import WidgetKit
|
|
|
|
/// mach_continuous_time 包含系统睡眠,和界面刷新次数无关。
|
|
enum RestClock {
|
|
static func now() -> UInt64 {
|
|
var info = mach_timebase_info_data_t()
|
|
mach_timebase_info(&info)
|
|
let ticks = mach_continuous_time()
|
|
return UInt64(info.denom).dividingFullWidth(
|
|
ticks.multipliedFullWidth(by: UInt64(info.numer))).quotient
|
|
}
|
|
}
|
|
|
|
enum RestPhase: String, CaseIterable, Identifiable {
|
|
case work, rest, longRest
|
|
var id: String { rawValue }
|
|
var isResting: Bool { self != .work }
|
|
}
|
|
|
|
/// 运行阶段按硬件绝对截止点计时;暂停时分别保存各阶段剩余时间。
|
|
struct RestSession {
|
|
var phase: RestPhase = .work
|
|
var deadline: UInt64
|
|
private(set) var phaseDuration: UInt64
|
|
private(set) var isRunning = false
|
|
private(set) var completedFocus = 0
|
|
private var pausedRemaining: UInt64
|
|
private var remainingByPhase: [RestPhase: UInt64]
|
|
private var activeDurationByPhase: [RestPhase: UInt64]
|
|
private var canUndoFocus = false
|
|
let workNanoseconds: UInt64
|
|
let restNanoseconds: UInt64
|
|
let longRestNanoseconds: UInt64
|
|
|
|
init(now: UInt64, workMinutes: Int, restMinutes: Int, longBreakMinutes: Int = 15,
|
|
completedFocus: Int = 0) {
|
|
workNanoseconds = UInt64(max(1, workMinutes)) * 60_000_000_000
|
|
restNanoseconds = UInt64(max(1, restMinutes)) * 60_000_000_000
|
|
longRestNanoseconds = UInt64(max(1, longBreakMinutes)) * 60_000_000_000
|
|
phaseDuration = workNanoseconds
|
|
pausedRemaining = workNanoseconds
|
|
remainingByPhase = [.work: workNanoseconds, .rest: restNanoseconds, .longRest: longRestNanoseconds]
|
|
activeDurationByPhase = remainingByPhase
|
|
deadline = now + workNanoseconds
|
|
self.completedFocus = completedFocus
|
|
}
|
|
|
|
func remaining(at now: UInt64) -> TimeInterval {
|
|
let value = isRunning ? (deadline > now ? deadline - now : 0) : pausedRemaining
|
|
return TimeInterval(value) / 1_000_000_000
|
|
}
|
|
|
|
var canContinue: Bool { !isRunning && pausedRemaining > 0 && pausedRemaining < phaseDuration }
|
|
|
|
/// “开始”从完整时长起步;已有进度显示为“继续”,从暂停处恢复。
|
|
mutating func startOrContinue(at now: UInt64) {
|
|
if !canContinue { resetCurrentPhase(at: now) }
|
|
resume(at: now)
|
|
}
|
|
|
|
mutating func resume(at now: UInt64) {
|
|
guard !isRunning else { return }
|
|
deadline = now + pausedRemaining
|
|
isRunning = true
|
|
}
|
|
|
|
mutating func pause(at now: UInt64) {
|
|
guard isRunning else { return }
|
|
pausedRemaining = deadline > now ? deadline - now : 0
|
|
remainingByPhase[phase] = pausedRemaining
|
|
isRunning = false
|
|
}
|
|
|
|
/// 手动切换保存运行中或已暂停阶段的进度;同一阶段再次点击不等于重置。
|
|
mutating func select(_ selected: RestPhase, at now: UInt64) {
|
|
guard selected != phase else { return }
|
|
if isRunning { pausedRemaining = deadline > now ? deadline - now : 0 }
|
|
remainingByPhase[phase] = pausedRemaining
|
|
phase = selected
|
|
phaseDuration = activeDurationByPhase[selected] ?? duration(for: selected)
|
|
pausedRemaining = remainingByPhase[selected] ?? phaseDuration
|
|
deadline = now + pausedRemaining
|
|
isRunning = false
|
|
canUndoFocus = false
|
|
}
|
|
|
|
mutating func resetCurrentPhase(at now: UInt64) {
|
|
phaseDuration = duration(for: phase)
|
|
pausedRemaining = phaseDuration
|
|
remainingByPhase[phase] = phaseDuration
|
|
activeDurationByPhase[phase] = phaseDuration
|
|
deadline = now + phaseDuration
|
|
isRunning = false
|
|
canUndoFocus = false
|
|
}
|
|
|
|
/// 睡眠后可跨越多个阶段;单次模式在一次休息结束后停在下一轮专注起点。
|
|
@discardableResult mutating func advance(to now: UInt64, cycle: Bool = false) -> [UInt64] {
|
|
guard isRunning else { return [] }
|
|
var completedAt: [UInt64] = []
|
|
while now >= deadline {
|
|
let reached = deadline
|
|
remainingByPhase[phase] = duration(for: phase)
|
|
activeDurationByPhase[phase] = duration(for: phase)
|
|
if phase == .work {
|
|
completedFocus += 1
|
|
completedAt.append(reached)
|
|
canUndoFocus = true
|
|
phase = completedFocus.isMultiple(of: 4) ? .longRest : .rest
|
|
} else {
|
|
phase = .work
|
|
canUndoFocus = false
|
|
}
|
|
phaseDuration = duration(for: phase)
|
|
pausedRemaining = phaseDuration
|
|
remainingByPhase[phase] = phaseDuration
|
|
activeDurationByPhase[phase] = phaseDuration
|
|
deadline = reached + phaseDuration
|
|
if phase == .work && !cycle {
|
|
isRunning = false
|
|
deadline = now + phaseDuration
|
|
break
|
|
}
|
|
}
|
|
return completedAt
|
|
}
|
|
|
|
mutating func skip(at now: UInt64) {
|
|
remainingByPhase[phase] = duration(for: phase)
|
|
activeDurationByPhase[phase] = duration(for: phase)
|
|
phase = phase.isResting ? .work : .rest
|
|
phaseDuration = duration(for: phase)
|
|
pausedRemaining = phaseDuration
|
|
remainingByPhase[phase] = phaseDuration
|
|
activeDurationByPhase[phase] = phaseDuration
|
|
deadline = now + phaseDuration
|
|
isRunning = true
|
|
canUndoFocus = false
|
|
}
|
|
|
|
mutating func postponeFocus(at now: UInt64) {
|
|
guard phase.isResting else { return }
|
|
remainingByPhase[phase] = duration(for: phase)
|
|
activeDurationByPhase[phase] = duration(for: phase)
|
|
if canUndoFocus { completedFocus = max(0, completedFocus - 1) }
|
|
canUndoFocus = false
|
|
phase = .work
|
|
phaseDuration = 5 * 60_000_000_000
|
|
pausedRemaining = phaseDuration
|
|
remainingByPhase[phase] = phaseDuration
|
|
activeDurationByPhase[phase] = phaseDuration
|
|
deadline = now + phaseDuration
|
|
isRunning = true
|
|
}
|
|
|
|
private func duration(for phase: RestPhase) -> UInt64 {
|
|
switch phase {
|
|
case .work: workNanoseconds
|
|
case .rest: restNanoseconds
|
|
case .longRest: longRestNanoseconds
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor @Observable
|
|
final class RestController {
|
|
private(set) var phase: RestPhase = .work
|
|
private(set) var isRunning = false
|
|
private(set) var isWorkdayActive = false
|
|
private(set) var canContinue = false
|
|
private(set) var secondsRemaining: TimeInterval = 0
|
|
private(set) var phaseDuration: TimeInterval = 0
|
|
private(set) var completedToday: Int
|
|
|
|
private var session: RestSession?
|
|
private var displayTimer: Timer?
|
|
private var deadlineTimer: Timer?
|
|
private var scheduledDeadline: UInt64?
|
|
private var hudVisible = false
|
|
private var pageVisible = false
|
|
private var menuBarVisible = false
|
|
private var restVisible = false
|
|
private var suspendedAt: UInt64?
|
|
private var lastFocusCredited = false
|
|
private var lastSharedDeadline: UInt64?
|
|
private var lastSharedRunning: Bool?
|
|
private var lastSharedPhase: RestPhase?
|
|
private var lastSharedLanguage: AppLanguage?
|
|
private let audio = RestSoundPlayer()
|
|
private let localDefaults: UserDefaults
|
|
private let defaults: UserDefaults?
|
|
private let clock: () -> UInt64
|
|
/// 墙上时间只用于按自然日统计;计时仍以 clock 的单调时钟为准。
|
|
private let date: () -> Date
|
|
var onRestChange: ((Bool) -> Void)?
|
|
var onStateChange: (() -> Void)?
|
|
let settings: AppSettings
|
|
|
|
init(settings: AppSettings, localDefaults: UserDefaults = .standard,
|
|
sharedDefaults: UserDefaults? = UserDefaults(suiteName: "group.work.12306.xstats"),
|
|
clock: @escaping () -> UInt64 = { RestClock.now() },
|
|
date: @escaping () -> Date = Date.init) {
|
|
self.settings = settings
|
|
self.localDefaults = localDefaults
|
|
defaults = sharedDefaults
|
|
self.clock = clock
|
|
self.date = date
|
|
let today = Self.todayKey(for: date())
|
|
completedToday = localDefaults.string(forKey: "rest.day") == today
|
|
? localDefaults.integer(forKey: "rest.completedToday") : 0
|
|
}
|
|
|
|
func sync() {
|
|
guard settings.restEnabled else { stop(); return }
|
|
refreshDay()
|
|
if session == nil {
|
|
session = RestSession(now: clock(), workMinutes: settings.restWorkMinutes,
|
|
restMinutes: settings.restBreakMinutes,
|
|
longBreakMinutes: settings.restLongBreakMinutes)
|
|
}
|
|
refresh()
|
|
}
|
|
|
|
func setHUDVisible(_ visible: Bool) {
|
|
hudVisible = visible
|
|
updateDisplayTimer()
|
|
if visible { refresh() }
|
|
}
|
|
|
|
func setPageVisible(_ visible: Bool) {
|
|
pageVisible = visible
|
|
updateDisplayTimer()
|
|
if visible { refresh() }
|
|
}
|
|
|
|
func setMenuBarVisible(_ visible: Bool) {
|
|
menuBarVisible = visible
|
|
updateDisplayTimer()
|
|
}
|
|
|
|
func startPause() {
|
|
guard settings.restEnabled else { return }
|
|
if settings.restMode == .workday && !isWorkdayActive {
|
|
startWorkday()
|
|
return
|
|
}
|
|
settleExpiredSession()
|
|
guard var session else { return }
|
|
let now = clock()
|
|
if session.isRunning {
|
|
session.pause(at: now)
|
|
} else {
|
|
session.startOrContinue(at: now)
|
|
}
|
|
self.session = session
|
|
refresh()
|
|
}
|
|
|
|
/// 工作时段必须由用户开始;开始时从完整的第一轮专注计时。
|
|
private func startWorkday() {
|
|
let now = clock()
|
|
var session = RestSession(now: now, workMinutes: settings.restWorkMinutes,
|
|
restMinutes: settings.restBreakMinutes,
|
|
longBreakMinutes: settings.restLongBreakMinutes)
|
|
session.startOrContinue(at: now)
|
|
self.session = session
|
|
isWorkdayActive = true
|
|
suspendedAt = nil
|
|
lastFocusCredited = false
|
|
refresh()
|
|
}
|
|
|
|
func endWorkday() {
|
|
guard settings.restMode == .workday, isWorkdayActive else { return }
|
|
settleExpiredSession()
|
|
isWorkdayActive = false
|
|
suspendedAt = nil
|
|
lastFocusCredited = false
|
|
session = RestSession(now: clock(), workMinutes: settings.restWorkMinutes,
|
|
restMinutes: settings.restBreakMinutes,
|
|
longBreakMinutes: settings.restLongBreakMinutes)
|
|
refresh()
|
|
}
|
|
|
|
/// 改运行方式只暂停当前阶段并保留剩余时间;工作时段只能显式结束。
|
|
func modeDidChange() {
|
|
settleExpiredSession()
|
|
if var session {
|
|
session.pause(at: clock())
|
|
self.session = session
|
|
}
|
|
refresh()
|
|
}
|
|
|
|
func selectPhase(_ selected: RestPhase) {
|
|
settleExpiredSession()
|
|
guard var session else { return }
|
|
session.select(selected, at: clock())
|
|
self.session = session
|
|
refresh()
|
|
}
|
|
|
|
func resetCurrentPhase() {
|
|
settleExpiredSession()
|
|
guard var session else { return }
|
|
session.resetCurrentPhase(at: clock())
|
|
self.session = session
|
|
refresh()
|
|
}
|
|
|
|
func restart() {
|
|
settleExpiredSession()
|
|
let completedFocus = isWorkdayActive ? session?.completedFocus ?? 0 : 0
|
|
suspendedAt = nil
|
|
lastFocusCredited = false
|
|
session = RestSession(now: clock(), workMinutes: settings.restWorkMinutes,
|
|
restMinutes: settings.restBreakMinutes,
|
|
longBreakMinutes: settings.restLongBreakMinutes,
|
|
completedFocus: completedFocus)
|
|
sync()
|
|
}
|
|
|
|
func skip() {
|
|
guard settings.restMode != .workday || isWorkdayActive else { return }
|
|
settleExpiredSession()
|
|
guard var session else { return }
|
|
session.skip(at: clock())
|
|
self.session = session
|
|
refresh()
|
|
}
|
|
|
|
func postponeFiveMinutes() {
|
|
settleExpiredSession()
|
|
guard var session else { return }
|
|
let previousCount = session.completedFocus
|
|
session.postponeFocus(at: clock())
|
|
if session.completedFocus < previousCount && lastFocusCredited { updateCompletedToday(by: -1) }
|
|
lastFocusCredited = false
|
|
self.session = session
|
|
refresh()
|
|
}
|
|
|
|
/// 手动操作可能抢在延迟的 Timer 回调前到达;先结算已过截止点,避免吞掉完成事件。
|
|
private func settleExpiredSession() {
|
|
guard let session, session.isRunning, clock() >= session.deadline else { return }
|
|
refresh()
|
|
}
|
|
|
|
private func refresh() {
|
|
guard var session else { return }
|
|
refreshDay()
|
|
let now = clock()
|
|
let continuesAutomatically = settings.restMode == .cycle
|
|
|| (settings.restMode == .workday && isWorkdayActive)
|
|
let completedAt = session.advance(to: now, cycle: continuesAutomatically)
|
|
let nowDate = date()
|
|
let today = Calendar.current.startOfDay(for: nowDate)
|
|
let credited = completedAt.filter { completion in
|
|
let date = nowDate.addingTimeInterval(-TimeInterval(now - completion) / 1_000_000_000)
|
|
let awake = suspendedAt.map { completion <= $0 } ?? true
|
|
return awake && date >= today
|
|
}
|
|
if let last = completedAt.last { lastFocusCredited = credited.contains(last) }
|
|
updateCompletedToday(by: credited.count)
|
|
suspendedAt = nil
|
|
self.session = session
|
|
phase = session.phase
|
|
isRunning = session.isRunning
|
|
canContinue = session.canContinue
|
|
secondsRemaining = session.remaining(at: now)
|
|
phaseDuration = TimeInterval(session.phaseDuration) / 1_000_000_000
|
|
|
|
if session.isRunning && scheduledDeadline != session.deadline {
|
|
deadlineTimer?.invalidate()
|
|
scheduledDeadline = session.deadline
|
|
deadlineTimer = Timer.scheduledTimer(withTimeInterval: max(0.001, secondsRemaining), repeats: false) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
// 系统若在截止点前触发 Timer,重新按硬件截止点调度。
|
|
self.scheduledDeadline = nil
|
|
self.deadlineTimer = nil
|
|
self.refresh()
|
|
}
|
|
}
|
|
deadlineTimer?.tolerance = 0.001
|
|
} else if !session.isRunning {
|
|
deadlineTimer?.invalidate()
|
|
deadlineTimer = nil
|
|
scheduledDeadline = nil
|
|
}
|
|
|
|
let shouldShowRest = session.phase.isResting && session.isRunning
|
|
if restVisible != shouldShowRest {
|
|
restVisible = shouldShowRest
|
|
onRestChange?(shouldShowRest)
|
|
}
|
|
updateDisplayTimer()
|
|
syncSound()
|
|
onStateChange?()
|
|
|
|
// 截止点或状态变化时才更新 Widget 共享值,避免每秒磁盘写入。
|
|
if lastSharedDeadline != session.deadline || lastSharedRunning != session.isRunning
|
|
|| lastSharedPhase != session.phase || lastSharedLanguage != settings.language {
|
|
defaults?.set(settings.restEnabled, forKey: "rest.enabled")
|
|
defaults?.set(session.phase.rawValue, forKey: "rest.phase")
|
|
defaults?.set(session.isRunning, forKey: "rest.running")
|
|
defaults?.set(secondsRemaining, forKey: "rest.remaining")
|
|
defaults?.set(date().addingTimeInterval(secondsRemaining).timeIntervalSince1970, forKey: "rest.deadline")
|
|
defaults?.set(phaseDuration, forKey: "rest.duration")
|
|
defaults?.set(settings.language.rawValue, forKey: "rest.language")
|
|
lastSharedDeadline = session.deadline
|
|
lastSharedRunning = session.isRunning
|
|
lastSharedPhase = session.phase
|
|
lastSharedLanguage = settings.language
|
|
WidgetCenter.shared.reloadTimelines(ofKind: "rest")
|
|
}
|
|
}
|
|
|
|
private func updateDisplayTimer() {
|
|
let needsUpdates = settings.restEnabled && isRunning && (restVisible || hudVisible || pageVisible || menuBarVisible)
|
|
if needsUpdates && displayTimer == nil {
|
|
displayTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.refresh() }
|
|
}
|
|
displayTimer?.tolerance = 0.1
|
|
} else if !needsUpdates {
|
|
displayTimer?.invalidate()
|
|
displayTimer = nil
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
suspend()
|
|
session = nil
|
|
isWorkdayActive = false
|
|
suspendedAt = nil
|
|
lastFocusCredited = false
|
|
lastSharedDeadline = nil
|
|
secondsRemaining = 0
|
|
isRunning = false
|
|
canContinue = false
|
|
if restVisible { onRestChange?(false) }
|
|
restVisible = false
|
|
phase = .work
|
|
onStateChange?()
|
|
defaults?.set(false, forKey: "rest.enabled")
|
|
WidgetCenter.shared.reloadTimelines(ofKind: "rest")
|
|
}
|
|
|
|
func suspend() {
|
|
// 屏幕休眠与系统睡眠会先后到达,保留最早的暂停时刻,之后完成的专注都不计入。
|
|
if session?.isRunning == true { suspendedAt = suspendedAt ?? clock() }
|
|
displayTimer?.invalidate()
|
|
displayTimer = nil
|
|
deadlineTimer?.invalidate()
|
|
deadlineTimer = nil
|
|
scheduledDeadline = nil
|
|
audio.stop()
|
|
}
|
|
|
|
func prepareForTermination() {
|
|
let remaining = session?.remaining(at: clock()) ?? secondsRemaining
|
|
suspend()
|
|
defaults?.set(false, forKey: "rest.running")
|
|
defaults?.set(remaining, forKey: "rest.remaining")
|
|
WidgetCenter.shared.reloadTimelines(ofKind: "rest")
|
|
}
|
|
|
|
func syncSound() {
|
|
if settings.restEnabled && isRunning && phase.isResting {
|
|
audio.play(settings.restSound)
|
|
} else {
|
|
audio.stop()
|
|
}
|
|
}
|
|
|
|
private func refreshDay() {
|
|
let today = Self.todayKey(for: date())
|
|
guard localDefaults.string(forKey: "rest.day") != today else { return }
|
|
completedToday = 0
|
|
localDefaults.set(today, forKey: "rest.day")
|
|
localDefaults.set(0, forKey: "rest.completedToday")
|
|
}
|
|
|
|
private func updateCompletedToday(by count: Int) {
|
|
guard count != 0 else { return }
|
|
completedToday = max(0, completedToday + count)
|
|
localDefaults.set(completedToday, forKey: "rest.completedToday")
|
|
}
|
|
|
|
private static func todayKey(for date: Date) -> String {
|
|
let parts = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
|
return "\(parts.year ?? 0)-\(parts.month ?? 0)-\(parts.day ?? 0)"
|
|
}
|
|
}
|