|
#!/bin/bash |
|
set -Efeuxo pipefail |
|
|
|
APP_NAME="Pseudo True Tone" |
|
VERSION="0.1.12" |
|
OUTPUT_DIR="${PWD}" |
|
FORCE=0 |
|
|
|
usage() { |
|
cat <<'USAGE' |
|
Usage: ./create_pseudo_true_tone_app.sh [--force] [--output DIR] |
|
|
|
Builds "Pseudo True Tone.app" using the Swift compiler included with macOS. |
|
No Xcode project, package manager, administrator access, or network access is used. |
|
|
|
Options: |
|
--force Replace an existing app at the output path. |
|
--output DIR Put the generated app in DIR (default: current directory). |
|
-h, --help Show this help. |
|
USAGE |
|
} |
|
|
|
while (($#)); do |
|
case "$1" in |
|
--force) |
|
FORCE=1 |
|
shift |
|
;; |
|
--output) |
|
[[ $# -ge 2 ]] || { echo "Error: --output needs a directory" >&2; exit 2; } |
|
OUTPUT_DIR="$2" |
|
shift 2 |
|
;; |
|
-h|--help) |
|
usage |
|
exit 0 |
|
;; |
|
*) |
|
echo "Error: unknown argument: $1" >&2 |
|
usage >&2 |
|
exit 2 |
|
;; |
|
esac |
|
done |
|
|
|
if [[ "$(uname -s)" != "Darwin" ]]; then |
|
echo "Error: this builder must be run on macOS." >&2 |
|
exit 1 |
|
fi |
|
|
|
for tool in xcrun plutil sips iconutil; do |
|
command -v "$tool" >/dev/null 2>&1 || { |
|
echo "Error: missing '$tool'. Install Apple's Command Line Tools with: xcode-select --install" >&2 |
|
exit 1 |
|
} |
|
done |
|
|
|
mkdir -p "$OUTPUT_DIR" |
|
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" |
|
APP_PATH="${OUTPUT_DIR}/${APP_NAME}.app" |
|
|
|
if [[ -e "$APP_PATH" && "$FORCE" -ne 1 ]]; then |
|
echo "Error: '$APP_PATH' already exists. Use --force to replace it." >&2 |
|
exit 1 |
|
fi |
|
|
|
BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pseudo-true-tone.XXXXXX")" |
|
STAGED_APP="$BUILD_DIR/${APP_NAME}.app" |
|
set +x |
|
FINAL_MSG="Error: build did not complete." |
|
set -x |
|
|
|
cleanup() { |
|
local status=$? |
|
set +x |
|
trap - ERR INT TERM HUP |
|
set -x |
|
local pid |
|
for pid in $(jobs -pr); do |
|
kill "$pid" 2>/dev/null || true |
|
done |
|
wait 2>/dev/null || true |
|
rm -rf -- "$BUILD_DIR" |
|
echo "Deleted temp dir: $BUILD_DIR" |
|
if [[ "$status" -eq 0 ]]; then |
|
echo "$FINAL_MSG" |
|
else |
|
echo "$FINAL_MSG" >&2 |
|
fi |
|
exit "$status" |
|
} |
|
|
|
set +x |
|
trap 'status=$?; FINAL_MSG="Build failed near line ${LINENO}: ${BASH_COMMAND}"; exit "$status"' ERR |
|
trap cleanup EXIT |
|
trap 'FINAL_MSG="Build cancelled."; exit 130' INT TERM HUP |
|
set -x |
|
|
|
mkdir -p "$STAGED_APP/Contents/MacOS" "$STAGED_APP/Contents/Resources" |
|
|
|
cat > "$BUILD_DIR/PTTBridge.h" <<'HEADER' |
|
#ifndef PTTBridge_h |
|
#define PTTBridge_h |
|
|
|
#include <stdbool.h> |
|
#include <stdint.h> |
|
|
|
typedef struct { |
|
bool available; |
|
bool active; |
|
bool enabled; |
|
int32_t mode; |
|
float strength; |
|
float cct; |
|
float minCCT; |
|
float maxCCT; |
|
float midCCT; |
|
} PTTNightShiftState; |
|
|
|
bool PTTNightShiftGetState(PTTNightShiftState *outState); |
|
bool PTTNightShiftApplyCCT(float kelvin, float transitionSeconds, float *outStrength); |
|
bool PTTNightShiftApplyStrength(float strength, float transitionSeconds); |
|
bool PTTNightShiftRestore(PTTNightShiftState state); |
|
bool PTTBuiltInDisplayGetBrightness(float *outBrightness); |
|
bool PTTBuiltInDisplayIsAsleep(bool *outAsleep); |
|
|
|
#ifdef __OBJC__ |
|
#import <AVFoundation/AVFoundation.h> |
|
// Read-only runtime probe. Returns false when macOS doesn't expose the |
|
// iOS-shaped AWB selectors on the concrete camera implementation. |
|
bool PTTCameraReadWhiteBalance(AVCaptureDevice *device, float *outKelvin, float *outTint); |
|
#endif |
|
|
|
#endif |
|
HEADER |
|
|
|
cat > "$BUILD_DIR/PTTBridge.m" <<'OBJC' |
|
#import "PTTBridge.h" |
|
#import <Foundation/Foundation.h> |
|
#import <AVFoundation/AVFoundation.h> |
|
#import <CoreGraphics/CoreGraphics.h> |
|
#import <dlfcn.h> |
|
#import <math.h> |
|
#import <string.h> |
|
|
|
typedef struct { |
|
int hour; |
|
int minute; |
|
} PTTTime; |
|
|
|
typedef struct { |
|
PTTTime fromTime; |
|
PTTTime toTime; |
|
} PTTSchedule; |
|
|
|
typedef struct { |
|
signed char active; |
|
signed char enabled; |
|
signed char sunSchedulePermitted; |
|
int mode; |
|
PTTSchedule schedule; |
|
unsigned long long disableFlags; |
|
} PTTBlueLightStatus; |
|
|
|
typedef struct { |
|
float minCCT; |
|
float maxCCT; |
|
float midCCT; |
|
} PTTCCTRange; |
|
|
|
// Partial runtime declaration for Apple's private CoreBrightness class. |
|
// The framework is loaded with dlopen so an OS change fails cleanly at runtime. |
|
@interface CBBlueLightClient : NSObject |
|
+ (BOOL)supportsBlueLightReduction; |
|
- (BOOL)getBlueLightStatus:(PTTBlueLightStatus *)status; |
|
- (BOOL)getStrength:(float *)strength; |
|
- (BOOL)getCCT:(float *)cct; |
|
- (BOOL)getCCTRange:(PTTCCTRange *)range; |
|
- (BOOL)setEnabled:(BOOL)enabled; |
|
- (BOOL)setActive:(BOOL)active; |
|
- (BOOL)setMode:(int)mode; |
|
- (BOOL)setStrength:(float)strength commit:(BOOL)commit; |
|
- (BOOL)setStrength:(float)strength withPeriod:(float)period commit:(BOOL)commit; |
|
- (BOOL)setCCT:(float)cct commit:(BOOL)commit; |
|
- (BOOL)setCCT:(float)cct withPeriod:(float)period commit:(BOOL)commit; |
|
@end |
|
|
|
static CBBlueLightClient *PTTClient(void) { |
|
static CBBlueLightClient *client = nil; |
|
static dispatch_once_t once; |
|
dispatch_once(&once, ^{ |
|
void *handle = dlopen( |
|
"/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness", |
|
RTLD_LAZY | RTLD_LOCAL |
|
); |
|
if (handle != NULL) { |
|
Class cls = NSClassFromString(@"CBBlueLightClient"); |
|
if (cls != Nil) { |
|
if (![cls respondsToSelector:@selector(supportsBlueLightReduction)] || |
|
[cls supportsBlueLightReduction]) { |
|
client = [[cls alloc] init]; |
|
} |
|
} |
|
} |
|
}); |
|
return client; |
|
} |
|
|
|
static float PTTClamp(float value, float low, float high) { |
|
return fminf(high, fmaxf(low, value)); |
|
} |
|
|
|
bool PTTNightShiftGetState(PTTNightShiftState *outState) { |
|
if (outState == NULL) { return false; } |
|
memset(outState, 0, sizeof(*outState)); |
|
|
|
CBBlueLightClient *client = PTTClient(); |
|
if (client == nil || |
|
![client respondsToSelector:@selector(setEnabled:)] || |
|
![client respondsToSelector:@selector(getStrength:)]) { |
|
return false; |
|
} |
|
|
|
outState->available = true; |
|
outState->minCCT = 2850.0f; |
|
outState->maxCCT = 6500.0f; |
|
outState->midCCT = 4500.0f; |
|
|
|
if ([client respondsToSelector:@selector(getBlueLightStatus:)]) { |
|
PTTBlueLightStatus status = {0}; |
|
if ([client getBlueLightStatus:&status]) { |
|
outState->active = status.active != 0; |
|
outState->enabled = status.enabled != 0; |
|
outState->mode = status.mode; |
|
} |
|
} |
|
|
|
float strength = 0.0f; |
|
if ([client getStrength:&strength]) { |
|
outState->strength = strength; |
|
} |
|
|
|
if ([client respondsToSelector:@selector(getCCT:)]) { |
|
float cct = 0.0f; |
|
if ([client getCCT:&cct]) { |
|
outState->cct = cct; |
|
} |
|
} |
|
|
|
if ([client respondsToSelector:@selector(getCCTRange:)]) { |
|
PTTCCTRange range = {0}; |
|
if ([client getCCTRange:&range] && |
|
range.minCCT >= 1000.0f && |
|
range.maxCCT > range.minCCT) { |
|
outState->minCCT = range.minCCT; |
|
outState->maxCCT = range.maxCCT; |
|
outState->midCCT = range.midCCT; |
|
} |
|
} |
|
|
|
return true; |
|
} |
|
|
|
bool PTTNightShiftApplyCCT(float kelvin, float transitionSeconds, float *outStrength) { |
|
CBBlueLightClient *client = PTTClient(); |
|
if (client == nil) { return false; } |
|
|
|
PTTNightShiftState state = {0}; |
|
if (!PTTNightShiftGetState(&state)) { return false; } |
|
|
|
float target = PTTClamp(kelvin, state.minCCT, state.maxCCT); |
|
float period = PTTClamp(transitionSeconds, 0.0f, 10.0f); |
|
float neutralMired = 1000000.0f / state.maxCCT; |
|
float warmMired = 1000000.0f / state.minCCT; |
|
float targetMired = 1000000.0f / target; |
|
float requestedStrength = PTTClamp( |
|
(targetMired - neutralMired) / (warmMired - neutralMired), |
|
0.0f, |
|
1.0f |
|
); |
|
BOOL applied = NO; |
|
|
|
// Strength is the path used by current macOS CoreBrightness utilities and |
|
// has proven more stable than asking the private framework for a raw CCT. |
|
if (period > 0.01f && |
|
[client respondsToSelector:@selector(setStrength:withPeriod:commit:)]) { |
|
applied = [client setStrength:requestedStrength withPeriod:period commit:YES]; |
|
} else if ([client respondsToSelector:@selector(setStrength:commit:)]) { |
|
applied = [client setStrength:requestedStrength commit:YES]; |
|
} |
|
|
|
// Fall back to the direct CCT selectors if the strength path changes. |
|
if (!applied) { |
|
if (period > 0.01f && |
|
[client respondsToSelector:@selector(setCCT:withPeriod:commit:)]) { |
|
applied = [client setCCT:target withPeriod:period commit:YES]; |
|
} else if ([client respondsToSelector:@selector(setCCT:commit:)]) { |
|
applied = [client setCCT:target commit:YES]; |
|
} |
|
} |
|
|
|
// At the neutral endpoint, actually turn Night Shift off instead of |
|
// leaving its system toggle active with a zero-strength transform. |
|
BOOL enabled = [client setEnabled:requestedStrength > 0.005f ? YES : NO]; |
|
float strength = 0.0f; |
|
if ([client respondsToSelector:@selector(getStrength:)]) { |
|
[client getStrength:&strength]; |
|
} |
|
if (outStrength != NULL) { *outStrength = strength; } |
|
return applied && enabled; |
|
} |
|
|
|
bool PTTNightShiftApplyStrength(float strength, float transitionSeconds) { |
|
CBBlueLightClient *client = PTTClient(); |
|
if (client == nil) { return false; } |
|
|
|
float requested = PTTClamp(strength, 0.0f, 1.0f); |
|
float period = PTTClamp(transitionSeconds, 0.0f, 10.0f); |
|
BOOL applied = NO; |
|
if (period > 0.01f && |
|
[client respondsToSelector:@selector(setStrength:withPeriod:commit:)]) { |
|
applied = [client setStrength:requested withPeriod:period commit:YES]; |
|
} else if ([client respondsToSelector:@selector(setStrength:commit:)]) { |
|
applied = [client setStrength:requested commit:YES]; |
|
} |
|
BOOL enabled = [client setEnabled:requested > 0.005f ? YES : NO]; |
|
return applied && enabled; |
|
} |
|
|
|
#pragma mark - Read-only built-in display brightness probe |
|
|
|
typedef int (*PTTDisplayServicesGetBrightnessFunction)(CGDirectDisplayID, float *); |
|
|
|
static PTTDisplayServicesGetBrightnessFunction PTTBrightnessGetter(void) { |
|
static PTTDisplayServicesGetBrightnessFunction function = NULL; |
|
static dispatch_once_t once; |
|
dispatch_once(&once, ^{ |
|
void *handle = dlopen( |
|
"/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices", |
|
RTLD_LAZY | RTLD_LOCAL |
|
); |
|
if (handle != NULL) { |
|
function = (PTTDisplayServicesGetBrightnessFunction)dlsym( |
|
handle, |
|
"DisplayServicesGetBrightness" |
|
); |
|
} |
|
}); |
|
return function; |
|
} |
|
|
|
bool PTTBuiltInDisplayGetBrightness(float *outBrightness) { |
|
if (outBrightness == NULL) { return false; } |
|
|
|
PTTDisplayServicesGetBrightnessFunction getBrightness = PTTBrightnessGetter(); |
|
if (getBrightness == NULL) { return false; } |
|
|
|
CGDirectDisplayID displays[16] = {0}; |
|
uint32_t count = 0; |
|
if (CGGetActiveDisplayList(16, displays, &count) != kCGErrorSuccess) { |
|
return false; |
|
} |
|
|
|
bool found = false; |
|
float lowest = 1.0f; |
|
for (uint32_t index = 0; index < count; index++) { |
|
CGDirectDisplayID display = displays[index]; |
|
if (!CGDisplayIsBuiltin(display)) { continue; } |
|
|
|
float brightness = 1.0f; |
|
if (getBrightness(display, &brightness) == 0 && |
|
isfinite(brightness) && brightness >= 0.0f && brightness <= 1.5f) { |
|
lowest = found ? fminf(lowest, brightness) : brightness; |
|
found = true; |
|
} |
|
} |
|
|
|
if (found) { *outBrightness = lowest; } |
|
return found; |
|
} |
|
|
|
bool PTTBuiltInDisplayIsAsleep(bool *outAsleep) { |
|
if (outAsleep == NULL) { return false; } |
|
|
|
// The online list deliberately includes sleeping displays, unlike the |
|
// active list used by the brightness probe above. |
|
CGDirectDisplayID displays[16] = {0}; |
|
uint32_t count = 0; |
|
if (CGGetOnlineDisplayList(16, displays, &count) != kCGErrorSuccess) { |
|
return false; |
|
} |
|
|
|
bool found = false; |
|
bool asleep = false; |
|
for (uint32_t index = 0; index < count; index++) { |
|
CGDirectDisplayID display = displays[index]; |
|
if (!CGDisplayIsBuiltin(display)) { continue; } |
|
found = true; |
|
asleep = asleep || CGDisplayIsAsleep(display); |
|
} |
|
|
|
if (found) { *outAsleep = asleep; } |
|
return found; |
|
} |
|
|
|
bool PTTNightShiftRestore(PTTNightShiftState state) { |
|
CBBlueLightClient *client = PTTClient(); |
|
if (client == nil) { return false; } |
|
|
|
BOOL valueRestored = YES; |
|
if ([client respondsToSelector:@selector(setStrength:withPeriod:commit:)]) { |
|
valueRestored = [client setStrength:PTTClamp(state.strength, 0.0f, 1.0f) |
|
withPeriod:0.35f |
|
commit:YES]; |
|
} else if (state.cct >= 1000.0f && |
|
[client respondsToSelector:@selector(setCCT:withPeriod:commit:)]) { |
|
valueRestored = [client setCCT:state.cct withPeriod:0.35f commit:YES]; |
|
} |
|
|
|
if ([client respondsToSelector:@selector(setMode:)]) { |
|
[client setMode:state.mode]; |
|
} |
|
BOOL enabledRestored = [client setEnabled:state.enabled ? YES : NO]; |
|
if ([client respondsToSelector:@selector(setActive:)]) { |
|
[client setActive:state.active ? YES : NO]; |
|
} |
|
return valueRestored && enabledRestored; |
|
} |
|
|
|
#pragma mark - Read-only macOS camera AWB runtime probe |
|
|
|
typedef struct { |
|
float redGain; |
|
float greenGain; |
|
float blueGain; |
|
} PTTWhiteBalanceGains; |
|
|
|
typedef struct { |
|
float temperature; |
|
float tint; |
|
} PTTTemperatureAndTint; |
|
|
|
bool PTTCameraReadWhiteBalance(AVCaptureDevice *device, float *outKelvin, float *outTint) { |
|
if (device == nil || outKelvin == NULL || outTint == NULL) { return false; } |
|
|
|
SEL gainsSelector = NSSelectorFromString(@"deviceWhiteBalanceGains"); |
|
if (![device respondsToSelector:gainsSelector]) { return false; } |
|
|
|
IMP gainsImplementation = [device methodForSelector:gainsSelector]; |
|
if (gainsImplementation == NULL) { return false; } |
|
PTTWhiteBalanceGains (*readGains)(id, SEL) = |
|
(PTTWhiteBalanceGains (*)(id, SEL))gainsImplementation; |
|
PTTWhiteBalanceGains gains = readGains(device, gainsSelector); |
|
|
|
if (!isfinite(gains.redGain) || !isfinite(gains.greenGain) || !isfinite(gains.blueGain) || |
|
gains.redGain <= 0.05f || gains.greenGain <= 0.05f || gains.blueGain <= 0.05f || |
|
gains.redGain > 32.0f || gains.greenGain > 32.0f || gains.blueGain > 32.0f) { |
|
return false; |
|
} |
|
|
|
SEL conversionSelector = NSSelectorFromString( |
|
@"temperatureAndTintValuesForDeviceWhiteBalanceGains:" |
|
); |
|
if ([device respondsToSelector:conversionSelector]) { |
|
IMP conversionImplementation = [device methodForSelector:conversionSelector]; |
|
if (conversionImplementation != NULL) { |
|
PTTTemperatureAndTint (*convert)(id, SEL, PTTWhiteBalanceGains) = |
|
(PTTTemperatureAndTint (*)(id, SEL, PTTWhiteBalanceGains))conversionImplementation; |
|
PTTTemperatureAndTint value = convert(device, conversionSelector, gains); |
|
if (isfinite(value.temperature) && value.temperature >= 1500.0f && |
|
value.temperature <= 15000.0f && isfinite(value.tint)) { |
|
*outKelvin = value.temperature; |
|
*outTint = PTTClamp(value.tint, -300.0f, 300.0f); |
|
return true; |
|
} |
|
} |
|
} |
|
|
|
// If the concrete camera exposes gains but not Apple's conversion helper, |
|
// use the correction-gain ratio as a conservative illuminant estimate. |
|
float blueToRed = gains.blueGain / gains.redGain; |
|
float greenToGeometricMean = gains.greenGain / sqrtf(gains.redGain * gains.blueGain); |
|
float kelvin = 6500.0f / powf(blueToRed, 1.05f); |
|
*outKelvin = PTTClamp(kelvin, 1800.0f, 10000.0f); |
|
*outTint = PTTClamp(logf(greenToGeometricMean) * 100.0f, -150.0f, 150.0f); |
|
return true; |
|
} |
|
OBJC |
|
|
|
cat > "$BUILD_DIR/main.swift" <<'SWIFT' |
|
import Foundation |
|
import AppKit |
|
@preconcurrency import AVFoundation |
|
import CoreMedia |
|
import CoreVideo |
|
import ServiceManagement |
|
import ApplicationServices |
|
import CoreGraphics |
|
|
|
private let appName = "Pseudo True Tone" |
|
private let samplingIntervalOptions: [TimeInterval] = [20.0, 60.0, 120.0, 300.0, 600.0] |
|
private let defaultSamplingInterval: TimeInterval = 120.0 |
|
private let minimumVisibleBrightness: Float = 0.001 |
|
private let wakeSettlingDelay: TimeInterval = 2.0 |
|
|
|
private final class AppLog { |
|
static let shared = AppLog() |
|
|
|
let url: URL |
|
private let queue = DispatchQueue(label: "local.pseudotruetone.log") |
|
private var enabled = false |
|
private let maximumBytes: UInt64 = 5 * 1024 * 1024 |
|
private let retainedBytes: UInt64 = 1024 * 1024 |
|
|
|
private init() { |
|
let logs = FileManager.default.homeDirectoryForCurrentUser |
|
.appendingPathComponent("Library/Logs", isDirectory: true) |
|
try? FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) |
|
url = logs.appendingPathComponent("PseudoTrueTone.log") |
|
} |
|
|
|
func setEnabled(_ value: Bool) { |
|
queue.sync { enabled = value } |
|
} |
|
|
|
func write(_ message: String) { |
|
queue.async { |
|
guard self.enabled else { return } |
|
let formatter = ISO8601DateFormatter() |
|
let line = "[\(formatter.string(from: Date()))] \(message)\n" |
|
guard let data = line.data(using: .utf8) else { return } |
|
if !FileManager.default.fileExists(atPath: self.url.path) { |
|
FileManager.default.createFile(atPath: self.url.path, contents: nil) |
|
} |
|
self.truncateIfNeeded() |
|
guard let handle = try? FileHandle(forWritingTo: self.url) else { return } |
|
defer { try? handle.close() } |
|
do { |
|
try handle.seekToEnd() |
|
try handle.write(contentsOf: data) |
|
} catch { |
|
// Logging must never interfere with display control. |
|
} |
|
} |
|
} |
|
|
|
private func truncateIfNeeded() { |
|
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), |
|
let size = (attributes[.size] as? NSNumber)?.uint64Value, |
|
size >= maximumBytes, |
|
let input = try? FileHandle(forReadingFrom: url) |
|
else { return } |
|
|
|
defer { try? input.close() } |
|
do { |
|
try input.seek(toOffset: size > retainedBytes ? size - retainedBytes : 0) |
|
let tail = try input.readToEnd() ?? Data() |
|
var replacement = Data("--- log truncated; newest entries retained ---\n".utf8) |
|
replacement.append(tail) |
|
try replacement.write(to: url, options: .atomic) |
|
} catch { |
|
// Log maintenance must never interfere with display control. |
|
} |
|
} |
|
} |
|
|
|
private struct AmbientReading { |
|
let kelvin: Double |
|
let tint: Double |
|
let spread: Double |
|
let brightness: Double |
|
let neutralFraction: Double |
|
let samples: Int |
|
let source: String |
|
|
|
var quality: String { |
|
if spread <= 180.0 && brightness >= 0.07 && brightness <= 0.92 { |
|
return neutralFraction >= 0.015 ? "good" : "good (colorful scene)" |
|
} |
|
if spread <= 500.0 && brightness >= 0.035 { |
|
return "fair" |
|
} |
|
return "rough" |
|
} |
|
|
|
var isColorfulScene: Bool { |
|
quality == "good (colorful scene)" |
|
} |
|
|
|
var menuQuality: String { |
|
isColorfulScene ? "good" : quality |
|
} |
|
} |
|
|
|
private struct SamplerFailure: LocalizedError { |
|
let message: String |
|
var errorDescription: String? { message } |
|
} |
|
|
|
private final class CameraSampler: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { |
|
private let sessionQueue = DispatchQueue(label: "local.pseudotruetone.camera.session") |
|
private let sampleQueue = DispatchQueue(label: "local.pseudotruetone.camera.frames") |
|
private let captureGateLock = NSLock() |
|
|
|
private var session: AVCaptureSession? |
|
private var camera: AVCaptureDevice? |
|
private var output: AVCaptureVideoDataOutput? |
|
private var captureAllowed = true |
|
|
|
// Accessed only on sessionQueue. |
|
private var currentID: UUID? |
|
private var completion: ((Result<AmbientReading, Error>) -> Void)? |
|
private var startedAt: TimeInterval = 0 |
|
private var lastAcceptedAt: TimeInterval = 0 |
|
private var temperatures: [Double] = [] |
|
private var tints: [Double] = [] |
|
private var brightnesses: [Double] = [] |
|
private var neutralFractions: [Double] = [] |
|
private var runtimeAWBSamples = 0 |
|
private var snapshotSamples = 0 |
|
|
|
func setCaptureAllowed(_ allowed: Bool) { |
|
captureGateLock.lock() |
|
captureAllowed = allowed |
|
captureGateLock.unlock() |
|
} |
|
|
|
private func isCaptureAllowed() -> Bool { |
|
captureGateLock.lock() |
|
defer { captureGateLock.unlock() } |
|
return captureAllowed |
|
} |
|
|
|
private func captureMayStart() -> Bool { |
|
guard isCaptureAllowed() else { return false } |
|
var asleep = false |
|
if PTTBuiltInDisplayIsAsleep(&asleep), asleep { |
|
return false |
|
} |
|
return true |
|
} |
|
|
|
func takeReading(completion: @escaping (Result<AmbientReading, Error>) -> Void) { |
|
guard captureMayStart() else { |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure(message: "Sampling paused."))) |
|
} |
|
return |
|
} |
|
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) { |
|
case .authorized: |
|
sessionQueue.async { self.begin(completion: completion) } |
|
case .notDetermined: |
|
AVCaptureDevice.requestAccess(for: .video) { granted in |
|
if granted { |
|
self.sessionQueue.async { self.begin(completion: completion) } |
|
} else { |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure(message: "Camera access was denied."))) |
|
} |
|
} |
|
} |
|
case .denied: |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure( |
|
message: "Camera access is off. Enable it in Privacy & Security → Camera." |
|
))) |
|
} |
|
case .restricted: |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure(message: "Camera access is restricted on this Mac."))) |
|
} |
|
@unknown default: |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure(message: "Unknown camera authorization state."))) |
|
} |
|
} |
|
} |
|
|
|
func cancelCurrentReading(completion: (() -> Void)? = nil) { |
|
sessionQueue.async { [weak self] in |
|
guard let self else { |
|
DispatchQueue.main.async { completion?() } |
|
return |
|
} |
|
if let id = self.currentID { |
|
self.finish( |
|
id: id, |
|
result: .failure(SamplerFailure(message: "Sampling paused.")) |
|
) |
|
} |
|
DispatchQueue.main.async { completion?() } |
|
} |
|
} |
|
|
|
func cancelCurrentReadingAndWait() { |
|
sessionQueue.sync { |
|
guard let id = currentID else { return } |
|
finish( |
|
id: id, |
|
result: .failure(SamplerFailure(message: "Sampling paused.")) |
|
) |
|
} |
|
} |
|
|
|
private func begin(completion: @escaping (Result<AmbientReading, Error>) -> Void) { |
|
guard captureMayStart() else { |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure(message: "Sampling paused."))) |
|
} |
|
return |
|
} |
|
|
|
guard currentID == nil else { |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure(message: "A camera sample is already running."))) |
|
} |
|
return |
|
} |
|
|
|
do { |
|
try prepareSessionIfNeeded() |
|
} catch { |
|
DispatchQueue.main.async { completion(.failure(error)) } |
|
return |
|
} |
|
|
|
guard let session else { |
|
DispatchQueue.main.async { |
|
completion(.failure(SamplerFailure(message: "Could not create a camera session."))) |
|
} |
|
return |
|
} |
|
|
|
let id = UUID() |
|
currentID = id |
|
self.completion = completion |
|
startedAt = ProcessInfo.processInfo.systemUptime |
|
lastAcceptedAt = 0 |
|
temperatures.removeAll(keepingCapacity: true) |
|
tints.removeAll(keepingCapacity: true) |
|
brightnesses.removeAll(keepingCapacity: true) |
|
neutralFractions.removeAll(keepingCapacity: true) |
|
runtimeAWBSamples = 0 |
|
snapshotSamples = 0 |
|
|
|
// Recheck immediately before the synchronous AVFoundation start. This |
|
// closes the permission/configuration window where the display could |
|
// have powered down after the menu-layer preflight. |
|
guard captureMayStart() else { |
|
finish( |
|
id: id, |
|
result: .failure(SamplerFailure(message: "Sampling paused.")) |
|
) |
|
return |
|
} |
|
|
|
session.startRunning() |
|
guard session.isRunning else { |
|
finish(id: id, result: .failure(SamplerFailure( |
|
message: "The camera did not start; it may be unavailable or in use." |
|
))) |
|
return |
|
} |
|
|
|
sessionQueue.asyncAfter(deadline: .now() + 5.0) { [weak self] in |
|
guard let self, self.currentID == id else { return } |
|
if self.temperatures.count >= 3 { |
|
self.finishFromCollectedValues(id: id) |
|
} else { |
|
self.finish(id: id, result: .failure(SamplerFailure( |
|
message: "The camera did not produce a stable white-balance reading." |
|
))) |
|
} |
|
} |
|
} |
|
|
|
private func prepareSessionIfNeeded() throws { |
|
if session != nil { return } |
|
|
|
let discovery = AVCaptureDevice.DiscoverySession( |
|
deviceTypes: [.builtInWideAngleCamera], |
|
mediaType: .video, |
|
position: .unspecified |
|
) |
|
guard let device = discovery.devices.first ?? AVCaptureDevice.default(for: .video) else { |
|
throw SamplerFailure(message: "No video camera was found.") |
|
} |
|
|
|
let input = try AVCaptureDeviceInput(device: device) |
|
let videoOutput = AVCaptureVideoDataOutput() |
|
videoOutput.alwaysDiscardsLateVideoFrames = true |
|
videoOutput.videoSettings = [ |
|
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA) |
|
] |
|
videoOutput.setSampleBufferDelegate(self, queue: sampleQueue) |
|
|
|
let captureSession = AVCaptureSession() |
|
captureSession.beginConfiguration() |
|
if captureSession.canSetSessionPreset(.low) { |
|
captureSession.sessionPreset = .low |
|
} |
|
guard captureSession.canAddInput(input), captureSession.canAddOutput(videoOutput) else { |
|
captureSession.commitConfiguration() |
|
throw SamplerFailure(message: "The built-in camera cannot provide video frames.") |
|
} |
|
captureSession.addInput(input) |
|
captureSession.addOutput(videoOutput) |
|
captureSession.commitConfiguration() |
|
|
|
camera = device |
|
output = videoOutput |
|
session = captureSession |
|
AppLog.shared.write("Prepared camera: \(device.localizedName)") |
|
} |
|
|
|
func captureOutput( |
|
_ output: AVCaptureOutput, |
|
didOutput sampleBuffer: CMSampleBuffer, |
|
from connection: AVCaptureConnection |
|
) { |
|
guard let device = camera, |
|
let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) |
|
else { return } |
|
|
|
let stats = frameStats(pixelBuffer) |
|
var runtimeKelvin: Float = 0 |
|
var runtimeTint: Float = 0 |
|
let hasRuntimeAWB = PTTCameraReadWhiteBalance(device, &runtimeKelvin, &runtimeTint) |
|
let kelvin = hasRuntimeAWB ? Double(runtimeKelvin) : stats.kelvin |
|
let tint = hasRuntimeAWB ? Double(runtimeTint) : stats.tint |
|
guard kelvin.isFinite, tint.isFinite, |
|
kelvin >= 1_500.0, kelvin <= 15_000.0, |
|
abs(tint) <= 300.0 |
|
else { return } |
|
|
|
let uptime = ProcessInfo.processInfo.systemUptime |
|
let adjusting = device.isAdjustingWhiteBalance |
|
|
|
sessionQueue.async { [weak self] in |
|
self?.ingest( |
|
uptime: uptime, |
|
kelvin: kelvin, |
|
tint: tint, |
|
brightness: stats.brightness, |
|
neutralFraction: stats.neutralFraction, |
|
adjusting: adjusting, |
|
runtimeAWB: hasRuntimeAWB |
|
) |
|
} |
|
} |
|
|
|
private func ingest( |
|
uptime: TimeInterval, |
|
kelvin: Double, |
|
tint: Double, |
|
brightness: Double, |
|
neutralFraction: Double, |
|
adjusting: Bool, |
|
runtimeAWB: Bool |
|
) { |
|
guard let id = currentID else { return } |
|
let elapsed = uptime - startedAt |
|
guard elapsed >= (runtimeAWB ? 0.5 : 0.05) else { return } |
|
guard uptime - lastAcceptedAt >= 0.12 else { return } |
|
if runtimeAWB && adjusting && elapsed < 2.2 { return } |
|
|
|
lastAcceptedAt = uptime |
|
temperatures.append(kelvin) |
|
tints.append(tint) |
|
brightnesses.append(brightness) |
|
neutralFractions.append(neutralFraction) |
|
if runtimeAWB { |
|
runtimeAWBSamples += 1 |
|
} else { |
|
snapshotSamples += 1 |
|
} |
|
|
|
if temperatures.count >= 10 && elapsed >= 1.5 { |
|
finishFromCollectedValues(id: id) |
|
} else if elapsed >= 3.4 && temperatures.count >= 3 { |
|
finishFromCollectedValues(id: id) |
|
} |
|
} |
|
|
|
private func finishFromCollectedValues(id: UUID) { |
|
let temperature = median(temperatures) |
|
let tint = median(tints) |
|
let brightness = median(brightnesses) |
|
let neutral = median(neutralFractions) |
|
let deviations = temperatures.map { abs($0 - temperature) } |
|
let spread = median(deviations) |
|
let source = runtimeAWBSamples >= snapshotSamples ? "camera AWB" : "snapshot estimate" |
|
|
|
let reading = AmbientReading( |
|
kelvin: temperature, |
|
tint: tint, |
|
spread: spread, |
|
brightness: brightness, |
|
neutralFraction: neutral, |
|
samples: temperatures.count, |
|
source: source |
|
) |
|
finish(id: id, result: .success(reading)) |
|
} |
|
|
|
private func finish(id: UUID, result: Result<AmbientReading, Error>) { |
|
guard currentID == id else { return } |
|
if session?.isRunning == true { |
|
session?.stopRunning() |
|
} |
|
let callback = completion |
|
currentID = nil |
|
completion = nil |
|
DispatchQueue.main.async { callback?(result) } |
|
} |
|
|
|
private func median(_ values: [Double]) -> Double { |
|
guard !values.isEmpty else { return 0 } |
|
let sorted = values.sorted() |
|
let middle = sorted.count / 2 |
|
if sorted.count.isMultiple(of: 2) { |
|
return (sorted[middle - 1] + sorted[middle]) / 2.0 |
|
} |
|
return sorted[middle] |
|
} |
|
|
|
private func frameStats(_ buffer: CVPixelBuffer) -> ( |
|
brightness: Double, |
|
neutralFraction: Double, |
|
kelvin: Double, |
|
tint: Double |
|
) { |
|
guard CVPixelBufferGetPixelFormatType(buffer) == kCVPixelFormatType_32BGRA else { |
|
return (0.5, 0, 6500, 0) |
|
} |
|
CVPixelBufferLockBaseAddress(buffer, .readOnly) |
|
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } |
|
|
|
guard let base = CVPixelBufferGetBaseAddress(buffer) else { return (0.5, 0, 6500, 0) } |
|
let width = CVPixelBufferGetWidth(buffer) |
|
let height = CVPixelBufferGetHeight(buffer) |
|
let bytesPerRow = CVPixelBufferGetBytesPerRow(buffer) |
|
let bytes = base.assumingMemoryBound(to: UInt8.self) |
|
let step = max(1, min(width, height) / 48) |
|
|
|
var lumaSum = 0.0 |
|
var count = 0 |
|
var neutralCount = 0 |
|
var weightedRed = 0.0 |
|
var weightedGreen = 0.0 |
|
var weightedBlue = 0.0 |
|
var weightSum = 0.0 |
|
|
|
for y in Swift.stride(from: 0, to: height, by: step) { |
|
let row = bytes.advanced(by: y * bytesPerRow) |
|
for x in Swift.stride(from: 0, to: width, by: step) { |
|
let p = row.advanced(by: x * 4) |
|
let blue = Int(p[0]) |
|
let green = Int(p[1]) |
|
let red = Int(p[2]) |
|
let maximum = max(red, max(green, blue)) |
|
let minimum = min(red, min(green, blue)) |
|
let luma = 0.2126 * Double(red) + 0.7152 * Double(green) + 0.0722 * Double(blue) |
|
lumaSum += luma / 255.0 |
|
if luma >= 20.0 && luma <= 238.0 && maximum > 0 { |
|
let chroma = Double(maximum - minimum) / Double(maximum) |
|
if chroma <= 0.18 { neutralCount += 1 } |
|
|
|
// Favor bright, low-chroma surfaces without requiring a |
|
// perfectly gray card. A warm-lit white surface is still |
|
// accepted because the chroma ceiling is intentionally wide. |
|
if chroma <= 0.52 { |
|
let normalizedLuma = luma / 255.0 |
|
let weight = pow(normalizedLuma, 1.4) * pow(1.0 - chroma, 2.0) |
|
weightedRed += linearSRGB(Double(red) / 255.0) * weight |
|
weightedGreen += linearSRGB(Double(green) / 255.0) * weight |
|
weightedBlue += linearSRGB(Double(blue) / 255.0) * weight |
|
weightSum += weight |
|
} |
|
} |
|
count += 1 |
|
} |
|
} |
|
guard count > 0, weightSum > 0.0001 else { return (0.5, 0, 6500, 0) } |
|
|
|
let red = weightedRed / weightSum |
|
let green = weightedGreen / weightSum |
|
let blue = weightedBlue / weightSum |
|
|
|
// Linear sRGB D65 → CIE XYZ, then McCamy's CCT approximation. |
|
let xValue = 0.4124564 * red + 0.3575761 * green + 0.1804375 * blue |
|
let yValue = 0.2126729 * red + 0.7151522 * green + 0.0721750 * blue |
|
let zValue = 0.0193339 * red + 0.1191920 * green + 0.9503041 * blue |
|
let xyzSum = xValue + yValue + zValue |
|
var kelvin = 6500.0 |
|
if xyzSum > 0.0001 { |
|
let chromaticityX = xValue / xyzSum |
|
let chromaticityY = yValue / xyzSum |
|
let denominator = 0.1858 - chromaticityY |
|
if abs(denominator) > 0.00001 { |
|
let n = (chromaticityX - 0.3320) / denominator |
|
let estimate = 449.0 * pow(n, 3.0) + 3525.0 * pow(n, 2.0) + 6823.3 * n + 5520.33 |
|
if estimate.isFinite { |
|
kelvin = min(max(estimate, 1800.0), 10000.0) |
|
} |
|
} |
|
} |
|
|
|
let expectedGreen = sqrt(max(red * blue, 0.000001)) |
|
let tint = min(max(log(max(green, 0.000001) / expectedGreen) * 100.0, -150.0), 150.0) |
|
return ( |
|
lumaSum / Double(count), |
|
Double(neutralCount) / Double(count), |
|
kelvin, |
|
tint |
|
) |
|
} |
|
|
|
private func linearSRGB(_ value: Double) -> Double { |
|
if value <= 0.04045 { return value / 12.92 } |
|
return pow((value + 0.055) / 1.055, 2.4) |
|
} |
|
} |
|
|
|
@MainActor private var showedAccessibilityAlert = false |
|
|
|
@MainActor |
|
private func relaunchPseudoTrueTone() { |
|
let isApp = Bundle.main.bundlePath.hasSuffix(".app") |
|
let target = isApp ? Bundle.main.bundlePath : (Bundle.main.executablePath ?? Bundle.main.bundlePath) |
|
let process = Process() |
|
process.executableURL = URL(fileURLWithPath: "/bin/sh") |
|
process.arguments = [ |
|
"-c", |
|
isApp ? "sleep 0.5; /usr/bin/open \"$1\"" : "sleep 0.5; \"$1\" >/dev/null 2>&1 &", |
|
"sh", |
|
target |
|
] |
|
try? process.run() |
|
NSApp.terminate(nil) |
|
} |
|
|
|
@MainActor |
|
private func openAccessibilitySettings() { |
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { |
|
NSWorkspace.shared.open(url) |
|
} |
|
} |
|
|
|
@MainActor |
|
private func showAccessibilityAlert() { |
|
guard !showedAccessibilityAlert else { return } |
|
showedAccessibilityAlert = true |
|
NSApp.activate(ignoringOtherApps: true) |
|
|
|
let alert = NSAlert() |
|
alert.alertStyle = .warning |
|
alert.messageText = "Accessibility Permission Needed" |
|
alert.informativeText = "Give Accessibility permission in System Settings, then restart Pseudo True Tone. This permission is used only to detect fullscreen windows." |
|
alert.addButton(withTitle: "Open Settings") |
|
alert.addButton(withTitle: "Restart Pseudo True Tone") |
|
alert.addButton(withTitle: "Quit Pseudo True Tone") |
|
|
|
switch alert.runModal() { |
|
case .alertFirstButtonReturn: |
|
openAccessibilitySettings() |
|
let followup = NSAlert() |
|
followup.alertStyle = .informational |
|
followup.messageText = "Restart After Permission" |
|
followup.informativeText = "After enabling Accessibility permission, restart Pseudo True Tone." |
|
followup.addButton(withTitle: "Restart Pseudo True Tone") |
|
followup.addButton(withTitle: "Quit Pseudo True Tone") |
|
if followup.runModal() == .alertFirstButtonReturn { |
|
relaunchPseudoTrueTone() |
|
} else { |
|
NSApp.terminate(nil) |
|
} |
|
case .alertSecondButtonReturn: |
|
relaunchPseudoTrueTone() |
|
default: |
|
NSApp.terminate(nil) |
|
} |
|
} |
|
|
|
private func axCopy(_ element: AXUIElement, _ attribute: String) -> AnyObject? { |
|
var value: CFTypeRef? |
|
return AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success ? value : nil |
|
} |
|
|
|
private func axBool(_ element: AXUIElement, _ attribute: String) -> Bool { |
|
(axCopy(element, attribute) as? NSNumber)?.boolValue ?? false |
|
} |
|
|
|
private func axValue(_ element: AXUIElement, _ attribute: String, _ type: AXValueType) -> AXValue? { |
|
guard let value = axCopy(element, attribute) else { return nil } |
|
let axValue = value as! AXValue |
|
return AXValueGetType(axValue) == type ? axValue : nil |
|
} |
|
|
|
private func axRect(_ element: AXUIElement) -> CGRect? { |
|
guard let position = axValue(element, kAXPositionAttribute, .cgPoint), |
|
let dimensions = axValue(element, kAXSizeAttribute, .cgSize) |
|
else { return nil } |
|
|
|
var point = CGPoint.zero |
|
var size = CGSize.zero |
|
guard AXValueGetValue(position, .cgPoint, &point), |
|
AXValueGetValue(dimensions, .cgSize, &size) |
|
else { return nil } |
|
return CGRect(origin: point, size: size) |
|
} |
|
|
|
private func focusedOrMainWindow(_ app: AXUIElement) -> AXUIElement? { |
|
if let window = axCopy(app, kAXFocusedWindowAttribute) { return (window as! AXUIElement) } |
|
if let window = axCopy(app, kAXMainWindowAttribute) { return (window as! AXUIElement) } |
|
return nil |
|
} |
|
|
|
private func axWindows(_ app: AXUIElement) -> [AXUIElement] { |
|
guard let values = axCopy(app, kAXWindowsAttribute) as? [AnyObject] else { return [] } |
|
return values.map { $0 as! AXUIElement } |
|
} |
|
|
|
private func displayRects() -> [CGRect] { |
|
var count: UInt32 = 0 |
|
CGGetActiveDisplayList(0, nil, &count) |
|
var identifiers = [CGDirectDisplayID](repeating: 0, count: Int(count)) |
|
CGGetActiveDisplayList(count, &identifiers, &count) |
|
return identifiers.prefix(Int(count)).map { CGDisplayBounds($0) } |
|
} |
|
|
|
private func fillsDisplay(_ rect: CGRect, _ display: CGRect) -> Bool { |
|
let tolerance: CGFloat = 8 |
|
let intersection = rect.intersection(display) |
|
return intersection.width >= display.width - tolerance && |
|
intersection.height >= display.height - tolerance |
|
} |
|
|
|
private func windowLooksFullscreen(_ window: AXUIElement, _ displays: [CGRect]) -> Bool { |
|
if axBool(window, "AXFullScreen") { return true } |
|
guard let rect = axRect(window) else { return false } |
|
return displays.contains { fillsDisplay(rect, $0) } |
|
} |
|
|
|
private func cgFullscreenWindowVisible() -> Bool { |
|
guard let app = NSWorkspace.shared.frontmostApplication, |
|
app.bundleIdentifier != "com.apple.finder" |
|
else { return false } |
|
|
|
let pid = Int(app.processIdentifier) |
|
let selfPID = Int(ProcessInfo.processInfo.processIdentifier) |
|
let displays = displayRects() |
|
guard pid != selfPID, |
|
let windows = CGWindowListCopyWindowInfo( |
|
[.optionOnScreenOnly, .excludeDesktopElements], |
|
kCGNullWindowID |
|
) as? [[String: Any]] |
|
else { return false } |
|
|
|
for window in windows { |
|
guard let owner = (window[kCGWindowOwnerPID as String] as? NSNumber)?.intValue, |
|
owner == pid, |
|
let layer = (window[kCGWindowLayer as String] as? NSNumber)?.intValue, |
|
layer == 0, |
|
let alpha = (window[kCGWindowAlpha as String] as? NSNumber)?.doubleValue, |
|
alpha > 0, |
|
let bounds = window[kCGWindowBounds as String] as? NSDictionary, |
|
let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary) |
|
else { continue } |
|
if displays.contains(where: { fillsDisplay(rect, $0) }) { return true } |
|
} |
|
return false |
|
} |
|
|
|
private func hasFullscreenWindowVisible() -> Bool { |
|
guard NSWorkspace.shared.frontmostApplication?.bundleIdentifier != "com.apple.finder" else { |
|
return false |
|
} |
|
let displays = displayRects() |
|
if AXIsProcessTrusted(), |
|
let app = NSWorkspace.shared.frontmostApplication, |
|
app.processIdentifier != ProcessInfo.processInfo.processIdentifier { |
|
let axApp = AXUIElementCreateApplication(app.processIdentifier) |
|
var windows = axWindows(axApp) |
|
if let window = focusedOrMainWindow(axApp) { windows.insert(window, at: 0) } |
|
for window in windows where windowLooksFullscreen(window, displays) { return true } |
|
} |
|
return cgFullscreenWindowVisible() |
|
} |
|
|
|
private func fallbackHalfMoonMenuImage() -> NSImage { |
|
let image = NSImage(size: NSSize(width: 18, height: 18)) |
|
image.lockFocus() |
|
NSColor.black.setFill() |
|
let center = NSPoint(x: 9, y: 9) |
|
let radius: CGFloat = 7 |
|
let halfMoon = NSBezierPath() |
|
halfMoon.move(to: NSPoint(x: center.x, y: center.y + radius)) |
|
halfMoon.appendArc( |
|
withCenter: center, |
|
radius: radius, |
|
startAngle: 90, |
|
endAngle: -90, |
|
clockwise: true |
|
) |
|
halfMoon.close() |
|
halfMoon.fill() |
|
image.unlockFocus() |
|
image.isTemplate = true |
|
return image |
|
} |
|
|
|
private func axCallback( |
|
_ observer: AXObserver, |
|
_ element: AXUIElement, |
|
_ notification: CFString, |
|
_ refcon: UnsafeMutableRawPointer? |
|
) { |
|
guard let refcon else { return } |
|
let app = Unmanaged<AppDelegate>.fromOpaque(refcon).takeUnretainedValue() |
|
DispatchQueue.main.async { app.axEvent() } |
|
} |
|
|
|
private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { |
|
private let defaults = UserDefaults.standard |
|
private let sampler = CameraSampler() |
|
private let savedNightShiftKey = "savedNightShiftState" |
|
private let nightShiftDirtyKey = "nightShiftControlWasActive" |
|
|
|
private var statusItem: NSStatusItem! |
|
private var menu: NSMenu! |
|
private var enabledItem: NSMenuItem! |
|
private var samplingPauseItem: NSMenuItem! |
|
private var fullscreenPauseItem: NSMenuItem! |
|
private var loggingItem: NSMenuItem! |
|
private var statusLineItem: NSMenuItem! |
|
private var ambientLineItem: NSMenuItem! |
|
private var sourceLineItem: NSMenuItem! |
|
private var targetLineItem: NSMenuItem! |
|
private var sampledLineItem: NSMenuItem! |
|
private var sampleNowItem: NSMenuItem! |
|
private var launchItem: NSMenuItem! |
|
private var samplingIntervalControl: NSSegmentedControl! |
|
private var samplingIntervalTitleLabel: NSTextField! |
|
private var responseControl: NSSegmentedControl! |
|
private var responseTitleLabel: NSTextField! |
|
private var offsetSlider: NSSlider! |
|
private var offsetSliderValue: NSTextField! |
|
private var outputSlider: NSSlider! |
|
private var outputSliderValue: NSTextField! |
|
|
|
private var timer: Timer? |
|
private var adaptiveEnabled = true |
|
private var samplingPaused = false |
|
private var pauseInFullscreen = false |
|
private var fullscreenActive = false |
|
private var loggingEnabled = false |
|
private var samplingInterval = defaultSamplingInterval |
|
private var response = 0.85 |
|
private var warmthOffset = 0.0 |
|
private var actualOutputStrength = 0.0 |
|
private var sampling = false |
|
private var brightnessBlocked = false |
|
private var displaySleeping = false |
|
private var systemSleeping = false |
|
private var displayProbeFailureLogged = false |
|
private var cancellationStatus: String? |
|
private var nightShiftAvailable = false |
|
private var originalState: PTTNightShiftState? |
|
private var smoothedMired: Double? |
|
private var lastAppliedCCT: Double? |
|
private var lastReading: AmbientReading? |
|
private var lastSampleDate: Date? |
|
private var statusText = "Starting…" |
|
private var hadError = false |
|
private var fullscreenDebounce: DispatchWorkItem? |
|
private var wakeGeneration = 0 |
|
private var wakeSettling = false |
|
private var axObserver: AXObserver? |
|
private var axApp: AXUIElement? |
|
private var axObservedWindows: [AXUIElement] = [] |
|
private var offsetSnapAnimationID = 0 |
|
private var offsetSnapAnimating = false |
|
|
|
func applicationWillFinishLaunching(_ notification: Notification) { |
|
_ = NSApp.setActivationPolicy(.accessory) |
|
} |
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) { |
|
if defaults.object(forKey: "adaptiveEnabled") != nil { |
|
adaptiveEnabled = defaults.bool(forKey: "adaptiveEnabled") |
|
} |
|
if let stored = defaults.object(forKey: "response") as? Double, |
|
[0.70, 0.85, 1.00].contains(where: { abs($0 - stored) < 0.001 }) { |
|
response = stored |
|
} |
|
if let stored = defaults.object(forKey: "samplingInterval") as? Double, |
|
samplingIntervalOptions.contains(where: { abs($0 - stored) < 0.001 }) { |
|
samplingInterval = stored |
|
} |
|
samplingPaused = defaults.bool(forKey: "samplingPaused") |
|
pauseInFullscreen = defaults.bool(forKey: "pauseInFullscreen") |
|
loggingEnabled = defaults.bool(forKey: "loggingEnabled") |
|
if let storedOffset = defaults.object(forKey: "warmthOffset") as? Double { |
|
warmthOffset = min(max(storedOffset, -0.35), 0.35) |
|
} |
|
AppLog.shared.setEnabled(loggingEnabled) |
|
|
|
buildMenu() |
|
|
|
if let asleep = builtInDisplaySleepState() { |
|
displaySleeping = asleep |
|
sampler.setCaptureAllowed(!asleep) |
|
} else { |
|
sampler.setCaptureAllowed(true) |
|
} |
|
|
|
var state = PTTNightShiftState() |
|
nightShiftAvailable = PTTNightShiftGetState(&state) |
|
let recoveryResult = nightShiftAvailable ? recoverInterruptedSessionIfNeeded() : nil |
|
if !nightShiftAvailable { |
|
statusText = "Night Shift private API unavailable" |
|
hadError = true |
|
} else if recoveryResult == false { |
|
adaptiveEnabled = false |
|
defaults.set(false, forKey: "adaptiveEnabled") |
|
statusText = "Could not recover Night Shift; paused" |
|
hadError = true |
|
} else if recoveryResult == true { |
|
if !adaptiveEnabled { |
|
statusText = "Recovered previous Night Shift state; stopped" |
|
} else if samplingPaused { |
|
statusText = "Recovered previous Night Shift state; camera paused" |
|
} else { |
|
statusText = "Recovered previous Night Shift state; waiting…" |
|
} |
|
} else if !adaptiveEnabled { |
|
statusText = "Stopped" |
|
} else if samplingPaused { |
|
statusText = "Camera sampling paused" |
|
} else if adaptiveEnabled { |
|
statusText = "Waiting for first camera sample…" |
|
} else { |
|
statusText = "Paused" |
|
} |
|
if displaySleeping { |
|
statusText = "Display off • camera stopped" |
|
} |
|
updateUI() |
|
|
|
scheduleSamplingTimer() |
|
|
|
let workspaceCenter = NSWorkspace.shared.notificationCenter |
|
workspaceCenter.addObserver( |
|
self, |
|
selector: #selector(didWake(_:)), |
|
name: NSWorkspace.didWakeNotification, |
|
object: nil |
|
) |
|
workspaceCenter.addObserver( |
|
self, |
|
selector: #selector(willSleep(_:)), |
|
name: NSWorkspace.willSleepNotification, |
|
object: nil |
|
) |
|
workspaceCenter.addObserver( |
|
self, |
|
selector: #selector(screensDidSleep(_:)), |
|
name: NSWorkspace.screensDidSleepNotification, |
|
object: nil |
|
) |
|
workspaceCenter.addObserver( |
|
self, |
|
selector: #selector(screensDidWake(_:)), |
|
name: NSWorkspace.screensDidWakeNotification, |
|
object: nil |
|
) |
|
workspaceCenter.addObserver( |
|
self, |
|
selector: #selector(frontAppChanged(_:)), |
|
name: NSWorkspace.activeSpaceDidChangeNotification, |
|
object: nil |
|
) |
|
workspaceCenter.addObserver( |
|
self, |
|
selector: #selector(frontAppChanged(_:)), |
|
name: NSWorkspace.didActivateApplicationNotification, |
|
object: nil |
|
) |
|
NotificationCenter.default.addObserver( |
|
self, |
|
selector: #selector(frontAppChanged(_:)), |
|
name: NSApplication.didChangeScreenParametersNotification, |
|
object: nil |
|
) |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
guard let self else { return } |
|
self.setupAXObserver() |
|
self.refreshFullscreenState(resampleOnResume: false) |
|
} |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in |
|
guard let self else { return } |
|
self.showWelcomeIfNeeded() |
|
self.requestSample(manual: false) |
|
} |
|
AppLog.shared.write("Application started; interval=\(Int(samplingInterval))s response=\(response)") |
|
} |
|
|
|
func applicationWillTerminate(_ notification: Notification) { |
|
timer?.invalidate() |
|
fullscreenDebounce?.cancel() |
|
sampler.setCaptureAllowed(false) |
|
sampler.cancelCurrentReadingAndWait() |
|
cleanupAXObserver() |
|
restoreOriginalNightShift(updateStatus: false) |
|
AppLog.shared.write("Application terminated") |
|
} |
|
|
|
private func scheduleSamplingTimer() { |
|
timer?.invalidate() |
|
let repeating = Timer(timeInterval: samplingInterval, repeats: true) { [weak self] _ in |
|
self?.requestSample(manual: false) |
|
} |
|
timer = repeating |
|
RunLoop.main.add(repeating, forMode: .common) |
|
} |
|
|
|
private func buildMenu() { |
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) |
|
statusItem.button?.toolTip = appName |
|
|
|
menu = NSMenu() |
|
menu.delegate = self |
|
|
|
enabledItem = NSMenuItem(title: "Adaptive warmth enabled", action: #selector(toggleEnabled), keyEquivalent: "") |
|
enabledItem.target = self |
|
menu.addItem(enabledItem) |
|
|
|
samplingPauseItem = NSMenuItem( |
|
title: "Pause camera sampling", |
|
action: #selector(toggleSamplingPause), |
|
keyEquivalent: "" |
|
) |
|
samplingPauseItem.target = self |
|
menu.addItem(samplingPauseItem) |
|
|
|
fullscreenPauseItem = NSMenuItem( |
|
title: "Pause sampling in fullscreen", |
|
action: #selector(toggleFullscreenPause), |
|
keyEquivalent: "" |
|
) |
|
fullscreenPauseItem.target = self |
|
menu.addItem(fullscreenPauseItem) |
|
menu.addItem(.separator()) |
|
|
|
statusLineItem = informationItem("Status: Starting…") |
|
ambientLineItem = informationItem("Ambient: —") |
|
sourceLineItem = informationItem("Estimator: —") |
|
targetLineItem = informationItem("Display target: —") |
|
sampledLineItem = informationItem("Last sample: —") |
|
menu.addItem(statusLineItem) |
|
menu.addItem(ambientLineItem) |
|
menu.addItem(sourceLineItem) |
|
menu.addItem(targetLineItem) |
|
menu.addItem(sampledLineItem) |
|
menu.addItem(.separator()) |
|
|
|
let offsetRow = sliderRow( |
|
title: "Warmth offset", |
|
minimum: -0.35, |
|
maximum: 0.35, |
|
value: warmthOffset, |
|
enabled: true, |
|
action: #selector(offsetChanged(_:)), |
|
showsZeroMarker: true |
|
) |
|
offsetSlider = offsetRow.slider |
|
offsetSliderValue = offsetRow.valueLabel |
|
offsetSlider.isContinuous = true |
|
_ = offsetSlider.sendAction(on: [.leftMouseDragged, .leftMouseUp]) |
|
menu.addItem(offsetRow.item) |
|
|
|
let outputRow = sliderRow( |
|
title: "Actual Night Shift output", |
|
minimum: 0, |
|
maximum: 1, |
|
value: 0, |
|
enabled: true, |
|
action: #selector(outputChanged(_:)), |
|
showsZeroMarker: false |
|
) |
|
outputSlider = outputRow.slider |
|
outputSliderValue = outputRow.valueLabel |
|
outputSlider.isContinuous = true |
|
_ = outputSlider.sendAction(on: [.leftMouseDragged, .leftMouseUp]) |
|
menu.addItem(outputRow.item) |
|
menu.addItem(.separator()) |
|
|
|
sampleNowItem = NSMenuItem(title: "Sample camera now", action: #selector(sampleNow), keyEquivalent: "r") |
|
sampleNowItem.target = self |
|
menu.addItem(sampleNowItem) |
|
menu.addItem(.separator()) |
|
|
|
let samplingIntervalRow = samplingIntervalSelectorRow() |
|
samplingIntervalControl = samplingIntervalRow.control |
|
samplingIntervalTitleLabel = samplingIntervalRow.titleLabel |
|
menu.addItem(samplingIntervalRow.item) |
|
menu.addItem(.separator()) |
|
|
|
let responseRow = responseSelectorRow() |
|
responseControl = responseRow.control |
|
responseTitleLabel = responseRow.titleLabel |
|
menu.addItem(responseRow.item) |
|
menu.addItem(.separator()) |
|
|
|
loggingItem = NSMenuItem(title: "Enable logging", action: #selector(toggleLogging), keyEquivalent: "") |
|
loggingItem.target = self |
|
menu.addItem(loggingItem) |
|
|
|
launchItem = NSMenuItem(title: "Launch at login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") |
|
launchItem.target = self |
|
menu.addItem(launchItem) |
|
menu.addItem(.separator()) |
|
|
|
let privacy = NSMenuItem(title: "Camera privacy settings…", action: #selector(openCameraSettings), keyEquivalent: "") |
|
privacy.target = self |
|
menu.addItem(privacy) |
|
|
|
let log = NSMenuItem(title: "Reveal log", action: #selector(revealLog), keyEquivalent: "") |
|
log.target = self |
|
menu.addItem(log) |
|
|
|
let about = NSMenuItem(title: "About Pseudo True Tone…", action: #selector(showAbout), keyEquivalent: "") |
|
about.target = self |
|
menu.addItem(about) |
|
menu.addItem(.separator()) |
|
|
|
let quit = NSMenuItem(title: "Quit Pseudo True Tone", action: #selector(quit), keyEquivalent: "q") |
|
quit.target = self |
|
menu.addItem(quit) |
|
|
|
statusItem.menu = menu |
|
} |
|
|
|
private func informationItem(_ title: String) -> NSMenuItem { |
|
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") |
|
item.isEnabled = false |
|
return item |
|
} |
|
|
|
private func sliderRow( |
|
title: String, |
|
minimum: Double, |
|
maximum: Double, |
|
value: Double, |
|
enabled: Bool, |
|
action: Selector?, |
|
showsZeroMarker: Bool |
|
) -> (item: NSMenuItem, slider: NSSlider, valueLabel: NSTextField) { |
|
let item = NSMenuItem() |
|
let view = NSView(frame: NSRect(x: 0, y: 0, width: 390, height: 64)) |
|
let titleLabel = NSTextField(labelWithString: title) |
|
let valueLabel = NSTextField(labelWithString: "—") |
|
let slider = NSSlider(value: value, minValue: minimum, maxValue: maximum, target: self, action: action) |
|
|
|
titleLabel.font = NSFont.menuFont(ofSize: 0) |
|
valueLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) |
|
valueLabel.textColor = .secondaryLabelColor |
|
valueLabel.alignment = .right |
|
slider.isEnabled = enabled |
|
|
|
for control in [titleLabel, valueLabel, slider] { |
|
control.translatesAutoresizingMaskIntoConstraints = false |
|
view.addSubview(control) |
|
} |
|
NSLayoutConstraint.activate([ |
|
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), |
|
titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 7), |
|
valueLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), |
|
valueLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), |
|
valueLabel.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8), |
|
slider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), |
|
slider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), |
|
slider.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 5) |
|
]) |
|
|
|
if showsZeroMarker { |
|
let marker = NSView() |
|
marker.translatesAutoresizingMaskIntoConstraints = false |
|
marker.wantsLayer = true |
|
marker.layer?.backgroundColor = NSColor.tertiaryLabelColor.cgColor |
|
view.addSubview(marker) |
|
NSLayoutConstraint.activate([ |
|
marker.centerXAnchor.constraint(equalTo: slider.centerXAnchor), |
|
marker.topAnchor.constraint(equalTo: slider.centerYAnchor, constant: 7), |
|
marker.widthAnchor.constraint(equalToConstant: 1), |
|
marker.heightAnchor.constraint(equalToConstant: 6) |
|
]) |
|
} |
|
item.view = view |
|
return (item, slider, valueLabel) |
|
} |
|
|
|
private func samplingIntervalSegmentIndex() -> Int { |
|
samplingIntervalOptions.enumerated().min { |
|
abs($0.element - samplingInterval) < abs($1.element - samplingInterval) |
|
}?.offset ?? 2 |
|
} |
|
|
|
private func samplingIntervalDisplayText(_ interval: TimeInterval) -> String { |
|
if interval < 60 { |
|
return "\(Int(interval)) sec" |
|
} |
|
return "\(Int(interval / 60)) min" |
|
} |
|
|
|
private func samplingIntervalTitleText() -> String { |
|
let pausedSuffix = samplingPaused ? " (paused)" : "" |
|
return "Sampling interval: \(samplingIntervalDisplayText(samplingInterval))\(pausedSuffix)" |
|
} |
|
|
|
private func samplingIntervalSelectorRow() -> ( |
|
item: NSMenuItem, |
|
control: NSSegmentedControl, |
|
titleLabel: NSTextField |
|
) { |
|
let item = NSMenuItem() |
|
let view = NSView(frame: NSRect(x: 0, y: 0, width: 390, height: 64)) |
|
let titleLabel = NSTextField(labelWithString: samplingIntervalTitleText()) |
|
let control = NSSegmentedControl(frame: .zero) |
|
|
|
titleLabel.font = NSFont.menuFont(ofSize: 0) |
|
control.segmentCount = samplingIntervalOptions.count |
|
control.trackingMode = .selectOne |
|
control.segmentStyle = .rounded |
|
let titles = ["20s", "1m", "2m", "5m", "10m"] |
|
let toolTips = [ |
|
"Every 20 seconds", |
|
"Every 1 minute", |
|
"Every 2 minutes", |
|
"Every 5 minutes", |
|
"Every 10 minutes" |
|
] |
|
let segmentWidth: CGFloat = (390 - 32) / CGFloat(samplingIntervalOptions.count) |
|
for segment in samplingIntervalOptions.indices { |
|
control.setLabel(titles[segment], forSegment: segment) |
|
control.setWidth(segmentWidth, forSegment: segment) |
|
control.setToolTip(toolTips[segment], forSegment: segment) |
|
} |
|
control.selectedSegment = samplingIntervalSegmentIndex() |
|
control.target = self |
|
control.action = #selector(samplingIntervalChanged(_:)) |
|
|
|
for child in [titleLabel, control] { |
|
child.translatesAutoresizingMaskIntoConstraints = false |
|
view.addSubview(child) |
|
} |
|
NSLayoutConstraint.activate([ |
|
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), |
|
titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 7), |
|
control.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), |
|
control.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), |
|
control.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 5), |
|
control.heightAnchor.constraint(equalToConstant: 26) |
|
]) |
|
item.view = view |
|
return (item, control, titleLabel) |
|
} |
|
|
|
private func responseSegmentIndex() -> Int { |
|
if response >= 0.925 { return 2 } |
|
if response >= 0.775 { return 1 } |
|
return 0 |
|
} |
|
|
|
private func responseTitleText() -> String { |
|
switch responseSegmentIndex() { |
|
case 0: return "Response: Gentle (70%)" |
|
case 1: return "Response: Balanced (85%)" |
|
default: return "Response: Match ambient (100%)" |
|
} |
|
} |
|
|
|
private func responseSelectorRow() -> ( |
|
item: NSMenuItem, |
|
control: NSSegmentedControl, |
|
titleLabel: NSTextField |
|
) { |
|
let item = NSMenuItem() |
|
let view = NSView(frame: NSRect(x: 0, y: 0, width: 390, height: 64)) |
|
let titleLabel = NSTextField(labelWithString: responseTitleText()) |
|
let control = NSSegmentedControl(frame: .zero) |
|
|
|
titleLabel.font = NSFont.menuFont(ofSize: 0) |
|
control.segmentCount = 3 |
|
control.trackingMode = .selectOne |
|
control.segmentStyle = .rounded |
|
let titles = ["Gentle", "Balanced", "Match ambient"] |
|
let percentages = ["(70%)", "(85%)", "(100%)"] |
|
let segmentWidth: CGFloat = (390 - 32) / 3 |
|
for segment in 0..<3 { |
|
control.setLabel(titles[segment], forSegment: segment) |
|
control.setWidth(segmentWidth, forSegment: segment) |
|
control.setToolTip( |
|
"\(titles[segment]) \(percentages[segment])", |
|
forSegment: segment |
|
) |
|
} |
|
control.selectedSegment = responseSegmentIndex() |
|
control.target = self |
|
control.action = #selector(responseChanged(_:)) |
|
|
|
for child in [titleLabel, control] { |
|
child.translatesAutoresizingMaskIntoConstraints = false |
|
view.addSubview(child) |
|
} |
|
NSLayoutConstraint.activate([ |
|
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), |
|
titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 7), |
|
control.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), |
|
control.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), |
|
control.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 5), |
|
control.heightAnchor.constraint(equalToConstant: 26) |
|
]) |
|
item.view = view |
|
return (item, control, titleLabel) |
|
} |
|
|
|
private func cleanupAXObserver() { |
|
if let observer = axObserver { |
|
CFRunLoopRemoveSource( |
|
CFRunLoopGetMain(), |
|
AXObserverGetRunLoopSource(observer), |
|
.defaultMode |
|
) |
|
} |
|
axObserver = nil |
|
axApp = nil |
|
axObservedWindows = [] |
|
} |
|
|
|
private func addAX(_ observer: AXObserver, _ element: AXUIElement, _ name: String) { |
|
_ = AXObserverAddNotification( |
|
observer, |
|
element, |
|
name as CFString, |
|
Unmanaged.passUnretained(self).toOpaque() |
|
) |
|
} |
|
|
|
private func attachAXWindows() { |
|
guard let observer = axObserver, let app = axApp else { return } |
|
var windows = axWindows(app) |
|
if let window = focusedOrMainWindow(app) { windows.insert(window, at: 0) } |
|
axObservedWindows = windows |
|
for window in windows { |
|
addAX(observer, window, kAXMovedNotification) |
|
addAX(observer, window, kAXResizedNotification) |
|
addAX(observer, window, kAXUIElementDestroyedNotification) |
|
addAX(observer, window, "AXFullScreenChanged") |
|
} |
|
} |
|
|
|
private func setupAXObserver() { |
|
cleanupAXObserver() |
|
guard pauseInFullscreen else { return } |
|
|
|
let options = [ |
|
kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true |
|
] as CFDictionary |
|
guard AXIsProcessTrustedWithOptions(options) else { |
|
Task { await MainActor.run { showAccessibilityAlert() } } |
|
return |
|
} |
|
|
|
guard let app = NSWorkspace.shared.frontmostApplication, |
|
app.processIdentifier != ProcessInfo.processInfo.processIdentifier, |
|
app.bundleIdentifier != "com.apple.finder" |
|
else { return } |
|
|
|
var observer: AXObserver? |
|
guard AXObserverCreate(app.processIdentifier, axCallback, &observer) == .success, |
|
let observer |
|
else { return } |
|
|
|
axObserver = observer |
|
axApp = AXUIElementCreateApplication(app.processIdentifier) |
|
if let axApp { |
|
addAX(observer, axApp, kAXFocusedWindowChangedNotification) |
|
addAX(observer, axApp, kAXMainWindowChangedNotification) |
|
addAX(observer, axApp, kAXWindowCreatedNotification) |
|
} |
|
attachAXWindows() |
|
CFRunLoopAddSource( |
|
CFRunLoopGetMain(), |
|
AXObserverGetRunLoopSource(observer), |
|
.defaultMode |
|
) |
|
} |
|
|
|
private func refreshFullscreenState(resampleOnResume: Bool) { |
|
let wasActive = fullscreenActive |
|
fullscreenActive = pauseInFullscreen && hasFullscreenWindowVisible() |
|
|
|
if fullscreenActive { |
|
if sampling { |
|
cancellationStatus = "Camera sampling paused for fullscreen; current output kept" |
|
sampler.cancelCurrentReading() |
|
} |
|
if adaptiveEnabled { |
|
statusText = "Camera sampling paused for fullscreen; current output kept" |
|
} |
|
} else if wasActive && resampleOnResume && adaptiveEnabled && !samplingPaused { |
|
statusText = "Fullscreen ended; sampling resumed" |
|
DispatchQueue.main.async { [weak self] in |
|
self?.requestSample(manual: false) |
|
} |
|
} |
|
} |
|
|
|
private func applyAfterFullscreenDebounce() { |
|
fullscreenDebounce?.cancel() |
|
let work = DispatchWorkItem { [weak self] in |
|
guard let self else { return } |
|
self.fullscreenDebounce = nil |
|
self.setupAXObserver() |
|
self.attachAXWindows() |
|
self.refreshFullscreenState(resampleOnResume: true) |
|
self.updateUI() |
|
} |
|
fullscreenDebounce = work |
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: work) |
|
} |
|
|
|
@objc private func frontAppChanged(_ notification: Notification) { |
|
setupAXObserver() |
|
attachAXWindows() |
|
refreshFullscreenState(resampleOnResume: true) |
|
updateUI() |
|
applyAfterFullscreenDebounce() |
|
} |
|
|
|
func axEvent() { |
|
attachAXWindows() |
|
refreshFullscreenState(resampleOnResume: true) |
|
updateUI() |
|
applyAfterFullscreenDebounce() |
|
} |
|
|
|
func menuWillOpen(_ menu: NSMenu) { |
|
if !captureBlockedByDisplayPower(updateStatus: true) { |
|
_ = captureBlockedByMinimumBrightness(updateStatus: true) |
|
} |
|
refreshFullscreenState(resampleOnResume: false) |
|
syncNightShiftOutput() |
|
updateUI() |
|
} |
|
|
|
private func builtInDisplaySleepState() -> Bool? { |
|
var asleep = false |
|
guard PTTBuiltInDisplayIsAsleep(&asleep) else { return nil } |
|
return asleep |
|
} |
|
|
|
@discardableResult |
|
private func captureBlockedByDisplayPower(updateStatus: Bool) -> Bool { |
|
let probedState = builtInDisplaySleepState() |
|
if probedState != nil { |
|
displayProbeFailureLogged = false |
|
} else if !displayProbeFailureLogged { |
|
displayProbeFailureLogged = true |
|
AppLog.shared.write( |
|
"Built-in display sleep-state probe unavailable; using workspace notifications" |
|
) |
|
} |
|
|
|
if probedState == true { |
|
if !displaySleeping || wakeSettling { |
|
suspendCameraForPowerState( |
|
reason: "Display power-state fallback", |
|
systemSleep: false, |
|
waitForStop: false |
|
) |
|
} |
|
} else if probedState == false, |
|
displaySleeping, |
|
!systemSleeping, |
|
!wakeSettling { |
|
// Recover even if a screen-wake notification was missed. |
|
beginWakeSettling(reason: "Display power-state fallback") |
|
} |
|
|
|
let blocked = systemSleeping || displaySleeping || wakeSettling || probedState == true |
|
if blocked && updateStatus { |
|
if systemSleeping { |
|
statusText = "Mac sleeping • camera stopped" |
|
} else if displaySleeping || probedState == true { |
|
statusText = "Display off • camera stopped" |
|
} else { |
|
statusText = "Display wake settling • camera waiting" |
|
} |
|
} |
|
return blocked |
|
} |
|
|
|
private func suspendCameraForPowerState( |
|
reason: String, |
|
systemSleep: Bool, |
|
waitForStop: Bool |
|
) { |
|
wakeGeneration += 1 |
|
wakeSettling = false |
|
if systemSleep { |
|
systemSleeping = true |
|
} |
|
displaySleeping = true |
|
fullscreenDebounce?.cancel() |
|
cleanupAXObserver() |
|
|
|
sampler.setCaptureAllowed(false) |
|
cancellationStatus = sampling |
|
? (systemSleep |
|
? "Mac sleeping • camera sample cancelled" |
|
: "Display off • camera sample cancelled") |
|
: nil |
|
|
|
if waitForStop { |
|
sampler.cancelCurrentReadingAndWait() |
|
} else { |
|
sampler.cancelCurrentReading() |
|
} |
|
|
|
statusText = systemSleep |
|
? "Mac sleeping • camera stopped" |
|
: "Display off • camera stopped" |
|
AppLog.shared.write("\(reason); camera gate closed and active sample cancelled") |
|
updateUI() |
|
} |
|
|
|
private func beginWakeSettling(reason: String) { |
|
guard !systemSleeping else { |
|
statusText = "Mac waking • camera waiting" |
|
updateUI() |
|
return |
|
} |
|
|
|
wakeGeneration += 1 |
|
let generation = wakeGeneration |
|
displaySleeping = false |
|
wakeSettling = true |
|
sampler.setCaptureAllowed(false) |
|
smoothedMired = nil |
|
lastAppliedCCT = nil |
|
statusText = "Display wake settling • camera waiting" |
|
AppLog.shared.write("\(reason); waiting \(wakeSettlingDelay)s before camera can resume") |
|
updateUI() |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + wakeSettlingDelay) { [weak self] in |
|
guard let self, |
|
self.wakeGeneration == generation, |
|
!self.systemSleeping |
|
else { return } |
|
|
|
if self.builtInDisplaySleepState() == true { |
|
self.suspendCameraForPowerState( |
|
reason: "Display still asleep after wake-settling delay", |
|
systemSleep: false, |
|
waitForStop: false |
|
) |
|
return |
|
} |
|
|
|
self.sampler.cancelCurrentReading { [weak self] in |
|
guard let self, |
|
self.wakeGeneration == generation, |
|
!self.systemSleeping |
|
else { return } |
|
|
|
if self.builtInDisplaySleepState() == true { |
|
self.suspendCameraForPowerState( |
|
reason: "Display slept again while wake was settling", |
|
systemSleep: false, |
|
waitForStop: false |
|
) |
|
return |
|
} |
|
|
|
self.sampling = false |
|
self.cancellationStatus = nil |
|
self.displaySleeping = false |
|
self.wakeSettling = false |
|
self.sampler.setCaptureAllowed(true) |
|
self.setupAXObserver() |
|
self.refreshFullscreenState(resampleOnResume: false) |
|
|
|
if !self.adaptiveEnabled { |
|
self.statusText = "Stopped" |
|
} else if !self.nightShiftAvailable { |
|
self.statusText = "Night Shift private API unavailable" |
|
} else if self.samplingPaused { |
|
self.statusText = "Camera sampling paused" |
|
} else if self.fullscreenActive { |
|
self.statusText = "Camera sampling paused for fullscreen" |
|
} else { |
|
self.statusText = "Display awake • sampling resumed" |
|
self.requestSample(manual: false) |
|
} |
|
self.updateUI() |
|
} |
|
} |
|
} |
|
|
|
@discardableResult |
|
private func captureBlockedByMinimumBrightness(updateStatus: Bool) -> Bool { |
|
let wasBlocked = brightnessBlocked |
|
var brightness: Float = 1.0 |
|
guard PTTBuiltInDisplayGetBrightness(&brightness) else { |
|
brightnessBlocked = false |
|
if wasBlocked && updateStatus { |
|
statusText = adaptiveEnabled ? "Brightness read unavailable • capture allowed" : "Stopped" |
|
} |
|
return false |
|
} |
|
|
|
brightnessBlocked = brightness <= minimumVisibleBrightness |
|
if brightnessBlocked { |
|
if updateStatus { |
|
statusText = "Screen brightness at minimum • camera skipped" |
|
} |
|
if !wasBlocked { |
|
AppLog.shared.write(String(format: "Camera paused at built-in brightness %.4f", brightness)) |
|
} |
|
} else if wasBlocked { |
|
AppLog.shared.write(String(format: "Built-in brightness restored to %.4f", brightness)) |
|
if updateStatus { |
|
if !adaptiveEnabled { |
|
statusText = "Stopped" |
|
} else if samplingPaused { |
|
statusText = "Camera sampling paused" |
|
} else if fullscreenActive { |
|
statusText = "Camera sampling paused for fullscreen" |
|
} else { |
|
statusText = "Brightness restored • waiting to sample" |
|
} |
|
} |
|
} |
|
return brightnessBlocked |
|
} |
|
|
|
private func requestSample(manual: Bool) { |
|
if captureBlockedByDisplayPower(updateStatus: true) { |
|
updateUI() |
|
return |
|
} |
|
if captureBlockedByMinimumBrightness(updateStatus: true) { |
|
updateUI() |
|
return |
|
} |
|
if pauseInFullscreen && hasFullscreenWindowVisible() { |
|
fullscreenActive = true |
|
statusText = "Camera sampling paused for fullscreen" |
|
updateUI() |
|
return |
|
} |
|
if !manual { |
|
guard adaptiveEnabled, !samplingPaused else { return } |
|
} |
|
guard !sampling else { return } |
|
guard nightShiftAvailable || manual else { return } |
|
|
|
sampling = true |
|
hadError = false |
|
statusText = "Sampling camera…" |
|
updateUI() |
|
AppLog.shared.write("Camera sample started") |
|
|
|
sampler.takeReading { [weak self] result in |
|
guard let self else { return } |
|
self.sampling = false |
|
self.lastSampleDate = Date() |
|
switch result { |
|
case .success(let reading): |
|
self.lastReading = reading |
|
AppLog.shared.write( |
|
String( |
|
format: "AWB %.0fK tint %.1f MAD %.0f brightness %.3f neutral %.3f (%d values)", |
|
reading.kelvin, |
|
reading.tint, |
|
reading.spread, |
|
reading.brightness, |
|
reading.neutralFraction, |
|
reading.samples |
|
) + " source=\(reading.source)" |
|
) |
|
if self.adaptiveEnabled && self.nightShiftAvailable { |
|
self.apply(reading) |
|
} else { |
|
self.statusText = self.adaptiveEnabled ? "Measured; Night Shift API unavailable" : "Measured; paused" |
|
} |
|
case .failure(let error): |
|
if error.localizedDescription == "Sampling paused." { |
|
if !self.captureBlockedByDisplayPower(updateStatus: true) { |
|
self.statusText = self.cancellationStatus ?? ( |
|
self.fullscreenActive |
|
? "Camera sampling paused for fullscreen" |
|
: "Camera sampling paused" |
|
) |
|
} |
|
self.cancellationStatus = nil |
|
} else { |
|
self.statusText = error.localizedDescription |
|
self.hadError = true |
|
AppLog.shared.write("Camera sample failed: \(error.localizedDescription)") |
|
} |
|
} |
|
self.updateUI() |
|
} |
|
} |
|
|
|
private func apply( |
|
_ reading: AmbientReading, |
|
transitionSeconds: Float = 1.8, |
|
force: Bool = false |
|
) { |
|
var liveState = PTTNightShiftState() |
|
guard PTTNightShiftGetState(&liveState) else { |
|
nightShiftAvailable = false |
|
statusText = "Night Shift private API became unavailable" |
|
hadError = true |
|
return |
|
} |
|
if originalState == nil { |
|
originalState = liveState |
|
persistOriginalNightShift(liveState) |
|
AppLog.shared.write( |
|
String(format: "Saved Night Shift state enabled=%d active=%d strength=%.3f cct=%.0f", |
|
liveState.enabled ? 1 : 0, |
|
liveState.active ? 1 : 0, |
|
liveState.strength, |
|
liveState.cct) |
|
) |
|
} |
|
|
|
let minCCT = Double(liveState.minCCT >= 1_000 ? liveState.minCCT : 2_850) |
|
let maxCCT = Double(liveState.maxCCT > liveState.minCCT ? liveState.maxCCT : 6_500) |
|
let ambient = min(max(reading.kelvin, minCCT), maxCCT) |
|
let neutralMired = 1_000_000.0 / maxCCT |
|
let warmMired = 1_000_000.0 / minCCT |
|
let ambientMired = 1_000_000.0 / ambient |
|
let adaptiveMired = neutralMired + (ambientMired - neutralMired) * response |
|
let baseStrength = min( |
|
max((adaptiveMired - neutralMired) / (warmMired - neutralMired), 0), |
|
1 |
|
) |
|
let desiredStrength = min(max(baseStrength + warmthOffset, 0), 1) |
|
let desiredMired = neutralMired + desiredStrength * (warmMired - neutralMired) |
|
|
|
if let previous = smoothedMired { |
|
let alpha = reading.quality == "rough" ? 0.16 : 0.34 |
|
let candidate = previous + alpha * (desiredMired - previous) |
|
let maximumStep = 48.0 |
|
smoothedMired = previous + min(max(candidate - previous, -maximumStep), maximumStep) |
|
} else { |
|
smoothedMired = desiredMired |
|
} |
|
|
|
guard let mired = smoothedMired else { return } |
|
let target = min(max(1_000_000.0 / mired, minCCT), maxCCT) |
|
|
|
if !force, let previous = lastAppliedCCT, abs(previous - target) < 35.0 { |
|
statusText = "Stable • \(reading.menuQuality)" |
|
return |
|
} |
|
|
|
var strength: Float = 0 |
|
if PTTNightShiftApplyCCT(Float(target), transitionSeconds, &strength) { |
|
lastAppliedCCT = target |
|
actualOutputStrength = Double(strength) |
|
statusText = "Applied • \(reading.menuQuality)" |
|
hadError = false |
|
AppLog.shared.write(String(format: "Applied target %.0fK (Night Shift %.1f%%)", target, Double(strength) * 100.0)) |
|
} else { |
|
statusText = "Night Shift rejected the temperature update" |
|
hadError = true |
|
AppLog.shared.write("Night Shift update failed") |
|
} |
|
} |
|
|
|
private func persistOriginalNightShift(_ state: PTTNightShiftState) { |
|
let encoded: [String: Any] = [ |
|
"active": state.active, |
|
"enabled": state.enabled, |
|
"mode": NSNumber(value: state.mode), |
|
"strength": NSNumber(value: state.strength), |
|
"cct": NSNumber(value: state.cct), |
|
"minCCT": NSNumber(value: state.minCCT), |
|
"maxCCT": NSNumber(value: state.maxCCT), |
|
"midCCT": NSNumber(value: state.midCCT) |
|
] |
|
defaults.set(encoded, forKey: savedNightShiftKey) |
|
defaults.set(true, forKey: nightShiftDirtyKey) |
|
defaults.synchronize() |
|
AppLog.shared.write("Persisted pre-control Night Shift state for crash recovery") |
|
} |
|
|
|
// nil: no interrupted session; true: recovered; false: recovery failed. |
|
private func recoverInterruptedSessionIfNeeded() -> Bool? { |
|
guard defaults.bool(forKey: nightShiftDirtyKey), |
|
let encoded = defaults.dictionary(forKey: savedNightShiftKey) |
|
else { return nil } |
|
|
|
func number(_ key: String) -> NSNumber? { encoded[key] as? NSNumber } |
|
guard let active = number("active"), |
|
let enabled = number("enabled"), |
|
let mode = number("mode"), |
|
let strength = number("strength"), |
|
let cct = number("cct"), |
|
let minCCT = number("minCCT"), |
|
let maxCCT = number("maxCCT"), |
|
let midCCT = number("midCCT") |
|
else { |
|
AppLog.shared.write("Crash-recovery state was malformed; leaving marker intact") |
|
return false |
|
} |
|
|
|
var saved = PTTNightShiftState() |
|
saved.available = true |
|
saved.active = active.boolValue |
|
saved.enabled = enabled.boolValue |
|
saved.mode = mode.int32Value |
|
saved.strength = strength.floatValue |
|
saved.cct = cct.floatValue |
|
saved.minCCT = minCCT.floatValue |
|
saved.maxCCT = maxCCT.floatValue |
|
saved.midCCT = midCCT.floatValue |
|
|
|
let restored = PTTNightShiftRestore(saved) |
|
AppLog.shared.write("Crash-recovery Night Shift restore: \(restored)") |
|
if restored { clearPersistedNightShift() } |
|
return restored |
|
} |
|
|
|
private func clearPersistedNightShift() { |
|
defaults.removeObject(forKey: savedNightShiftKey) |
|
defaults.removeObject(forKey: nightShiftDirtyKey) |
|
defaults.synchronize() |
|
} |
|
|
|
private func restoreOriginalNightShift(updateStatus: Bool = true) { |
|
if let saved = originalState { |
|
let ok = PTTNightShiftRestore(saved) |
|
AppLog.shared.write("Restored original Night Shift state: \(ok)") |
|
if !ok { |
|
hadError = true |
|
if updateStatus { |
|
statusText = "Night Shift restore failed; saved for next launch" |
|
updateUI() |
|
} |
|
return |
|
} |
|
} |
|
clearPersistedNightShift() |
|
originalState = nil |
|
smoothedMired = nil |
|
lastAppliedCCT = nil |
|
if updateStatus { |
|
statusText = adaptiveEnabled ? "Waiting for camera sample…" : "Stopped; original Night Shift restored" |
|
syncNightShiftOutput() |
|
updateUI() |
|
} |
|
} |
|
|
|
private func syncNightShiftOutput() { |
|
var state = PTTNightShiftState() |
|
guard PTTNightShiftGetState(&state) else { return } |
|
actualOutputStrength = (state.active || state.enabled) ? Double(state.strength) : 0 |
|
} |
|
|
|
private func updateUI() { |
|
enabledItem?.state = adaptiveEnabled ? .on : .off |
|
samplingPauseItem?.state = samplingPaused ? .on : .off |
|
fullscreenPauseItem?.state = pauseInFullscreen ? .on : .off |
|
loggingItem?.state = loggingEnabled ? .on : .off |
|
sampleNowItem?.isEnabled = !sampling |
|
&& !systemSleeping |
|
&& !displaySleeping |
|
&& !wakeSettling |
|
&& !brightnessBlocked |
|
&& !(pauseInFullscreen && fullscreenActive) |
|
launchItem?.state = SMAppService.mainApp.status == .enabled ? .on : .off |
|
samplingIntervalControl?.selectedSegment = samplingIntervalSegmentIndex() |
|
samplingIntervalTitleLabel?.stringValue = samplingIntervalTitleText() |
|
responseControl?.selectedSegment = responseSegmentIndex() |
|
responseTitleLabel?.stringValue = responseTitleText() |
|
|
|
if !offsetSnapAnimating { |
|
offsetSlider?.doubleValue = warmthOffset |
|
} |
|
offsetSliderValue?.stringValue = abs(warmthOffset) < 0.000_001 |
|
? "0%" |
|
: String(format: "%+.0f%%", warmthOffset * 100.0) |
|
outputSlider?.doubleValue = min(max(actualOutputStrength, 0), 1) |
|
outputSliderValue?.stringValue = String(format: "%.0f%%", actualOutputStrength * 100.0) |
|
|
|
statusLineItem?.title = "Status: \(statusText)" |
|
if let reading = lastReading { |
|
ambientLineItem?.title = String( |
|
format: "Ambient: %.0f K • tint %+.0f", |
|
reading.kelvin, |
|
reading.tint |
|
) + " • \(reading.menuQuality)" |
|
let estimator = reading.source == "camera AWB" |
|
? "Estimator: Camera AWB" |
|
: "Estimator: Snapshot estimate" |
|
sourceLineItem?.title = estimator + (reading.isColorfulScene ? " • colorful scene" : "") |
|
} else { |
|
ambientLineItem?.title = "Ambient: —" |
|
sourceLineItem?.title = "Estimator: —" |
|
} |
|
if let target = lastAppliedCCT { |
|
targetLineItem?.title = String(format: "Display target: %.0f K", target) |
|
} else { |
|
targetLineItem?.title = "Display target: —" |
|
} |
|
if let date = lastSampleDate { |
|
let formatter = RelativeDateTimeFormatter() |
|
formatter.unitsStyle = .short |
|
sampledLineItem?.title = "Last sample: \(formatter.localizedString(for: date, relativeTo: Date()))" |
|
} else { |
|
sampledLineItem?.title = "Last sample: —" |
|
} |
|
|
|
let symbol: String |
|
if hadError { |
|
symbol = "exclamationmark.triangle.fill" |
|
} else if !adaptiveEnabled { |
|
symbol = "stop.circle.fill" |
|
} else if samplingPaused |
|
|| fullscreenActive |
|
|| brightnessBlocked |
|
|| systemSleeping |
|
|| displaySleeping |
|
|| wakeSettling { |
|
symbol = "pause.circle.fill" |
|
} else { |
|
symbol = "moonphase.last.quarter" |
|
} |
|
let menuImage = NSImage(systemSymbolName: symbol, accessibilityDescription: appName) |
|
?? (symbol == "moonphase.last.quarter" ? fallbackHalfMoonMenuImage() : nil) |
|
if let image = menuImage { |
|
image.isTemplate = true |
|
statusItem?.button?.image = image |
|
statusItem?.button?.title = "" |
|
} else { |
|
statusItem?.button?.image = nil |
|
statusItem?.button?.title = "PT" |
|
} |
|
statusItem?.button?.toolTip = "\(appName): \(statusText)" |
|
} |
|
|
|
private func showWelcomeIfNeeded() { |
|
guard !defaults.bool(forKey: "didShowWelcome") else { return } |
|
defaults.set(true, forKey: "didShowWelcome") |
|
|
|
NSApp.activate(ignoringOtherApps: true) |
|
let alert = NSAlert() |
|
alert.messageText = "Pseudo True Tone is ready" |
|
alert.informativeText = "At the selected interval (2 minutes by default), the app turns on the built-in camera briefly, makes a read-only color-temperature estimate, smooths it, and changes only Night Shift through CoreBrightness. It skips the camera while the built-in display is asleep or while its user-brightness slider is at zero. It never changes camera settings, and no image is saved or transmitted. The green camera indicator will appear for each reading.\n\nPause camera sampling keeps the current output. Stopping adaptive warmth or quitting restores the original Night Shift state. Logging is off by default." |
|
alert.addButton(withTitle: "Continue") |
|
alert.addButton(withTitle: "Quit") |
|
if alert.runModal() == .alertSecondButtonReturn { |
|
NSApp.terminate(nil) |
|
} |
|
} |
|
|
|
@objc private func toggleEnabled() { |
|
adaptiveEnabled.toggle() |
|
defaults.set(adaptiveEnabled, forKey: "adaptiveEnabled") |
|
if adaptiveEnabled { |
|
hadError = false |
|
if fullscreenActive { |
|
statusText = "Camera sampling paused for fullscreen" |
|
} else if samplingPaused { |
|
statusText = "Camera sampling paused" |
|
} else { |
|
statusText = "Waiting for camera sample…" |
|
requestSample(manual: false) |
|
} |
|
} else { |
|
cancellationStatus = "Stopped; original Night Shift restored" |
|
sampler.cancelCurrentReading() |
|
restoreOriginalNightShift() |
|
} |
|
updateUI() |
|
} |
|
|
|
@objc private func toggleSamplingPause() { |
|
samplingPaused.toggle() |
|
defaults.set(samplingPaused, forKey: "samplingPaused") |
|
if samplingPaused { |
|
cancellationStatus = "Camera sampling paused; current output kept" |
|
sampler.cancelCurrentReading() |
|
statusText = "Camera sampling paused; current output kept" |
|
} else if fullscreenActive { |
|
statusText = "Camera sampling paused for fullscreen" |
|
} else if adaptiveEnabled { |
|
statusText = "Camera sampling resumed" |
|
requestSample(manual: false) |
|
} else { |
|
statusText = "Stopped" |
|
} |
|
updateUI() |
|
} |
|
|
|
@objc private func toggleFullscreenPause() { |
|
pauseInFullscreen.toggle() |
|
defaults.set(pauseInFullscreen, forKey: "pauseInFullscreen") |
|
setupAXObserver() |
|
refreshFullscreenState(resampleOnResume: true) |
|
updateUI() |
|
} |
|
|
|
@objc private func toggleLogging() { |
|
if loggingEnabled { |
|
AppLog.shared.write("Logging disabled by user") |
|
loggingEnabled = false |
|
AppLog.shared.setEnabled(false) |
|
} else { |
|
loggingEnabled = true |
|
AppLog.shared.setEnabled(true) |
|
AppLog.shared.write("Logging enabled by user") |
|
} |
|
defaults.set(loggingEnabled, forKey: "loggingEnabled") |
|
updateUI() |
|
} |
|
|
|
@objc private func sampleNow() { |
|
requestSample(manual: true) |
|
} |
|
|
|
private func commitWarmthOffset(_ value: Double) { |
|
warmthOffset = min(max(value, -0.35), 0.35) |
|
defaults.set(warmthOffset, forKey: "warmthOffset") |
|
smoothedMired = nil |
|
lastAppliedCCT = nil |
|
if adaptiveEnabled, let reading = lastReading { |
|
apply(reading, transitionSeconds: 0, force: true) |
|
} |
|
} |
|
|
|
private func snapOffsetToZero(from start: Double) { |
|
offsetSnapAnimationID += 1 |
|
let animationID = offsetSnapAnimationID |
|
offsetSnapAnimating = true |
|
commitWarmthOffset(0) |
|
updateUI() |
|
|
|
let steps = 7 |
|
let duration = 0.12 |
|
for step in 1...steps { |
|
DispatchQueue.main.asyncAfter( |
|
deadline: .now() + duration * Double(step) / Double(steps) |
|
) { [weak self] in |
|
guard let self, self.offsetSnapAnimationID == animationID else { return } |
|
let progress = Double(step) / Double(steps) |
|
let remaining = pow(1.0 - progress, 3.0) |
|
self.offsetSlider.doubleValue = start * remaining |
|
if step == steps { |
|
self.offsetSnapAnimating = false |
|
self.offsetSlider.doubleValue = 0 |
|
self.updateUI() |
|
} |
|
} |
|
} |
|
} |
|
|
|
@objc private func offsetChanged(_ sender: NSSlider) { |
|
let rawValue = min(max(sender.doubleValue, -0.35), 0.35) |
|
if NSApp.currentEvent?.type == .leftMouseUp, |
|
abs(rawValue) <= 0.02, |
|
rawValue != 0 { |
|
snapOffsetToZero(from: rawValue) |
|
return |
|
} |
|
|
|
offsetSnapAnimationID += 1 |
|
offsetSnapAnimating = false |
|
commitWarmthOffset(abs(rawValue) < 0.000_001 ? 0 : rawValue) |
|
updateUI() |
|
} |
|
|
|
@objc private func outputChanged(_ sender: NSSlider) { |
|
let requested = min(max(sender.doubleValue, 0), 1) |
|
|
|
// Treat this exactly as a manual Night Shift takeover: stop adaptive |
|
// control without restoring the pre-control value, then apply the |
|
// user's requested output and forget our recovery snapshot. |
|
adaptiveEnabled = false |
|
defaults.set(false, forKey: "adaptiveEnabled") |
|
cancellationStatus = "Stopped • manual Night Shift output" |
|
sampler.cancelCurrentReading() |
|
originalState = nil |
|
clearPersistedNightShift() |
|
smoothedMired = nil |
|
lastAppliedCCT = nil |
|
|
|
let applied = PTTNightShiftApplyStrength(Float(requested), 0) |
|
actualOutputStrength = requested |
|
hadError = !applied |
|
statusText = applied |
|
? "Stopped • manual Night Shift output" |
|
: "Manual Night Shift adjustment failed" |
|
updateUI() |
|
} |
|
|
|
@objc private func samplingIntervalChanged(_ sender: NSSegmentedControl) { |
|
let index = sender.selectedSegment |
|
guard index >= 0, index < samplingIntervalOptions.count else { return } |
|
|
|
samplingInterval = samplingIntervalOptions[index] |
|
defaults.set(samplingInterval, forKey: "samplingInterval") |
|
scheduleSamplingTimer() |
|
AppLog.shared.write("Sampling interval changed to \(Int(samplingInterval))s") |
|
updateUI() |
|
} |
|
|
|
@objc private func responseChanged(_ sender: NSSegmentedControl) { |
|
let values = [0.70, 0.85, 1.00] |
|
guard sender.selectedSegment >= 0, sender.selectedSegment < values.count else { return } |
|
let value = values[sender.selectedSegment] |
|
response = value |
|
defaults.set(value, forKey: "response") |
|
smoothedMired = nil |
|
lastAppliedCCT = nil |
|
if adaptiveEnabled, let reading = lastReading { |
|
statusText = "Response changed" |
|
apply(reading, transitionSeconds: 0, force: true) |
|
} else { |
|
statusText = adaptiveEnabled ? "Response changed; waiting…" : "Stopped" |
|
} |
|
updateUI() |
|
} |
|
|
|
@objc private func toggleLaunchAtLogin() { |
|
do { |
|
if SMAppService.mainApp.status == .enabled { |
|
try SMAppService.mainApp.unregister() |
|
} else { |
|
try SMAppService.mainApp.register() |
|
} |
|
} catch { |
|
NSApp.activate(ignoringOtherApps: true) |
|
let alert = NSAlert(error: error) |
|
alert.messageText = "Could not change Launch at Login" |
|
alert.informativeText = "Move the app to /Applications and try again.\n\n\(error.localizedDescription)" |
|
alert.runModal() |
|
AppLog.shared.write("Launch-at-login error: \(error.localizedDescription)") |
|
} |
|
updateUI() |
|
} |
|
|
|
@objc private func openCameraSettings() { |
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") { |
|
NSWorkspace.shared.open(url) |
|
} |
|
} |
|
|
|
@objc private func revealLog() { |
|
if !FileManager.default.fileExists(atPath: AppLog.shared.url.path) { |
|
FileManager.default.createFile(atPath: AppLog.shared.url.path, contents: nil) |
|
} |
|
NSWorkspace.shared.activateFileViewerSelecting([AppLog.shared.url]) |
|
} |
|
|
|
@objc private func showAbout() { |
|
NSApp.activate(ignoringOtherApps: true) |
|
let alert = NSAlert() |
|
alert.messageText = "Pseudo True Tone 0.1.12" |
|
alert.informativeText = "Read-only camera estimate → smoothed ambient CCT → private CoreBrightness Night Shift control.\n\nSampling interval: selectable (20s, 1m, 2m, 5m, 10m)\nCamera while built-in display sleeps: blocked\nDisplay-state fallback: public CoreGraphics power-state probe\nCamera at minimum built-in brightness: skipped\nCamera before system sleep: explicitly cancelled\nWake settling delay: 2.0 seconds\nCamera settings changed: none\nImages stored or transmitted: none\nOther display settings changed: none\nLogging: opt-in, capped near 5 MB\n\nThis is an approximate comfort feature, not a calibrated colorimeter. Colored scenes, mixed lighting, darkness, camera auto-white-balance, and the display's own light can bias the estimate. Because the brightness and CoreBrightness calls are private, a future macOS update may break those checks." |
|
alert.addButton(withTitle: "OK") |
|
alert.runModal() |
|
} |
|
|
|
@objc private func willSleep(_ notification: Notification) { |
|
suspendCameraForPowerState( |
|
reason: "Workspace will sleep", |
|
systemSleep: true, |
|
waitForStop: true |
|
) |
|
} |
|
|
|
@objc private func didWake(_ notification: Notification) { |
|
systemSleeping = false |
|
if builtInDisplaySleepState() == true { |
|
displaySleeping = true |
|
wakeSettling = false |
|
sampler.setCaptureAllowed(false) |
|
statusText = "Display off • camera stopped" |
|
AppLog.shared.write("Workspace woke while built-in display remained asleep") |
|
updateUI() |
|
return |
|
} |
|
|
|
displaySleeping = false |
|
beginWakeSettling(reason: "Workspace did wake") |
|
} |
|
|
|
@objc private func screensDidSleep(_ notification: Notification) { |
|
suspendCameraForPowerState( |
|
reason: "Workspace screens did sleep", |
|
systemSleep: false, |
|
waitForStop: false |
|
) |
|
} |
|
|
|
@objc private func screensDidWake(_ notification: Notification) { |
|
displaySleeping = false |
|
if systemSleeping { |
|
statusText = "Mac waking • camera waiting" |
|
updateUI() |
|
return |
|
} |
|
beginWakeSettling(reason: "Workspace screens did wake") |
|
} |
|
|
|
@objc private func quit() { |
|
NSApp.terminate(nil) |
|
} |
|
} |
|
|
|
let application = NSApplication.shared |
|
private let applicationDelegate = AppDelegate() |
|
application.delegate = applicationDelegate |
|
_ = application.setActivationPolicy(.accessory) |
|
application.run() |
|
SWIFT |
|
|
|
cat > "$STAGED_APP/Contents/Info.plist" <<PLIST |
|
<?xml version="1.0" encoding="UTF-8"?> |
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
<plist version="1.0"> |
|
<dict> |
|
<key>CFBundleDisplayName</key> |
|
<string>${APP_NAME}</string> |
|
<key>CFBundleExecutable</key> |
|
<string>${APP_NAME}</string> |
|
<key>CFBundleIconFile</key> |
|
<string>AppIcon</string> |
|
<key>CFBundleInfoDictionaryVersion</key> |
|
<string>6.0</string> |
|
<key>CFBundleName</key> |
|
<string>${APP_NAME}</string> |
|
<key>CFBundlePackageType</key> |
|
<string>APPL</string> |
|
<key>CFBundleShortVersionString</key> |
|
<string>${VERSION}</string> |
|
<key>CFBundleVersion</key> |
|
<string>12</string> |
|
<key>LSMinimumSystemVersion</key> |
|
<string>13.0</string> |
|
<key>LSUIElement</key> |
|
<true/> |
|
<key>NSCameraUsageDescription</key> |
|
<string>Pseudo True Tone briefly samples the built-in camera at your selected interval to estimate ambient light color temperature. Images are never saved or transmitted.</string> |
|
<key>NSHighResolutionCapable</key> |
|
<true/> |
|
<key>NSPrincipalClass</key> |
|
<string>NSApplication</string> |
|
</dict> |
|
</plist> |
|
PLIST |
|
|
|
echo "Compiling Objective-C CoreBrightness bridge…" |
|
set +x |
|
FINAL_MSG="Objective-C bridge compilation failed." |
|
set -x |
|
xcrun clang \ |
|
-fobjc-arc \ |
|
-fblocks \ |
|
-fmodules \ |
|
-mmacosx-version-min=13.0 \ |
|
-c "$BUILD_DIR/PTTBridge.m" \ |
|
-o "$BUILD_DIR/PTTBridge.o" |
|
|
|
echo "Compiling Swift menu-bar application…" |
|
set +x |
|
FINAL_MSG="Swift application compilation failed." |
|
set -x |
|
xcrun swiftc \ |
|
-O \ |
|
-whole-module-optimization \ |
|
-swift-version 5 \ |
|
-target "$(uname -m)-apple-macosx13.0" \ |
|
-import-objc-header "$BUILD_DIR/PTTBridge.h" \ |
|
"$BUILD_DIR/main.swift" \ |
|
"$BUILD_DIR/PTTBridge.o" \ |
|
-framework AppKit \ |
|
-framework AVFoundation \ |
|
-framework CoreMedia \ |
|
-framework CoreVideo \ |
|
-framework CoreGraphics \ |
|
-framework Foundation \ |
|
-framework ServiceManagement \ |
|
-framework ApplicationServices \ |
|
-Xlinker -dead_strip \ |
|
-o "$STAGED_APP/Contents/MacOS/$APP_NAME" & |
|
APP_COMPILE_PID=$! |
|
|
|
###### BUILD APP ICON IN PARALLEL ###### |
|
BASE_ICON="$STAGED_APP/Contents/Resources/moon.png" |
|
ICONSET="$STAGED_APP/Contents/Resources/AppIcon.iconset" |
|
mkdir -p "$ICONSET" |
|
|
|
xcrun swift - "$BASE_ICON" <<'SWIFT' & |
|
import AppKit |
|
|
|
let output = CommandLine.arguments[1] |
|
let image = NSImage(size: NSSize(width: 1024, height: 1024)) |
|
image.lockFocus() |
|
NSColor.clear.setFill() |
|
NSRect(x: 0, y: 0, width: 1024, height: 1024).fill() |
|
|
|
let moon = "🌗" as NSString |
|
let attributes: [NSAttributedString.Key: Any] = [ |
|
.font: NSFont(name: "Apple Color Emoji", size: 820) |
|
?? NSFont.systemFont(ofSize: 820) |
|
] |
|
let size = moon.size(withAttributes: attributes) |
|
moon.draw( |
|
at: NSPoint( |
|
x: (1024 - size.width) / 2, |
|
y: (1024 - size.height) / 2 - 40 |
|
), |
|
withAttributes: attributes |
|
) |
|
image.unlockFocus() |
|
|
|
let representation = NSBitmapImageRep(data: image.tiffRepresentation!)! |
|
let png = representation.representation(using: .png, properties: [:])! |
|
try png.write(to: URL(fileURLWithPath: output)) |
|
SWIFT |
|
ICON_RENDER_PID=$! |
|
|
|
ICON_RENDER_STATUS=0 |
|
wait "$ICON_RENDER_PID" || ICON_RENDER_STATUS=1 |
|
if [[ "$ICON_RENDER_STATUS" -ne 0 ]]; then |
|
FINAL_MSG="Icon render failed." |
|
wait "$APP_COMPILE_PID" || true |
|
exit 1 |
|
fi |
|
|
|
SIPS_PIDS=() |
|
for size in 16 32 128 256 512; do |
|
sips -z "$size" "$size" "$BASE_ICON" \ |
|
--out "$ICONSET/icon_${size}x${size}.png" >/dev/null & |
|
SIPS_PIDS+=("$!") |
|
|
|
doubled=$((size * 2)) |
|
sips -z "$doubled" "$doubled" "$BASE_ICON" \ |
|
--out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null & |
|
SIPS_PIDS+=("$!") |
|
done |
|
|
|
SIPS_STATUS=0 |
|
for pid in "${SIPS_PIDS[@]}"; do |
|
wait "$pid" || SIPS_STATUS=1 |
|
done |
|
if [[ "$SIPS_STATUS" -ne 0 ]]; then |
|
FINAL_MSG="Icon resize failed." |
|
wait "$APP_COMPILE_PID" || true |
|
exit 1 |
|
fi |
|
|
|
set +x |
|
FINAL_MSG="Icon packaging failed." |
|
set -x |
|
iconutil -c icns "$ICONSET" -o "$STAGED_APP/Contents/Resources/AppIcon.icns" |
|
rm -rf "$BASE_ICON" "$ICONSET" |
|
######################################## |
|
|
|
APP_COMPILE_STATUS=0 |
|
wait "$APP_COMPILE_PID" || APP_COMPILE_STATUS=1 |
|
if [[ "$APP_COMPILE_STATUS" -ne 0 ]]; then |
|
FINAL_MSG="Swift application compilation failed." |
|
exit 1 |
|
fi |
|
|
|
chmod 755 "$STAGED_APP/Contents/MacOS/$APP_NAME" |
|
plutil -lint "$STAGED_APP/Contents/Info.plist" |
|
find "$STAGED_APP" |
|
|
|
# Publish only after every build and icon step succeeded. |
|
if [[ -e "$APP_PATH" ]]; then |
|
if [[ "$FORCE" -eq 1 ]]; then |
|
rm -rf "$APP_PATH" |
|
else |
|
FINAL_MSG="Error: '$APP_PATH' appeared while building." |
|
exit 1 |
|
fi |
|
fi |
|
|
|
set +x |
|
FINAL_MSG="Could not publish the completed app." |
|
set -x |
|
mv "$STAGED_APP" "$APP_PATH" |
|
|
|
echo |
|
echo "Built: $APP_PATH" |
|
echo "Move it to /Applications for reliable Launch at Login behavior." |
|
echo "On first launch, macOS will ask for Camera permission." |
|
open -R "$APP_PATH" |
|
FINAL_MSG="App created: $APP_PATH" |