diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..9f23b1d --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,17 @@ +name: Swift +on: [push] +jobs: + build: + name: Swift ${{ matrix.swift }} on ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest] + swift: ["5.9"] + runs-on: ${{ matrix.os }} + steps: + - uses: swift-actions/setup-swift@v1.25.0 + with: + swift-version: ${{ matrix.swift }} + - uses: actions/checkout@v4 + - name: Build + run: swift build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..322474e --- /dev/null +++ b/.swiftformat @@ -0,0 +1 @@ +--indent 2 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..2688ea8 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,15 @@ +included: + - Package.swift + - Sources/Swift +strict: true +opt_in_rules: + - indentation_width +line_length: 100 +indentation_width: + indentation_width: 2 +identifier_name: + excluded: # excluded via string array + - id + - to +trailing_comma: + mandatory_comma: true diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..2a10819 --- /dev/null +++ b/DOCS.md @@ -0,0 +1,40 @@ +# DOCS + +> Night Shift automatically shifts the colours of your display to the warmer end of the colour spectrum after dark. This may help you get a better night's sleep. + +Under "System Settings > Display > Night Shift" you find the UI controls. + +It supports three schedules + +1. Off +2. Sunset to Sunrise +3. Custom + +When "Off" (`mode: 0`), you can turn it on for a single day toggling "Turn on until "tomorrow" + +When "Sunset to Sunrise" (`mode: 1`), the schedule is automatic, and you can toggle it active until the next "sunrise" + +When "Custom" (`mode: 2`), you can set a daily schedule, and additionally toggle it active until "tomorrow" + +In all three modes, you can select a colour temperature, on a sliding scale between "Less Warm" and "More Warm" + +## Technical + +The status object + +``` + var status: Status = Status() + client.getBlueLightStatus(&status) +``` + +``` +// seems to control the temporary setting until tomorrow/sunrise +enabled: true|false +available: true|false + +// I guesss this has to do with location based information +sunSchedulePermitted: true|false +disableFlags: 0 +mode: 0|1|2 +schedule(fromTime(hour,minute),toTime(hour,minute)) +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef17a26 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +name = nightshift +prefix ?= $(HOME)/.local +bindir = $(prefix)/bin + +build: + swift build + +run: build + swift run $(name) + +release: + swift build --configuration release --disable-sandbox --arch arm64 + +install: release + install -d "$(bindir)" + install ".build/release/$(name)" "$(bindir)" + +uninstall: + rm -rf "$(bindir)/$(name)" + +# requires `brew install swiftlint` +lint: + swiftlint + +clean: + rm -rf .build + +.PHONY: build install uninstall clean release diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..b1ce5aa --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version" : "1.2.3" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..426b4f3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "nightshift", + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.2.3"), + ], + targets: [ + .target( + name: "DisplayClient", + path: "Sources/ObjC/DisplayClient", + linkerSettings: [ + .unsafeFlags(["-F/System/Library/PrivateFrameworks"]), + .linkedFramework("CoreBrightness"), + ] + ), + .executableTarget( + name: "nightshift", + dependencies: [ + .byName(name: "DisplayClient"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "Sources/Swift" + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0dbfda --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# README + +A macOS tool to read and control the [Night Shift](https://support.apple.com/en-us/102191) feature. + +## Usage + +Print out current settings + +``` +nightshift +Schedule: sunrise +Enabled: true +Temperature: 0.49 +``` + +Control modes + +``` +nightshift schedule +nightshift schedule off +nightshift schedule custom --from 11:00 --to 23:00 -t 0.3 +nightshift schedule sunrise --temperature 0.3 +``` + +Control details + +``` +nightshift enable +nightshift disable +nightshift temperature +nightshift temperature 0.33 +``` + +## Installation + +**Via Github** + +``` +git clone git@github.com:oschrenk/nightshift.swift.git +cd nightshift.swift + +# installs to $(HOME)/.local/bin/nightshift +make install +``` + +## Development + +- **Build** `make build` +- **Run** `make run` +- **Lint** `make lint` + +## Release + +``` +make release +# produces artifact in in ./.build/release/nightshift +``` diff --git a/Sources/ObjC/DisplayClient/include/DisplayClient.h b/Sources/ObjC/DisplayClient/include/DisplayClient.h new file mode 100644 index 0000000..315952f --- /dev/null +++ b/Sources/ObjC/DisplayClient/include/DisplayClient.h @@ -0,0 +1,34 @@ +#import + +// Partial header for CBBlueLightClient in private CoreBrightness API +@interface CBBlueLightClient : NSObject + +typedef struct { + int hour; + int minute; +} Time; + +typedef struct { + Time fromTime; + Time toTime; +} ScheduleObjC; + +typedef struct { + BOOL active; + BOOL enabled; + BOOL sunSchedulePermitted; + int mode; + ScheduleObjC schedule; + unsigned long long disableFlags; + BOOL available; +} Status; + +- (BOOL)setStrength:(float)strength commit:(BOOL)commit; +- (BOOL)setEnabled:(BOOL)enabled; +- (BOOL)setMode:(int)mode; +- (BOOL)setSchedule:(ScheduleObjC *)schedule; +- (BOOL)getStrength:(float *)strength; +- (BOOL)getBlueLightStatus:(Status *)status; +- (void)setStatusNotificationBlock:(void (^)(void))block; ++ (BOOL)supportsBlueLightReduction; +@end diff --git a/Sources/Swift/AppError.swift b/Sources/Swift/AppError.swift new file mode 100644 index 0000000..05c17f3 --- /dev/null +++ b/Sources/Swift/AppError.swift @@ -0,0 +1,14 @@ +import Foundation + +enum AppError: Error { + case decodingError(String) +} + +extension AppError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .decodingError(comment): + return NSLocalizedString("Application Error.", comment: comment) + } + } +} diff --git a/Sources/Swift/Cli.swift b/Sources/Swift/Cli.swift new file mode 100644 index 0000000..ac44a3e --- /dev/null +++ b/Sources/Swift/Cli.swift @@ -0,0 +1,251 @@ +import ArgumentParser + +@main +struct Cli: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Control Night Shift", + subcommands: [ + Disable.self, + Enable.self, + Schedule.self, + Temperature.self, + ] + ) + + mutating func run() { + let contextOrError = NightShift.current() + switch contextOrError { + case let .success(context): + switch context.schedule { + case .off: + print("Schedule: off") + print("Enabled: \(context.enabled)") + print("Temperature: \(context.temperature)") + case .sunrise: + print("Schedule: sunrise") + print("Enabled: \(context.enabled)") + print("Temperature: \(context.temperature)") + case .custom: + print("Schedule: custom \(context.schedule)") + print("Enabled: \(context.enabled)") + print("Temperature: \(context.temperature)") + } + case .failure: + print("Failed to fetch Night Shift settings") + } + } +} + +extension Cli { + static func validateTemperature(value: Float?) throws { + guard value ?? 1 <= 1 else { + throw ValidationError("Temperature value too large. Needs to between (or at) 0 and 1.") + } + + guard value ?? 0 >= 0 else { + throw ValidationError("Temperature value too small. Needs to between (or at) 0 and 1.") + } + } + + /// `nightshift schedule [off|sunrise|custom]` + /// + /// only holds sub-commands + struct Schedule: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "schedule", + abstract: "Select schedule", + subcommands: [ + Off.self, + Sunrise.self, + Custom.self, + ] + ) + + mutating func run() { + let contextOrError = NightShift.current() + switch contextOrError { + case let .success(context): + print("Schedule: \(context.schedule)") + case .failure: + print("Failed to fetch Night Shift settings") + } + } + } + + /// `nightshift schedule off` + /// + /// Turns Night Shift off + struct Off: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Set schedule to off" + ) + + mutating func run() { + NightShift.setMode(value: 0) + print("Turning Nightshift off") + } + } + + /// `nightshift schedule sunrise` + /// + /// Activates Night Shift from sunrise to sunset depending on your location + struct Sunrise: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Set schedule to sunrise" + ) + + @Option( + name: [.customShort("t"), .customLong("temperature")], + help: "Color temperature. Between 0 (less warm) and 1 (more warm)." + ) + var temperature: Float? + + mutating func validate() throws { + try validateTemperature(value: temperature) + } + + mutating func run() { + NightShift.setMode(value: 1) + print("Setting Sunrise schedule") + + if let temperature = temperature { + let oldTemperature = NightShift.getTemperature() + NightShift.setTemperature(value: temperature) + print("Temperature. Old: \(oldTemperature)") + print("Temperature. New: \(temperature)") + } + } + } + + /// `nightshift schedule custom [--from HH:mm] [--to HH:mm] [--temperature/-t 0-1]` + /// + /// Activates Night Shift with a custom start and end time + struct Custom: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Set schedule to custom" + ) + + @Option( + name: [.customShort("t"), .customLong("temperature")], + help: "Color temperature. Between 0 (less warm) and 1 (more warm)." + ) + var temperature: Float? + + @Option( + name: .customLong("from"), + help: "From time in 24h format eg. 7:00" + ) + var maybeFrom: LocalTime? + + @Option( + name: .customLong("to"), + help: "To time in 24h format eg. 17:00" + ) + var maybeTo: LocalTime? + + mutating func validate() throws { + try validateTemperature(value: temperature) + + if let from = maybeFrom { + if let to = maybeTo { + guard from < to else { + throw ValidationError("`From` must be earlier than `to`") + } + } + } + } + + mutating func run() { + NightShift.setMode(value: 2) + print("Setting custom schedule") + + if let temperature = temperature { + let oldTemperature = NightShift.getTemperature() + NightShift.setTemperature(value: temperature) + print("Temperature. Old: \(oldTemperature)") + print("Temperature. New: \(temperature)") + } + + if maybeFrom != nil || maybeTo != nil { + let (oldFrom, oldTo) = NightShift.getSchedule() + let from = maybeFrom ?? oldFrom + let to = maybeTo ?? oldTo + NightShift.setSchedule(from: from, to: to) + print("Schedule. Old: From \(oldFrom) to \(oldTo)") + print("Schedule. New: From \(from) to \(to)") + } + } + } + + /// `nightshift enable` + /// + /// Enables Night Shift until tomorrow, or until the next sunrise depending + /// on the schedule + struct Enable: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Enable Night Shift until tomorrow or next sunrise" + ) + + mutating func run() { + let contextOrError = NightShift.current() + switch contextOrError { + case let .success(context): + NightShift.enable() + + switch context.schedule { + case .sunrise: + print("Enabled until next sunrise") + default: + print("Enabled until tomorrow") + } + case .failure: + print("Failed to enable nightshift") + } + } + } + + /// `nightshift disable` + /// + /// Disable Night Shift until tomorrow, or until the next sunrise depending + /// on the schedule + struct Disable: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Disable Nightshift" + ) + + mutating func run() { + NightShift.disable() + print("Disabled nightshift") + } + } + + /// `nightshift temperature [0-1]` + /// + /// Print or control the color temperature. Between 0 (less warm) and 1 + /// (more warm). + struct Temperature: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Get or set color temperature." + ) + + @Argument(help: "Color temperature. Between 0 (less warm) and 1 (more warm).") + var temperature: Float? + + mutating func validate() throws { + try validateTemperature(value: temperature) + } + + mutating func run() { + let oldTemperature = NightShift.getTemperature() + + if let temperature = temperature { + NightShift.setTemperature(value: temperature) + + print("Temperature. Old: \(oldTemperature)") + print("Temperature. New: \(temperature)") + } else { + print("Temperature. Current: \(oldTemperature)") + } + } + } +} diff --git a/Sources/Swift/LocalTime.swift b/Sources/Swift/LocalTime.swift new file mode 100644 index 0000000..950d48f --- /dev/null +++ b/Sources/Swift/LocalTime.swift @@ -0,0 +1,51 @@ +import ArgumentParser + +/// Represents local time in 24h format like `HH:mm` from `00:00` to `23:59` +struct LocalTime: ExpressibleByArgument, CustomStringConvertible { + var hour: Int + var minute: Int + + var description: String { + return "\(hour):\(minute)" + } + + init(hour: Int, minute: Int) { + self.hour = hour + self.minute = minute + } + + /// Smart constructor to build `LocalTime` from string + /// + /// Accepts 24h format like `HH:mm` from `00:00` to `23:59` + init?(argument: String) { + let timeSplits = argument.trim().split(separator: ":").compactMap { Int($0) } + if timeSplits.count != 2 { + return nil + } + let maybeHour: Int = timeSplits[0] + if maybeHour < 0 || maybeHour > 23 { + return nil + } + let maybeMinute: Int = timeSplits[1] + if maybeMinute < 0 || maybeMinute > 59 { + return nil + } + + hour = Int(maybeHour) + minute = Int(maybeMinute) + } +} + +extension LocalTime: Comparable { + static func < (lhs: LocalTime, rhs: LocalTime) -> Bool { + if lhs.hour != rhs.hour { + return lhs.hour < rhs.hour + } else { + return lhs.minute < rhs.minute + } + } + + static func == (lhs: LocalTime, rhs: LocalTime) -> Bool { + return lhs.hour == rhs.hour && lhs.minute == rhs.minute + } +} diff --git a/Sources/Swift/NightShift.swift b/Sources/Swift/NightShift.swift new file mode 100644 index 0000000..1fa07ae --- /dev/null +++ b/Sources/Swift/NightShift.swift @@ -0,0 +1,124 @@ +import Cocoa +import DisplayClient + +/// Hold the Swift representation and translation layer to private API +struct NightShift { + // block strict being initialized + private init() {} + + /// Night Shift schedule + enum Schedule { + case off + case custom(from: LocalTime, to: LocalTime) + case sunrise + } + + /// Night Shift context + struct Context { + let schedule: Schedule + let enabled: Bool + let temperature: Float + } + + static func setMode(value: Int) { + let client = CBBlueLightClient() + client.setMode(Int32(value)) + } + + static func isEnabled() -> Bool { + let client = CBBlueLightClient() + var status = Status() + client.getBlueLightStatus(&status) + return status.enabled.boolValue + } + + static func enable() { + let client = CBBlueLightClient() + client.setEnabled(true) + } + + static func disable() { + let client = CBBlueLightClient() + client.setEnabled(false) + } + + static func setTemperature(value: Float) { + let client = CBBlueLightClient() + client.setStrength(value, commit: false) + } + + static func getTemperature() -> Float { + let client = CBBlueLightClient() + var temperature: Float = 0 + client.getStrength(&temperature) + return temperature + } + + static func getSchedule() -> (LocalTime, LocalTime) { + let client = CBBlueLightClient() + var status = Status() + client.getBlueLightStatus(&status) + + let schedule = status.schedule + let from = LocalTime( + hour: Int(schedule.fromTime.hour), + minute: Int(schedule.fromTime.minute) + ) + let to = LocalTime( + hour: Int(schedule.toTime.hour), + minute: Int(schedule.toTime.minute) + ) + return (from, to) + } + + static func setSchedule(from: LocalTime, to: LocalTime) { + let client = CBBlueLightClient() + + let schedule = ScheduleObjC( + fromTime: Time(hour: Int32(from.hour), minute: Int32(from.minute)), + toTime: Time(hour: Int32(to.hour), minute: Int32(to.minute)) + ) + let scheduleP = UnsafeMutablePointer.allocate(capacity: 1) + scheduleP.pointee = schedule + client.setSchedule(scheduleP) + } + + static func current() -> Result { + let client = CBBlueLightClient() + + var status = Status() + client.getBlueLightStatus(&status) + let enabled = status.enabled.boolValue + + var temperature: Float = 0 + client.getStrength(&temperature) + + let contextOrError: Result + switch status.mode { + case 0: + contextOrError = Result.success(NightShift.Schedule.off) + case 1: + contextOrError = Result.success(NightShift.Schedule.sunrise) + case 2: + let schedule = status.schedule + let from = LocalTime( + hour: Int(schedule.fromTime.hour), + minute: Int(schedule.fromTime.minute) + ) + let to = LocalTime( + hour: Int(schedule.toTime.hour), + minute: Int(schedule.toTime.minute) + ) + contextOrError = Result.success(NightShift.Schedule.custom(from: from, to: to)) + default: + contextOrError = Result.failure(AppError.decodingError("Unknown mode \(status.mode)")) + } + return contextOrError.map { + Context( + schedule: $0, + enabled: enabled, + temperature: temperature + ) + } + } +} diff --git a/Sources/Swift/String.swift b/Sources/Swift/String.swift new file mode 100644 index 0000000..5b01965 --- /dev/null +++ b/Sources/Swift/String.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Comveniece method to strip whitespace from String +extension String { + func trim() -> String { + return trimmingCharacters(in: NSCharacterSet.whitespaces) + } +}