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 @@ -58,6 +58,8 @@
F06792F6200A745F0066C438 /* MonitorControlHelper.app in [Login] Copy Helper to start at Login */ = {isa = PBXBuildFile; fileRef = F06792E7200A73460066C438 /* MonitorControlHelper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
F0A489C4279C71B200BEDFD6 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A489C3279C71B200BEDFD6 /* OnboardingViewController.swift */; };
FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4E0895249D584C003A50BB /* OSDUtils.swift */; };
E1A0C0032B01000100000003 /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A0C0042B01000100000004 /* URLSchemeHandler.swift */; };
E1A0C0012B01000100000001 /* BrightnessActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A0C0022B01000100000002 /* BrightnessActions.swift */; };
/* End PBXBuildFile section */

/* Begin PBXCopyFilesBuildPhase section */
Expand Down Expand Up @@ -181,6 +183,8 @@
FB5DB28F2AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/InternetAccessPolicy.strings"; sourceTree = "<group>"; };
FB5DB2902AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = "<group>"; };
FE4E0895249D584C003A50BB /* OSDUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDUtils.swift; sourceTree = "<group>"; };
E1A0C0042B01000100000004 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = "<group>"; };
E1A0C0022B01000100000002 /* BrightnessActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessActions.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -317,6 +321,8 @@
AA16139A26BE772E00DCF027 /* Arm64DDC.swift */,
AA4398A826DD55DA00943F16 /* IntelDDC.swift */,
FE4E0895249D584C003A50BB /* OSDUtils.swift */,
E1A0C0042B01000100000004 /* URLSchemeHandler.swift */,
E1A0C0022B01000100000002 /* BrightnessActions.swift */,
);
path = Support;
sourceTree = "<group>";
Expand Down Expand Up @@ -629,6 +635,8 @@
AA062E8E26CA7BE5007E628C /* DisplaysPrefsCellView.swift in Sources */,
AA25F6D726E68C160087F3A2 /* MediaKeyTapManager.swift in Sources */,
FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */,
E1A0C0032B01000100000003 /* URLSchemeHandler.swift in Sources */,
E1A0C0012B01000100000001 /* BrightnessActions.swift in Sources */,
6CBFE27A23DB266000D1BC41 /* Display.swift in Sources */,
AA44E70727038F7F00E06865 /* KeyboardShortcutsManager.swift in Sources */,
F03A8DF21FFBAA6F0034DC27 /* OtherDisplay.swift in Sources */,
Expand Down
15 changes: 15 additions & 0 deletions MonitorControl/Enums/PrefKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ enum PrefKey: String {
// Sliders for multiple displays
case multiSliders

// Ignore the monitorcontrol:// URL scheme
case disableExternalControl

// Brightness percentage for the first preset shortcut
case brightnessPreset1

// Brightness percentage for the second preset shortcut
case brightnessPreset2

// Brightness percentage for the third preset shortcut
case brightnessPreset3

// Brightness percentage for the fourth preset shortcut
case brightnessPreset4

/* -- Display specific settings */

// Enable mute DDC for display
Expand Down
16 changes: 16 additions & 0 deletions MonitorControl/Extensions/KeyboardShortcuts+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,21 @@ extension KeyboardShortcuts.Name {
static let volumeDown = Self("volumeDown")
static let mute = Self("mute")

static let brightnessPreset1 = Self("brightnessPreset1")
static let brightnessPreset2 = Self("brightnessPreset2")
static let brightnessPreset3 = Self("brightnessPreset3")
static let brightnessPreset4 = Self("brightnessPreset4")

static let none = Self("none")
}

extension BrightnessPreset {
var shortcutName: KeyboardShortcuts.Name {
switch self {
case .first: return .brightnessPreset1
case .second: return .brightnessPreset2
case .third: return .brightnessPreset3
case .fourth: return .brightnessPreset4
}
}
}
13 changes: 13 additions & 0 deletions MonitorControl/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleURLSchemes</key>
<array>
<string>monitorcontrol</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>7141</string>
<key>LSApplicationCategoryType</key>
Expand Down
4 changes: 4 additions & 0 deletions MonitorControl/Support/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return true
}

func application(_: NSApplication, open urls: [URL]) {
URLSchemeHandler.handle(urls)
}

func applicationWillTerminate(_: Notification) {
os_log("Goodbye!", type: .info)
DisplayManager.shared.resetSwBrightnessForAllDisplays(noPrefSave: true)
Expand Down
140 changes: 140 additions & 0 deletions MonitorControl/Support/BrightnessActions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others

import Foundation
import os.log

/// Sets the brightness of the affected displays to an absolute level, or
/// changes it by a relative amount.
///
/// The preset keyboard shortcuts and the `monitorcontrol://` URL scheme both
/// call in here, so the two entry points always behave the same way.
enum BrightnessActions {
/// The lowest level an outside caller can set. It stops a stray URL from
/// making every screen fully black.
static let externalMinimumValue: Float = 0.01

/// Sets the brightness of the affected displays.
///
/// - Parameters:
/// - value: The new brightness, from 0 to 1. Values outside that range are
/// clamped.
/// - allDisplays: Set this to true to change every display. If it is false,
/// the app uses the display selection from the settings.
/// - minimum: The lowest level to allow. Outside callers use this to keep a
/// screen from going fully black.
/// - Returns: The number of displays that changed.
@discardableResult static func setLevel(_ value: Float, allDisplays: Bool = false, minimum: Float = 0) -> Int {
let target = max(minimum, min(1, value))
var changed = 0
for display in self.targetDisplays(allDisplays: allDisplays) where self.apply(target, to: display) {
changed += 1
}
os_log("Brightness set to %{public}@ on %{public}@ display(s)", type: .info, String(target), String(changed))
return changed
}

/// Changes the brightness of the affected displays by a relative amount.
///
/// The app does the arithmetic per display, so a caller does not need to read
/// the current level first.
///
/// - Parameters:
/// - delta: The amount to add, from -1 to 1.
/// - allDisplays: Set this to true to change every display.
/// - minimum: The lowest level to allow.
/// - Returns: The number of displays that changed.
@discardableResult static func changeLevel(by delta: Float, allDisplays: Bool = false, minimum: Float = 0) -> Int {
var changed = 0
for display in self.targetDisplays(allDisplays: allDisplays) {
let target = max(minimum, min(1, display.getBrightness() + delta))
if self.apply(target, to: display) {
changed += 1
}
}
os_log("Brightness changed by %{public}@ on %{public}@ display(s)", type: .info, String(delta), String(changed))
return changed
}

/// Returns the displays a brightness action applies to, without the ones the
/// user disabled.
private static func targetDisplays(allDisplays: Bool) -> [Display] {
guard app.sleepID == 0, app.reconfigureID == 0 else {
return []
}
let displays: [Display]
if allDisplays {
displays = DisplayManager.shared.getAllDisplays()
} else {
displays = DisplayManager.shared.getAffectedDisplays(isBrightness: true, isVolume: false) ?? []
}
return displays.filter { !$0.readPrefAsBool(key: .isDisabled) }
}

/// Sets one display and performs the same side effects as a brightness key
/// press: the OSD, the menu slider, and the value the brightness sync reads.
private static func apply(_ value: Float, to display: Display) -> Bool {
guard !display.readPrefAsBool(key: .unavailableDDC, for: .brightness), display.setBrightness(value) else {
return false
}
if !display.readPrefAsBool(key: .hideOsd) {
OSDUtils.showOsd(displayID: display.identifier, command: .brightness, value: value * 64, maxValue: 64)
}
if let slider = display.sliderHandler[.brightness] {
slider.setValue(value, displayID: display.identifier)
}
// Always update this, even without a slider. The brightness sync in
// AppDelegate.job() compares against it, and a stale value makes the sync
// push the difference to every other display.
display.brightnessSyncSourceValue = value
return true
}
}

/// A fixed brightness level the user can put on a keyboard shortcut.
///
/// A preset always changes every display. A preset is a scene, so a level that
/// reached only the display under the pointer would be of little use.
enum BrightnessPreset: Int, CaseIterable {
case first
case second
case third
case fourth

/// The level to use while the user has not set one. Without this a new
/// install would put every preset at 0 percent, which is a black screen.
var defaultPercentage: Int {
switch self {
case .first: return 10
case .second: return 50
case .third: return 75
case .fourth: return 100
}
}

var prefKey: PrefKey {
switch self {
case .first: return .brightnessPreset1
case .second: return .brightnessPreset2
case .third: return .brightnessPreset3
case .fourth: return .brightnessPreset4
}
}

/// The level this preset sets, from 0 to 100.
var percentage: Int {
get {
guard prefs.object(forKey: self.prefKey.rawValue) != nil else {
return self.defaultPercentage
}
return max(0, min(100, prefs.integer(forKey: self.prefKey.rawValue)))
}
nonmutating set {
prefs.set(max(0, min(100, newValue)), forKey: self.prefKey.rawValue)
}
}

/// Sets every display to the level of this preset.
func apply() {
BrightnessActions.setLevel(Float(self.percentage) / 100, allDisplays: true)
}
}
15 changes: 14 additions & 1 deletion MonitorControl/Support/KeyboardShortcutsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import os.log
class KeyboardShortcutsManager {
private let brightnessShortcuts: [KeyboardShortcuts.Name] = [.brightnessUp, .brightnessDown, .contrastUp, .contrastDown]
private let volumeShortcuts: [KeyboardShortcuts.Name] = [.volumeUp, .volumeDown, .mute]
// The preset shortcuts stay on whichever way the brightness keys are set.
// Most users keep the media keys, and a preset must still work for them.
private let presetShortcuts: [KeyboardShortcuts.Name] = BrightnessPreset.allCases.map(\.shortcutName)

var initialKeyRepeat = 0.21
var keyRepeat = 0.028
Expand Down Expand Up @@ -39,6 +42,13 @@ class KeyboardShortcutsManager {
KeyboardShortcuts.onKeyDown(for: .mute) { [self] in
self.mute()
}
// A preset sets one level, so it uses no key repeat. This follows the mute
// shortcut, not the brightness ones.
for preset in BrightnessPreset.allCases {
KeyboardShortcuts.onKeyDown(for: preset.shortcutName) {
preset.apply()
}
}
KeyboardShortcuts.onKeyUp(for: .brightnessUp) { [self] in
self.disengage()
}
Expand All @@ -63,7 +73,7 @@ class KeyboardShortcutsManager {
func updateRegistrations() {
let brightnessEnabled = [KeyboardBrightness.custom.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
let volumeEnabled = [KeyboardVolume.custom.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
let enabledShortcuts = (brightnessEnabled ? self.brightnessShortcuts : []) + (volumeEnabled ? self.volumeShortcuts : [])
let enabledShortcuts = (brightnessEnabled ? self.brightnessShortcuts : []) + (volumeEnabled ? self.volumeShortcuts : []) + self.presetShortcuts
let disabledShortcuts = (brightnessEnabled ? [] : self.brightnessShortcuts) + (volumeEnabled ? [] : self.volumeShortcuts)

self.disengage()
Expand Down Expand Up @@ -99,6 +109,9 @@ class KeyboardShortcutsManager {
if self.volumeShortcuts.contains(shortcut) {
return [KeyboardVolume.custom.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
}
if self.presetShortcuts.contains(shortcut) {
return true
}
return false
}

Expand Down
80 changes: 80 additions & 0 deletions MonitorControl/Support/URLSchemeHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others

import Foundation
import os.log

/// Handles the `monitorcontrol://` URL scheme.
///
/// The scheme lets scripts, launchers and automation apps change the
/// brightness. It accepts these URLs:
///
/// monitorcontrol://brightness/set?value=10
/// monitorcontrol://brightness/set?value=10&display=all
/// monitorcontrol://brightness/change?delta=-10
/// monitorcontrol://brightness/change?delta=10&display=all
///
/// `value` and `delta` are percentages, not fractions. Give `display=all` to
/// change every display. Without it the app uses the display selection from
/// the settings, the same one the brightness keys use.
///
/// To switch the scheme off:
///
/// defaults write app.monitorcontrol.MonitorControl disableExternalControl -bool true
enum URLSchemeHandler {
static let scheme = "monitorcontrol"

/// Handles the URLs macOS delivered to the app.
static func handle(_ urls: [URL]) {
guard !prefs.bool(forKey: PrefKey.disableExternalControl.rawValue) else {
os_log("Ignoring a URL because control from other apps is off.", type: .info)
return
}
for url in urls where url.scheme?.lowercased() == self.scheme {
self.handle(url)
}
}

private static func handle(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
os_log("Ignoring a URL that cannot be read.", type: .info)
return
}
let group = components.host?.lowercased() ?? ""
let action = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
var query: [String: String] = [:]
for item in components.queryItems ?? [] {
query[item.name.lowercased()] = item.value
}
let allDisplays = query["display"]?.lowercased() == "all"

guard group == "brightness" else {
os_log("Ignoring a URL with the unknown group %{public}@.", type: .info, group)
return
}
switch action {
case "set":
guard let percent = self.percentage(query["value"]), percent >= 0 else {
os_log("Ignoring a brightness URL without a usable value.", type: .info)
return
}
BrightnessActions.setLevel(percent / 100, allDisplays: allDisplays, minimum: BrightnessActions.externalMinimumValue)
case "change":
guard let percent = self.percentage(query["delta"]) else {
os_log("Ignoring a brightness URL without a usable delta.", type: .info)
return
}
BrightnessActions.changeLevel(by: percent / 100, allDisplays: allDisplays, minimum: BrightnessActions.externalMinimumValue)
default:
os_log("Ignoring a brightness URL with the unknown action %{public}@.", type: .info, action)
}
}

/// Reads a percentage from a query value. Returns nil if it is missing, not a
/// number, or infinite.
private static func percentage(_ raw: String?) -> Float? {
guard let raw = raw, let value = Float(raw), value.isFinite else {
return nil
}
return value
}
}
Loading