Skip to content
Open
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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# Finder/iCloud duplicate copies ("name 2.ext")
* 2.md
* 2.png

# Generated performance fixtures and measurement output
perf/results/
perf/fixtures/*.mp4
perf/fixtures/*.mov
perf/fixtures/*.m4v
perf/fixtures/*.hevc

.DS_Store
.claude/worktrees/
/.build
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ All notable changes to Lively are documented here. The format follows [Keep a Ch

- New suites for update checking (stubbed network), video validation with generated H.264 and Motion-JPEG clips, config persistence/migration, preferences, Reset Data, and system suspension.
- New CI workflow runs build, tests, and packaging on every push and pull request.
- Performance harness: `os_signpost` intervals, `scripts/perf/measure.sh`, generated fixtures. No runtime behaviour change unless `LIVELY_PERF_MODE` is set.
- Measure isolation: `LIVELY_APPLICATION_SUPPORT` and `LIVELY_CONFIG_FILE` redirect Application Support. Setting `HOME` does not move `userDomainMask` on current macOS.

## [1.2.0] - 2026-07-14

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ swift build # build
swift test # run tests (full Xcode)
./scripts/check_hygiene.sh # paths, secrets, duplicates, plist and script syntax
./package.sh # build an ad-hoc signed Lively.app (output in $LIVELY_OUTPUT_DIR)
scripts/perf/measure.sh idle 30 # isolated Application Support sample; see scripts/perf/README.md
```

`swiftlint` (config in `.swiftlint.yml`) runs in CI; run it locally if installed.
Expand Down
46 changes: 28 additions & 18 deletions Sources/Lively/Core/WallpaperController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ private final class WallpaperSession {

currentURL = url
failedURL = nil
loadVideo(url: url, muted: wallpaper.isMuted, volume: wallpaper.volume, onError: onError)
PerfSignposts.interval(PerfSignposts.playerBuild) {
loadVideo(url: url, muted: wallpaper.isMuted, volume: wallpaper.volume, onError: onError)
}
}

private func loadVideo(url: URL, muted: Bool, volume: Float, onError: @escaping @Sendable (Error) -> Void) {
Expand Down Expand Up @@ -215,13 +217,17 @@ private final class WallpaperSession {
}

func pause() {
wantsPlayback = false
updatePlaybackRate()
PerfSignposts.interval(PerfSignposts.pause) {
wantsPlayback = false
updatePlaybackRate()
}
}

func resume() {
wantsPlayback = true
updatePlaybackRate()
PerfSignposts.interval(PerfSignposts.resume) {
wantsPlayback = true
updatePlaybackRate()
}
}

private func occlusionDidChange() {
Expand Down Expand Up @@ -494,7 +500,9 @@ public final class WallpaperController: ObservableObject {
} else {
// Re-sync rather than just resuming, so any config or space changes
// that arrived while paused are picked up immediately.
synchronize(to: spaceMonitor.screenSpaces)
PerfSignposts.interval(PerfSignposts.resume) {
synchronize(to: spaceMonitor.screenSpaces)
}
}
}

Expand Down Expand Up @@ -785,19 +793,21 @@ public final class WallpaperController: ObservableObject {
private func synchronize(to spaces: [ScreenSpace]) {
// NSApp is nil in unit test contexts — can't create windows without a running application
guard let app = NSApp else { return }
let appearance = app.effectiveAppearance
sessionManager.synchronize(
to: spaces,
appearance: appearance,
configStore: configStore,
bookmarkManager: bookmarkManager,
isPaused: shouldHaltPlayback,
onPlaybackError: { [weak self] spaceKey, message in
Task { @MainActor in
self?.playbackErrors.send((spaceKey, message))
PerfSignposts.interval(PerfSignposts.synchronize) {
let appearance = app.effectiveAppearance
sessionManager.synchronize(
to: spaces,
appearance: appearance,
configStore: configStore,
bookmarkManager: bookmarkManager,
isPaused: shouldHaltPlayback,
onPlaybackError: { [weak self] spaceKey, message in
Task { @MainActor in
self?.playbackErrors.send((spaceKey, message))
}
}
}
)
)
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/Lively/Logic/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import AppKit
/// assignments) so preference toggles stay lightweight and independent.
@MainActor
public final class AppPreferences: ObservableObject {
public static let shared = AppPreferences()
public static let shared = AppPreferences(defaults: AppSupportPaths.perfUserDefaults())

/// Hard floor: wallpapers always pause on battery at or below this level.
nonisolated public static let forcedBatteryPausePercent: Double = 25
Expand Down Expand Up @@ -208,7 +208,7 @@ public final class AppPreferences: ObservableObject {
didSet { defaults.set(hasSeenSpacesCoach, forKey: Keys.hasSeenSpacesCoach) }
}

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

func seedDefault(_ key: String, _ value: Any) {
Expand Down
41 changes: 41 additions & 0 deletions Sources/Lively/Logic/AppSupportPaths.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

/// Resolves Application Support. `LIVELY_APPLICATION_SUPPORT` wins so
/// `scripts/perf/measure.sh` can isolate I/O. Setting `HOME` is not enough:
/// `FileManager.SearchPathDomainMask.userDomainMask` still points at the
/// real user Library on current macOS.
public enum AppSupportPaths {
public static func applicationSupportDirectory(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default
) -> URL {
if let override = environment["LIVELY_APPLICATION_SUPPORT"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty {
return URL(fileURLWithPath: override, isDirectory: true)
}
return fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory.appendingPathComponent("Application Support", isDirectory: true)
}

public static func livelyDirectory(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default
) -> URL {
applicationSupportDirectory(environment: environment, fileManager: fileManager)
.appendingPathComponent("Lively", isDirectory: true)
}

public static func perfUserDefaults(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> UserDefaults {
let mode = environment["LIVELY_PERF_MODE"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !mode.isEmpty, mode != "off" else { return .standard }
let suite = "com.lively.app.perf"
if let defaults = UserDefaults(suiteName: suite) {
defaults.removePersistentDomain(forName: suite)
return defaults
}
return .standard
}
}
21 changes: 10 additions & 11 deletions Sources/Lively/Logic/ConfigStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,18 @@ public class ConfigStore: ObservableObject {
public init(configFileURL: URL? = nil) {
if let provided = configFileURL {
self.configFileURL = provided
} else if let envPath = ProcessInfo.processInfo.environment["LIVELY_CONFIG_FILE"],
!envPath.isEmpty {
self.configFileURL = URL(fileURLWithPath: envPath)
} else {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
let dir = appSupport.appendingPathComponent("Lively")
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
} catch {
LivelyLogger.config.error("Failed to create config directory: \(error.localizedDescription)")
errors.send(.directoryCreationFailed(error))
}
self.configFileURL = dir.appendingPathComponent("config_v2.json")
} else {
self.configFileURL = URL(fileURLWithPath: "/tmp/lively_config.json")
let dir = AppSupportPaths.livelyDirectory()
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
} catch {
LivelyLogger.config.error("Failed to create config directory: \(error.localizedDescription)")
errors.send(.directoryCreationFailed(error))
}
self.configFileURL = dir.appendingPathComponent("config_v2.json")
}
load()
}
Expand Down
5 changes: 2 additions & 3 deletions Sources/Lively/Logic/Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,8 @@ public struct RunMarker: Sendable {
}

public static var `default`: RunMarker {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
return RunMarker(url: base.appendingPathComponent("Lively", isDirectory: true).appendingPathComponent("running.marker"))
let dir = AppSupportPaths.livelyDirectory()
return RunMarker(url: dir.appendingPathComponent("running.marker"))
}

/// Call once at launch. Returns true when the previous run left its marker behind.
Expand Down
1 change: 1 addition & 0 deletions Sources/Lively/Logic/LivelyLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,5 @@ public enum LivelyLogger {
public static let videoPreview = LivelyCategoryLogger(category: "VideoPlayerView")
public static let spaceMonitor = LivelyCategoryLogger(category: "SpaceMonitor")
public static let updater = LivelyCategoryLogger(category: "Updater")
public static let perf = LivelyCategoryLogger(category: "Performance")
}
90 changes: 90 additions & 0 deletions Sources/Lively/Logic/PerfHarness.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Foundation

/// Isolated launch path for `scripts/perf/measure.sh`.
///
/// Reads `LIVELY_PERF_MODE` and `LIVELY_PERF_FIXTURE`. Does not change the
/// config schema or spaceKey format. Measure with a throwaway `HOME` so the
/// operator's real Application Support is never touched.
public enum PerfHarness {
public enum Mode: String, Equatable, Sendable {
case off
case idle
case playing
case paused
}

public struct Request: Equatable, Sendable {
public var mode: Mode
public var fixtureURL: URL?

public static func parse(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Request {
let raw = environment["LIVELY_PERF_MODE"]?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard let raw, let mode = Mode(rawValue: raw), mode != .off else {
return Request(mode: .off, fixtureURL: nil)
}
let fixture = environment["LIVELY_PERF_FIXTURE"]
.flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) }
return Request(mode: mode, fixtureURL: fixture)
}
}

public static func isValidRequest(_ request: Request) -> Bool {
switch request.mode {
case .off, .idle:
return true
case .playing, .paused:
guard let url = request.fixtureURL else { return false }
return isValidLivelyVideoFile(url)
}
}

/// Assigns the fixture to every currently visible Space via the existing
/// `ConfigStore.assign` API. Paused mode waits one run-loop turn plus a
/// short grace so the player is built before `togglePause`.
@MainActor
public static func apply(
_ request: Request,
spaceMonitor: SpaceMonitor,
configStore: ConfigStore,
wallpaperController: WallpaperController
) {
guard request.mode != .off else { return }
guard isValidRequest(request) else {
LivelyLogger.perf.error("Perf harness: playing/paused requires a supported LIVELY_PERF_FIXTURE")
return
}

spaceMonitor.refresh()

if request.mode == .idle {
LivelyLogger.perf.info("Perf harness: idle (no wallpaper assigned)")
return
}

guard let url = request.fixtureURL else { return }

var wallpaper = DynamicWallpaper()
wallpaper.mode = .staticVideo
wallpaper.staticURL = url
wallpaper.isMuted = true
wallpaper.volume = 0

let spaces = spaceMonitor.screenSpaces
for space in spaces {
configStore.assign(dynamicWallpaper: wallpaper, toSpaceKey: space.spaceKey)
}
LivelyLogger.perf.info("Perf harness: assigned fixture to \(spaces.count) space(s)")

if request.mode == .paused {
// Config sink refreshes asynchronously; pause after the player is up
// so the paused footprint includes a resident AVPlayer (hypothesis B).
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
if !wallpaperController.isPaused {
wallpaperController.togglePause()
}
}
}
}
}
27 changes: 27 additions & 0 deletions Sources/Lively/Logic/PerfSignposts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import os

/// `os_signpost` intervals for Instruments. Cheap when a recording is not
/// attached: begin/end become no-ops at the tracing layer.
///
/// Names match `docs/PERFORMANCE_MASTER_PLAN.md` P0: launch, synchronize,
/// playerBuild, pause, resume.
public enum PerfSignposts {
public static let subsystem = "com.lively.app"
public static let category = "Performance"

public static let launch: StaticString = "launch"
public static let synchronize: StaticString = "synchronize"
public static let playerBuild: StaticString = "playerBuild"
public static let pause: StaticString = "pause"
public static let resume: StaticString = "resume"

public static let signposter = OSSignposter(subsystem: subsystem, category: category)

/// Runs `body` inside a matching begin/end interval.
@discardableResult
public static func interval<T>(_ name: StaticString, _ body: () throws -> T) rethrows -> T {
let state = signposter.beginInterval(name)
defer { signposter.endInterval(name, state) }
return try body()
}
}
3 changes: 3 additions & 0 deletions Sources/Lively/Logic/WallpaperLibraryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public final class WallpaperLibraryManager: ObservableObject {

if let libraryDir {
self.libraryDir = libraryDir
} else if ProcessInfo.processInfo.environment["LIVELY_APPLICATION_SUPPORT"] != nil {
self.libraryDir = AppSupportPaths.livelyDirectory()
.appendingPathComponent("Library", isDirectory: true)
} else if let support = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
self.libraryDir = support.appendingPathComponent("Lively/Library", isDirectory: true)
} else {
Expand Down
35 changes: 24 additions & 11 deletions Sources/LivelyApp/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private let runMarker = RunMarker.default

func applicationDidFinishLaunching(_ notification: Notification) {
if runMarker.begin() {
LogStore.shared.previousRunEndedUncleanly = true
LivelyLogger.config.error("Previous run ended unexpectedly (crash, force quit or power loss)")
PerfSignposts.interval(PerfSignposts.launch) {
if runMarker.begin() {
LogStore.shared.previousRunEndedUncleanly = true
LivelyLogger.config.error("Previous run ended unexpectedly (crash, force quit or power loss)")
}
// Background-only process — no Dock icon
NSApp.setActivationPolicy(.accessory)

// Warm up the controller (triggers initial wallpaper load for all screens)
_ = wallpaperController

setupMenuBar()

let request = PerfHarness.Request.parse()
if request.mode != .off {
PerfHarness.apply(
request,
spaceMonitor: spaceMonitor,
configStore: configStore,
wallpaperController: wallpaperController
)
}
}
// Background-only process — no Dock icon
NSApp.setActivationPolicy(.accessory)

// Warm up the controller (triggers initial wallpaper load for all screens)
_ = wallpaperController

setupMenuBar()
}

func applicationWillTerminate(_ notification: Notification) {
Expand Down Expand Up @@ -84,7 +96,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// Apply saved appearance (Light / Dark / System) before first paint.
AppPreferences.applyAppAppearance(AppPreferences.shared.appearance)

if AppPreferences.shared.checkForUpdates {
let perfMode = PerfHarness.Request.parse().mode
if AppPreferences.shared.checkForUpdates && perfMode == .off {
Task { await UpdateChecker.shared.checkIfEnabled() }
}
}
Expand Down
Loading
Loading