Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions MonitorControl.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
AA78BDBD2709FE7B00CA8DF7 /* UpdaterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA78BDBC2709FE7B00CA8DF7 /* UpdaterDelegate.swift */; };
AA99521726FE25AB00612E07 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA99521626FE25AB00612E07 /* AppDelegate.swift */; };
AA99521926FE49A300612E07 /* MenuHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA99521826FE49A300612E07 /* MenuHandler.swift */; };
AA9A10012DCD000100000001 /* BrightnessAutomationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9A10002DCD000100000001 /* BrightnessAutomationManager.swift */; };
AA9A10022DCD000100000001 /* BrightnessAutomationWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9A10032DCD000100000001 /* BrightnessAutomationWindowController.swift */; };
AA9AE86F26B5BF3D00B6CA65 /* OSD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA9AE86E26B5BF3D00B6CA65 /* OSD.framework */; };
AA9AE87126B5BFB700B6CA65 /* CoreDisplay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA9AE87026B5BFB700B6CA65 /* CoreDisplay.framework */; };
AAB2F638273ED099004AB5A4 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = AAB2F637273ED099004AB5A4 /* .swiftlint.yml */; };
Expand Down Expand Up @@ -128,6 +130,8 @@
AA99E81527622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = "<group>"; };
AA99E81627622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InternetAccessPolicy.strings"; sourceTree = "<group>"; };
AA99E81727622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
AA9A10002DCD000100000001 /* BrightnessAutomationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessAutomationManager.swift; sourceTree = "<group>"; };
AA9A10032DCD000100000001 /* BrightnessAutomationWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessAutomationWindowController.swift; sourceTree = "<group>"; };
AA9AE86E26B5BF3D00B6CA65 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = "<absolute>"; };
AA9AE87026B5BFB700B6CA65 /* CoreDisplay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreDisplay.framework; path = /System/Library/Frameworks/CoreDisplay.framework; sourceTree = "<absolute>"; };
AA9CB6252704BB440086DC0E /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Main.strings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -309,6 +313,8 @@
F01B0685228221B6008E64DB /* Bridging-Header.h */,
AA99521626FE25AB00612E07 /* AppDelegate.swift */,
AA78BDBC2709FE7B00CA8DF7 /* UpdaterDelegate.swift */,
AA9A10002DCD000100000001 /* BrightnessAutomationManager.swift */,
AA9A10032DCD000100000001 /* BrightnessAutomationWindowController.swift */,
AA99521826FE49A300612E07 /* MenuHandler.swift */,
F01B068F228221B7008E64DB /* SliderHandler.swift */,
6C85EFD922C941B000227EA1 /* DisplayManager.swift */,
Expand Down Expand Up @@ -639,6 +645,8 @@
F0445D3820023E710025AE82 /* MainPrefsViewController.swift in Sources */,
28D1DDF2227FBE71004CB494 /* NSScreen+Extension.swift in Sources */,
AA99521726FE25AB00612E07 /* AppDelegate.swift in Sources */,
AA9A10012DCD000100000001 /* BrightnessAutomationManager.swift in Sources */,
AA9A10022DCD000100000001 /* BrightnessAutomationWindowController.swift in Sources */,
AA25F6D126E681D30087F3A2 /* KeyboardPrefsViewController.swift in Sources */,
F01B069F228221B7008E64DB /* SliderHandler.swift in Sources */,
AACE5E2327050C63006C2A48 /* NSNotification+Extension.swift in Sources */,
Expand Down
3 changes: 3 additions & 0 deletions MonitorControl/Enums/PrefKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ enum PrefKey: String {
// Sliders for multiple displays
case multiSliders

// Daily brightness automations
case brightnessAutomations

/* -- Display specific settings */

// Enable mute DDC for display
Expand Down
15 changes: 15 additions & 0 deletions MonitorControl/Support/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var startupActionWriteCounter: Int = 0
var audioPlayer: AVAudioPlayer?
let updaterController = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: UpdaterDelegate(), userDriverDelegate: nil)
let brightnessAutomationManager = BrightnessAutomationManager()
var brightnessAutomationWindowController: BrightnessAutomationWindowController?

var settingsPaneStyle: Settings.Style {
if !DEBUG_MACOS10, #available(macOS 11.0, *) {
Expand Down Expand Up @@ -65,6 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
CGDisplayRegisterReconfigurationCallback({ _, _, _ in app.displayReconfigured() }, nil)
self.configure(firstrun: true)
DisplayManager.shared.createGammaActivityEnforcer()
self.brightnessAutomationManager.start()
self.updaterController.startUpdater()
}

Expand All @@ -81,6 +84,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.settingsWindowController.show()
}

@objc func brightnessAutomationsClicked(_: AnyObject) {
os_log("Brightness Automations clicked", type: .info)
menu.closeMenu()
if self.brightnessAutomationWindowController == nil {
self.brightnessAutomationWindowController = BrightnessAutomationWindowController(manager: self.brightnessAutomationManager)
}
self.brightnessAutomationWindowController?.showWindow(self)
}

func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
app.prefsClicked(self)
return true
Expand Down Expand Up @@ -152,6 +164,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
displaysPrefsVc?.loadDisplayList()
self.brightnessAutomationWindowController?.reloadData()
self.brightnessAutomationManager.handleWakeOrDisplayChange()
self.job(start: true)
}

Expand Down Expand Up @@ -208,6 +222,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.job(start: true)
}
self.startupActionWriteRepeatAfterSober()
self.brightnessAutomationManager.handleWakeOrDisplayChange()
self.updateMediaKeyTap()
}
}
Expand Down
246 changes: 246 additions & 0 deletions MonitorControl/Support/BrightnessAutomationManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others

import Cocoa
import os.log

struct BrightnessAutomation: Codable, Equatable {
enum TargetMode: String, Codable {
case all
case specific
}

var id: String
var isEnabled: Bool
var hour: Int
var minute: Int
var brightness: Float
var targetMode: TargetMode
var targetDisplayPrefsIds: [String]
var targetDisplayLabels: [String]
var lastRunDate: Date?

var minuteOfDay: Int {
self.hour * 60 + self.minute
}
}

final class BrightnessAutomationManager {
private(set) var automations: [BrightnessAutomation] = []
private var timer: Timer?
private let calendar = Calendar.current

init() {
self.load()
}

deinit {
self.timer?.invalidate()
}

func start() {
self.applyLatestMissedAutomationForToday()
self.scheduleNextRun()
}

func handleWakeOrDisplayChange() {
self.applyLatestMissedAutomationForToday()
self.scheduleNextRun()
}

func upsert(_ automation: BrightnessAutomation) {
if let index = self.automations.firstIndex(where: { $0.id == automation.id }) {
self.automations[index] = automation
} else {
self.automations.append(automation)
}
self.sortAutomations()
self.save()
self.scheduleNextRun()
}

func delete(id: String) {
self.automations.removeAll { $0.id == id }
self.save()
self.scheduleNextRun()
}

func availableDisplayTargets() -> [(prefsId: String, label: String)] {
DisplayManager.shared.getAllDisplays()
.filter { self.canControlBrightness($0) }
.map { display in
let friendlyName = display.readPrefAsString(key: .friendlyName)
return (display.prefsId, friendlyName.isEmpty ? display.name : friendlyName)
}
}

func summary(for automation: BrightnessAutomation) -> String {
let time = String(format: "%02d:%02d", automation.hour, automation.minute)
let brightness = String(format: "%.0f%%", Double(automation.brightness) * 100)
let target: String
if automation.targetMode == .all {
target = NSLocalizedString("All displays", comment: "Shown in brightness automation window")
} else if automation.targetDisplayLabels.isEmpty {
target = NSLocalizedString("No displays", comment: "Shown in brightness automation window")
} else {
target = automation.targetDisplayLabels.joined(separator: ", ")
}
return "\(automation.isEnabled ? "" : "Off - ")\(time) - \(brightness) - \(target)"
}

private func load() {
guard let data = prefs.data(forKey: PrefKey.brightnessAutomations.rawValue) else {
self.automations = []
return
}
do {
self.automations = try JSONDecoder().decode([BrightnessAutomation].self, from: data)
self.sortAutomations()
} catch {
os_log("Unable to load brightness automations: %{public}@", type: .error, error.localizedDescription)
self.automations = []
}
}

private func save() {
do {
let data = try JSONEncoder().encode(self.automations)
prefs.set(data, forKey: PrefKey.brightnessAutomations.rawValue)
} catch {
os_log("Unable to save brightness automations: %{public}@", type: .error, error.localizedDescription)
}
}

private func sortAutomations() {
self.automations.sort {
if $0.minuteOfDay == $1.minuteOfDay {
return self.summary(for: $0).localizedStandardCompare(self.summary(for: $1)) == .orderedAscending
}
return $0.minuteOfDay < $1.minuteOfDay
}
}

private func scheduleNextRun() {
self.timer?.invalidate()
self.timer = nil

let enabledAutomations = self.automations.filter(\.isEnabled)
guard !enabledAutomations.isEmpty else {
return
}

let now = Date()
guard let next = enabledAutomations.compactMap({ automation -> (Date, String)? in
guard let date = self.nextRunDate(for: automation, after: now) else {
return nil
}
return (date, automation.id)
}).min(by: { $0.0 < $1.0 }) else {
return
}

let timer = Timer(timeInterval: max(0.1, next.0.timeIntervalSince(now)), repeats: false) { [weak self] _ in
self?.timerFired(automationID: next.1)
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}

private func timerFired(automationID: String) {
defer {
self.scheduleNextRun()
}
guard app.sleepID == 0, app.reconfigureID == 0 else {
return
}
guard let index = self.automations.firstIndex(where: { $0.id == automationID }), self.automations[index].isEnabled else {
return
}
if self.apply(self.automations[index]) {
self.automations[index].lastRunDate = Date()
self.save()
}
}

private func applyLatestMissedAutomationForToday() {
guard app.sleepID == 0, app.reconfigureID == 0 else {
return
}
let now = Date()
let currentMinuteOfDay = self.minuteOfDay(for: now)
guard let automation = self.automations
.filter({ $0.isEnabled && $0.minuteOfDay <= currentMinuteOfDay && !self.hasRun($0, onSameDayAs: now) })
.max(by: { $0.minuteOfDay < $1.minuteOfDay }) else {
return
}
guard let index = self.automations.firstIndex(where: { $0.id == automation.id }) else {
return
}
if self.apply(automation) {
self.automations[index].lastRunDate = now
self.save()
}
}

private func apply(_ automation: BrightnessAutomation) -> Bool {
let value = max(0, min(1, automation.brightness))
let targetDisplays = self.targetDisplays(for: automation)
guard !targetDisplays.isEmpty else {
os_log("Brightness automation %{public}@ skipped because no target displays are available.", type: .info, automation.id)
return false
}
for display in targetDisplays {
if display.setBrightness(value) {
display.sliderHandler[.brightness]?.setValue(value, displayID: display.identifier)
}
}
return true
}

private func targetDisplays(for automation: BrightnessAutomation) -> [Display] {
let displays = DisplayManager.shared.getAllDisplays().filter { self.canControlBrightness($0) }
if automation.targetMode == .all {
return displays
}
let selected = Set(automation.targetDisplayPrefsIds)
return displays.filter { selected.contains($0.prefsId) }
}

private func canControlBrightness(_ display: Display) -> Bool {
if display.isDummy {
return false
}
if let otherDisplay = display as? OtherDisplay, otherDisplay.isSw() {
return true
}
return !display.readPrefAsBool(key: .unavailableDDC, for: .brightness)
}

private func nextRunDate(for automation: BrightnessAutomation, after date: Date) -> Date? {
var components = self.calendar.dateComponents([.year, .month, .day], from: date)
components.hour = automation.hour
components.minute = automation.minute
components.second = 0
guard var runDate = self.calendar.date(from: components) else {
return nil
}
if runDate <= date {
guard let tomorrow = self.calendar.date(byAdding: .day, value: 1, to: runDate) else {
return nil
}
runDate = tomorrow
}
return runDate
}

private func minuteOfDay(for date: Date) -> Int {
let components = self.calendar.dateComponents([.hour, .minute], from: date)
return (components.hour ?? 0) * 60 + (components.minute ?? 0)
}

private func hasRun(_ automation: BrightnessAutomation, onSameDayAs date: Date) -> Bool {
guard let lastRunDate = automation.lastRunDate else {
return false
}
return self.calendar.isDate(lastRunDate, inSameDayAs: date)
}
}
Loading