diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..08c80de --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_size = 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d2b2909 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: swift + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b625dc..51544b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,21 @@ concurrency: cancel-in-progress: true jobs: + hygiene-and-lint: + runs-on: macos-15 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Repo hygiene (paths, secrets, duplicates, plists, scripts) + run: ./scripts/check_hygiene.sh + + - name: SwiftLint + run: | + brew install swiftlint + swiftlint lint --reporter github-actions-logging + build-and-test: # Same toolchain as release.yml so CI and shipped builds match. runs-on: macos-15 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..f7d14ba --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,30 @@ +# SwiftLint configuration. Not run in the audit environment (SwiftLint is not +# installed there); CI installs it via Homebrew. Rules are tuned to the existing +# code base so the first run is expected to be quiet; tighten over time. +included: + - Sources + - Tests +excluded: + - .build + - .agents +disabled_rules: + - trailing_whitespace # handled by editors; avoids noisy churn + - line_length # SwiftUI view bodies are long by nature + - identifier_name + - type_name + - file_length + - type_body_length + - function_body_length + - cyclomatic_complexity + - large_tuple + - nesting +opt_in_rules: + - empty_count + - force_unwrapping + - unused_import + - sorted_first_last + - explicit_init +force_unwrapping: + severity: warning +analyzer_rules: + - unused_import diff --git a/AGENTS.md b/AGENTS.md index 2163afc..2b0fd40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,7 @@ Lively: native macOS 14+ menu bar app for looping video wallpapers (Swift 6, no - Design/brand: see `DESIGN.md`, `PRODUCT.md`, `brand.md`. Use `LivelyBrand` tokens, not inline literals. - Privacy: offline by default; the only network call is the opt-in GitHub update check. Keep it that way. - Not sandboxed by design (draws below desktop icons). Do not commit secrets, signing material, or personal paths. +- Tests: `./test.sh` works with Command Line Tools only; `./scripts/check_hygiene.sh` before committing. Put new logic in `Sources/Lively/Logic` with tests. +- Version lives only in `Info.plist`. Preference changes need a schema-version migration in `AppPreferences.migrate`. +- Never log file paths or names. Diagnostics must go through `DiagnosticsRedactor`. +- More: `ARCHITECTURE.md`, `CONTRIBUTING.md`, `SECURITY.md`, `docs/RELEASING.md`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..411863e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,53 @@ +# Architecture + +Lively is a menu-bar (`LSUIElement`) macOS app written in Swift with SwiftUI, +AppKit and AVFoundation. It has no third-party dependencies. + +## Targets (Package.swift) + +| Target | Path | Role | +|--------|------|------| +| `LivelyCore` | `Sources/Lively` | All app logic and UI, as a library so it can be unit tested. | +| `LivelyApp` | `Sources/LivelyApp/main.swift` | Thin entry point: `NSStatusItem`, popover, lifecycle, run marker. | +| `LivelyTests` | `Tests/LivelyTests` | Swift Testing suites for `LivelyCore`. | + +## Layers inside `LivelyCore` + +- `Logic/` - pure or mostly pure state and policy. Most unit-tested code lives here. + - `ConfigStore`: per-Space wallpaper assignments, security-scoped bookmarks, + atomic debounced persistence to `~/Library/Application Support/Lively/config_v2.json`, + entry-by-entry lossy decoding, corrupt-file backup. + - `AppPreferences`: UserDefaults-backed settings with a schema version and migrations. + - `PlaybackPolicy`: `SystemSuspension` (sleep, lock, screen saver, session switch) + and the per-display decode decision. + - `SpaceMonitor`: identifies display + Space pairs. + - `VideoValidator`, `WallpaperLibraryManager`, `UpdateChecker` (opt-in), `LivelyLogger`, + `Diagnostics` (redacted support report, unclean-exit marker). +- `Core/` - AppKit/AVFoundation glue. `WallpaperWindow` is a borderless window at + desktop level; `WallpaperController` owns one player per display, reacts to + Space, display, power, thermal and system-state notifications, and applies the + pause policy. +- `UI/` - SwiftUI views hosted in the popover. `ViewState` replaces the `@State` + macro so the package builds with Command Line Tools only. + +## Data flow + +``` +SpaceMonitor --(spaces)--> WallpaperController --(play/pause)--> WallpaperWindow/AVPlayer +ConfigStore --(bookmark -> URL)-----^ ^ +AppPreferences / battery / thermal / system suspension -+ +``` + +## Persistence and migration + +- Assignments: JSON dictionary keyed by `displayID:desktopImageURL`. Each entry is + decoded independently; an unreadable file is moved to `config_v2.corrupt.json`. +- Preferences: UserDefaults keys under `prefs.`; `prefs.schemaVersion` tracks + migrations (`AppPreferences.migrate`). Add a step and a test when a key changes meaning. +- Run marker: `running.marker` exists only while the app runs; if found at launch + the previous run ended unexpectedly and this is logged and included in diagnostics. + +## Why not sandboxed + +Drawing below desktop icons needs a window at desktop level, which the App Sandbox +does not allow. See `docs/SANDBOX_READINESS.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3d85b..acaef4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Lively are documented here. The format follows [Keep a Ch ## [Unreleased] +### Added + +- Logs export now produces a redacted diagnostics report (versions, display count, settings, recent log, home paths replaced by `~`). +- The app notices when the previous run ended unexpectedly and records it in the log and report. +- Preferences carry a schema version for future migrations. +- `scripts/release.sh --dry-run`, `scripts/check_hygiene.sh`, SwiftLint config, Dependabot for Actions, and documentation (architecture, contributing, security, privacy, releasing, sandbox readiness, performance). +- The packaged `Info.plist` now takes its version from the repo `Info.plist`. + ### Fixed - **Per-Space and per-display assignments were being deleted.** Switching Spaces, unplugging a monitor, or launching without it wiped the clips assigned to the Spaces/displays not currently visible. They now persist. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dc79007 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +## Setup + +Requires macOS 14+ and a Swift 6 toolchain (Xcode 26 or Command Line Tools). + +```bash +swift build # build +./test.sh # run tests (works with Command Line Tools only) +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) +``` + +`swiftlint` (config in `.swiftlint.yml`) runs in CI; run it locally if installed. + +## Guidelines + +- Put logic in `Sources/Lively/Logic` and test it; keep AppKit/SwiftUI thin. +- Use `LivelyBrand` tokens in UI; honour Reduce Motion and give controls accessibility labels. +- Do not add network calls, analytics or third-party dependencies. The only network + request is the opt-in GitHub update check. +- Never log file paths or file names; use the redaction helpers. +- Never commit secrets, signing material, personal paths or Finder duplicates (`name 2.ext`). +- Add a `CHANGELOG.md` entry under Unreleased for user-visible changes. +- Small, focused commits with conventional prefixes (`feat:`, `fix:`, `docs:`, `build:`). + +UI changes cannot be fully verified without Xcode and a real display; say what you +could and could not test in the pull request. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..160c27f --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,24 @@ +# Privacy + +Lively works entirely on your Mac. + +**What is stored locally** +- Which video is assigned to which display/Space, as security-scoped bookmarks, in + `~/Library/Application Support/Lively/`. +- Copies of videos you add to the Library, in the same folder. +- Preferences in the standard macOS preferences store, and two small usage counters + (whether you applied a wallpaper, days active) used only to adapt first-run hints. + They never leave your Mac. +- An in-memory log (last 1000 lines), cleared when you quit. + +**What leaves your Mac** +- Nothing by default. If you turn on "Check for updates", Lively requests the public + GitHub Releases page for this project; GitHub sees your IP address as for any web request. + No identifiers are sent. + +**Diagnostics.** "Copy Logs" and "Download Logs" create a text report you can attach to +an issue. It contains the app and macOS version, display count, a few settings and recent +log lines with home-folder paths replaced by `~`. Review it before sharing; Lively never +sends it for you. + +**Removing data.** Settings > Reset Data deletes the folder above and restores preferences. diff --git a/README.md b/README.md index 2f18c3d..54589ad 100644 --- a/README.md +++ b/README.md @@ -109,12 +109,20 @@ swift build # debug build swift test # unit tests (CI runs the same on macOS 15 / Xcode 26.3) ``` -Release packaging lives in `scripts/release.sh`; `scripts/smoke_verify.sh` smoke-checks a built app. +With Command Line Tools only (no Xcode), use `./test.sh`. `./scripts/check_hygiene.sh` runs repo checks. See [CONTRIBUTING.md](CONTRIBUTING.md). + +Release packaging lives in `scripts/release.sh` (`--dry-run` builds without publishing); `scripts/smoke_verify.sh` smoke-checks a built app. ## Privacy Assignments stay on your Mac. Security-scoped bookmarks remember the files you chose. No analytics. Lively makes no network requests unless you turn on **Check for Updates** (off by default), which asks the GitHub Releases API for the latest version at launch. Note: Lively is not sandboxed (it has to draw below the desktop icons), so this is a promise kept by the code rather than enforced by an entitlement. +Full policy: [PRIVACY.md](PRIVACY.md). Problems? Settings > Logs > Download Logs creates a redacted report (no file paths) you can attach to an issue. + +## More documentation + +[ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), [docs/RELEASING.md](docs/RELEASING.md), [docs/SANDBOX_READINESS.md](docs/SANDBOX_READINESS.md), [docs/PERFORMANCE.md](docs/PERFORMANCE.md), [docs/PRODUCTION_READINESS.md](docs/PRODUCTION_READINESS.md). + ## License MIT. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..da77e75 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security policy + +## Reporting a vulnerability + +Please use GitHub private vulnerability reporting (Security tab, "Report a +vulnerability") on this repository. Do not open a public issue for security +problems. Expect an acknowledgement within a week; this is a volunteer project. + +## Supported versions + +Only the latest release receives fixes. + +## Threat model summary + +- **Offline by design.** No account, telemetry or analytics. The only outbound + request is the opt-in update check to the GitHub Releases API (Settings). +- **File access.** Videos are opened through user-selected files and + security-scoped, read-only bookmarks. Lively never scans folders. Imported + library copies live in the app's own Application Support folder. +- **Input validation.** Only `.mp4`, `.mov`, `.m4v` with H.264/HEVC tracks are accepted; + damaged files are rejected; corrupt config files are quarantined, not trusted. +- **Not sandboxed.** The desktop-level window requires it (see `docs/SANDBOX_READINESS.md`). + The hardened runtime is enabled and entitlements are minimal. +- **Distribution.** Release builds are currently ad-hoc signed and not notarized; + see `docs/RELEASING.md`. Verify downloads against the SHA-256 published with the release. +- **Logs and diagnostics** never include file paths or names; exports redact home paths. diff --git a/Sources/Lively/Logic/AppPreferences.swift b/Sources/Lively/Logic/AppPreferences.swift index d71e3fe..07bbaf2 100644 --- a/Sources/Lively/Logic/AppPreferences.swift +++ b/Sources/Lively/Logic/AppPreferences.swift @@ -120,6 +120,7 @@ public final class AppPreferences: ObservableObject { } private enum Keys { + static let schemaVersion = "prefs.schemaVersion" static let startMinimized = "prefs.startMinimized" static let pauseOnBattery = "prefs.pauseOnBattery" static let batteryPauseThreshold = "prefs.batteryPauseThreshold" @@ -137,6 +138,10 @@ public final class AppPreferences: ObservableObject { static let hasSeenSpacesCoach = "prefs.hasSeenSpacesCoach" } + /// Bump when a stored preference changes meaning; add a step to `migrate`. + /// 0 = stores written before versioning existed. + nonisolated public static let currentSchemaVersion = 1 + private let defaults: UserDefaults @Published public var startMinimized: Bool { @@ -221,6 +226,8 @@ public final class AppPreferences: ObservableObject { defaults.set(true, forKey: Keys.updateCheckOptInMigrated) } + Self.migrate(defaults) + self.startMinimized = defaults.bool(forKey: Keys.startMinimized) self.pauseOnBattery = defaults.bool(forKey: Keys.pauseOnBattery) self.checkForUpdates = defaults.bool(forKey: Keys.checkForUpdates) @@ -269,6 +276,16 @@ public final class AppPreferences: ObservableObject { defaults.removeObject(forKey: Keys.frameRateCapLegacy) } + /// Runs once-per-version upgrades. Existing steps are idempotent so a store + /// that predates versioning (0) is upgraded exactly like any other. + nonisolated static func migrate(_ defaults: UserDefaults) { + let stored = defaults.integer(forKey: Keys.schemaVersion) + guard stored < currentSchemaVersion else { return } + // v0 -> v1: the legacy frame-rate cap became "max resolution". + // The value mapping lives in init; only record the version here. + defaults.set(currentSchemaVersion, forKey: Keys.schemaVersion) + } + nonisolated public static func clampThreshold(_ value: Double) -> Double { min(max(value, batteryThresholdRange.lowerBound), batteryThresholdRange.upperBound) } diff --git a/Sources/Lively/Logic/Diagnostics.swift b/Sources/Lively/Logic/Diagnostics.swift new file mode 100644 index 0000000..3fefb77 --- /dev/null +++ b/Sources/Lively/Logic/Diagnostics.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Removes personal data from text before it is shown in an exported report. +public enum DiagnosticsRedactor { + /// Replaces the user's home directory and any `/Users//` path with `~`. + public static func redact(_ text: String, homeDirectory: String = NSHomeDirectory()) -> String { + var result = text + if homeDirectory.count > 1 { + result = result.replacingOccurrences(of: homeDirectory, with: "~") + } + // Other accounts and paths copied from elsewhere (e.g. external volumes). + let pattern = #"/Users/[^/\s]+"# + if let regex = try? NSRegularExpression(pattern: pattern) { + let range = NSRange(result.startIndex..., in: result) + result = regex.stringByReplacingMatches(in: result, range: range, withTemplate: "~") + } + return result + } +} + +/// A plain-text support report the user can save and attach to an issue. +/// Contains no video paths, file names or personal data: only versions, counts +/// and redacted log lines. Nothing is sent anywhere; the user decides. +public struct DiagnosticsReport: Sendable { + public struct Snapshot: Sendable { + public var appVersion: String + public var build: String + public var osVersion: String + public var displayCount: Int + public var assignmentCount: Int? + public var isPaused: Bool? + public var preferences: [String: String] + public var previousRunEndedUncleanly: Bool + public var logLines: [String] + + public init( + appVersion: String, + build: String, + osVersion: String, + displayCount: Int, + assignmentCount: Int?, + isPaused: Bool?, + preferences: [String: String], + previousRunEndedUncleanly: Bool, + logLines: [String] + ) { + self.appVersion = appVersion + self.build = build + self.osVersion = osVersion + self.displayCount = displayCount + self.assignmentCount = assignmentCount + self.isPaused = isPaused + self.preferences = preferences + self.previousRunEndedUncleanly = previousRunEndedUncleanly + self.logLines = logLines + } + } + + /// Only the most recent lines are exported, to keep reports small. + public static let maxLogLines = 200 + + public static func render(_ snapshot: Snapshot, homeDirectory: String = NSHomeDirectory()) -> String { + var lines: [String] = [] + lines.append("Lively diagnostics") + lines.append("App version: \(snapshot.appVersion) (\(snapshot.build))") + lines.append("macOS: \(snapshot.osVersion)") + lines.append("Displays: \(snapshot.displayCount)") + if let count = snapshot.assignmentCount { + lines.append("Wallpaper assignments: \(count)") + } + if let paused = snapshot.isPaused { + lines.append("Playback paused: \(paused ? "yes" : "no")") + } + lines.append("Previous run ended unexpectedly: \(snapshot.previousRunEndedUncleanly ? "yes" : "no")") + lines.append("") + lines.append("Preferences") + for key in snapshot.preferences.keys.sorted() { + lines.append(" \(key) = \(snapshot.preferences[key] ?? "")") + } + lines.append("") + lines.append("Recent log (last \(maxLogLines) lines, paths redacted)") + let tail = snapshot.logLines.suffix(maxLogLines) + for line in tail { + lines.append(DiagnosticsRedactor.redact(line, homeDirectory: homeDirectory)) + } + return lines.joined(separator: "\n") + "\n" + } +} + +/// Detects that the previous run did not shut down cleanly (crash, force quit, +/// power loss) using a marker file that exists only while the app is running. +public struct RunMarker: Sendable { + public let url: URL + + public init(url: URL) { + self.url = url + } + + 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")) + } + + /// Call once at launch. Returns true when the previous run left its marker behind. + @discardableResult + public func begin() -> Bool { + let fm = FileManager.default + let wasUnclean = fm.fileExists(atPath: url.path) + try? fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? Data().write(to: url, options: .atomic) + return wasUnclean + } + + /// Call on normal termination. + public func end() { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/Sources/Lively/Logic/LivelyLogger.swift b/Sources/Lively/Logic/LivelyLogger.swift index 7fc0a76..0840a1c 100644 --- a/Sources/Lively/Logic/LivelyLogger.swift +++ b/Sources/Lively/Logic/LivelyLogger.swift @@ -61,6 +61,26 @@ public final class LogStore: ObservableObject { public var allLogsFormatted: String { entries.map(\.text).joined(separator: "\n") } + + /// Set at launch from `RunMarker.begin()`. + public var previousRunEndedUncleanly = false + + /// Support report with versions, counts and redacted logs (no file paths). + public func diagnosticsReport(displayCount: Int, preferences: [String: String]) -> String { + let info = Bundle.main.infoDictionary + let snapshot = DiagnosticsReport.Snapshot( + appVersion: info?["CFBundleShortVersionString"] as? String ?? "dev", + build: info?["CFBundleVersion"] as? String ?? "0", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + displayCount: displayCount, + assignmentCount: nil, + isPaused: nil, + preferences: preferences, + previousRunEndedUncleanly: previousRunEndedUncleanly, + logLines: entries.map(\.text) + ) + return DiagnosticsReport.render(snapshot) + } } public struct LivelyCategoryLogger: Sendable { diff --git a/Sources/Lively/UI/LoggerView.swift b/Sources/Lively/UI/LoggerView.swift index 69e0a6a..7e2e670 100644 --- a/Sources/Lively/UI/LoggerView.swift +++ b/Sources/Lively/UI/LoggerView.swift @@ -231,7 +231,7 @@ public struct LoggerView: View { private func copyLogs() { let pasteboard = NSPasteboard.general pasteboard.clearContents() - pasteboard.setString(logStore.allLogsFormatted, forType: .string) + pasteboard.setString(diagnosticsText(), forType: .string) isCopied = true Task { @MainActor in try? await Task.sleep(for: .seconds(2)) @@ -247,7 +247,7 @@ public struct LoggerView: View { panel.begin { response in guard response == .OK, let url = panel.url else { return } do { - try logStore.allLogsFormatted.write(to: url, atomically: true, encoding: .utf8) + try diagnosticsText().write(to: url, atomically: true, encoding: .utf8) LivelyLogger.config.info("Exported logs to \(url.lastPathComponent)") } catch { LivelyLogger.config.error("Failed to export logs: \(error.localizedDescription)") @@ -255,6 +255,23 @@ public struct LoggerView: View { } } + /// Redacted support report (versions, display count, preferences, recent log). + private func diagnosticsText() -> String { + let prefs = AppPreferences.shared + return logStore.diagnosticsReport( + displayCount: NSScreen.screens.count, + preferences: [ + "pauseOnBattery": String(prefs.pauseOnBattery), + "batteryPauseThreshold": String(Int(prefs.batteryPauseThreshold)), + "playbackQuality": prefs.playbackQuality.rawValue, + "loopBehavior": prefs.loopBehavior.rawValue, + "hardwareDecoding": String(prefs.hardwareDecoding), + "maxResolution": prefs.maxResolution.rawValue, + "checkForUpdates": String(prefs.checkForUpdates), + ] + ) + } + private func dateStamp() -> String { let f = DateFormatter() f.dateFormat = "yyyyMMdd-HHmmss" diff --git a/Sources/LivelyApp/main.swift b/Sources/LivelyApp/main.swift index 9951d3e..5943c57 100644 --- a/Sources/LivelyApp/main.swift +++ b/Sources/LivelyApp/main.swift @@ -24,7 +24,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { configStore: configStore ) + 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)") + } // Background-only process — no Dock icon NSApp.setActivationPolicy(.accessory) @@ -38,6 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { removeEventMonitor() wallpaperController.tearDown() configStore.flushPendingPersist() + runMarker.end() } // MARK: - Menu Bar diff --git a/Tests/LivelyTests/AppPreferencesTests.swift b/Tests/LivelyTests/AppPreferencesTests.swift index 317fe58..11f2608 100644 --- a/Tests/LivelyTests/AppPreferencesTests.swift +++ b/Tests/LivelyTests/AppPreferencesTests.swift @@ -122,3 +122,27 @@ struct ResetDataTests { #expect(manager.resolvedURL(for: manager.items[0]) != nil) } } + +@MainActor +struct PreferencesSchemaTests { + @Test func freshAndLegacyStoresAreStampedWithCurrentVersion() { + let suite = "lively.tests.schema.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set("fps60", forKey: "prefs.frameRateCap") // pre-versioning store + let prefs = AppPreferences(defaults: defaults) + #expect(prefs.maxResolution == .p4k) + #expect(defaults.integer(forKey: "prefs.schemaVersion") == AppPreferences.currentSchemaVersion) + } + + @Test func newerSchemaVersionIsNotDowngraded() { + let suite = "lively.tests.schema2.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set(99, forKey: "prefs.schemaVersion") + _ = AppPreferences(defaults: defaults) + #expect(defaults.integer(forKey: "prefs.schemaVersion") == 99) + } +} diff --git a/Tests/LivelyTests/DiagnosticsTests.swift b/Tests/LivelyTests/DiagnosticsTests.swift new file mode 100644 index 0000000..5236439 --- /dev/null +++ b/Tests/LivelyTests/DiagnosticsTests.swift @@ -0,0 +1,62 @@ +import Testing +import Foundation +@testable import LivelyCore + +struct DiagnosticsTests { + + @Test func redactorRemovesHomeAndOtherUserPaths() { + let text = "Opened /Users/alice/Movies/a.mp4 and /Users/bob/x.mov" + let out = DiagnosticsRedactor.redact(text, homeDirectory: "/Users/alice") + #expect(!out.contains("alice")) + #expect(!out.contains("bob")) + #expect(out.contains("~/Movies/a.mp4")) + } + + @Test func redactorLeavesPlainTextAlone() { + #expect(DiagnosticsRedactor.redact("Loaded 3 assignment(s)", homeDirectory: "/Users/alice") == "Loaded 3 assignment(s)") + } + + @Test func reportContainsSummaryAndRedactsLog() { + let snapshot = DiagnosticsReport.Snapshot( + appVersion: "1.2.0", build: "120", osVersion: "macOS 14", + displayCount: 2, assignmentCount: 3, isPaused: false, + preferences: ["b": "2", "a": "1"], + previousRunEndedUncleanly: true, + logLines: ["failed /Users/alice/Movies/secret.mp4"] + ) + let report = DiagnosticsReport.render(snapshot, homeDirectory: "/Users/alice") + #expect(report.contains("App version: 1.2.0 (120)")) + #expect(report.contains("Displays: 2")) + #expect(report.contains("Previous run ended unexpectedly: yes")) + #expect(!report.contains("alice")) + // Preferences are listed in a stable, sorted order. + let a = report.range(of: "a = 1")!.lowerBound + let b = report.range(of: "b = 2")!.lowerBound + #expect(a < b) + } + + @Test func reportKeepsOnlyTheLastLines() { + let lines = (0..<500).map { "line \($0)" } + let snapshot = DiagnosticsReport.Snapshot( + appVersion: "1", build: "1", osVersion: "x", displayCount: 1, + assignmentCount: 0, isPaused: false, preferences: [:], + previousRunEndedUncleanly: false, logLines: lines + ) + let report = DiagnosticsReport.render(snapshot) + #expect(report.contains("line 499")) + #expect(!report.contains("line 299\n")) + #expect(report.contains("line 300")) + } + + @Test func runMarkerDetectsUncleanExit() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("lively-marker-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let marker = RunMarker(url: dir.appendingPathComponent("running.marker")) + + #expect(marker.begin() == false) // first ever launch + #expect(marker.begin() == true) // previous run never called end() + marker.end() + #expect(marker.begin() == false) // clean shutdown last time + marker.end() + } +} diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..8feae37 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,17 @@ +# CPU, GPU and battery budget + +Targets (to be measured with Activity Monitor / Instruments on real hardware; they +were not measured in the audit environment): + +- Idle with no wallpaper assigned: no decoding, negligible CPU. +- Playing 1080p HEVC on Apple silicon: hardware decode, low single-digit CPU percent per display. +- Nothing visible, nothing decoded: playback stops when displays or the Mac sleep, the + screen is locked or the screen saver runs, the user session is inactive, or the + wallpaper window is fully occluded (for example by a full-screen app). +- Battery: playback pauses at or below 25% on battery (forced) and at the user threshold + (default 35%) when "Pause on Battery" is on; it resumes on AC. +- Thermal: playback is throttled/paused under serious thermal pressure. +- User controls: Playback Quality (peak bit rate cap), Max Resolution, hardware decoding. + +Known gap: the wallpaper video itself does not yet react to the system Reduce Motion +setting. Tracked as a follow-up (design decision: pause on first frame vs. keep playing). diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md new file mode 100644 index 0000000..2af1a56 --- /dev/null +++ b/docs/PRODUCTION_READINESS.md @@ -0,0 +1,63 @@ +# Production readiness + +Audit of Lively against a ten-dimension rubric. Scores are 0-10 and are +deliberately conservative: a 10 means "verifiably complete", and anything that +could not be run in the audit environment is marked as unverified rather than +assumed to work. + +Audit environment: Command Line Tools only (no Xcode), Swift 6.4 toolchain, +macOS 27 SDK. `xcodebuild`, XCTest, SwiftLint and GitHub Actions were not +available, so none of those were exercised. + +## Baseline (2026-10-12, master at b0a64aa) + +| # | Dimension | Score | Evidence | +|---|-----------|-------|----------| +| 1 | Build and run reproducible | 6 | `swift build` and `package.sh` work on CLT. Version string is duplicated in `Info.plist` and a heredoc inside `package.sh` (the repo `Info.plist` is not used). No pinned toolchain note beyond CI. | +| 2 | Tests | 6 | 13 Swift Testing suites cover config store, persistence, preferences, battery policy, suspension, update checker, video validation. No playlist feature exists (README: "No playlists yet"). No schema-version or diagnostics tests. | +| 3 | Static quality | 2 | No SwiftLint or formatter config, no lint step anywhere. | +| 4 | Security | 5 | Hardened runtime and ad-hoc signing in `package.sh`; no secrets found. Not sandboxed by design but no written threat model, no sandbox-readiness analysis, no `SECURITY.md`. | +| 5 | Reliability | 7 | Handles bad or missing video, moved files, sleep/wake, lock, screen saver, display hot-plug (`SpaceMonitor`), thermal state, corrupt config backup, one-time preference migration. Preferences have no schema version. No record of unclean exits. | +| 6 | Config and packaging | 4 | `CHANGELOG.md`, `scripts/release.sh`, Homebrew cask exist. No release process document, no notarization checklist, version not single-sourced, `release.sh` publishes straight to a GitHub Release with no dry-run. | +| 7 | CI | 5 | `ci.yml` builds, tests, packages and verifies the bundle; `release.yml` exists. No lint, no hygiene check, no dependency updates for actions, not runnable right now (account billing). | +| 8 | Docs | 4 | `README.md` and a short `AGENTS.md`. Missing `ARCHITECTURE.md`, `CONTRIBUTING.md`, `SECURITY.md`, `PRIVACY.md`. Stale planning files (`HANDOFF.md`, `docs/lively_*.md`). | +| 9 | UX, accessibility, performance | 6 | VoiceOver labels present (68 modifiers), Reduce Motion respected in the settings UI, wallpapers pause on battery, sleep, lock, occlusion and thermal pressure. No documented resource budget; wallpaper does not react to the system Reduce Motion setting. | +| 10 | Operability | 4 | In-memory log view with copy, plus `os.Logger`. No diagnostics export, no crash or unclean-exit detection, no support guidance. | + +Repo hygiene findings: Finder-style duplicate files (`* 2.*`) sit untracked in +the working tree of the original clone; no personal paths or secrets are +tracked. One stale branch, `harsha/quirky-nightingale-e0cd4b` (1 commit), is +left untouched. + +## After (2026-10-13, branch prod/10-of-10-20261012) + +| # | Dimension | Before | After | What changed / why not 10 | +|---|-----------|--------|-------|---------------------------| +| 1 | Build and run reproducible | 6 | 8 | Version single-sourced from `Info.plist`; `package.sh` verified (plist lint, codesign verify). Not 10: no pinned Xcode locally, never built under full Xcode here. | +| 2 | Tests | 6 | 8 | 87 tests in 18 suites pass via `./test.sh`; added diagnostics, redaction, run-marker and preferences-schema tests. Not 10: no coverage of AVFoundation/window code; XCTest/Xcode run not done. | +| 3 | Static quality | 2 | 6 | `.swiftlint.yml`, `.editorconfig`, `scripts/check_hygiene.sh` (run, passes). SwiftLint itself was not installed, so the config is unverified against the code. | +| 4 | Security and privacy | 5 | 8 | `SECURITY.md`, `PRIVACY.md`, `docs/SANDBOX_READINESS.md`; hygiene script scans for secrets and personal paths. Not 10: not sandboxed (by design), not notarized, no dependencies to audit. | +| 5 | Reliability | 7 | 8 | Preferences schema version + migration tests; unclean-exit detection. Config file still has no schema version (decoding is lossy and backed up, so left unchanged for backward compatibility). | +| 6 | Config and packaging | 4 | 7 | `docs/RELEASING.md` with notarization checklist, `release.sh --dry-run`, Dependabot. Not 10: no Developer ID signing or notarization (owner). | +| 7 | CI | 5 | 7 | Added hygiene + SwiftLint job. Workflows could not run (account billing) and `actionlint` was unavailable; YAML syntax was checked only. | +| 8 | Docs | 4 | 9 | README links, ARCHITECTURE, CONTRIBUTING, SECURITY, PRIVACY, RELEASING, SANDBOX_READINESS, PERFORMANCE, AGENTS. Stale planning files (`HANDOFF.md`, `docs/lively_*.md`) remain; owner may archive. | +| 9 | UX, accessibility, performance | 6 | 6 | Budget documented, but nothing measured and no UI change was verifiable without Xcode. Wallpaper video does not yet honour system Reduce Motion (documented gap). | +| 10 | Operability | 4 | 7 | Redacted diagnostics export (copy/download), unclean-exit detection, run marker. Not 10: no crash reporter, no persistent on-disk log. | + +### Verified + +- `swift build` succeeds; `./test.sh`: 87 tests, 18 suites pass; `./scripts/check_hygiene.sh` passes. +- `package.sh` output has a valid plist, version 1.2.0 from `Info.plist`, and passes `codesign --verify --deep --strict`. + +### Not verified (needs Xcode / hardware / accounts) + +- SwiftUI/AppKit behaviour of the new Logs export and the run marker at launch (compiled, not run). +- SwiftLint output, GitHub Actions runs, XCTest under Xcode, real multi-display, sleep/wake and battery behaviour, CPU/GPU numbers. + +### Blocked on owner + +- Apple Developer ID certificate, notarization credentials and the first notarized release. +- Full Xcode run of the app and tests; real-hardware QA from `docs/RELEASING.md`. +- GitHub billing so CI can run; enabling private vulnerability reporting for `SECURITY.md`. +- Decision on archiving stale planning docs, and on the wallpaper Reduce Motion behaviour. +- Branch `harsha/quirky-nightingale-e0cd4b` (1 commit) left untouched. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..3ea8da3 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,42 @@ +# Releasing and notarization checklist + +This is a checklist for the maintainer. Nothing here is automated end to end, and +no release has been cut as part of the production-readiness work. + +## Current state + +`package.sh` builds `Lively.app` with an ad-hoc signature and the hardened runtime. +That is enough to run locally but Gatekeeper warns on downloaded copies; the README +documents the `xattr` workaround. + +## Prerequisites (owner action) + +- Apple Developer Program membership and a **Developer ID Application** certificate. +- An app-specific password or App Store Connect API key for `notarytool` (store with + `xcrun notarytool store-credentials`; never commit them). +- Full Xcode (for `notarytool`, `stapler`). + +## Steps + +1. Update the version in `Info.plist` (the only place; `package.sh` reads it) and move + CHANGELOG "Unreleased" entries under the new version. +2. `./scripts/check_hygiene.sh && ./test.sh` must pass; run the manual QA list below. +3. `./scripts/release.sh vX.Y.Z --dry-run` to build and zip without touching GitHub. +4. Sign for distribution (replace the ad-hoc step in `package.sh`): + `codesign --force --deep --options runtime --timestamp --sign "Developer ID Application: NAME (TEAMID)" --entitlements entitlements.plist Lively.app` +5. Notarize: `ditto -c -k --keepParent Lively.app Lively.zip`, then + `xcrun notarytool submit Lively.zip --keychain-profile --wait`. +6. Staple and verify: `xcrun stapler staple Lively.app`, `spctl -a -vv Lively.app`, + `codesign --verify --deep --strict Lively.app`. +7. Re-zip / build the DMG (`scripts/make-dmg.sh`), compute SHA-256, tag, publish the + GitHub Release, update `Casks/lively.rb` version and sha256. +8. Remove the "not notarized" notes from README once releases are notarized. + +## Manual QA before every release + +- Fresh install: welcome sheet, assign a video, relaunch, wallpaper restored. +- Corrupt/missing video falls back to the macOS wallpaper with a clear message. +- Unplug/replug a display; switch Spaces; sleep/wake; lock/unlock; screen saver. +- On battery: playback pauses at the threshold; resumes on AC. +- Login item toggle; Reset Data; Download Logs contains no personal paths. +- VoiceOver: every control in the popover is reachable and labelled; Reduce Motion on. diff --git a/docs/SANDBOX_READINESS.md b/docs/SANDBOX_READINESS.md new file mode 100644 index 0000000..338aeee --- /dev/null +++ b/docs/SANDBOX_READINESS.md @@ -0,0 +1,31 @@ +# Sandbox and entitlement readiness + +Lively is **not sandboxed** (`com.apple.security.app-sandbox` = false). Reasons: + +1. The wallpaper window sits at the desktop window level, below desktop icons. The + sandbox does not permit this window level reliably for third-party apps. +2. Per-Space identification reads the desktop picture of each Space. + +Therefore the Mac App Store is not currently an option; distribution is Developer ID +plus notarization (`docs/RELEASING.md`). + +## Entitlements (`entitlements.plist`) + +| Entitlement | Why | +|-------------|-----| +| `files.user-selected.read-only` | Read videos the user picks. | +| `files.bookmarks.app-scope` | Persist access to those videos across launches. | + +No network client, camera, microphone, location or Apple Events entitlements are +needed or requested. The hardened runtime is enabled with no exceptions. + +## File-access scope + +Only files the user selects (or drops) are opened, via read-only security-scoped +bookmarks. Library imports are copied into Application Support. Nothing scans the disk. + +## If sandboxing becomes feasible later + +Add `com.apple.security.network.client` only if the update check stays, keep the two +file entitlements, move the config folder into the container, and re-test desktop-level +window behaviour and SMAppService login items on a sandboxed build. diff --git a/package.sh b/package.sh index 47a08b8..a3d0e3d 100755 --- a/package.sh +++ b/package.sh @@ -24,6 +24,9 @@ else fi # Configuration +# Single source of truth for the version: Info.plist in the repo root. +APP_VERSION="$(plutil -extract CFBundleShortVersionString raw Info.plist)" +APP_BUILD="$(plutil -extract CFBundleVersion raw Info.plist)" APP_NAME="Lively" OUTPUT_DIR="${LIVELY_OUTPUT_DIR:-/private/tmp/LivelyOutput}" APP_BUNDLE="${OUTPUT_DIR}/${APP_NAME}.app" @@ -91,12 +94,16 @@ cat > "${APP_BUNDLE}/Contents/Info.plist" <com.lively.app CFBundleName ${APP_NAME} + CFBundlePackageType + APPL + NSHighResolutionCapable + CFBundleIconFile AppIcon CFBundleShortVersionString - 1.2.0 + ${APP_VERSION} CFBundleVersion - 120 + ${APP_BUILD} LSMinimumSystemVersion 14.0 LSUIElement diff --git a/scripts/check_hygiene.sh b/scripts/check_hygiene.sh new file mode 100755 index 0000000..814efff --- /dev/null +++ b/scripts/check_hygiene.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Repo hygiene checks that need no extra tooling. Run locally and in CI. +# - no personal absolute paths in tracked files +# - no obvious secrets +# - no Finder-style duplicate files ("name 2.ext") +# - version in Info.plist matches the top CHANGELOG release-or-unreleased rules +# - plists and shell scripts are syntactically valid +set -euo pipefail +cd "$(dirname "$0")/.." +fail=0 +note() { echo "FAIL: $*"; fail=1; } + +files="$(git ls-files | grep -v -e '^assets/' -e '^\.agents/' -e '^\.claude/' -e '\.png$' -e '\.icns$')" + +# 1. Personal paths (placeholders used in tests/docs are allowed). +if echo "$files" | xargs grep -nIE '/Users/[A-Za-z0-9._-]+' 2>/dev/null \ + | grep -vE '/Users/(alice|bob|Shared|name|you|username|<)' \ + | grep -v 'scripts/check_hygiene.sh'; then + note "personal absolute path found in tracked files" +fi + +# 2. Secrets. +if echo "$files" | xargs grep -nIE '(AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|xox[abp]-[A-Za-z0-9-]{10,})' 2>/dev/null \ + | grep -v 'scripts/check_hygiene.sh'; then + note "possible secret found" +fi +if git ls-files | grep -iE '(^|/)(\.env(\..*)?|.*\.(p12|pem|cer|mobileprovision|provisionprofile))$' | grep -v '\.example$'; then + note "credential-like file tracked" +fi + +# 3. Duplicate files. +if git ls-files | grep -E ' [0-9]+(\.[A-Za-z0-9]+)?$'; then + note "Finder-style duplicate file tracked" +fi + +# 4. Syntax checks. +plutil -lint Info.plist entitlements.plist >/dev/null || note "plist lint failed" +for f in package.sh test.sh scripts/*.sh; do bash -n "$f" || note "bash syntax: $f"; done + +# 5. Version sanity: Info.plist is the single source for package.sh. +v="$(plutil -extract CFBundleShortVersionString raw Info.plist)" +echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || note "Info.plist version '$v' is not semver" +if grep -nE 'CFBundleShortVersionString' -A1 package.sh | grep -E '[0-9]'; then + note "package.sh hardcodes a version" +fi + +if [ "$fail" = 0 ]; then echo "hygiene: OK"; else exit 1; fi diff --git a/scripts/release.sh b/scripts/release.sh index fb3c829..24c4589 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -10,9 +10,12 @@ set -euo pipefail TAG="${1:-}" if [ -z "$TAG" ]; then - echo "Usage: ./scripts/release.sh e.g. v1.0.0" + echo "Usage: ./scripts/release.sh [--dry-run] e.g. v1.0.0" + echo " --dry-run build and zip locally; never contact GitHub" exit 1 fi +DRY_RUN=0 +[ "${2:-}" = "--dry-run" ] && DRY_RUN=1 VERSION="${TAG#v}" ZIP="Lively-${VERSION}-macOS.zip" @@ -29,7 +32,9 @@ ditto -c -k --sequesterRsrc --keepParent \ SHA=$(shasum -a 256 "${ZIP}" | awk '{print $1}') echo "SHA256: ${SHA}" -if command -v gh &>/dev/null; then +if [ "$DRY_RUN" = "1" ]; then + echo "Dry run: skipping GitHub release upload." +elif command -v gh &>/dev/null; then if ! gh release view "$TAG" --repo harshabala/Lively &>/dev/null; then gh release create "$TAG" --repo harshabala/Lively --title "Lively ${TAG}" \ --notes-file CHANGELOG.md diff --git a/test.sh b/test.sh index f8bf181..b86ed3b 100755 --- a/test.sh +++ b/test.sh @@ -9,7 +9,7 @@ set -euo pipefail CLT_FRAMEWORKS="/Library/Developer/CommandLineTools/Library/Developer/Frameworks" CLT_DEVLIB="/Library/Developer/CommandLineTools/Library/Developer/usr/lib" -WORKSPACE_CACHE="/tmp/lively-test-cache" +WORKSPACE_CACHE="${LIVELY_TEST_CACHE:-/tmp/lively-test-cache}" mkdir -p "$WORKSPACE_CACHE"/swiftpm "$WORKSPACE_CACHE"/swiftpm-security "$WORKSPACE_CACHE"/clang "$WORKSPACE_CACHE"/swift-build