remindwall / RWPureSwift / Sources / ScreenControl / ScreenControl.swift
Back to remindwall — Current RemindWall
import Dependencies
import DependenciesMacros
import Foundation
import os
#if canImport(UIKit)
import UIKit
#endif
/// Errors from the brightness/display-power pipeline. Failures are THROWN, not
/// masked — the old `?? 1.0` fallback made a failed DDC read indistinguishable
/// from a bright screen and broke the restore logic.
public enum ScreenControlError: Error, Equatable {
/// ddcd didn't answer (not running, wrong port).
case daemonUnreachable(String)
/// ddcd answered with an error (m1ddc failure, timeout, no display).
case daemonError(status: Int, message: String)
case invalidResponse
}
@DependencyClient
public struct ScreenControl: Sendable {
/// Current screen brightness (0.0 to 1.0). Throws when it can't be read —
/// callers must not fabricate a value.
public var getBrightness: @Sendable () async throws -> CGFloat
/// Sets the screen brightness (0.0 to 1.0).
public var setBrightness: @Sendable (CGFloat) async throws -> Void
/// True display power (Mac kiosk: panel standby via ddcd /display).
/// No-op on iOS — the brightness path is the iOS dim mechanism.
public var setDisplayPower: @Sendable (_ on: Bool) async throws -> Void
/// Whether brightness control is usable right now (ddcd reachable and
/// m1ddc present on the Mac; always true on iOS).
public var isAvailable: @Sendable () async -> Bool = { false }
}
let logger = Logger(subsystem: "RemindWall", category: "ScreenControl")
extension ScreenControl: DependencyKey {
public static var liveValue: Self {
#if targetEnvironment(macCatalyst)
let client = DdcdClient.live()
return Self(
getBrightness: { try await client.getBrightness() },
setBrightness: { try await client.setBrightness($0) },
setDisplayPower: { try await client.setDisplayPower(on: $0) },
isAvailable: { await client.healthy() }
)
#elseif canImport(UIKit)
return Self(
getBrightness: {
await MainActor.run {
guard let scene = UIApplication.shared.connectedScenes.first,
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
let window = windowSceneDelegate.window else {
return 1.0
}
return window?.windowScene?.screen.brightness ?? 1.0
}
},
setBrightness: { brightness in
await MainActor.run {
guard let scene = UIApplication.shared.connectedScenes.first,
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
let window = windowSceneDelegate.window,
let screen = window?.windowScene?.keyWindow?.screen else {
return
}
screen.brightness = brightness
}
},
setDisplayPower: { _ in },
isAvailable: { true }
)
#else
return Self(
getBrightness: { 1.0 },
setBrightness: { _ in },
setDisplayPower: { _ in },
isAvailable: { false }
)
#endif
}
}
extension ScreenControl: TestDependencyKey {
public static let testValue = Self()
public static var previewValue: Self {
Self(
getBrightness: { 0.75 },
setBrightness: { _ in },
setDisplayPower: { _ in },
isAvailable: { true }
)
}
}
extension DependencyValues {
public var screenControl: ScreenControl {
get { self[ScreenControl.self] }
set { self[ScreenControl.self] = newValue }
}
}
// MARK: - ddcd HTTP client (Mac Catalyst)
/// Talks to the local ddcd daemon. The daemon owns the hard problems
/// (m1ddc timeouts, DDC bus serialization, max-luminance caching); this is a
/// thin, honest transport. Internal for tests — production reaches it only
/// through ScreenControl.liveValue.
struct DdcdClient: Sendable {
let baseURL: URL
let session: URLSession
/// Every request carries this header; ddcd refuses bare requests (the
/// kiosk box serves public traffic, localhost is not a trust boundary).
static let guardHeader = "x-ddcd"
static func live() -> DdcdClient {
let port = ProcessInfo.processInfo.environment["DDCD_PORT"].flatMap(Int.init) ?? 8377
let config = URLSessionConfiguration.ephemeral
// Above ddcd's worst case (5s m1ddc timeout + retry backoff), below
// the 30s monitor tick so calls can't stack.
config.timeoutIntervalForRequest = 10
return DdcdClient(
baseURL: URL(string: "http://127.0.0.1:\(port)")!,
session: URLSession(configuration: config)
)
}
private struct BrightnessResponse: Decodable {
let brightness: Double
}
private struct HealthResponse: Decodable {
let status: String
let m1ddc_present: Bool
}
private struct ErrorResponse: Decodable {
let error: String
}
private struct SetBrightnessBody: Encodable {
let brightness: Double
}
private struct DisplayPowerBody: Encodable {
let on: Bool
}
func getBrightness() async throws -> CGFloat {
let data = try await send("GET", "brightness")
guard let response = try? JSONDecoder().decode(BrightnessResponse.self, from: data) else {
throw ScreenControlError.invalidResponse
}
return CGFloat(response.brightness)
}
func setBrightness(_ value: CGFloat) async throws {
_ = try await send("PUT", "brightness", body: SetBrightnessBody(brightness: Double(value)))
}
func setDisplayPower(on: Bool) async throws {
_ = try await send("PUT", "display", body: DisplayPowerBody(on: on))
}
/// Available means the daemon answers AND it can see m1ddc — a running
/// daemon with no binary would fail every operation.
func healthy() async -> Bool {
guard let data = try? await send("GET", "health"),
let health = try? JSONDecoder().decode(HealthResponse.self, from: data) else {
return false
}
return health.status == "ok" && health.m1ddc_present
}
private func send(_ method: String, _ path: String, body: (some Encodable)? = nil as Int?) async throws -> Data {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = method
request.setValue("1", forHTTPHeaderField: Self.guardHeader)
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
}
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
logger.warning("ddcd unreachable: \(error.localizedDescription, privacy: .public)")
throw ScreenControlError.daemonUnreachable(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw ScreenControlError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(ErrorResponse.self, from: data))?.error
?? String(data: data, encoding: .utf8) ?? "no detail"
logger.warning("ddcd \(method, privacy: .public) /\(path, privacy: .public) -> \(http.statusCode): \(message, privacy: .public)")
throw ScreenControlError.daemonError(status: http.statusCode, message: message)
}
return data
}
}