From e489f785482fbdd1e66bd125dda2aa50a61f9502 Mon Sep 17 00:00:00 2001 From: Oliver Schrenk Date: Sat, 2 Dec 2023 11:58:27 +0100 Subject: [PATCH] Initial commit --- .github/workflows/swift.yml | 17 ++ .gitignore | 8 + .swiftformat | 1 + .swiftlint.yml | 15 ++ DOCS.md | 40 +++ Makefile | 28 ++ Package.resolved | 14 + Package.swift | 29 ++ README.md | 57 ++++ .../DisplayClient/include/DisplayClient.h | 34 +++ Sources/Swift/AppError.swift | 14 + Sources/Swift/Cli.swift | 251 ++++++++++++++++++ Sources/Swift/LocalTime.swift | 51 ++++ Sources/Swift/NightShift.swift | 124 +++++++++ Sources/Swift/String.swift | 8 + 15 files changed, 691 insertions(+) create mode 100644 .github/workflows/swift.yml create mode 100644 .gitignore create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 DOCS.md create mode 100644 Makefile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/ObjC/DisplayClient/include/DisplayClient.h create mode 100644 Sources/Swift/AppError.swift create mode 100644 Sources/Swift/Cli.swift create mode 100644 Sources/Swift/LocalTime.swift create mode 100644 Sources/Swift/NightShift.swift create mode 100644 Sources/Swift/String.swift 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) + } +}