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
11 changes: 11 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: swift
directory: /
schedule:
interval: weekly
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
53 changes: 53 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
26 changes: 26 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions Sources/Lively/Logic/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading