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
17 changes: 7 additions & 10 deletions Sources/Lively/Core/WallpaperController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,10 @@ private final class WallpaperSessionManager {
guard !isPaused else { return }

for space in spaces {
var session = sessions[space.id]
if session == nil {
let session: WallpaperSession
if let existing = sessions[space.id] {
session = existing
} else {
session = WallpaperSession(screen: space.screen)
sessions[space.id] = session
}
Expand All @@ -372,7 +374,7 @@ private final class WallpaperSessionManager {
let wallpaperConfig = configStore.configs[spaceKey]?.dynamicWallpaper ?? DynamicWallpaper()

if let videoURL = bookmarkManager.urlForSpace(space, appearance: appearance) {
session?.play(
session.play(
url: videoURL,
wallpaper: wallpaperConfig,
screen: space.screen,
Expand All @@ -382,7 +384,7 @@ private final class WallpaperSessionManager {
)
} else {
bookmarkManager.stopScope(for: spaceKey)
session?.hide()
session.hide()
}
}
}
Expand Down Expand Up @@ -532,10 +534,7 @@ public final class WallpaperController: ObservableObject {
}

private func setupBatteryMonitor() {
// Lightweight poll — power-source CF notifications are awkward to bridge;
// 30s is enough for "pause on battery" without battery impact.
// Poll power source periodically (IOKit has no simple Combine publisher).
// 20s is responsive enough for "pause on battery" without busy-waiting.
// IOKit has no simple Combine publisher; 20s is enough for battery pause.
powerSourceTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.refreshBatteryState()
Expand Down Expand Up @@ -848,6 +847,4 @@ enum PowerSourceMonitor {

return Snapshot(isOnBattery: onBattery, levelPercent: level)
}

static var isOnBattery: Bool { snapshot().isOnBattery }
}
3 changes: 0 additions & 3 deletions Sources/Lively/Core/WallpaperWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,6 @@ class WallpaperWindow: NSWindow {
self.canHide = false
self.sharingType = .none
self.animationBehavior = .none

// Cover the full screen frame
setFrame(screen.frame, display: false)
}

// MARK: - Ordering
Expand Down
18 changes: 7 additions & 11 deletions Sources/Lively/Logic/AppMetrics.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import Foundation

// I-3: @MainActor matches actual usage — both call sites (ScreenCardView.onDrop
// and ConfigStore.assign) run on the main thread. This is safer than @unchecked
// Sendable, which promised thread-safety without a lock.
@MainActor
public final class AppMetrics {
public static let shared = AppMetrics()

private let defaults = UserDefaults.standard

private let activatedKey = "wallpaper_applied_once"
private let daysActiveKey = "days_with_wallpaper_active"
private let lastActiveDateKey = "last_wallpaper_active_date"

// I-1/I-2: Create DateFormatter once; en_US_POSIX + Gregorian ensures
// "yyyy-MM-dd" is stable across all device locales (Arabic, Hebrew, etc.).

// en_US_POSIX + Gregorian keeps "yyyy-MM-dd" stable across locales.
private let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
Expand All @@ -26,11 +22,11 @@ public final class AppMetrics {
private init() {}

public var isActivated: Bool {
return defaults.bool(forKey: activatedKey)
defaults.bool(forKey: activatedKey)
}

public var daysWithWallpaperActive: Int {
return defaults.integer(forKey: daysActiveKey)
defaults.integer(forKey: daysActiveKey)
}

public func recordWallpaperApplied() {
Expand Down
24 changes: 6 additions & 18 deletions Sources/Lively/Logic/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,6 @@ public final class AppPreferences: ObservableObject {
}
}

public typealias FrameRateCap = MaxResolution

private enum Keys {
static let startMinimized = "prefs.startMinimized"
static let pauseOnBattery = "prefs.pauseOnBattery"
Expand Down Expand Up @@ -205,33 +203,23 @@ public final class AppPreferences: ObservableObject {
didSet { defaults.set(hasSeenSpacesCoach, forKey: Keys.hasSeenSpacesCoach) }
}

public var frameRateCap: MaxResolution {
get { maxResolution }
set { maxResolution = newValue }
}

public init(defaults: UserDefaults = .standard) {
self.defaults = defaults

if defaults.object(forKey: Keys.startMinimized) == nil {
defaults.set(true, forKey: Keys.startMinimized)
}
if defaults.object(forKey: Keys.pauseOnBattery) == nil {
defaults.set(true, forKey: Keys.pauseOnBattery)
func seedDefault(_ key: String, _ value: Any) {
if defaults.object(forKey: key) == nil { defaults.set(value, forKey: key) }
}
seedDefault(Keys.startMinimized, true)
seedDefault(Keys.pauseOnBattery, true)
seedDefault(Keys.hardwareDecoding, true)
seedDefault(Keys.batteryPauseThreshold, 35.0)
// The GitHub update check is opt-in (README privacy promise). Builds up
// to 1.2.0 wrote `true` here as a default on first launch, which is
// indistinguishable from an explicit opt-in, so reset it once.
if !defaults.bool(forKey: Keys.updateCheckOptInMigrated) {
defaults.set(false, forKey: Keys.checkForUpdates)
defaults.set(true, forKey: Keys.updateCheckOptInMigrated)
}
if defaults.object(forKey: Keys.hardwareDecoding) == nil {
defaults.set(true, forKey: Keys.hardwareDecoding)
}
if defaults.object(forKey: Keys.batteryPauseThreshold) == nil {
defaults.set(35.0, forKey: Keys.batteryPauseThreshold)
}

self.startMinimized = defaults.bool(forKey: Keys.startMinimized)
self.pauseOnBattery = defaults.bool(forKey: Keys.pauseOnBattery)
Expand Down
156 changes: 70 additions & 86 deletions Sources/Lively/Logic/ConfigStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,24 @@ import Combine
public struct SpaceConfig: Codable, Sendable {
/// Composite key: "displayID:desktopImageURL.absoluteString"
public let spaceKey: String

/// The dynamic wallpaper configuration.
public let dynamicWallpaper: DynamicWallpaper

/// Bookmarks for all relevant URLs.
/// Key: "static", "light", "dark".
public let bookmarks: [String: Data]

public let addedAt: Date

func replacing(dynamicWallpaper: DynamicWallpaper? = nil, bookmarks: [String: Data]? = nil) -> SpaceConfig {
SpaceConfig(
spaceKey: spaceKey,
dynamicWallpaper: dynamicWallpaper ?? self.dynamicWallpaper,
bookmarks: bookmarks ?? self.bookmarks,
addedAt: addedAt
)
}
}

// MARK: - ConfigStore
Expand Down Expand Up @@ -66,33 +75,15 @@ public class ConfigStore: ObservableObject {
AppMetrics.shared.recordWallpaperApplied()

var bookmarks: [String: Data] = [:]

func bookmark(for url: URL?, key: String) -> Data? {
guard let url else { return nil }
do {
return try url.bookmarkData(
options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
} catch {
errors.send(.bookmarkCreationFailed(key))
LivelyLogger.config.error("Failed to create bookmark for \(key): \(error.localizedDescription)")
return nil
}
}

if let url = dynamicWallpaper.staticURL {
guard let data = bookmark(for: url, key: "static") else { return }
bookmarks["static"] = data
}
if let url = dynamicWallpaper.lightURL {
guard let data = bookmark(for: url, key: "light") else { return }
bookmarks["light"] = data
}
if let url = dynamicWallpaper.darkURL {
guard let data = bookmark(for: url, key: "dark") else { return }
bookmarks["dark"] = data
let sources: [(String, URL?)] = [
("static", dynamicWallpaper.staticURL),
("light", dynamicWallpaper.lightURL),
("dark", dynamicWallpaper.darkURL),
]
for (bookmarkKey, url) in sources {
guard let url else { continue }
guard let data = makeBookmarkData(for: url, key: bookmarkKey) else { return }
bookmarks[bookmarkKey] = data
}

let config = SpaceConfig(
Expand Down Expand Up @@ -130,22 +121,32 @@ public class ConfigStore: ObservableObject {
return nil
}

var isStale = false
return resolveURL(fromBookmark: data, spaceKey: spaceKey, bookmarkKey: bookmarkKey, verbose: true)
}

/// Resolves a specific bookmark key directly.
/// Used by secondary components like VideoThumbnailView to gain access.
public func resolveBookmark(for spaceKey: String, bookmarkKey: String) -> URL? {
guard let data = configs[spaceKey]?.bookmarks[bookmarkKey] else { return nil }
return resolveURL(fromBookmark: data, spaceKey: spaceKey, bookmarkKey: bookmarkKey, verbose: false)
}

private func resolveURL(fromBookmark data: Data, spaceKey: String, bookmarkKey: String, verbose: Bool) -> URL? {
var isStale = false
do {
let resolved = try URL(
resolvingBookmarkData: data,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)

if isStale {
LivelyLogger.config.info("Refreshing stale bookmark for \(bookmarkKey)")
if verbose { LivelyLogger.config.info("Refreshing stale bookmark for \(bookmarkKey)") }
refreshBookmark(spaceKey: spaceKey, bookmarkKey: bookmarkKey, url: resolved)
}

LivelyLogger.config.info("Resolved \(bookmarkKey) via security-scoped bookmark → \(resolved.lastPathComponent)")
if verbose {
LivelyLogger.config.info("Resolved \(bookmarkKey) via security-scoped bookmark → \(resolved.lastPathComponent)")
}
return resolved
} catch {
do {
Expand All @@ -156,44 +157,43 @@ public class ConfigStore: ObservableObject {
relativeTo: nil,
bookmarkDataIsStale: &legacyStale
)
LivelyLogger.config.info("Legacy bookmark resolved for \(bookmarkKey), upgrading to security-scoped")
if verbose {
LivelyLogger.config.info("Legacy bookmark resolved for \(bookmarkKey), upgrading to security-scoped")
}
refreshBookmark(spaceKey: spaceKey, bookmarkKey: bookmarkKey, url: legacyResolved)
return legacyResolved
} catch let legacyError {
LivelyLogger.config.error("Bookmark resolution failed for \(bookmarkKey). Primary error: \(error.localizedDescription). Legacy fallback error: \(legacyError.localizedDescription)")
if verbose {
LivelyLogger.config.error("Bookmark resolution failed for \(bookmarkKey). Primary error: \(error.localizedDescription). Legacy fallback error: \(legacyError.localizedDescription)")
}
return nil
}
}
}

/// Resolves a specific bookmark key directly.
/// Used by secondary components like VideoThumbnailView to gain access.
public func resolveBookmark(for spaceKey: String, bookmarkKey: String, fallbackURL: URL?) -> URL? {
guard let config = configs[spaceKey] else { return nil }
guard let data = config.bookmarks[bookmarkKey] else { return nil }

var isStale = false

private func makeBookmarkData(for url: URL, key: String) -> Data? {
do {
let resolved = try URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale)
if isStale { refreshBookmark(spaceKey: spaceKey, bookmarkKey: bookmarkKey, url: resolved) }
return resolved
return try Self.securityScopedBookmarkData(for: url)
} catch {
do {
var legacyStale = false
let legacyResolved = try URL(resolvingBookmarkData: data, options: [], relativeTo: nil, bookmarkDataIsStale: &legacyStale)
refreshBookmark(spaceKey: spaceKey, bookmarkKey: bookmarkKey, url: legacyResolved)
return legacyResolved
} catch {
return nil
}
errors.send(.bookmarkCreationFailed(key))
LivelyLogger.config.error("Failed to create bookmark for \(key): \(error.localizedDescription)")
return nil
}
}


nonisolated private static func securityScopedBookmarkData(for url: URL) throws -> Data {
try url.bookmarkData(
options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
}

/// Re-creates a bookmark for a URL with security scope.
private func refreshBookmark(spaceKey: String, bookmarkKey: String, url: URL) {
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }

guard let config = configs[spaceKey] else {
errors.send(.bookmarkRefreshFailed(bookmarkKey))
LivelyLogger.config.error("No config found while refreshing bookmark for \(bookmarkKey)")
Expand All @@ -202,31 +202,22 @@ public class ConfigStore: ObservableObject {

let newData: Data
do {
newData = try url.bookmarkData(
options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
newData = try Self.securityScopedBookmarkData(for: url)
} catch {
errors.send(.bookmarkRefreshFailed(bookmarkKey))
LivelyLogger.config.error("Failed to refresh bookmark for \(bookmarkKey): \(error.localizedDescription)")
return
}

var updatedBookmarks = config.bookmarks
updatedBookmarks[bookmarkKey] = newData

// A stale bookmark usually means the file was moved or renamed; keep the
// stored path in step so the UI shows the real name.
let wallpaper = Self.wallpaper(config.dynamicWallpaper, replacing: bookmarkKey, with: url)

let refreshed = SpaceConfig(
spaceKey: config.spaceKey,
dynamicWallpaper: wallpaper,
bookmarks: updatedBookmarks,
addedAt: config.addedAt
configs[spaceKey] = config.replacing(
dynamicWallpaper: Self.wallpaper(config.dynamicWallpaper, replacing: bookmarkKey, with: url),
bookmarks: updatedBookmarks
)
configs[spaceKey] = refreshed
persist()
LivelyLogger.config.info("Bookmark upgraded to security-scoped for \(bookmarkKey)")
}
Expand Down Expand Up @@ -254,13 +245,7 @@ public class ConfigStore: ObservableObject {

guard updated != existing.dynamicWallpaper else { return }

let refreshed = SpaceConfig(
spaceKey: existing.spaceKey,
dynamicWallpaper: updated,
bookmarks: existing.bookmarks,
addedAt: existing.addedAt
)
configs[key] = refreshed
configs[key] = existing.replacing(dynamicWallpaper: updated)
persist()
}

Expand Down Expand Up @@ -414,16 +399,15 @@ public class ConfigStore: ObservableObject {

private func isConfigPlayable(_ config: SpaceConfig) -> Bool {
let wallpaper = config.dynamicWallpaper
func playable(_ url: URL?, key: String) -> Bool {
guard let url, isValidLivelyVideoFile(url), config.bookmarks[key] != nil else { return false }
return true
}
switch wallpaper.mode {
case .staticVideo:
guard let url = wallpaper.staticURL,
isValidLivelyVideoFile(url),
config.bookmarks["static"] != nil else { return false }
return true
return playable(wallpaper.staticURL, key: "static")
case .appearance:
let hasLight = wallpaper.lightURL.map { isValidLivelyVideoFile($0) && config.bookmarks["light"] != nil } ?? false
let hasDark = wallpaper.darkURL.map { isValidLivelyVideoFile($0) && config.bookmarks["dark"] != nil } ?? false
return hasLight || hasDark
return playable(wallpaper.lightURL, key: "light") || playable(wallpaper.darkURL, key: "dark")
}
}
}
Expand Down
Loading
Loading