Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1420;
LastUpgradeCheck = 1530;
LastUpgradeCheck = 2600;
TargetAttributes = {
A8E59C34297F5E9A0064D4BA = {
CreatedOnToolsVersion = 14.2;
Expand Down Expand Up @@ -210,6 +210,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
Expand Down Expand Up @@ -242,6 +243,7 @@
CURRENT_PROJECT_VERSION = "$(BUILD_NUMBER)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5F967GYF84;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
Expand All @@ -264,6 +266,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
Expand All @@ -276,6 +279,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
Expand Down Expand Up @@ -308,6 +312,7 @@
CURRENT_PROJECT_VERSION = "$(BUILD_NUMBER)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 5F967GYF84;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
Expand All @@ -323,6 +328,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
Expand All @@ -339,7 +345,6 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5F967GYF84;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand Down Expand Up @@ -377,7 +382,6 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5F967GYF84;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
LastUpgradeVersion = "2600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
2 changes: 1 addition & 1 deletion Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
74 changes: 74 additions & 0 deletions Loop/Accent Color/AccentColorController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// AccentColorController.swift
// Loop
//
// Created by Kai Azim on 2025-09-06.
//

import Defaults
import OSLog
import SwiftUI

final class AccentColorController: ObservableObject {
static let shared = AccentColorController()

@Published var color1: Color = Defaults[.lastUsedAccentColor1]
@Published var color2: Color = Defaults[.lastUsedAccentColor2]

private let wallpaperProcessor = WallpaperProcessor()
private var observationTask: Task<(), Never>?
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.loop", category: "AccentColorController")

private init() {
self.observationTask = Task { [weak self] in
let updates = Defaults.updates(
.accentColorMode,
.customAccentColor,
.useGradient,
.gradientColor
)

for await _ in updates {
guard
!Task.isCancelled,
let self
else {
break
}
await refresh()
}
}
}

deinit {
observationTask?.cancel()
}

@MainActor
func refresh() async {
switch Defaults[.accentColorMode] {
case .system:
logger.log("AccentColorController: Refreshing accent color based on system")
color1 = Color.accentColor
color2 = Defaults[.useGradient] ? Color(nsColor: NSColor.controlAccentColor.blended(withFraction: 0.5, of: .black)!) : Color.accentColor
case .wallpaper:
logger.log("AccentColorController: Refreshing accent color based on wallpaper")
let colors = await wallpaperProcessor.fetchLatest()
color1 = colors.primary
color2 = Defaults[.useGradient] ? colors.secondary : colors.primary
case .custom:
logger.log("AccentColorController: Refreshing accent color based on custom colors")
color1 = Defaults[.customAccentColor]
color2 = Defaults[.useGradient] ? Defaults[.gradientColor] : Defaults[.customAccentColor]
}

Defaults[.lastUsedAccentColor1] = color1
Defaults[.lastUsedAccentColor2] = color2
}
}

extension Color {
static var systemGray: Color {
Color(nsColor: NSColor.systemGray.blended(withFraction: 0.2, of: .black)!)
}
}
31 changes: 31 additions & 0 deletions Loop/Accent Color/AccentColorOption.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// AccentColorOption.swift
// Loop
//
// Created by Kai Azim on 2025-09-07.
//

import Defaults
import SwiftUI

enum AccentColorOption: Int, Codable, Defaults.Serializable, CaseIterable {
case system
case wallpaper
case custom

var image: Image {
switch self {
case .system: Image(systemName: "apple.logo")
case .wallpaper: Image(.imageDepth)
case .custom: Image(.colorPalette)
}
}

var text: String {
switch self {
case .system: .init(localized: "Accent color option: System", defaultValue: "System")
case .wallpaper: .init(localized: "Accent color option: Wallpaper", defaultValue: "Wallpaper")
case .custom: .init(localized: "Accent color option: Custom", defaultValue: "Custom")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import AppKit
import Defaults
import SwiftUI

// MARK: - Wallpaper processor error
// MARK: - Wallpaper processor errors

/// Represents errors that can occur during wallpaper processing.
public enum WallpaperProcessorError: Error {
Expand All @@ -22,12 +22,8 @@ public enum WallpaperProcessorError: Error {
case bitmapCreationFailed
}

// MARK: - Wallpaper colour processor
// MARK: - NSImage extensions

/// IMPORTANT: FOR THE COLOR EXTRACTION FEATURE TO FUNCTION AUTOMATICALLY WITH LOOP, IT'S CRUCIAL TO GRANT
/// ACCESSIBILITY PERMISSIONS TO YOUR DEVELOPMENT VERSION OF LOOP. ADDITIONALLY, ENSURE THAT ANY PREVIOUS
/// PERMISSIONS GRANTED TO OFFICIALLY SIGNED VERSIONS OF LOOP ARE REVOKED. WITHOUT THESE STEPS, LOOP WILL
/// NOT BE ABLE TO AUTOMATICALLY FETCH WALLPAPER COLORS, AND YOU'LL BE LIMITED TO THE MANUAL EXTRACTION METHOD.
///
/// This implementation provides an advanced color extraction algorithm that:
/// - Efficiently processes desktop wallpaper images to extract vibrant colors
Expand Down Expand Up @@ -208,18 +204,27 @@ extension NSImage {
/// This class provides methods to capture the current desktop wallpaper and extract
/// vibrant, visually appealing colors that can be used as accent colors in the UI.
public class WallpaperProcessor {
private static var lastProcessedDate: Date = .distantPast
private var lastProcessedDate: Date = .distantPast
private var lastResult: (primary: Color, secondary: Color) = (.black, .black)

/// Fetches the latest wallpaper colors, respecting a throttle period.
/// This helps prevent excessive processing if called frequently, when the wallpaper is most likely unchanged.
/// - Parameter ignoreThrottle: If true, the method will ignore the throttle and fetch colors immediately. This is useful when called from settings or manual triggers.
static func fetchLatest(ignoreThrottle: Bool = false) async {
func fetchLatest(ignoreThrottle: Bool = false) async -> (primary: Color, secondary: Color) {
// Only proceed if the caller has chosen to ignore the throttle, or over 5 seconds have passed since the last refresh
guard ignoreThrottle || lastProcessedDate.distance(to: .now) > 5.0 else {
return
return lastResult
}

lastProcessedDate = .now
await fetchLatestWallpaperColors()

// If we succeed in obtaining new colors, then return them
if let newColors = await fetchLatestWallpaperColors() {
lastResult = newColors
return newColors
}

// If we didn't succeed, simply return the last set of valid colors
return lastResult
}

/// Fetches the latest wallpaper colors and updates the app's theme settings.
Expand All @@ -232,7 +237,9 @@ public class WallpaperProcessor {
/// The first (most vibrant) color is used as the primary accent color, while
/// the second color is used as a gradient/secondary color. This provides
/// a cohesive theme that matches the user's desktop environment.
private static func fetchLatestWallpaperColors() async {
///
/// Note that you shouldn't call this method directly, but rather, call ``AccentColorController.refresh``.
private func fetchLatestWallpaperColors() async -> (primary: Color, secondary: Color)? {
do {
// Attempt to process the current wallpaper to get the dominant colors.
let dominantColors = try await processCurrentWallpaper()
Expand All @@ -242,13 +249,17 @@ public class WallpaperProcessor {
// which typically works better for UI elements that need good contrast
let colors = dominantColors.prefix(2).sorted(by: { $0.brightness > $1.brightness })

// Update the custom accent color with the first dominant color or clear if none.
Defaults[.customAccentColor] = Color(colors.first ?? .clear)
// Update the gradient color with the second dominant color or the existing gradient color if only one color is found.
Defaults[.gradientColor] = colors.count > 1 ? Color(colors[1]) : Defaults[.gradientColor]
// Use the first dominant color or clear if none.
let primaryColor = Color(colors.first ?? .clear)

// Use the second dominant color if possible, otherwise return the primary color.
let secondaryColor = colors.count > 1 ? Color(colors[1]) : primaryColor

return (primaryColor, secondaryColor)
} catch {
// If an error occurs, print the error description.
print(error.localizedDescription)
return nil
}
}

Expand All @@ -260,7 +271,7 @@ public class WallpaperProcessor {
/// It first attempts to capture a screenshot of the desktop wallpaper, then
/// passes that image to the color analysis algorithm to extract vibrant,
/// visually distinct colors suitable for UI accents.
private static func processCurrentWallpaper() async throws -> [NSColor] {
private func processCurrentWallpaper() async throws -> [NSColor] {
let wallpaperImageFetcher = WallpaperImageFetcher()

// Take a screenshot of the main display.
Expand Down
1 change: 1 addition & 0 deletions Loop/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

DataPatcher.run()
IconManager.refreshCurrentAppIcon()
LoopManager.shared.start()
WindowDragManager.shared.addObservers()
Expand Down
36 changes: 36 additions & 0 deletions Loop/App/DataPatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// DataPatcher.swift
// Loop
//
// Created by Kai Azim on 2025-09-07.
//

import Defaults
import Foundation

enum DataPatcher {
static func run() {
let initialPatches = Defaults[.patchesApplied]

if !initialPatches.contains(.accentColorMode) {
// Migrate to accent color mode
// We need to migrate `useSystemAccentColor` and `processWallpaper` over to `accentColorMode`
if Defaults[.useSystemAccentColor] {
Defaults[.accentColorMode] = .system
} else if Defaults[.processWallpaper] {
Defaults[.accentColorMode] = .wallpaper
} else {
Defaults[.accentColorMode] = .custom
}

Defaults[.patchesApplied].formUnion(.accentColorMode)
print("DataPatcher: Ran patch accentColorMode")
}
}

struct Patch: OptionSet, Defaults.Serializable {
let rawValue: Int

static let accentColorMode = Self(rawValue: 1 << 0)
}
}
8 changes: 3 additions & 5 deletions Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,9 @@ extension LoopManager {
WindowRecords.recordFirst(for: targetWindow)
}

// Only recalculate wallpaper colors if user has enabled it.
if Defaults[.processWallpaper] {
Task {
await WallpaperProcessor.fetchLatest()
}
// Refresh accent colors in case user has enabled the wallpaper processor
Task {
await AccentColorController.shared.refresh()
}

currentAction = .init(.noAction)
Expand Down
8 changes: 3 additions & 5 deletions Loop/Core/WindowDragManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,9 @@ class WindowDragManager {
let oldDirection = direction

if !ignoredFrame.contains(mousePosition) {
// Only recalculate wallpaper colors if user has enabled it.
if Defaults[.processWallpaper] {
Task {
await WallpaperProcessor.fetchLatest()
}
// Refresh accent colors in case user has enabled the wallpaper processor
Task {
await AccentColorController.shared.refresh()
}

direction = WindowDirection.processSnap(
Expand Down
16 changes: 14 additions & 2 deletions Loop/Extensions/Defaults+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,10 @@ extension Defaults.Keys {
static let notificationWhenIconUnlocked = Key<Bool>("notificationWhenIconUnlocked", default: true, iCloud: true)

// Accent Color
static let useSystemAccentColor = Key<Bool>("useSystemAccentColor", default: true, iCloud: true)
static let accentColorMode: Key<AccentColorOption> = Key("accentColorMode", default: .system, iCloud: true)
static let customAccentColor = Key<Color>("customAccentColor", default: Color(.white), iCloud: true)
static let useGradient = Key<Bool>("useGradient", default: true, iCloud: true)
static let gradientColor = Key<Color>("gradientColor", default: Color(.black), iCloud: true)
static let processWallpaper = Key<Bool>("processWallpaper", default: false, iCloud: true)

// Radial Menu
static let radialMenuVisibility = Key<Bool>("radialMenuVisibility", default: true, iCloud: true)
Expand Down Expand Up @@ -217,4 +216,17 @@ extension Defaults.Keys {
// StashManager
static let stashManagerRevealedWindows = Key<Set<CGWindowID>>("stashManagerRevealed", default: Set<CGWindowID>())
static let stashManagerStashedWindows = Key<[CGWindowID: WindowAction]>("stashManagerStashed", default: [:])

// AccentColorController
static let lastUsedAccentColor1 = Key<Color>("lastUsedAccentColor1", default: .black)
static let lastUsedAccentColor2 = Key<Color>("lastUsedAccentColor2", default: .black)

@available(*, deprecated, renamed: "accentColorMode", message: "Use accentColorMode.system")
static let useSystemAccentColor = Key<Bool>("useSystemAccentColor", default: true, iCloud: true)

@available(*, deprecated, renamed: "accentColorMode", message: "Use accentColorMode.wallpaper")
static let processWallpaper = Key<Bool>("processWallpaper", default: false, iCloud: true)

// DataPatcher
static let patchesApplied = Key<DataPatcher.Patch>("patchesApplied", default: [], iCloud: true)
}
Loading