From 2be74355926e144e00cceeed0bf145b49a57c9c3 Mon Sep 17 00:00:00 2001 From: "Raz.Bahri" Date: Fri, 8 May 2026 22:46:05 +0300 Subject: [PATCH 1/3] docs: spec for menu bar app (v0.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin observer + commander design — Swift+SwiftUI menu bar app that reads state.db / sync.log / config.yaml and shells out to the existing CLI. Adds a small Python diff (pause flag, pause/unpause subcommands) and drops the dead sync.interval_seconds field as side work. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-08-menu-bar-app-design.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-menu-bar-app-design.md diff --git a/docs/superpowers/specs/2026-05-08-menu-bar-app-design.md b/docs/superpowers/specs/2026-05-08-menu-bar-app-design.md new file mode 100644 index 0000000..1533386 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-menu-bar-app-design.md @@ -0,0 +1,239 @@ +# ogmac menu bar app — design + +**Status:** draft +**Author:** Raz +**Date:** 2026-05-08 + +## Goal + +Add a minimal macOS menu bar app for ogmac that exposes three things: + +1. **Status** — sync health at a glance. +2. **Settings** — view and edit the most-used config fields. +3. **History** — recent sync runs. + +The app is the productized 1.0 face of ogmac: most users will never open the CLI again. + +## Non-goals (v0.1) + +- In-app OAuth / Google login flow. First-launch points users to `ogmac login` in Terminal. +- Replacing `install.sh`. A separate installation rework is happening in parallel. +- In-app calendar pickers (free-text calendar IDs for now). +- Notarization / code signing. Ship unsigned, distribute via Homebrew cask with `xattr` post-install. +- Reboot-persistent pause via `launchctl disable`. Pause is a flag in `state.db`; the daemon respects it. +- `failure.*` settings in the GUI (power-user knobs stay YAML-only). +- Push notification refinement. Existing `notify_on_failure` keeps its current behavior. +- "Syncing" icon state for launchd-driven runs. Only user-triggered syncs animate the icon. + +## Architecture + +Thin observer + commander. The Python daemon and `launchctl` schedule are unchanged; the app reads state and shells out for actions. + +``` +┌─────────────────────────┐ ┌──────────────────────────────────────┐ +│ Swift menu bar app │ │ Existing ogmac daemon │ +│ (LSUIElement, no Dock) │ │ │ +│ │ read │ ~/.config/ogmac/config.yaml │ +│ Status panel ────────►├────────►│ ~/Library/Application Support/ │ +│ Settings sheet ───────►│ write │ ogmac/state.db (SQLite) │ +│ History view ────────►│ tail │ ~/Library/Logs/ogmac/sync.log │ +│ │ │ │ +│ Sync now ────────┼─Process─► ogmac sync │ +│ Resume ────────┼─Process─► ogmac resume │ +│ Pause ────────┼─Process─► ogmac pause (new subcommand) │ +│ │ │ │ +│ │ │ launchd plist (every 15m) │ +└─────────────────────────┘ └──────────────────────────────────────┘ +``` + +## Components + +### Swift app modules + +| Module | Responsibility | +|---|---| +| `OgmacApp` | App entry point, `LSUIElement`, `MenuBarExtra` | +| `StatusController` | Polls state every 5s when panel is open, every 60s when closed; derives icon state | +| `StateReader` | Read-only SQLite open against `state.db`; reads run state and the new `paused` flag | +| `LogReader` | Tails `sync.log`, parses the last N runs into structured records | +| `ConfigStore` | Loads, validates (light), and atomically writes `~/.config/ogmac/config.yaml` | +| `OgmacRunner` | Wraps `Process` invocations of the `ogmac` CLI | +| `MenuPanelView` | SwiftUI view for the dropdown | +| `SettingsScene` | SwiftUI `Settings` scene with three tabs | +| `HistoryView` | List of recent sync records | + +### Python-side changes (small) + +| Change | File | Purpose | +|---|---|---| +| Add `paused` flag to state | `state.py` | Mirror of `disabled` but user-set; survives reboots | +| `_run_sync` skips when paused | `cli.py` | Same pattern as `state.is_disabled` early return | +| New `ogmac pause` / `ogmac unpause` subcommands | `cli.py` | App invokes these instead of touching `state.db` directly | +| **Side fix:** drop dead `sync.interval_seconds` from `Config` | `config.py`, `docs/setup.md` | Currently never read; misleads users | + +The Python diff is intentionally small. `state.db` writes stay in Python; the Swift app only reads it. + +## Status + +### Icon states + +| State | SF Symbol / treatment | Condition | +|---|---|---| +| **Healthy** | `circle.fill` (accent tint) | `disabled=0`, `paused=0`, last success within `2 × interval` (i.e., within 30 minutes) | +| **Warning** | `exclamationmark.triangle.fill` (yellow) | `disabled=0`, `paused=0`, last success between 30 min and 24 h ago | +| **Error** | `xmark.octagon.fill` (red) | `disabled=0`, `paused=0`, last success > 24 h OR `consecutive_failures > 0` and last attempt failed | +| **Auto-disabled** | `xmark.octagon.fill` with badge | `disabled=1` (auto-disable triggered) | +| **Paused** | `pause.circle.fill` (grayscale) | `paused=1` | +| **Syncing** | rotating `arrow.triangle.2.circlepath` | A user-triggered `Sync now` is in flight (process is alive) | +| **Needs login** | `circle` (hollow) | Token-refresh failure detected on last run (parsed from log: `TokenRefreshError`) | + +State is mutually exclusive. Resolution order: Syncing → Paused → Auto-disabled → Needs login → Error → Warning → Healthy. + +### Dropdown panel + +``` +┌────────────────────────────────────┐ +│ ogmac ● Healthy │ +│ Last sync · 2 min ago │ +│ Next sync · in 13 min │ +│ │ +│ Read Outlook (EventKit) │ +│ Write Work (synced) — Google │ +│ │ +│ Last run │ +│ ✓ 14 created · 2 updated · 1 del │ +│ │ +│ ┌────────────────────────────────┐ │ +│ │ Sync now │ │ +│ └────────────────────────────────┘ │ +│ │ +│ Settings… │ +│ History… │ +│ Pause │ +│ Quit ogmac │ +└────────────────────────────────────┘ +``` + +- "Next sync" is computed from the plist's `StartCalendarInterval` (next of :00/:15/:30/:45 from now). +- "Last run" is the most recent `reconcile create=… update=… delete=… skip=…` line in `sync.log`. +- "Read" / "Write" labels derive from `outlook.read_method` and `google.target_calendar_id` (resolve calendar name once via `ogmac` if available; otherwise show the ID). + +## Settings + +Native macOS `Settings` scene, three tabs. + +### Connection tab + +| Field | Type | Bound to | Notes | +|---|---|---|---| +| Read backend | Segmented picker | `outlook.read_method` | `apple_calendar` / `microsoft_graph` | +| Outlook account | Text field | `outlook.account` | | +| Outlook source calendar | Text field | `outlook.source_calendar` | Free-text in v0.1 | +| Google account | Text field | `google.account` | | +| Google client secret path | Path field with "Choose…" | `google.client_secret_path` | | +| Google target calendar ID | Text field | `google.target_calendar_id` | Free-text in v0.1 | + +Switching read backend shows an inline note: "You may need to run `ogmac login microsoft` in Terminal." + +### Sync tab + +| Field | Type | Bound to | +|---|---|---| +| Window — past | Stepper (1–30 days) | `sync.window_past_days` | +| Window — future | Stepper (1–365 days) | `sync.window_future_days` | +| Schedule | Read-only text + "Edit in plist…" link | `StartCalendarInterval` from plist | + +Schedule is **read-only** in v0.1. Editing `StartCalendarInterval` requires writing the plist + `launchctl bootout`/`bootstrap`. Out of scope; documented as known limitation. + +### Privacy tab + +| Field | Type | Bound to | +|---|---|---| +| Copy subject | Toggle | `privacy.copy_subject` | +| Copy location | Toggle | `privacy.copy_location` | +| Copy body | Toggle | `privacy.copy_body` | +| Copy attendees | Toggle (locked off, disabled) | `privacy.copy_attendees` | + +The locked attendees toggle has a tooltip: "Attendee syncing is disabled by design. See README → Privacy." + +### Settings persistence + +- Source of truth: `~/.config/ogmac/config.yaml`. +- Writes are atomic: write to `config.yaml.tmp`, `fsync`, `rename` over the original. +- The app loads on open and on save; no in-memory cache between sessions. +- Validation: minimal (non-empty strings, integer ranges). The Python `Config.load` will reject a bad write on the next sync; the app surfaces that via the icon's Error state, not a save-time block. + +## History + +Sheet listing the last 50 sync runs. + +| Column | Source | +|---|---| +| Started at | `sync.start` line timestamp | +| Result | `sync.success` or `sync.failure` | +| Duration | `duration_ms` from `sync.success` | +| Counts | `reconcile create=N update=N delete=N skip=N` | + +Implementation: `LogReader` scans `sync.log` (and rotated `sync.log.1…sync.log.10`) line by line, groups lines into runs by start/end markers. No structured runs table in v0.1 — defer to v0.2 if the parser becomes a maintenance burden. + +## Actions + +| Menu item | Implementation | +|---|---| +| Sync now | `Process` running `ogmac sync`; icon shows Syncing while alive | +| Pause | `Process` running `ogmac pause`; icon flips to Paused | +| Resume (when paused) | `Process` running `ogmac unpause` | +| Resume (when auto-disabled) | `Process` running `ogmac resume` | +| Settings… | Open Settings scene | +| History… | Open History sheet | +| Quit ogmac | Quit the menu bar app (does **not** stop launchd; tooltip clarifies) | + +`ogmac` binary path resolution: try `which ogmac` via `/usr/bin/env`, fall back to `~/.local/share/ogmac/venv/bin/ogmac` (the install.sh path). If neither exists, the app shows an "ogmac CLI not found" first-launch screen. + +## State and IPC contract + +| Resource | Owner | Reader | Writer | +|---|---|---|---| +| `~/.config/ogmac/config.yaml` | User | App, daemon | App | +| `~/Library/Application Support/ogmac/state.db` | Daemon | App (read-only), daemon | Daemon only | +| `~/Library/Logs/ogmac/sync.log` | Daemon | App (read-only), daemon | Daemon | +| `~/Library/LaunchAgents/com.ogmac.sync.plist` | install.sh | App (read-only) | install.sh | + +The app never writes to `state.db` or the plist in v0.1. + +## App lifecycle + +- `Info.plist`: `LSUIElement = true` (no Dock icon, no `App` menu). +- "Launch at login" toggle in Settings → Sync tab, implemented via `SMAppService.mainApp`. +- Quit removes the menu bar item but does **not** stop launchd. Tooltip on the menu item makes this explicit. + +## Distribution + +- Build: `xcodebuild` produces an unsigned `.app`. +- Distribute: Homebrew cask with `postflight` block running `xattr -d com.apple.quarantine "/Applications/ogmac.app"`. +- When sponsor lands: enroll in Apple Developer Program, codesign with Developer ID, notarize, drop the `xattr` workaround. Cask spec gets the SHA updated; users transparently move to the signed build. + +## EventKit / TCC + +The Swift app does **not** read EventKit directly in v0.1. All Calendar reads stay in the Python daemon. The app therefore does **not** need `NSCalendarsUsageDescription`. (When calendar pickers ship in v0.2, the app will either: shell out to a new `ogmac list-calendars` CLI, or add the entitlement and prompt.) + +## Side work (in scope, separate commit) + +1. Drop `sync.interval_seconds` from `Config` and `docs/setup.md`. The field is never read; `StartCalendarInterval` in the plist is the only schedule source. +2. Add `paused` boolean to `state.db` run-state. +3. Add `ogmac pause` and `ogmac unpause` subcommands. +4. Make `_run_sync` early-return when `state.is_paused` (mirror of `state.is_disabled`). + +## Testing + +- **Unit (Swift):** `LogReader` parser against fixture `sync.log` files (success run, failure run, partial run, rotated logs). `ConfigStore` round-trip (load → mutate → save → reload). +- **Unit (Python):** `state.is_paused` flag set/unset; `_run_sync` skip path when paused. +- **Manual:** boot the app, observe each icon state by manipulating `state.db` (sqlite3 CLI), exercise Sync now / Pause / Resume, change a setting and verify the next sync picks it up. +- No automated UI tests in v0.1. + +## Open questions deferred + +- v0.2 calendar pickers (`ogmac list-calendars` JSON output). +- v0.2 schedule editing (write plist + reload). +- v0.2 structured `runs` table in `state.db`. +- v0.2 in-app `ogmac login` flow with `ASWebAuthenticationSession`. From 17a88bba7047f301d230c45056d29b56065dccc8 Mon Sep 17 00:00:00 2001 From: "Raz.Bahri" Date: Fri, 8 May 2026 22:55:31 +0300 Subject: [PATCH 2/3] docs: agent team breakdown for menu bar app v0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7-agent decomposition with 4 execution waves. F+P parallel in Wave 0, R+C parallel in Wave 1, V+G parallel in Wave 2, I terminal in Wave 3. File ownership map is collision-free except for one sanctioned handoff (OgmacApp.swift, F→I). Co-Authored-By: Claude Opus 4.7 --- .../2026-05-08-menu-bar-app-team-breakdown.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-menu-bar-app-team-breakdown.md diff --git a/docs/superpowers/plans/2026-05-08-menu-bar-app-team-breakdown.md b/docs/superpowers/plans/2026-05-08-menu-bar-app-team-breakdown.md new file mode 100644 index 0000000..ded57d6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-menu-bar-app-team-breakdown.md @@ -0,0 +1,351 @@ +# ogmac menu bar app — Agent Team Breakdown + +**Source spec:** `docs/superpowers/specs/2026-05-08-menu-bar-app-design.md` +**Date:** 2026-05-08 +**Purpose:** How to parallelize the v0.1 menu bar app across a team of subagents — roles, file ownership, dependencies, and execution waves. + +--- + +## Headcount + +**7 specialist agents + 1 orchestrator (you).** + +Decomposition principle, same as v1: split by *responsibility boundary*, not by file count. The Swift app is layered into adapters → commander → views → integrator. The Python diff is its own agent because it runs in parallel with the Swift scaffold and ships independently. + +| ID | Role | Layer | +|---|---|---| +| F | Foundation (Swift scaffold) | scaffolding | +| P | Python diff (pause + side fix) | daemon | +| R | Read adapters | I/O (read-only) | +| C | Commander | I/O (subprocess) | +| V | Views (status + history) | UI | +| G | Settings UI | UI | +| I | Integrator + Packaging | wiring + ops | + +--- + +## Execution waves + +``` +Wave 0 ── F ─────────────────────────────────────► + └─ P ─────────────────────────────────────► + │ + ├── Wave 1 ── R ────────────────────► + │ C ────────────────────► + │ │ + │ ├── Wave 2 ── V ─► + │ └─ G ───► + │ │ + │ └── Wave 3 ── I ──► +``` + +- **Wave 0 (2 parallel):** F is the Swift blocker; P is fully independent (Python-only). They run concurrently. +- **Wave 1 (2 parallel):** R and C run after Wave 0 merges (both F and P). R needs P for the `paused` row contract. +- **Wave 2 (2 parallel):** V and G run after R + C merge. V also consumes C; G is read-only against R. +- **Wave 3 (sequential):** I wires `OgmacApp` to the real scenes, ships the Homebrew cask, updates docs. + +Wall-clock estimate: ≈ 4 dispatch rounds. Critical path: F → R → V → I (R is the longest single Wave 1 task because it carries fixture-heavy log-parser tests). + +--- + +## Agent specs + +### F — Foundation (Swift scaffold) + +**Mission:** Stand up the Xcode project so every other Swift agent can `xcodebuild` and start writing tests. + +**Owns:** +- `Ogmac.xcodeproj/` (project, scheme, build settings) +- `Ogmac/Info.plist` — `LSUIElement=true`, no `CFBundleIconFile`, bundle id `com.ogmac.app` +- `Ogmac/OgmacApp.swift` — `@main` entry with a placeholder `MenuBarExtra("ogmac", systemImage: "circle") { Text("loading") }` body. **I will modify this file in Wave 3** — sanctioned handoff, not a collision. +- `Ogmac/Assets.xcassets/AppIcon.appiconset/` (placeholder app icon — final art is out of scope) +- `OgmacTests/` empty target, hooked into the scheme +- Xcode build target: macOS 13.0+, Swift 5.9+ +- **SPM dependencies pre-wired** (so Wave 1 agents don't need to touch `project.pbxproj`): + - `GRDB.swift` ≥ 6.x — used by R for SQLite reads + - `Yams` ≥ 5.x — used by R for YAML load/save + +**Does NOT own:** any feature module, any view, any test of behavior, the IconStates asset catalog (V owns those). + +**Depends on:** nothing. + +**Blocks:** R, C, V, G, I. + +**Done when:** `xcodebuild -project Ogmac.xcodeproj -scheme Ogmac build` succeeds; running the resulting `.app` shows a placeholder menu bar icon and no Dock icon; `OgmacTests` target runs zero tests cleanly. + +--- + +### P — Python diff (pause + side fix) + +**Mission:** Add the `paused` flag to the daemon and drop the dead `sync.interval_seconds` field. Independent of all Swift work. + +**Owns:** +- `src/ogmac/state.py` — add `paused` boolean column to `run_state`, `is_paused`/`pause()`/`unpause()` methods mirroring the existing `is_disabled`/`disable()`/`enable()` shape +- `src/ogmac/cli.py` — add `pause` and `unpause` subcommands; insert `if state.is_paused: return 0` early-return in `_run_sync` (alongside the existing `state.is_disabled` check at lines 61–63) +- `src/ogmac/config.py` — remove `interval_seconds` from `SyncConfig` +- `docs/setup.md` — remove the `sync.interval_seconds` line from the example YAML and any prose that references it +- `docs/operations.md` — add a "Pause syncing" section documenting `ogmac pause` / `ogmac unpause` +- `tests/unit/test_state.py` — extend with `paused` flag tests (set/unset, default false, persistence across `State` reopen) +- `tests/unit/test_config.py` — assert `interval_seconds` is no longer accepted (extra-field rejection if model is strict, otherwise just verify it's gone from the schema) +- `tests/integration/test_pause_flow.py` (new) — `ogmac pause` → `ogmac sync` returns 0 with no Outlook/Google calls; `ogmac unpause` → `ogmac sync` runs normally + +**Interface produced (consumed by Swift agents):** +- New CLI subcommands: `ogmac pause`, `ogmac unpause` +- New row in `run_state` table: `(key='paused', value='1'|'0')` + +**Does NOT own:** any Swift file; the launchd plist; the install script; reconciler / google / outlook modules. + +**Depends on:** nothing. + +**Blocks:** C (commander invokes `pause`/`unpause`), R (StateReader reads the `paused` row). + +**Done when:** All extended tests pass; `ogmac pause && ogmac sync && ogmac status` prints `last_success_at` unchanged; `ogmac unpause && ogmac sync` updates it; `Config.load` rejects (or ignores) `sync.interval_seconds` without error on legacy configs. + +**Compatibility note:** Existing user configs may still contain `interval_seconds`. Decide between strict rejection (clean) or silent-drop (compatible). Spec is silent — pick silent-drop and log a one-line deprecation warning. + +--- + +### R — Read adapters + +**Mission:** Build the read-only data layer the views consume. Three small files, each with one responsibility. + +**Owns:** +- `Ogmac/Models/StateSnapshot.swift` — `struct StateSnapshot { lastSuccessAt: Date?; consecutiveFailures: Int; disabled: Bool; paused: Bool; disableReason: String? }` +- `Ogmac/Models/SyncRun.swift` — `struct SyncRun { startedAt: Date; result: SyncResult; durationMs: Int?; counts: ReconcileCounts? }`; `enum SyncResult { case success, failure(reason: String) }`; `struct ReconcileCounts { create, update, delete, skip: Int }` +- `Ogmac/Models/ConfigDoc.swift` — Codable struct mirroring `~/.config/ogmac/config.yaml` shape (Connection / Sync / Privacy nested structs; Failure ignored on write to preserve user's value) +- `Ogmac/StateReader.swift` — opens `~/Library/Application Support/ogmac/state.db` read-only with SQLite C API or `GRDB.swift`; queries `run_state` rows; returns `StateSnapshot` +- `Ogmac/LogReader.swift` — reads `~/Library/Logs/ogmac/sync.log` and rotated copies (`.1` … `.10`); groups lines into `SyncRun` records using `sync.start` / `sync.success` / `sync.failure` / `reconcile` markers; returns the most recent N (default 50) +- `Ogmac/ConfigStore.swift` — loads `config.yaml` via `Yams`; saves via temp-file + `rename` for atomicity; never blocks on the main thread +- `OgmacTests/StateReaderTests.swift` — fixture sqlite databases (healthy / disabled / paused / failed) +- `OgmacTests/LogReaderTests.swift` — fixture `sync.log` files (clean run, failed run, partial run truncated mid-sync, rotated logs spanning files) +- `OgmacTests/ConfigStoreTests.swift` — round-trip (load → mutate → save → reload), atomicity (write fails partway → original intact), legacy field handling (`interval_seconds` ignored) +- `OgmacTests/Fixtures/state_*.sqlite` — generated once; checked in +- `OgmacTests/Fixtures/sync_log_*.txt` +- `OgmacTests/Fixtures/config_*.yaml` + +**Interface produced:** +- `protocol StateReading { func snapshot() async throws -> StateSnapshot }` +- `protocol LogReading { func tail(maxRuns: Int) async throws -> [SyncRun] }` +- `protocol ConfigStoring { func load() throws -> ConfigDoc; func save(_ doc: ConfigDoc) throws }` + +**Does NOT own:** any view, the OgmacRunner, anything that writes to `state.db`, anything that talks to a subprocess. + +**Depends on:** F (project scaffold), P (the `paused` row in `run_state` — read by `StateReader.snapshot()`). + +**Blocks:** V, G, I. + +**Done when:** All three protocols have a default implementation; unit tests cover happy path + each failure mode listed above; tests pass under `xcodebuild test`. + +**Library choice:** Use `GRDB.swift` for SQLite (cleaner than raw C API) and `Yams` for YAML. Both are SPM-installable, MIT-licensed, lightweight. R adds them to `Package.swift` / project SPM dependencies. + +--- + +### C — Commander + +**Mission:** Wrap `Process` invocations of the `ogmac` CLI. One file, one job: shell out and report. + +**Owns:** +- `Ogmac/OgmacRunner.swift` — `class OgmacRunner` with: `binaryPath: URL?` resolver (PATH first, then `~/.local/share/ogmac/venv/bin/ogmac`); `func sync() async throws`; `func pause() async throws`; `func unpause() async throws`; `func resume() async throws`; `func reset(yes: Bool) async throws`; each method runs the CLI, captures stdout/stderr, throws if exit != 0 +- `Ogmac/RunnerError.swift` — `enum RunnerError: Error { case binaryNotFound; case nonZeroExit(code: Int32, stderr: String) }` +- `OgmacTests/OgmacRunnerTests.swift` — uses a fake `ogmac` shim (a shell script the test creates in a temp dir, prepended to PATH) that echoes a known string and exits with controllable code; verifies path resolution preference, exit-code handling, stderr capture + +**Interface produced:** +- `protocol OgmacCommanding { func sync() async throws; func pause() async throws; func unpause() async throws; func resume() async throws; func reset(yes: Bool) async throws; var binaryPath: URL? { get } }` + +**Does NOT own:** any view, any data reader, anything that parses the daemon's output beyond exit code + stderr text. + +**Depends on:** F (scaffold), P (so `ogmac pause`/`unpause` exist when integration testing — but unit tests use a fake shim, so P is not a hard prerequisite for C's unit tests). + +**Blocks:** V (status panel's "Sync now" button), I. + +**Done when:** All commands exercised against a fake shim; binary-not-found path returns `RunnerError.binaryNotFound`; `sync()` running against the real installed `ogmac` (manual smoke test) completes without throwing. + +--- + +### V — Views (status + history) + +**Mission:** Render the dropdown panel and the history sheet from the read adapters. Owns the icon-state derivation logic. + +**Owns:** +- `Ogmac/IconState.swift` — `enum IconState: String { case healthy, warning, error, autoDisabled, paused, syncing, needsLogin }`; computed `systemImageName: String`; computed `tint: Color` +- `Ogmac/StatusController.swift` — `@MainActor class StatusController: ObservableObject` with `@Published var icon: IconState`, `@Published var summary: PanelSummary`; runs a polling loop (5s when panel open, 60s when closed); resolves icon state per the priority order in spec §Status (Syncing → Paused → Auto-disabled → Needs login → Error → Warning → Healthy); exposes a `triggerSync()` that flips `icon` to `.syncing` while `OgmacRunner.sync()` is in flight +- `Ogmac/PanelSummary.swift` — `struct PanelSummary { lastSyncRelative: String; nextSyncRelative: String; readBackend: String; writeBackend: String; lastRunCounts: ReconcileCounts? }` +- `Ogmac/MenuPanelView.swift` — SwiftUI view rendering the dropdown shown in spec §Status +- `Ogmac/HistoryView.swift` — SwiftUI sheet displaying the last 50 `SyncRun`s as a list (started-at, result chip, duration, counts) +- `Ogmac/Assets.xcassets/IconStates/` — SF-Symbol-based icon variants per state (or PNG fallbacks) +- `OgmacTests/StatusControllerTests.swift` — table-driven: every (snapshot, syncing-flag) combination → expected `IconState`; verify the priority order resolves correctly (e.g., paused beats error) +- `OgmacTests/PanelSummaryTests.swift` — relative-time formatting (`"2 min ago"`, `"in 13 min"`, `"never"`) + +**Interface produced:** +- `MenuPanelView(controller: StatusController, runner: OgmacCommanding)` — embeds in MenuBarExtra +- `HistoryView(reader: LogReading)` — opens as a sheet +- `StatusController(stateReader: StateReading, logReader: LogReading, runner: OgmacCommanding)` + +**Does NOT own:** Settings UI, the app entry point, the launchd plist, anything that writes config. + +**Depends on:** F, R, C. + +**Blocks:** I. + +**Parallelizable with:** G (zero shared files; both consume R but read-only). + +**Done when:** Unit tests for the icon-state state machine all pass; SwiftUI Previews render each state correctly; clicking "Sync now" in a manual smoke test triggers a real sync and the icon animates. + +--- + +### G — Settings UI + +**Mission:** Three tabs of form fields backed by `ConfigStore`, plus the "Launch at login" toggle. + +**Owns:** +- `Ogmac/SettingsScene.swift` — `struct SettingsScene: Scene` composing the three tabs as `TabView` +- `Ogmac/SettingsConnectionTab.swift` — backend segmented control, source/target free-text fields, account fields, client-secret path field with file picker +- `Ogmac/SettingsSyncTab.swift` — past/future-window steppers, read-only schedule label, "Launch at login" toggle +- `Ogmac/SettingsPrivacyTab.swift` — three toggles + the locked attendees toggle with tooltip +- `Ogmac/LaunchAtLogin.swift` — thin wrapper around `SMAppService.mainApp` exposing `var isEnabled: Bool { get set }` +- `Ogmac/SettingsViewModel.swift` — `@MainActor class SettingsViewModel: ObservableObject` holding the in-memory `ConfigDoc` for editing; constructor takes a `ConfigStoring`; `func save()` calls `store.save(doc)` +- `OgmacTests/SettingsViewModelTests.swift` — load → mutate → save → assert atomic file write +- `OgmacTests/LaunchAtLoginTests.swift` — limited (SMAppService is hard to test without a signed app; assert getter/setter at minimum) + +**Interface produced:** +- `SettingsScene(viewModel: SettingsViewModel)` — registered in the App body alongside `MenuBarExtra` + +**Does NOT own:** anything in the dropdown panel or history view, anything that writes to `state.db` or the plist, the OgmacRunner. + +**Depends on:** F, R (`ConfigStore`). + +**Blocks:** I. + +**Parallelizable with:** V. + +**Done when:** Each tab renders all its fields; saving a change writes `config.yaml` atomically (verified by reading the temp-file + rename pattern); "Launch at login" toggle survives an app restart; switching read backend shows the inline note about `ogmac login microsoft`. + +--- + +### I — Integrator + Packaging + +**Mission:** Wire `OgmacApp` to the real scenes, ship the Homebrew cask, write the build script, update user-facing docs. Runs last because every other agent's interface must already exist. + +**Owns:** +- `Ogmac/OgmacApp.swift` — **modify** F's placeholder body to compose `MenuBarExtra { MenuPanelView(controller, runner) }`, the `SettingsScene(viewModel)`, and a `Window("History") { HistoryView(reader) }` triggered from a NotificationCenter event +- `Ogmac/AppDeps.swift` — dependency container constructing concrete `StateReader`, `LogReader`, `ConfigStore`, `OgmacRunner`, `StatusController`, `SettingsViewModel` instances +- `Ogmac/FirstLaunchView.swift` — shown when `OgmacRunner.binaryPath == nil` or `ConfigStore.load()` throws; explains "Run `ogmac login` in Terminal" with a copy button +- `OgmacTests/AppLaunchTests.swift` — minimal smoke test that `AppDeps.shared` constructs without crashing +- `packaging/Casks/ogmac.rb` — Homebrew cask spec; `postflight` clears `com.apple.quarantine` +- `packaging/build_app.sh` — `xcodebuild archive` + `xcodebuild -exportArchive` producing `Ogmac.app`; output to `dist/Ogmac.app` +- `README.md` — add "Menu bar app" section under Quickstart with install via Homebrew cask +- `docs/operations.md` — already touched by P for pause/unpause; I adds a "Menu bar app" subsection covering Quit-vs-Pause distinction +- `docs/troubleshooting.md` — add "Menu bar app shows 'CLI not found'" entry + +**Does NOT own:** any file owned by F/P/R/C/V/G. Imports them; modifies only `OgmacApp.swift` (sanctioned handoff from F). If a bug surfaces in a dependency during integration, route it back to that agent — I does not patch other agents' files. + +**Depends on:** F, P, R, C, V, G — everyone. + +**Blocks:** nothing (terminal). + +**Done when:** `packaging/build_app.sh` produces a launchable `.app`; manual install of the cask on a clean machine results in a working menu bar icon, working Sync-now, working Pause, working Settings save round-trip; all 7 agents' tests still green together under `xcodebuild test` + `pytest`. + +--- + +## File ownership map (collision-free) + +``` +Ogmac.xcodeproj/ F +Ogmac/Info.plist F +Ogmac/Assets.xcassets/AppIcon.appiconset/ F +Ogmac/Assets.xcassets/IconStates/ V +Ogmac/OgmacApp.swift F (initial) → I (final wiring) +Ogmac/AppDeps.swift I +Ogmac/FirstLaunchView.swift I +Ogmac/Models/StateSnapshot.swift R +Ogmac/Models/SyncRun.swift R +Ogmac/Models/ConfigDoc.swift R +Ogmac/Models/PanelSummary.swift V +Ogmac/StateReader.swift R +Ogmac/LogReader.swift R +Ogmac/ConfigStore.swift R +Ogmac/OgmacRunner.swift C +Ogmac/RunnerError.swift C +Ogmac/IconState.swift V +Ogmac/StatusController.swift V +Ogmac/MenuPanelView.swift V +Ogmac/HistoryView.swift V +Ogmac/SettingsScene.swift G +Ogmac/SettingsConnectionTab.swift G +Ogmac/SettingsSyncTab.swift G +Ogmac/SettingsPrivacyTab.swift G +Ogmac/SettingsViewModel.swift G +Ogmac/LaunchAtLogin.swift G +OgmacTests/StateReaderTests.swift R +OgmacTests/LogReaderTests.swift R +OgmacTests/ConfigStoreTests.swift R +OgmacTests/OgmacRunnerTests.swift C +OgmacTests/StatusControllerTests.swift V +OgmacTests/PanelSummaryTests.swift V +OgmacTests/SettingsViewModelTests.swift G +OgmacTests/LaunchAtLoginTests.swift G +OgmacTests/AppLaunchTests.swift I +OgmacTests/Fixtures/state_*.sqlite R +OgmacTests/Fixtures/sync_log_*.txt R +OgmacTests/Fixtures/config_*.yaml R +src/ogmac/state.py P (modify) +src/ogmac/cli.py P (modify) +src/ogmac/config.py P (modify) +docs/setup.md P (modify) +docs/operations.md P (modify) → I (modify, add menu-bar section) +tests/unit/test_state.py P (modify) +tests/unit/test_config.py P (modify) +tests/integration/test_pause_flow.py P +packaging/Casks/ogmac.rb I +packaging/build_app.sh I +README.md I (modify) +docs/troubleshooting.md I (modify) +``` + +The only handoff is `OgmacApp.swift` (F → I) and `docs/operations.md` (P → I, additive section). All other files have a single owner. + +--- + +## Dependency matrix + +| Agent | Reads from | +|---|---| +| F | — | +| P | — | +| R | F (scaffold), P (`paused` row contract) | +| C | F | +| V | F, R, C | +| G | F, R | +| I | F, P, R, C, V, G | + +**Critical path:** F → R → V → I. C runs alongside R in Wave 1; G runs alongside V in Wave 2; P runs alongside F in Wave 0 and only re-enters when C/R consume its outputs. + +--- + +## Orchestrator responsibilities (you) + +1. Dispatch F + P in parallel (Wave 0). Both must merge before Wave 1. +2. Dispatch R + C in parallel (Wave 1). Both must merge before Wave 2. +3. Dispatch V + G in parallel (Wave 2). Both must merge before Wave 3. +4. Dispatch I alone (Wave 3). Treat any cross-module bug surfaced here as a ticket back to the original owner — I does not patch other agents' files (the one exception is the sanctioned `OgmacApp.swift` rewrite). +5. Between waves, run the appropriate test suite (`xcodebuild test` for Swift waves, `pytest` after P, both at the end). A new wave does not start until the prior wave is green. +6. Code review per agent: check the interface they produced matches the contract listed under their "Interface produced" section. Anything beyond that contract is scope creep — push back. + +--- + +## Out-of-scope guardrails (apply to all agents) + +Mirrors spec "Non-goals (v0.1)". Reject any agent output that adds: + +- In-app OAuth / Google login flow. First-launch points users to Terminal, period. +- Replacing `install.sh`. The separate installation rework is happening in parallel. +- Calendar pickers (free-text only in v0.1; defer `ogmac list-calendars` CLI to v0.2). +- Code signing or notarization scaffolding (unsigned + Homebrew cask only). +- Reboot-persistent pause via `launchctl disable`. The `paused` flag in `state.db` is the only pause mechanism. +- `failure.*` settings exposure in any tab. +- Push-notification refinement beyond the existing `notify_on_failure` behavior. +- "Syncing" icon state for launchd-driven (non-user-triggered) syncs. +- Schedule editing in the Settings UI (label is read-only; spec §Settings is explicit). +- Writes to `state.db` or the launchd plist from the Swift side. The Swift app reads only. +- Any `NSCalendarsUsageDescription` entitlement. EventKit reads stay in the Python daemon. +- A structured `runs` table in `state.db` (defer to v0.2 if log parsing becomes burdensome). From e23a0d024e10a4554018def06854d402693eb310 Mon Sep 17 00:00:00 2001 From: "Raz.Bahri" Date: Sat, 9 May 2026 12:39:02 +0300 Subject: [PATCH 3/3] feat: menu bar app + pause subcommands + side fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Ogmac.app, a SwiftUI menu bar utility that surfaces sync status, settings, and run history for the existing Python daemon. The daemon's launchd schedule and CLI surface are unchanged; the app is a thin observer + commander that reads state.db, tails sync.log, edits config.yaml, and shells out to the ogmac CLI for actions. ## Menu bar app - Status icon — uses SF Symbol "app" family (hollow rounded rectangle, matches the bundle icon motif). State distinguished by tint + badge: healthy=app.fill/accent, warning=app.badge/yellow, error=app.badge.fill/red, paused=app/secondary, needsLogin=app.dashed, syncing=arrow.triangle.2.circlepath. - Live updates via DispatchSource file-system watcher on state.db — no periodic polling. Refresh fires within ~200ms of each daemon write, regardless of how long the sync took. - Panel: next-sync countdown, "Last change" row that shows the most recent run with non-zero CRUD activity (or "Up to date · checked X min ago" when only no-op syncs exist), Sync now, Settings, History, Pause/Resume, Quit. - Settings scene with three tabs (Connection / Sync / Privacy); atomic temp-file + rename writes to ~/.config/ogmac/config.yaml. Launch-at-login via SMAppService. - History view with meaningful-runs filter — hides the every-2-min network-change-triggered no-op syncs, keeps real changes, failures, and scheduled :00/:15/:30/:45 ticks. Inline "← Back" navigation inside the dropdown (avoids MenuBarExtra sheet collapse bug). - First-launch screen when ogmac CLI is missing or config can't load. - Diagnostic log at ~/Library/Logs/ogmac/menubar.log captures every refresh, watcher event, and reader error. - Reads state.db with raw SQLite3 + URI ?immutable=1 (the daemon's WAL-mode file can't be opened cleanly via GRDB DatabasePool/Queue without -shm coordination access). ## App bundle icon Custom AppIcon.appiconset rendered programmatically in 7 sizes (16/32/64/128/256/512/1024 px). Dark squircle background with a white rounded-rectangle outline forming the ╭─╮╰─╯ shape — visible in Finder, Spotlight, and notifications. ## Python daemon - ogmac pause / ogmac unpause subcommands. paused flag persists in state.db (run_state row, mirroring the existing disabled pattern). _run_sync early-returns when paused. - sync.interval_seconds dropped from SyncConfig — was never read; StartCalendarInterval in the launchd plist is the only schedule source. Legacy configs still containing the field are silently accepted with a DeprecationWarning. ## Packaging - Xcode project generated by XcodeGen (project.yml). macOS 14 minimum (panel uses @Environment(\.openSettings)). LSUIElement=true, no Dock icon. Unsigned build via packaging/build_app.sh produces dist/Ogmac.app. Homebrew cask spec at packaging/Casks/ogmac.rb with xattr post-install for the unsigned build. - SPM lockfile (Package.resolved) pins GRDB.swift 6.29.3 and Yams 5.4.0 for reproducible builds. ## Tests 62 Swift tests cover IconState resolution, PanelSummary formatting, StateReader/LogReader/ConfigStore round-trips with fixtures, OgmacRunner shim-based command tests, SettingsViewModel save flow, and AppLaunch smoke tests. Python suite expanded to 172 (was 156) covering the pause flag, the deprecated-field warning, and the end-to-end pause flow. ## Docs - README: Menu bar app section with install steps and behavior. - docs/operations.md: Pause syncing, How the panel stays fresh, History view filter, Diagnostic log, Launch at login. - docs/troubleshooting.md: CLI not found, icon doesn't update, History empty, Settings window doesn't appear. - docs/superpowers/specs/2026-05-08-menu-bar-app-design.md - docs/superpowers/plans/2026-05-08-menu-bar-app-team-breakdown.md - CHANGELOG: Unreleased entries for everything above. ## Known issue The launchd plist has a NetworkChange LaunchEvent with ThrottleInterval=120 that causes the daemon to fire every ~2 min on network activity. Filed as razbahri/ogmac#1; the menu bar app's History filter works around it client-side. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 + CHANGELOG.md | 12 + Ogmac.xcodeproj/project.pbxproj | 661 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 24 + .../xcshareddata/xcschemes/Ogmac.xcscheme | 116 +++ Ogmac/AppDeps.swift | 70 ++ .../AppIcon.appiconset/Contents.json | 18 + .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 46249 bytes .../AppIcon.appiconset/icon-128.png | Bin 0 -> 3410 bytes .../AppIcon.appiconset/icon-16.png | Bin 0 -> 456 bytes .../AppIcon.appiconset/icon-256.png | Bin 0 -> 7301 bytes .../AppIcon.appiconset/icon-32.png | Bin 0 -> 793 bytes .../AppIcon.appiconset/icon-512.png | Bin 0 -> 17871 bytes .../AppIcon.appiconset/icon-64.png | Bin 0 -> 1595 bytes Ogmac/ConfigStore.swift | 48 ++ Ogmac/DebugLog.swift | 30 + Ogmac/FirstLaunchView.swift | 38 + Ogmac/HistoryView.swift | 162 +++++ Ogmac/IconState.swift | 33 + Ogmac/Info.plist | 28 + Ogmac/LaunchAtLogin.swift | 21 + Ogmac/LogReader.swift | 154 ++++ Ogmac/MenuPanelView.swift | 198 ++++++ Ogmac/Models/ConfigDoc.swift | 123 ++++ Ogmac/Models/PanelSummary.swift | 64 ++ Ogmac/Models/StateSnapshot.swift | 9 + Ogmac/Models/SyncRun.swift | 20 + Ogmac/OgmacApp.swift | 36 + Ogmac/OgmacRunner.swift | 121 ++++ Ogmac/RunnerError.swift | 6 + Ogmac/SettingsConnectionTab.swift | 64 ++ Ogmac/SettingsPrivacyTab.swift | 19 + Ogmac/SettingsScene.swift | 27 + Ogmac/SettingsSyncTab.swift | 48 ++ Ogmac/SettingsViewModel.swift | 34 + Ogmac/StateFileWatcher.swift | 67 ++ Ogmac/StateReader.swift | 97 +++ Ogmac/StatusController.swift | 164 +++++ OgmacTests/AppLaunchTests.swift | 82 +++ OgmacTests/ConfigStoreTests.swift | 94 +++ OgmacTests/Fixtures/config_canonical.yaml | 23 + OgmacTests/Fixtures/config_legacy.yaml | 24 + OgmacTests/Fixtures/state_disabled.sqlite | Bin 0 -> 24576 bytes OgmacTests/Fixtures/state_failed.sqlite | Bin 0 -> 24576 bytes OgmacTests/Fixtures/state_healthy.sqlite | Bin 0 -> 24576 bytes OgmacTests/Fixtures/state_paused.sqlite | Bin 0 -> 24576 bytes OgmacTests/Fixtures/sync_log_clean.txt | 5 + OgmacTests/Fixtures/sync_log_failed.txt | 2 + OgmacTests/Fixtures/sync_log_partial.txt | 3 + OgmacTests/Fixtures/sync_log_rotated.log | 3 + OgmacTests/Fixtures/sync_log_rotated.log.1 | 3 + OgmacTests/Fixtures/sync_log_skipped.txt | 5 + OgmacTests/LaunchAtLoginTests.swift | 10 + OgmacTests/LogReaderTests.swift | 93 +++ OgmacTests/OgmacRunnerTests.swift | 171 +++++ OgmacTests/PanelSummaryTests.swift | 110 +++ OgmacTests/SettingsViewModelTests.swift | 70 ++ OgmacTests/SmokeTests.swift | 7 + OgmacTests/StateReaderTests.swift | 91 +++ OgmacTests/StatusControllerTests.swift | 143 ++++ README.md | 33 +- docs/operations.md | 61 ++ docs/troubleshooting.md | 39 ++ packaging/Casks/ogmac.rb | 23 + packaging/build_app.sh | 34 + packaging/com.ogmac.sync.plist | 26 +- packaging/exportOptions.plist | 10 + project.yml | 66 ++ src/ogmac/cli.py | 30 + src/ogmac/config.py | 13 +- src/ogmac/state.py | 11 + tests/integration/test_pause_flow.py | 100 +++ tests/unit/test_config.py | 26 +- tests/unit/test_state.py | 28 + 75 files changed, 3952 insertions(+), 9 deletions(-) create mode 100644 Ogmac.xcodeproj/project.pbxproj create mode 100644 Ogmac.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Ogmac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Ogmac.xcodeproj/xcshareddata/xcschemes/Ogmac.xcscheme create mode 100644 Ogmac/AppDeps.swift create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/icon-128.png create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/icon-16.png create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/icon-256.png create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/icon-32.png create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/icon-512.png create mode 100644 Ogmac/Assets.xcassets/AppIcon.appiconset/icon-64.png create mode 100644 Ogmac/ConfigStore.swift create mode 100644 Ogmac/DebugLog.swift create mode 100644 Ogmac/FirstLaunchView.swift create mode 100644 Ogmac/HistoryView.swift create mode 100644 Ogmac/IconState.swift create mode 100644 Ogmac/Info.plist create mode 100644 Ogmac/LaunchAtLogin.swift create mode 100644 Ogmac/LogReader.swift create mode 100644 Ogmac/MenuPanelView.swift create mode 100644 Ogmac/Models/ConfigDoc.swift create mode 100644 Ogmac/Models/PanelSummary.swift create mode 100644 Ogmac/Models/StateSnapshot.swift create mode 100644 Ogmac/Models/SyncRun.swift create mode 100644 Ogmac/OgmacApp.swift create mode 100644 Ogmac/OgmacRunner.swift create mode 100644 Ogmac/RunnerError.swift create mode 100644 Ogmac/SettingsConnectionTab.swift create mode 100644 Ogmac/SettingsPrivacyTab.swift create mode 100644 Ogmac/SettingsScene.swift create mode 100644 Ogmac/SettingsSyncTab.swift create mode 100644 Ogmac/SettingsViewModel.swift create mode 100644 Ogmac/StateFileWatcher.swift create mode 100644 Ogmac/StateReader.swift create mode 100644 Ogmac/StatusController.swift create mode 100644 OgmacTests/AppLaunchTests.swift create mode 100644 OgmacTests/ConfigStoreTests.swift create mode 100644 OgmacTests/Fixtures/config_canonical.yaml create mode 100644 OgmacTests/Fixtures/config_legacy.yaml create mode 100644 OgmacTests/Fixtures/state_disabled.sqlite create mode 100644 OgmacTests/Fixtures/state_failed.sqlite create mode 100644 OgmacTests/Fixtures/state_healthy.sqlite create mode 100644 OgmacTests/Fixtures/state_paused.sqlite create mode 100644 OgmacTests/Fixtures/sync_log_clean.txt create mode 100644 OgmacTests/Fixtures/sync_log_failed.txt create mode 100644 OgmacTests/Fixtures/sync_log_partial.txt create mode 100644 OgmacTests/Fixtures/sync_log_rotated.log create mode 100644 OgmacTests/Fixtures/sync_log_rotated.log.1 create mode 100644 OgmacTests/Fixtures/sync_log_skipped.txt create mode 100644 OgmacTests/LaunchAtLoginTests.swift create mode 100644 OgmacTests/LogReaderTests.swift create mode 100644 OgmacTests/OgmacRunnerTests.swift create mode 100644 OgmacTests/PanelSummaryTests.swift create mode 100644 OgmacTests/SettingsViewModelTests.swift create mode 100644 OgmacTests/SmokeTests.swift create mode 100644 OgmacTests/StateReaderTests.swift create mode 100644 OgmacTests/StatusControllerTests.swift create mode 100644 packaging/Casks/ogmac.rb create mode 100755 packaging/build_app.sh create mode 100644 packaging/exportOptions.plist create mode 100644 project.yml create mode 100644 tests/integration/test_pause_flow.py diff --git a/.gitignore b/.gitignore index f695458..01d185c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ htmlcov/ *.db-wal *.db-shm client_secret.json +*.local.md +.DS_Store +xcuserdata/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b400e4..773c71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added +- **Menu bar app** (`Ogmac.app`) — SwiftUI status item that surfaces sync state, settings, and run history without a terminal. Built unsigned; install via `bash packaging/build_app.sh` then `open dist/Ogmac.app`. Spec: `docs/superpowers/specs/2026-05-08-menu-bar-app-design.md`. Plan: `docs/superpowers/plans/2026-05-08-menu-bar-app-team-breakdown.md`. +- File-system event watcher on `state.db` (DispatchSource). The panel refreshes within ~200 ms of each daemon write — no periodic polling, no missed updates regardless of sync duration. +- "Last change" panel row that shows the most recent run with non-zero CRUD activity, or "Up to date · checked X min ago" when only no-op syncs exist in the last 50 runs. +- History view with a "meaningful runs only" filter. Hides the every-2-min network-change-triggered no-op syncs; keeps real changes, failures, and the scheduled `:00`/`:15`/`:30`/`:45` ticks. +- Diagnostic log file at `~/Library/Logs/ogmac/menubar.log` with structured per-event lines for refresh activity, file-watcher events, and reader errors. +- `ogmac pause` and `ogmac unpause` subcommands. `_run_sync` early-returns when paused. The `paused` flag persists in `state.db` and survives reboots, mirroring the existing `disabled` pattern. + +### Changed +- `sync.interval_seconds` removed from the config schema. The field was never read; `StartCalendarInterval` in the launchd plist is the only schedule source. Existing configs that still contain the field are silently accepted with a deprecation warning. +- Minimum macOS for the menu bar app is **14.0** (the panel uses `@Environment(\.openSettings)`, which is macOS 14+). The Python daemon's minimum is unchanged. + ## [0.1.0] - 2026-04-28 Initial release. diff --git a/Ogmac.xcodeproj/project.pbxproj b/Ogmac.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3f1aba9 --- /dev/null +++ b/Ogmac.xcodeproj/project.pbxproj @@ -0,0 +1,661 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 057C078059547C4DCE39DB8C /* SyncRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0238856C5FF1B5CB36FA1F9 /* SyncRun.swift */; }; + 0A35FDC07209F13BF0A8FAFB /* sync_log_rotated.log.1 in Resources */ = {isa = PBXBuildFile; fileRef = 27BF5ECD281EFA35768AA8F8 /* sync_log_rotated.log.1 */; }; + 1A16D8360FECC3B536927CB4 /* OgmacRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754A29EB498C0EFB4A5ABE11 /* OgmacRunnerTests.swift */; }; + 1ADB480FBB0E57DCD5649227 /* state_failed.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 1699B9A86BE655CB89677CB5 /* state_failed.sqlite */; }; + 1FBE91220DA9F0165D04E432 /* RunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98C2ABD97D65A2797AA4269 /* RunnerError.swift */; }; + 219735A91220E47065FA5980 /* LogReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCE0941328F1B622CE4BF46 /* LogReaderTests.swift */; }; + 2348573B36F657994D4BB376 /* SettingsConnectionTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = E304846B2A65412721C8F010 /* SettingsConnectionTab.swift */; }; + 25D5D0A1B1A2D494AC946F10 /* sync_log_skipped.txt in Resources */ = {isa = PBXBuildFile; fileRef = 44145B1827869B60828BD08A /* sync_log_skipped.txt */; }; + 27C6A841AAEDCE392B5E6F37 /* StatusControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19D3E58BC17668245CB40E9 /* StatusControllerTests.swift */; }; + 28F48D7F52F3D9CA3C10163B /* PanelSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED357DE32E5B4CCB62021C17 /* PanelSummary.swift */; }; + 29072873CC6DD47B4E7A712E /* state_disabled.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 62C16023571A65093FEAE190 /* state_disabled.sqlite */; }; + 2B2380A5F71D1C3FAD742FBF /* ConfigStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242A983FA0DF9058B1F290BD /* ConfigStoreTests.swift */; }; + 345B39C9AADA91F5EF1E3235 /* AppDeps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3735904D9534B5A0B61C3119 /* AppDeps.swift */; }; + 371791D833E1AF2D89B31CAC /* StateSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3884B61EF5A33EB7A5E2C698 /* StateSnapshot.swift */; }; + 3CDEE17EE8735EDDFDB648F7 /* FirstLaunchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C1B0EA0E2332D1297165925 /* FirstLaunchView.swift */; }; + 43FCB11DA7CE2A2ADF81BAA8 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = F02840E0915C796F12376B15 /* GRDB */; }; + 4A1D89A2D89722ABF6B1A185 /* ConfigDoc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4822D052077D99DFCC64B4C3 /* ConfigDoc.swift */; }; + 4CCA0B4573275D336A5F47C3 /* StateReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 765B2488E357CB8A098F0A89 /* StateReader.swift */; }; + 505A5488EB25974D63B9B2E8 /* StatusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D9D2DCD267B3AA2FF6971F1 /* StatusController.swift */; }; + 5271ECB6D639A9499CF88A72 /* IconState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8AC1EA076C0B44750B96425 /* IconState.swift */; }; + 5A544125F2341AF8AE637C5E /* SettingsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3D2E770090A36E3BA95428 /* SettingsScene.swift */; }; + 5E816B48D3BDCB92769EEBBB /* config_legacy.yaml in Resources */ = {isa = PBXBuildFile; fileRef = 2E36F63543A4ABE20125BF2A /* config_legacy.yaml */; }; + 69D6395E63F1C217453C4125 /* StateReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078C39C40A518E06622170ED /* StateReaderTests.swift */; }; + 6EA08FC128B3340C00CF226E /* StateFileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 480BB533B7F22814737D2481 /* StateFileWatcher.swift */; }; + 6F0C69FDA877F19C70370F87 /* SmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E0751C56A79B58D9AB55EA /* SmokeTests.swift */; }; + 73B025825EBC6A5513FD5966 /* sync_log_rotated.log in Resources */ = {isa = PBXBuildFile; fileRef = 37356AA3860D2BD0B7A2FED9 /* sync_log_rotated.log */; }; + 7946B86A3514184DE4DD88D1 /* config_canonical.yaml in Resources */ = {isa = PBXBuildFile; fileRef = 1AFFCC281D5BD002A780C990 /* config_canonical.yaml */; }; + 803C84D9BF1EA566CB59BB1F /* DebugLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7A5FAA46D86402F8DDD0B /* DebugLog.swift */; }; + 813E698264358E0F7767CC2D /* sync_log_clean.txt in Resources */ = {isa = PBXBuildFile; fileRef = 975ED2665B659A2AAF426655 /* sync_log_clean.txt */; }; + 88A6620FBB293D612122B173 /* sync_log_failed.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7BF687ACF768D62D002AA390 /* sync_log_failed.txt */; }; + 93424DC89E5A1404C675ED99 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 326FDAB29F098EAE8E5696E7 /* Yams */; }; + 9507E66B774A425840B3FCB0 /* ConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E000EC964485F800A9DFD7 /* ConfigStore.swift */; }; + 98088CE80EB8A3D17A17911D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B35587CD93227EB67FC5F11 /* SettingsViewModelTests.swift */; }; + 990E08CDEAC5E6553F99D393 /* MenuPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E5ECF13C0B4C10213249DF /* MenuPanelView.swift */; }; + 9B33BC156B88759827655C4A /* SettingsSyncTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C156141A9811BFE1F630AC08 /* SettingsSyncTab.swift */; }; + 9E096BC07046274333FF712F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397AD996013CFD6EAA207782 /* SettingsViewModel.swift */; }; + 9FCDABEBB913A580B0D15E49 /* sync_log_partial.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4ABE1336AD6B32DDC20003F8 /* sync_log_partial.txt */; }; + C98EDE0485F77957E5FFB02C /* PanelSummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A406021531DC728A02A32C /* PanelSummaryTests.swift */; }; + C9A9975E09E2BA84EF44CFA7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A44576351AB40E1D0B0576E /* LaunchAtLogin.swift */; }; + CA7156DC45A48443A1A171A0 /* LogReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0808684A1EFA622532CEF761 /* LogReader.swift */; }; + D77B01C92C6200B73DF5F860 /* SettingsPrivacyTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1352772056A24B8BC8DF0BAF /* SettingsPrivacyTab.swift */; }; + D9D5B294C4742B7C2183CD3A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FEB26E5F6ABA6F274648A311 /* Assets.xcassets */; }; + DAA8E74B5C5D72861839F61C /* state_paused.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 49E818676C37AAD8F2242DFC /* state_paused.sqlite */; }; + E5F3BE148EFD9AE2523D1476 /* AppLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200FC379CA35AEDEF919D1E /* AppLaunchTests.swift */; }; + E977A5CAC2DB905AFAA2499B /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA608AD2558218F968291EE /* HistoryView.swift */; }; + F1FA58B239142226095A3823 /* state_healthy.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 733A0899C5244CC001EFD54C /* state_healthy.sqlite */; }; + F55CD4820E4AC45B585EA24A /* OgmacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFACDAE94FC557BE01FCAC4D /* OgmacApp.swift */; }; + F7511A0C9B653C5BF6066480 /* OgmacRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30916105ACC482161461DCB /* OgmacRunner.swift */; }; + FDA0B06F7A90902A43293A62 /* LaunchAtLoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19DB9054A472A93F5C8D5D4C /* LaunchAtLoginTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CA167A4823506DBEB8C112E5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8FAC5C630C10F14FE4569FB3 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 66F56C885F925A1CFBC80DE8; + remoteInfo = Ogmac; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 04E5ECF13C0B4C10213249DF /* MenuPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPanelView.swift; sourceTree = ""; }; + 05A406021531DC728A02A32C /* PanelSummaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelSummaryTests.swift; sourceTree = ""; }; + 078C39C40A518E06622170ED /* StateReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateReaderTests.swift; sourceTree = ""; }; + 0808684A1EFA622532CEF761 /* LogReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogReader.swift; sourceTree = ""; }; + 0A44576351AB40E1D0B0576E /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = ""; }; + 1352772056A24B8BC8DF0BAF /* SettingsPrivacyTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPrivacyTab.swift; sourceTree = ""; }; + 1699B9A86BE655CB89677CB5 /* state_failed.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = state_failed.sqlite; sourceTree = ""; }; + 19DB9054A472A93F5C8D5D4C /* LaunchAtLoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginTests.swift; sourceTree = ""; }; + 1AFFCC281D5BD002A780C990 /* config_canonical.yaml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = config_canonical.yaml; sourceTree = ""; }; + 242A983FA0DF9058B1F290BD /* ConfigStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigStoreTests.swift; sourceTree = ""; }; + 27BF5ECD281EFA35768AA8F8 /* sync_log_rotated.log.1 */ = {isa = PBXFileReference; lastKnownFileType = text.man; path = sync_log_rotated.log.1; sourceTree = ""; }; + 2E36F63543A4ABE20125BF2A /* config_legacy.yaml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = config_legacy.yaml; sourceTree = ""; }; + 37356AA3860D2BD0B7A2FED9 /* sync_log_rotated.log */ = {isa = PBXFileReference; path = sync_log_rotated.log; sourceTree = ""; }; + 3735904D9534B5A0B61C3119 /* AppDeps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDeps.swift; sourceTree = ""; }; + 3884B61EF5A33EB7A5E2C698 /* StateSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateSnapshot.swift; sourceTree = ""; }; + 397AD996013CFD6EAA207782 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 44145B1827869B60828BD08A /* sync_log_skipped.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = sync_log_skipped.txt; sourceTree = ""; }; + 480BB533B7F22814737D2481 /* StateFileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateFileWatcher.swift; sourceTree = ""; }; + 4822D052077D99DFCC64B4C3 /* ConfigDoc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigDoc.swift; sourceTree = ""; }; + 49E818676C37AAD8F2242DFC /* state_paused.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = state_paused.sqlite; sourceTree = ""; }; + 4ABE1336AD6B32DDC20003F8 /* sync_log_partial.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = sync_log_partial.txt; sourceTree = ""; }; + 4F3D2E770090A36E3BA95428 /* SettingsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScene.swift; sourceTree = ""; }; + 62C16023571A65093FEAE190 /* state_disabled.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = state_disabled.sqlite; sourceTree = ""; }; + 70E0751C56A79B58D9AB55EA /* SmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeTests.swift; sourceTree = ""; }; + 733A0899C5244CC001EFD54C /* state_healthy.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = state_healthy.sqlite; sourceTree = ""; }; + 754A29EB498C0EFB4A5ABE11 /* OgmacRunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OgmacRunnerTests.swift; sourceTree = ""; }; + 765B2488E357CB8A098F0A89 /* StateReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateReader.swift; sourceTree = ""; }; + 7BF687ACF768D62D002AA390 /* sync_log_failed.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = sync_log_failed.txt; sourceTree = ""; }; + 872606D7C220B31F7CCCCCF3 /* Ogmac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ogmac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 94A7A5FAA46D86402F8DDD0B /* DebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLog.swift; sourceTree = ""; }; + 96E000EC964485F800A9DFD7 /* ConfigStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigStore.swift; sourceTree = ""; }; + 975ED2665B659A2AAF426655 /* sync_log_clean.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = sync_log_clean.txt; sourceTree = ""; }; + 9B35587CD93227EB67FC5F11 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModelTests.swift; sourceTree = ""; }; + 9C1B0EA0E2332D1297165925 /* FirstLaunchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstLaunchView.swift; sourceTree = ""; }; + 9D9D2DCD267B3AA2FF6971F1 /* StatusController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusController.swift; sourceTree = ""; }; + A8AC1EA076C0B44750B96425 /* IconState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconState.swift; sourceTree = ""; }; + B200FC379CA35AEDEF919D1E /* AppLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLaunchTests.swift; sourceTree = ""; }; + BCA608AD2558218F968291EE /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + C156141A9811BFE1F630AC08 /* SettingsSyncTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSyncTab.swift; sourceTree = ""; }; + D19D3E58BC17668245CB40E9 /* StatusControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusControllerTests.swift; sourceTree = ""; }; + DFACDAE94FC557BE01FCAC4D /* OgmacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OgmacApp.swift; sourceTree = ""; }; + E304846B2A65412721C8F010 /* SettingsConnectionTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsConnectionTab.swift; sourceTree = ""; }; + E30916105ACC482161461DCB /* OgmacRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OgmacRunner.swift; sourceTree = ""; }; + E88D74FBA0D6993031556420 /* OgmacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OgmacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EBCE0941328F1B622CE4BF46 /* LogReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogReaderTests.swift; sourceTree = ""; }; + ED357DE32E5B4CCB62021C17 /* PanelSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelSummary.swift; sourceTree = ""; }; + F0238856C5FF1B5CB36FA1F9 /* SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRun.swift; sourceTree = ""; }; + F7FF13016E749888FEBE7EC6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + F98C2ABD97D65A2797AA4269 /* RunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerError.swift; sourceTree = ""; }; + FEB26E5F6ABA6F274648A311 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 14216B7D3AAEC52E9105F977 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 43FCB11DA7CE2A2ADF81BAA8 /* GRDB in Frameworks */, + 93424DC89E5A1404C675ED99 /* Yams in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 04D24EF38D6426BC3139406D /* Ogmac */ = { + isa = PBXGroup; + children = ( + 3735904D9534B5A0B61C3119 /* AppDeps.swift */, + FEB26E5F6ABA6F274648A311 /* Assets.xcassets */, + 96E000EC964485F800A9DFD7 /* ConfigStore.swift */, + 94A7A5FAA46D86402F8DDD0B /* DebugLog.swift */, + 9C1B0EA0E2332D1297165925 /* FirstLaunchView.swift */, + BCA608AD2558218F968291EE /* HistoryView.swift */, + A8AC1EA076C0B44750B96425 /* IconState.swift */, + F7FF13016E749888FEBE7EC6 /* Info.plist */, + 0A44576351AB40E1D0B0576E /* LaunchAtLogin.swift */, + 0808684A1EFA622532CEF761 /* LogReader.swift */, + 04E5ECF13C0B4C10213249DF /* MenuPanelView.swift */, + DFACDAE94FC557BE01FCAC4D /* OgmacApp.swift */, + E30916105ACC482161461DCB /* OgmacRunner.swift */, + F98C2ABD97D65A2797AA4269 /* RunnerError.swift */, + E304846B2A65412721C8F010 /* SettingsConnectionTab.swift */, + 1352772056A24B8BC8DF0BAF /* SettingsPrivacyTab.swift */, + 4F3D2E770090A36E3BA95428 /* SettingsScene.swift */, + C156141A9811BFE1F630AC08 /* SettingsSyncTab.swift */, + 397AD996013CFD6EAA207782 /* SettingsViewModel.swift */, + 480BB533B7F22814737D2481 /* StateFileWatcher.swift */, + 765B2488E357CB8A098F0A89 /* StateReader.swift */, + 9D9D2DCD267B3AA2FF6971F1 /* StatusController.swift */, + 4AA97AED9D2EEC82F380825B /* Models */, + ); + path = Ogmac; + sourceTree = ""; + }; + 19346C7F3C872FFB0B171AAF /* Products */ = { + isa = PBXGroup; + children = ( + 872606D7C220B31F7CCCCCF3 /* Ogmac.app */, + E88D74FBA0D6993031556420 /* OgmacTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 35EF67577EF96F7068AB5B9C = { + isa = PBXGroup; + children = ( + 04D24EF38D6426BC3139406D /* Ogmac */, + DE113FEB4D4B57615DA67848 /* OgmacTests */, + 19346C7F3C872FFB0B171AAF /* Products */, + ); + sourceTree = ""; + }; + 4AA97AED9D2EEC82F380825B /* Models */ = { + isa = PBXGroup; + children = ( + 4822D052077D99DFCC64B4C3 /* ConfigDoc.swift */, + ED357DE32E5B4CCB62021C17 /* PanelSummary.swift */, + 3884B61EF5A33EB7A5E2C698 /* StateSnapshot.swift */, + F0238856C5FF1B5CB36FA1F9 /* SyncRun.swift */, + ); + path = Models; + sourceTree = ""; + }; + 93D2CDD53DAB4231354BEF63 /* Fixtures */ = { + isa = PBXGroup; + children = ( + 1AFFCC281D5BD002A780C990 /* config_canonical.yaml */, + 2E36F63543A4ABE20125BF2A /* config_legacy.yaml */, + 62C16023571A65093FEAE190 /* state_disabled.sqlite */, + 1699B9A86BE655CB89677CB5 /* state_failed.sqlite */, + 733A0899C5244CC001EFD54C /* state_healthy.sqlite */, + 49E818676C37AAD8F2242DFC /* state_paused.sqlite */, + 975ED2665B659A2AAF426655 /* sync_log_clean.txt */, + 7BF687ACF768D62D002AA390 /* sync_log_failed.txt */, + 4ABE1336AD6B32DDC20003F8 /* sync_log_partial.txt */, + 37356AA3860D2BD0B7A2FED9 /* sync_log_rotated.log */, + 27BF5ECD281EFA35768AA8F8 /* sync_log_rotated.log.1 */, + 44145B1827869B60828BD08A /* sync_log_skipped.txt */, + ); + path = Fixtures; + sourceTree = ""; + }; + DE113FEB4D4B57615DA67848 /* OgmacTests */ = { + isa = PBXGroup; + children = ( + B200FC379CA35AEDEF919D1E /* AppLaunchTests.swift */, + 242A983FA0DF9058B1F290BD /* ConfigStoreTests.swift */, + 19DB9054A472A93F5C8D5D4C /* LaunchAtLoginTests.swift */, + EBCE0941328F1B622CE4BF46 /* LogReaderTests.swift */, + 754A29EB498C0EFB4A5ABE11 /* OgmacRunnerTests.swift */, + 05A406021531DC728A02A32C /* PanelSummaryTests.swift */, + 9B35587CD93227EB67FC5F11 /* SettingsViewModelTests.swift */, + 70E0751C56A79B58D9AB55EA /* SmokeTests.swift */, + 078C39C40A518E06622170ED /* StateReaderTests.swift */, + D19D3E58BC17668245CB40E9 /* StatusControllerTests.swift */, + 93D2CDD53DAB4231354BEF63 /* Fixtures */, + ); + path = OgmacTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1B01FE72B690FF1D747CBA17 /* OgmacTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 43CF786ED144814C269CEE3B /* Build configuration list for PBXNativeTarget "OgmacTests" */; + buildPhases = ( + C8F87DC7D77C6DFE44195172 /* Sources */, + BD382E1F34CEEB4018C4323C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 0B7E13069728360DF3C37B4C /* PBXTargetDependency */, + ); + name = OgmacTests; + packageProductDependencies = ( + ); + productName = OgmacTests; + productReference = E88D74FBA0D6993031556420 /* OgmacTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 66F56C885F925A1CFBC80DE8 /* Ogmac */ = { + isa = PBXNativeTarget; + buildConfigurationList = AFACF5128DD8EF493F82FC78 /* Build configuration list for PBXNativeTarget "Ogmac" */; + buildPhases = ( + 7D7EEC29631BF37AF7D3ADB4 /* Sources */, + 4F01EE085C62A0FF4D9F5657 /* Resources */, + 14216B7D3AAEC52E9105F977 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Ogmac; + packageProductDependencies = ( + F02840E0915C796F12376B15 /* GRDB */, + 326FDAB29F098EAE8E5696E7 /* Yams */, + ); + productName = Ogmac; + productReference = 872606D7C220B31F7CCCCCF3 /* Ogmac.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8FAC5C630C10F14FE4569FB3 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 1B01FE72B690FF1D747CBA17 = { + ProvisioningStyle = Automatic; + }; + 66F56C885F925A1CFBC80DE8 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 877A44D4FB6161F2D3058099 /* Build configuration list for PBXProject "Ogmac" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 35EF67577EF96F7068AB5B9C; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 19D8D73FC231AEE725956428 /* XCRemoteSwiftPackageReference "GRDB.swift" */, + 37EDA099F468D0D75D310184 /* XCRemoteSwiftPackageReference "Yams" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 19346C7F3C872FFB0B171AAF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 66F56C885F925A1CFBC80DE8 /* Ogmac */, + 1B01FE72B690FF1D747CBA17 /* OgmacTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4F01EE085C62A0FF4D9F5657 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D9D5B294C4742B7C2183CD3A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BD382E1F34CEEB4018C4323C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7946B86A3514184DE4DD88D1 /* config_canonical.yaml in Resources */, + 5E816B48D3BDCB92769EEBBB /* config_legacy.yaml in Resources */, + 29072873CC6DD47B4E7A712E /* state_disabled.sqlite in Resources */, + 1ADB480FBB0E57DCD5649227 /* state_failed.sqlite in Resources */, + F1FA58B239142226095A3823 /* state_healthy.sqlite in Resources */, + DAA8E74B5C5D72861839F61C /* state_paused.sqlite in Resources */, + 813E698264358E0F7767CC2D /* sync_log_clean.txt in Resources */, + 88A6620FBB293D612122B173 /* sync_log_failed.txt in Resources */, + 9FCDABEBB913A580B0D15E49 /* sync_log_partial.txt in Resources */, + 73B025825EBC6A5513FD5966 /* sync_log_rotated.log in Resources */, + 0A35FDC07209F13BF0A8FAFB /* sync_log_rotated.log.1 in Resources */, + 25D5D0A1B1A2D494AC946F10 /* sync_log_skipped.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7D7EEC29631BF37AF7D3ADB4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 345B39C9AADA91F5EF1E3235 /* AppDeps.swift in Sources */, + 4A1D89A2D89722ABF6B1A185 /* ConfigDoc.swift in Sources */, + 9507E66B774A425840B3FCB0 /* ConfigStore.swift in Sources */, + 803C84D9BF1EA566CB59BB1F /* DebugLog.swift in Sources */, + 3CDEE17EE8735EDDFDB648F7 /* FirstLaunchView.swift in Sources */, + E977A5CAC2DB905AFAA2499B /* HistoryView.swift in Sources */, + 5271ECB6D639A9499CF88A72 /* IconState.swift in Sources */, + C9A9975E09E2BA84EF44CFA7 /* LaunchAtLogin.swift in Sources */, + CA7156DC45A48443A1A171A0 /* LogReader.swift in Sources */, + 990E08CDEAC5E6553F99D393 /* MenuPanelView.swift in Sources */, + F55CD4820E4AC45B585EA24A /* OgmacApp.swift in Sources */, + F7511A0C9B653C5BF6066480 /* OgmacRunner.swift in Sources */, + 28F48D7F52F3D9CA3C10163B /* PanelSummary.swift in Sources */, + 1FBE91220DA9F0165D04E432 /* RunnerError.swift in Sources */, + 2348573B36F657994D4BB376 /* SettingsConnectionTab.swift in Sources */, + D77B01C92C6200B73DF5F860 /* SettingsPrivacyTab.swift in Sources */, + 5A544125F2341AF8AE637C5E /* SettingsScene.swift in Sources */, + 9B33BC156B88759827655C4A /* SettingsSyncTab.swift in Sources */, + 9E096BC07046274333FF712F /* SettingsViewModel.swift in Sources */, + 6EA08FC128B3340C00CF226E /* StateFileWatcher.swift in Sources */, + 4CCA0B4573275D336A5F47C3 /* StateReader.swift in Sources */, + 371791D833E1AF2D89B31CAC /* StateSnapshot.swift in Sources */, + 505A5488EB25974D63B9B2E8 /* StatusController.swift in Sources */, + 057C078059547C4DCE39DB8C /* SyncRun.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8F87DC7D77C6DFE44195172 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E5F3BE148EFD9AE2523D1476 /* AppLaunchTests.swift in Sources */, + 2B2380A5F71D1C3FAD742FBF /* ConfigStoreTests.swift in Sources */, + FDA0B06F7A90902A43293A62 /* LaunchAtLoginTests.swift in Sources */, + 219735A91220E47065FA5980 /* LogReaderTests.swift in Sources */, + 1A16D8360FECC3B536927CB4 /* OgmacRunnerTests.swift in Sources */, + C98EDE0485F77957E5FFB02C /* PanelSummaryTests.swift in Sources */, + 98088CE80EB8A3D17A17911D /* SettingsViewModelTests.swift in Sources */, + 6F0C69FDA877F19C70370F87 /* SmokeTests.swift in Sources */, + 69D6395E63F1C217453C4125 /* StateReaderTests.swift in Sources */, + 27C6A841AAEDCE392B5E6F37 /* StatusControllerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 0B7E13069728360DF3C37B4C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 66F56C885F925A1CFBC80DE8 /* Ogmac */; + targetProxy = CA167A4823506DBEB8C112E5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 05367081D014D658B9BAB762 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.ogmac.OgmacTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ogmac.app/Contents/MacOS/Ogmac"; + }; + name = Release; + }; + 41B3C66D04098E68A19AC9C4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Ogmac/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.ogmac.app; + SDKROOT = macosx; + }; + name = Release; + }; + 5B158D8D780D11281D9C5E58 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Ogmac/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.ogmac.app; + SDKROOT = macosx; + }; + name = Debug; + }; + 7279EE37680696460028A249 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 9FCB6E22F8A149C0059EDFFD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.ogmac.OgmacTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ogmac.app/Contents/MacOS/Ogmac"; + }; + name = Debug; + }; + F4791962EB83E023F88B4C17 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 43CF786ED144814C269CEE3B /* Build configuration list for PBXNativeTarget "OgmacTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9FCB6E22F8A149C0059EDFFD /* Debug */, + 05367081D014D658B9BAB762 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 877A44D4FB6161F2D3058099 /* Build configuration list for PBXProject "Ogmac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7279EE37680696460028A249 /* Debug */, + F4791962EB83E023F88B4C17 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + AFACF5128DD8EF493F82FC78 /* Build configuration list for PBXNativeTarget "Ogmac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5B158D8D780D11281D9C5E58 /* Debug */, + 41B3C66D04098E68A19AC9C4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 19D8D73FC231AEE725956428 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDB.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 6.0.0; + }; + }; + 37EDA099F468D0D75D310184 /* XCRemoteSwiftPackageReference "Yams" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jpsim/Yams"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 326FDAB29F098EAE8E5696E7 /* Yams */ = { + isa = XCSwiftPackageProductDependency; + package = 37EDA099F468D0D75D310184 /* XCRemoteSwiftPackageReference "Yams" */; + productName = Yams; + }; + F02840E0915C796F12376B15 /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = 19D8D73FC231AEE725956428 /* XCRemoteSwiftPackageReference "GRDB.swift" */; + productName = GRDB; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 8FAC5C630C10F14FE4569FB3 /* Project object */; +} diff --git a/Ogmac.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Ogmac.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Ogmac.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Ogmac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Ogmac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..91dde8d --- /dev/null +++ b/Ogmac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "51108713462ec109d2aa5ebcb65b9ad4cd2b46996a18fbb6789eef71f5ef095c", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version" : "6.29.3" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", + "version" : "5.4.0" + } + } + ], + "version" : 3 +} diff --git a/Ogmac.xcodeproj/xcshareddata/xcschemes/Ogmac.xcscheme b/Ogmac.xcodeproj/xcshareddata/xcschemes/Ogmac.xcscheme new file mode 100644 index 0000000..0bb3496 --- /dev/null +++ b/Ogmac.xcodeproj/xcshareddata/xcschemes/Ogmac.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ogmac/AppDeps.swift b/Ogmac/AppDeps.swift new file mode 100644 index 0000000..6c7012a --- /dev/null +++ b/Ogmac/AppDeps.swift @@ -0,0 +1,70 @@ +import Foundation + +@MainActor +final class AppDeps: ObservableObject { + let stateReader: any StateReading + let logReader: any LogReading + let configStore: any ConfigStoring + let runner: any OgmacCommanding + let settingsViewModel: SettingsViewModel + let statusController: StatusController + let launchAtLogin: LaunchAtLogin + @Published var firstLaunch: Bool + + convenience init() { + let home = FileManager.default.homeDirectoryForCurrentUser + let dbPath = home + .appendingPathComponent("Library/Application Support/ogmac/state.db") + .path + let logPath = home + .appendingPathComponent("Library/Logs/ogmac/sync.log") + let configPath = home + .appendingPathComponent(".config/ogmac/config.yaml") + + self.init( + runnerOverride: OgmacRunner(), + configStoreOverride: ConfigStore(configPath: configPath), + stateReaderOverride: StateReader(dbPath: dbPath), + logReaderOverride: LogReader(logPath: logPath) + ) + } + + init( + runnerOverride: any OgmacCommanding, + configStoreOverride: any ConfigStoring, + stateReaderOverride: (any StateReading)? = nil, + logReaderOverride: (any LogReading)? = nil + ) { + let home = FileManager.default.homeDirectoryForCurrentUser + let dbPath = home + .appendingPathComponent("Library/Application Support/ogmac/state.db") + .path + let logPath = home + .appendingPathComponent("Library/Logs/ogmac/sync.log") + + let stateReader = stateReaderOverride ?? StateReader(dbPath: dbPath) + let logReader = logReaderOverride ?? LogReader( + logPath: home.appendingPathComponent("Library/Logs/ogmac/sync.log") + ) + _ = logPath + + self.stateReader = stateReader + self.logReader = logReader + self.configStore = configStoreOverride + self.runner = runnerOverride + self.launchAtLogin = LaunchAtLogin() + + self.settingsViewModel = SettingsViewModel(store: configStoreOverride) + + let cliMissing = runnerOverride.binaryPath == nil + let configMissing = (try? configStoreOverride.load()) == nil + self.firstLaunch = cliMissing || configMissing + + self.statusController = StatusController( + stateReader: stateReader, + logReader: logReader, + runner: runnerOverride, + stateDbPath: dbPath + ) + } +} diff --git a/Ogmac/Assets.xcassets/AppIcon.appiconset/Contents.json b/Ogmac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2cacd44 --- /dev/null +++ b/Ogmac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { "filename" : "icon-16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, + { "filename" : "icon-32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, + { "filename" : "icon-32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, + { "filename" : "icon-64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, + { "filename" : "icon-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, + { "filename" : "icon-256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, + { "filename" : "icon-256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, + { "filename" : "icon-512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, + { "filename" : "icon-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, + { "filename" : "icon-1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..5cd216ed1ba6f0c2ff5bad8035f84ccb1c3e68f6 GIT binary patch literal 46249 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 literal 0 HcmV?d00001 diff --git a/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-128.png b/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..09f5af3c95e6cdbd8714120146b6d0c7211a8693 GIT binary patch literal 3410 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 literal 0 HcmV?d00001 diff --git a/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-16.png b/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..e499cd40da81e171b058eafcbc146243b89c3c8c GIT binary patch literal 456 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|EK(yp(|mmy zw18|52FCVG1{RPKAeI7R1_q`DOmGo_1xq(sPtj?P`>Wwy`A-on9 z|0VPO|NMDZCWV9BgY!n(iZp%y^tLNYB--Td8>cBY=HK6EoO5SSbK>P^a}}~f71yP% zV3lSlG-GbwKcC$>zFgTktxWQ8d;k0iTZ|1peEev1X3n*vMM(#}<3nuq7@Z4WRxY@yI>d|7w_o5P__PBqR&mX;P0`)Yo6>9ibT+sSn| zTZ3)E3bjebCwLw_d6Mvez0FLIp^G!1M2R`Vq)EB<+@7U8mzR|^B{SI?II=ZA;qrd9 z;Fse{A;I3|4HsW*%l#e47Iv+wt>XhjL65?1mZI1^M^R=EUY0-XuNYnJ1Og^a(LCp) cz#hQDuv@p>R&+traZreQy85}Sb4q9e000%EtN;K2 literal 0 HcmV?d00001 diff --git a/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-256.png b/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..250658759427df3b837e58ff46cc27f3e6828b41 GIT binary patch literal 7301 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 SRW?|GlBlPvpUXO@geCxo^jH4? literal 0 HcmV?d00001 diff --git a/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-512.png b/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b0733d258697a3dc7709986953e00bde428151a6 GIT binary patch literal 17871 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 Ie{7!rFOPSw*#H0l literal 0 HcmV?d00001 diff --git a/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-64.png b/Ogmac/Assets.xcassets/AppIcon.appiconset/icon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..d3b817e31386e9918d4a092415bb18240771ee15 GIT binary patch literal 1595 zcma)6Ydq5n82)du5l+!WBloskBF-?9Hou`s63VTGB$tddX`+&3Z9>g)JDL=^&1K8& zY(k;cDz}i~D0IP+)R^URcKCA6`FMWM?|Gl+dEfWL`{|{+yE!XuhHnM{KnZ^k=drGs z4az~*Yh`%MyLADPJe(bXnr_XR^?(lX!Jj#N7%*D*asY@D4#;e*tP{RY000+(|LI_g zHvQ{2h9UWZGysrg<8fFoGU%&6Ax!fa%)XA<{6{lBLUp9`o&cjZnrv1fUlLdiYd{QW zZ+%*d#YKKitCt9@l`r@5pu{(NMh&W7^JVpaH3+oBbj;)HZ=@SUF~c`K95K)s!&nqX zk7|To{Knw?I`{szjae3lGlH5tVXm;^x-qg!j!P{u(Ei zdro__1i1?e>@P;=Bf9Fw3?T=2ag-^gWnIQgP2_S&Hv@6X(zK+a!g6~$Ma(L${lUD* zB{zUQyO^cnj|;k9FS)tidM$(xAOZ)4P;I>qbf+2Wi%~7lE`x{4KhqeXLkU!wuQsmY~l;NZ?zrtc*kBY zJjeQEjNa2Yg^%rpx9<7e*FV}Y`B>H2)R+GA=2)D&%I!mB>ikc?_oCyJkgNL0)1jY- zDpiUtsz}4FylsWAC)&+T0#RD;2x!weRb`gx8~Uxa32`~|P-l35hemlzOE%oQrYH>6 z4yClGV8+i`+GNxX+i(*@=n$y$^uP=WwLdF{%$#S(Uof#u$erDXrPbIBi&4?bW(tGl za~g1A_)xc6Si({rEFI_7?_yENC@4Pi%T?^RrOP-^&;AkHDQY3UEVb1r5zCczDE&#E zzzwdASmJHPT|t*-o)JSXYN=!PM(#XtDJ9U~`+Pt(o!@kjx3#hQ(f4+9Kr-8Z9hNqh zvlJF5j&=!n+-Z!;vYuNkZMDaCExpSMEvtxe>=u)H+FYm1O70#LA)6!Vu?kf#jf4-- zM(zD_2~~DUa`=E%hImojpNZ`0)ucoi?rJxdPJd3X4_$IYe3)|_+?I>1_`;9kUb!3* z<0^*UgIvuhOU+TU&x!)A2798}^I8e-gsYrux+BY8TXwF9aeM}axOMXw3*_&R1oF{z zoXA3f{pdv3JU^jO1%;j;n=E|(a+AQ7M~pi@%1WT$d_FThMA@znqOx0p6%HR6jpnci z-0!sgkYm+#_gE2UePXpd-_Lwy=80I)2kgNV9dmG&+IJk#9j|+kjC3nhZBlL4>v__+ z)fi!3N?ODL_UBV{MssZ^aYEj3@F;SD!|@~fn{)ams7cb5 z_%D#6xf8W{cFIKxFSm%m;8m8(r%rVo#%6f&`kc=HiPMH)_nJz}?==24Q2C%dw(o)8 zsRrV+kC03x*u5NFpYKI!pE2X|#9uQ3Y&ulr$mwSc(jKi~rkY9Ts{~u{@Ekl7zrb z+!b=$q`?kB9xqZe8c%R4MbxekzPv!xn@(4clm|f;Hlu0nWe@yzz+#B!^30^!S79Se zUoEJCS8DyM4>4S$QeCfgx7Y2|ALQE|T~9q-VOryjLxf(0KWvj#aA3R*>x>T8`AD{uB- zKCy&h?1PTlpbYkgj=0Z$1}Fza}t a5 ConfigDoc + func save(_ doc: ConfigDoc) throws +} + +enum ConfigStoreError: Error { + case fileNotFound + case encodingFailed(String) +} + +struct ConfigStore: ConfigStoring { + let configPath: URL + + init(configPath: URL) { + self.configPath = configPath + } + + func load() throws -> ConfigDoc { + guard FileManager.default.fileExists(atPath: configPath.path) else { + throw ConfigStoreError.fileNotFound + } + let data = try Data(contentsOf: configPath) + let decoder = YAMLDecoder() + return try decoder.decode(ConfigDoc.self, from: data) + } + + func save(_ doc: ConfigDoc) throws { + let encoder = YAMLEncoder() + let yaml: String + do { + yaml = try encoder.encode(doc) + } catch { + throw ConfigStoreError.encodingFailed(error.localizedDescription) + } + if !FileManager.default.fileExists(atPath: configPath.path) { + let dir = configPath.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try yaml.write(to: configPath, atomically: true, encoding: .utf8) + return + } + let tmp = configPath.appendingPathExtension("tmp") + try yaml.write(to: tmp, atomically: true, encoding: .utf8) + _ = try FileManager.default.replaceItemAt(configPath, withItemAt: tmp) + } +} diff --git a/Ogmac/DebugLog.swift b/Ogmac/DebugLog.swift new file mode 100644 index 0000000..9e62caf --- /dev/null +++ b/Ogmac/DebugLog.swift @@ -0,0 +1,30 @@ +import Foundation + +enum DebugLog { + static let path: URL = { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/ogmac") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("menubar.log") + }() + + private static let formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + static func write(_ message: String) { + let line = "\(formatter.string(from: Date())) \(message)\n" + guard let data = line.data(using: .utf8) else { return } + if !FileManager.default.fileExists(atPath: path.path) { + try? data.write(to: path) + return + } + if let fh = try? FileHandle(forWritingTo: path) { + try? fh.seekToEnd() + try? fh.write(contentsOf: data) + try? fh.close() + } + } +} diff --git a/Ogmac/FirstLaunchView.swift b/Ogmac/FirstLaunchView.swift new file mode 100644 index 0000000..0a573bd --- /dev/null +++ b/Ogmac/FirstLaunchView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct FirstLaunchView: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Welcome to ogmac") + .font(.headline) + + Text("Before this app can show sync status, you need to install and configure the ogmac CLI.") + .font(.caption) + .foregroundStyle(.secondary) + + Text("Run in Terminal:") + .font(.caption) + + HStack { + Text("ogmac login") + .font(.system(.body, design: .monospaced)) + .padding(6) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(4) + + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("ogmac login", forType: .string) + } + .buttonStyle(.borderless) + } + + Divider() + + Button("Quit ogmac") { + NSApp.terminate(nil) + } + } + .padding(16) + } +} diff --git a/Ogmac/HistoryView.swift b/Ogmac/HistoryView.swift new file mode 100644 index 0000000..e44317a --- /dev/null +++ b/Ogmac/HistoryView.swift @@ -0,0 +1,162 @@ +import SwiftUI + +struct HistoryView: View { + let reader: any LogReading + var onDone: () -> Void = {} + + @State private var runs: [SyncRun] = [] + @State private var isLoading = true + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm" + return f + }() + + var body: some View { + VStack(spacing: 0) { + HStack { + Button { + onDone() + } label: { + Image(systemName: "chevron.left") + Text("Back") + } + .buttonStyle(.borderless) + Spacer() + Text("Sync History") + .font(.headline) + Spacer() + // Spacer balance so the title centers + Image(systemName: "chevron.left").opacity(0) + Text("Back").opacity(0) + } + .padding(12) + + Divider() + + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 200) + } else if runs.isEmpty { + Text("No meaningful sync runs recorded.") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 200) + } else { + List(runs.indices, id: \.self) { index in + RunRowView(run: runs[index]) + } + .listStyle(.plain) + .frame(minHeight: 280, maxHeight: 320) + } + } + .frame(minWidth: 320) + .task { + await loadRuns() + } + } + + private func loadRuns() async { + isLoading = true + do { + let raw = try await reader.tail(maxRuns: 200) + runs = raw.filter(Self.isMeaningful).reversed() + } catch { + runs = [] + } + isLoading = false + } + + /// A run is "meaningful" if it did real work OR landed on the 15-min + /// schedule (:00/:15/:30/:45 within 90s tolerance). This filters out + /// the network-change-triggered no-op syncs that fire every ~2 min + /// because the launchd plist registers a NetworkChange LaunchEvent + /// with ThrottleInterval=120. + static func isMeaningful(_ run: SyncRun) -> Bool { + if let c = run.counts, c.create + c.update + c.delete > 0 { + return true + } + if case .failure = run.result { + return true // failures are always meaningful + } + let cal = Calendar(identifier: .gregorian) + let comp = cal.dateComponents([.minute, .second], from: run.startedAt) + let minute = comp.minute ?? 0 + let second = comp.second ?? 0 + let scheduledMinutes: Set = [0, 15, 30, 45] + if scheduledMinutes.contains(minute) && second <= 90 { return true } + if scheduledMinutes.contains((minute - 1 + 60) % 60) && second + 60 <= 90 { return true } + return false + } +} + +// MARK: - Row + +private struct RunRowView: View { + let run: SyncRun + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm" + return f + }() + + var body: some View { + HStack(alignment: .top, spacing: 8) { + resultIcon + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(Self.dateFormatter.string(from: run.startedAt)) + .font(.subheadline) + .foregroundStyle(.primary) + Spacer() + Text(durationText) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Text(detailText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + @ViewBuilder + private var resultIcon: some View { + switch run.result { + case .success: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .failure(let reason) where reason == "incomplete": + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + case .failure: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + } + + private var durationText: String { + guard let ms = run.durationMs else { return "—" } + let seconds = Double(ms) / 1000.0 + return String(format: "%.1fs", seconds) + } + + private var detailText: String { + switch run.result { + case .success: + if let c = run.counts { + return "✓ \(c.create) created · \(c.update) updated · \(c.delete) deleted · \(c.skip) unchanged" + } + return "✓ completed" + case .failure(let reason) where reason == "incomplete": + return "⚠ incomplete" + case .failure(let reason): + return "✗ \(reason)" + } + } +} diff --git a/Ogmac/IconState.swift b/Ogmac/IconState.swift new file mode 100644 index 0000000..f5144d8 --- /dev/null +++ b/Ogmac/IconState.swift @@ -0,0 +1,33 @@ +import SwiftUI + +enum IconState: String, Equatable { + case healthy + case warning + case error + case autoDisabled + case paused + case syncing + case needsLogin + + var systemImageName: String { + switch self { + case .healthy: return "app.fill" + case .warning: return "app.badge" + case .error: return "app.badge.fill" + case .autoDisabled: return "app.badge.fill" // same glyph; tint differs + case .paused: return "app" + case .syncing: return "arrow.triangle.2.circlepath" + case .needsLogin: return "app.dashed" + } + } + + var tint: Color { + switch self { + case .healthy: return .accentColor + case .warning: return .yellow + case .error, .autoDisabled: return .red + case .paused, .needsLogin: return .secondary + case .syncing: return .accentColor + } + } +} diff --git a/Ogmac/Info.plist b/Ogmac/Info.plist new file mode 100644 index 0000000..75065a3 --- /dev/null +++ b/Ogmac/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ogmac + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.ogmac.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ogmac + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.0 + CFBundleVersion + 1 + LSUIElement + + NSHumanReadableCopyright + + + diff --git a/Ogmac/LaunchAtLogin.swift b/Ogmac/LaunchAtLogin.swift new file mode 100644 index 0000000..4a0829f --- /dev/null +++ b/Ogmac/LaunchAtLogin.swift @@ -0,0 +1,21 @@ +import ServiceManagement + +@MainActor +final class LaunchAtLogin { + var isEnabled: Bool { + get { + SMAppService.mainApp.status == .enabled + } + set { + do { + if newValue { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + NSLog("LaunchAtLogin toggle failed: \(error)") + } + } + } +} diff --git a/Ogmac/LogReader.swift b/Ogmac/LogReader.swift new file mode 100644 index 0000000..9491d24 --- /dev/null +++ b/Ogmac/LogReader.swift @@ -0,0 +1,154 @@ +import Foundation + +protocol LogReading { + func tail(maxRuns: Int) async throws -> [SyncRun] +} + +struct LogReader: LogReading { + let logPath: URL + + init(logPath: URL) { + self.logPath = logPath + } + + func tail(maxRuns: Int = 50) async throws -> [SyncRun] { + let lines = collectLines() + let runs = parse(lines: lines) + return Array(runs.suffix(maxRuns)) + } + + private func collectLines() -> [String] { + var allLines: [String] = [] + for i in stride(from: 10, through: 1, by: -1) { + let rotated = logPath.appendingPathExtension("\(i)") + if let content = try? String(contentsOf: rotated, encoding: .utf8) { + allLines.append(contentsOf: content.components(separatedBy: "\n")) + } + } + if let content = try? String(contentsOf: logPath, encoding: .utf8) { + allLines.append(contentsOf: content.components(separatedBy: "\n")) + } + return allLines + } + + private struct ParsedLine { + let timestamp: Date + let marker: String + let rest: String + } + + private func parseLine(_ raw: String, formatter: DateFormatter) -> ParsedLine? { + // Production format: "2026-05-09T06:44:22Z INFO ogmac.cli marker rest..." + // (20-char ISO timestamp with literal Z, then space, then 5-char + // padded levelname, then space, then logger name, then marker, then args.) + guard raw.count >= 20 else { return nil } + let prefix = String(raw.prefix(20)) + guard let ts = formatter.date(from: prefix) else { return nil } + + let levelTokens = [" INFO ", " ERROR", " WARNI", " DEBUG"] + var afterLevel: String? = nil + for token in levelTokens { + if let r = raw.range(of: token) { + // skip past the level token + any padding spaces before the logger name + let after = raw[r.upperBound...] + afterLevel = String(after.drop(while: { $0 == " " })) + break + } + } + guard let al = afterLevel else { return nil } + + // al = "ogmac.cli sync.start window=..." + let parts = al.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard parts.count >= 2 else { return nil } + let markerAndRest = String(parts[1]) + let markerEnd = markerAndRest.firstIndex(of: " ") ?? markerAndRest.endIndex + let marker = String(markerAndRest[.. [String: String] { + var result: [String: String] = [:] + for token in s.split(separator: " ") { + let kv = token.split(separator: "=", maxSplits: 1) + if kv.count == 2 { + result[String(kv[0])] = String(kv[1]) + } + } + return result + } + + private func parse(lines: [String]) -> [SyncRun] { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + + var parsed: [ParsedLine] = [] + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { continue } + if let pl = parseLine(trimmed, formatter: formatter) { + parsed.append(pl) + } + } + + var runs: [SyncRun] = [] + var i = 0 + + while i < parsed.count { + let pl = parsed[i] + + guard pl.marker == "sync.start" else { + i += 1 + continue + } + + let startedAt = pl.timestamp + var counts: ReconcileCounts? = nil + var result: SyncResult? = nil + var durationMs: Int? = nil + var j = i + 1 + + while j < parsed.count { + let cur = parsed[j] + if cur.marker == "sync.start" { + result = .failure(reason: "incomplete") + break + } else if cur.marker == "sync.success" { + let kv = parseKV(cur.rest) + durationMs = kv["duration_ms"].flatMap { Int($0) } + result = .success + j += 1 + break + } else if cur.marker == "sync.failure" { + let kv = parseKV(cur.rest) + result = .failure(reason: kv["type"] ?? "unknown") + j += 1 + break + } else if cur.marker == "reconcile" { + let kv = parseKV(cur.rest) + counts = ReconcileCounts( + create: kv["create"].flatMap { Int($0) } ?? 0, + update: kv["update"].flatMap { Int($0) } ?? 0, + delete: kv["delete"].flatMap { Int($0) } ?? 0, + skip: kv["skip"].flatMap { Int($0) } ?? 0 + ) + } + j += 1 + } + + runs.append(SyncRun( + startedAt: startedAt, + result: result ?? .failure(reason: "incomplete"), + durationMs: durationMs, + counts: counts + )) + i = j + } + + return runs + } +} diff --git a/Ogmac/MenuPanelView.swift b/Ogmac/MenuPanelView.swift new file mode 100644 index 0000000..3735d67 --- /dev/null +++ b/Ogmac/MenuPanelView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct MenuPanelView: View { + @ObservedObject var controller: StatusController + let runner: OgmacCommanding + let logReader: any LogReading + + @State private var showingHistory = false + @Environment(\.openSettings) private var openSettings + + var body: some View { + if showingHistory { + HistoryView(reader: logReader, onDone: { showingHistory = false }) + } else { + mainPanel + } + } + + private var mainPanel: some View { + VStack(alignment: .leading, spacing: 0) { + headerSection + Divider().padding(.vertical, 8) + backendSection + Divider().padding(.vertical, 8) + lastRunSection + Divider().padding(.vertical, 8) + syncButton + Divider().padding(.vertical, 8) + menuItems + } + .padding(12) + .frame(minWidth: 260) + } + + // MARK: - Header + + private var headerSection: some View { + HStack(alignment: .top) { + Text("ogmac") + .font(.headline) + Spacer() + HStack(spacing: 4) { + Image(systemName: controller.icon.systemImageName) + .foregroundStyle(controller.icon.tint) + Text(controller.icon.label) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.bottom, 4) + } + + // MARK: - Sync timing + + private var timingSection: some View { + HStack { + Text("Next sync") + .foregroundStyle(.secondary) + Text("·") + .foregroundStyle(.secondary) + Text(controller.summary.nextSyncRelative) + } + .font(.subheadline) + } + + // MARK: - Timing + + private var backendSection: some View { + timingSection + } + + // MARK: - Last run counts + + private var lastRunSection: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Last change") + .font(.subheadline) + .foregroundStyle(.secondary) + switch controller.summary.lastChange { + case .meaningful(let counts, let at): + Text("✓ \(counts.create) created · \(counts.update) updated · \(counts.delete) del") + .font(.subheadline) + .padding(.leading, 4) + Text(MenuPanelView.relativeTimeFormatter.localizedString(for: at, relativeTo: .now)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + case .noChanges(let lastRunAt): + Text("Up to date") + .font(.subheadline) + .padding(.leading, 4) + Text("checked \(MenuPanelView.relativeTimeFormatter.localizedString(for: lastRunAt, relativeTo: .now))") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + case nil: + Text("No successful run recorded") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } + } + } + + private static let relativeTimeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() + + // MARK: - Sync button + + private var syncButton: some View { + Button { + Task { await controller.triggerSync() } + } label: { + HStack { + Spacer() + if controller.isSyncing { + ProgressView().controlSize(.small) + Text("Syncing…") + } else { + Text("Sync now") + } + Spacer() + } + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(controller.isSyncing) + } + + // MARK: - Menu items + + private var menuItems: some View { + VStack(alignment: .leading, spacing: 0) { + Button("Settings…") { + NSApp.activate(ignoringOtherApps: true) + openSettings() + } + .buttonStyle(.plain) + .padding(.vertical, 4) + + Button("History…") { + showingHistory = true + } + .buttonStyle(.plain) + .padding(.vertical, 4) + + pauseResumeButton + .padding(.vertical, 4) + + Divider().padding(.vertical, 4) + + Button("Quit ogmac") { + NSApp.terminate(nil) + } + .buttonStyle(.plain) + .padding(.vertical, 4) + } + } + + @ViewBuilder + private var pauseResumeButton: some View { + if controller.icon == .paused { + Button("Resume") { + Task { try? await runner.unpause() } + } + .buttonStyle(.plain) + } else if controller.icon == .autoDisabled { + Button("Resume") { + Task { try? await runner.resume() } + } + .buttonStyle(.plain) + } else { + Button("Pause") { + Task { try? await runner.pause() } + } + .buttonStyle(.plain) + } + } +} + +// MARK: - IconState display label + +private extension IconState { + var label: String { + switch self { + case .healthy: return "Healthy" + case .warning: return "Warning" + case .error: return "Error" + case .autoDisabled: return "Auto-disabled" + case .paused: return "Paused" + case .syncing: return "Syncing" + case .needsLogin: return "Needs login" + } + } +} diff --git a/Ogmac/Models/ConfigDoc.swift b/Ogmac/Models/ConfigDoc.swift new file mode 100644 index 0000000..c155a8b --- /dev/null +++ b/Ogmac/Models/ConfigDoc.swift @@ -0,0 +1,123 @@ +import Foundation + +struct ConfigDoc: Codable, Equatable { + var outlook: OutlookDoc + var google: GoogleDoc + var sync: SyncDoc + var privacy: PrivacyDoc + var failure: FailureDoc + + struct OutlookDoc: Codable, Equatable { + var account: String + var sourceCalendar: String + var readMethod: String + + enum CodingKeys: String, CodingKey { + case account + case sourceCalendar = "source_calendar" + case readMethod = "read_method" + } + + init(account: String = "", sourceCalendar: String = "", readMethod: String = "apple_calendar") { + self.account = account + self.sourceCalendar = sourceCalendar + self.readMethod = readMethod + } + } + + struct GoogleDoc: Codable, Equatable { + var account: String + var clientSecretPath: String + var targetCalendarId: String + + enum CodingKeys: String, CodingKey { + case account + case clientSecretPath = "client_secret_path" + case targetCalendarId = "target_calendar_id" + } + + init(account: String = "", clientSecretPath: String = "", targetCalendarId: String = "") { + self.account = account + self.clientSecretPath = clientSecretPath + self.targetCalendarId = targetCalendarId + } + } + + struct SyncDoc: Codable, Equatable { + var windowPastDays: Int + var windowFutureDays: Int + + enum CodingKeys: String, CodingKey { + case windowPastDays = "window_past_days" + case windowFutureDays = "window_future_days" + } + + init(windowPastDays: Int = 1, windowFutureDays: Int = 30) { + self.windowPastDays = windowPastDays + self.windowFutureDays = windowFutureDays + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + windowPastDays = try container.decodeIfPresent(Int.self, forKey: .windowPastDays) ?? 1 + windowFutureDays = try container.decodeIfPresent(Int.self, forKey: .windowFutureDays) ?? 30 + } + } + + struct PrivacyDoc: Codable, Equatable { + var copySubject: Bool + var copyLocation: Bool + var copyBody: Bool + var copyAttendees: Bool + + enum CodingKeys: String, CodingKey { + case copySubject = "copy_subject" + case copyLocation = "copy_location" + case copyBody = "copy_body" + case copyAttendees = "copy_attendees" + } + + init( + copySubject: Bool = true, + copyLocation: Bool = true, + copyBody: Bool = true, + copyAttendees: Bool = false + ) { + self.copySubject = copySubject + self.copyLocation = copyLocation + self.copyBody = copyBody + self.copyAttendees = copyAttendees + } + } + + struct FailureDoc: Codable, Equatable { + var maxConsecutiveBeforeDisable: Int + var notifyOnFailure: Bool + + enum CodingKeys: String, CodingKey { + case maxConsecutiveBeforeDisable = "max_consecutive_before_disable" + case notifyOnFailure = "notify_on_failure" + } + + init(maxConsecutiveBeforeDisable: Int = 5, notifyOnFailure: Bool = true) { + self.maxConsecutiveBeforeDisable = maxConsecutiveBeforeDisable + self.notifyOnFailure = notifyOnFailure + } + } + + static let empty = ConfigDoc( + outlook: OutlookDoc(), + google: GoogleDoc(), + sync: SyncDoc(), + privacy: PrivacyDoc(), + failure: FailureDoc() + ) + + static let canonical = ConfigDoc( + outlook: OutlookDoc(account: "you@example.com", sourceCalendar: "default", readMethod: "apple_calendar"), + google: GoogleDoc(account: "you@gmail.com", clientSecretPath: "", targetCalendarId: "abc123@group.calendar.google.com"), + sync: SyncDoc(windowPastDays: 1, windowFutureDays: 30), + privacy: PrivacyDoc(copySubject: true, copyLocation: true, copyBody: true, copyAttendees: false), + failure: FailureDoc(maxConsecutiveBeforeDisable: 5, notifyOnFailure: true) + ) +} diff --git a/Ogmac/Models/PanelSummary.swift b/Ogmac/Models/PanelSummary.swift new file mode 100644 index 0000000..4c3fd1a --- /dev/null +++ b/Ogmac/Models/PanelSummary.swift @@ -0,0 +1,64 @@ +import Foundation + +struct PanelSummary { + let nextSyncRelative: String + let lastChange: LastChange? + + enum LastChange: Equatable { + case meaningful(counts: ReconcileCounts, at: Date) + case noChanges(lastRunAt: Date) + } + + static let empty = PanelSummary( + nextSyncRelative: "—", + lastChange: nil + ) + + static func nextSyncRelative(from now: Date) -> String { + let calendar = Calendar.current + let components = calendar.dateComponents([.minute, .second], from: now) + let currentMinute = components.minute ?? 0 + let currentSecond = components.second ?? 0 + + let gridMinutes = [0, 15, 30, 45] + let nextMinute: Int + if let found = gridMinutes.first(where: { $0 > currentMinute }) { + nextMinute = found + } else { + nextMinute = 60 + } + + let minutesUntil: Int + if nextMinute == 60 { + minutesUntil = 60 - currentMinute + } else { + minutesUntil = nextMinute - currentMinute + } + + let secondsUntil = minutesUntil * 60 - currentSecond + let displayMinutes = max(1, Int(ceil(Double(secondsUntil) / 60.0))) + return "in \(displayMinutes) min" + } + + /// Pick the most recent successful run with any CRUD activity. If no + /// recent run has activity, fall back to the most recent successful run + /// (so the panel can show "Up to date as of X min ago"). Returns nil if + /// no successful run is in the slice. + static func lastChange(from recentRuns: [SyncRun]) -> LastChange? { + let successes = recentRuns.compactMap { run -> (SyncRun, ReconcileCounts)? in + guard case .success = run.result else { return nil } + let counts = run.counts ?? ReconcileCounts(create: 0, update: 0, delete: 0, skip: 0) + return (run, counts) + } + guard !successes.isEmpty else { return nil } + + if let meaningful = successes.reversed().first(where: { _, c in + c.create + c.update + c.delete > 0 + }) { + return .meaningful(counts: meaningful.1, at: meaningful.0.startedAt) + } + + let latest = successes.last! + return .noChanges(lastRunAt: latest.0.startedAt) + } +} diff --git a/Ogmac/Models/StateSnapshot.swift b/Ogmac/Models/StateSnapshot.swift new file mode 100644 index 0000000..cf59b5e --- /dev/null +++ b/Ogmac/Models/StateSnapshot.swift @@ -0,0 +1,9 @@ +import Foundation + +struct StateSnapshot { + let lastSuccessAt: Date? + let consecutiveFailures: Int + let disabled: Bool + let paused: Bool + let disableReason: String? +} diff --git a/Ogmac/Models/SyncRun.swift b/Ogmac/Models/SyncRun.swift new file mode 100644 index 0000000..12ef6ad --- /dev/null +++ b/Ogmac/Models/SyncRun.swift @@ -0,0 +1,20 @@ +import Foundation + +struct ReconcileCounts: Equatable { + let create: Int + let update: Int + let delete: Int + let skip: Int +} + +enum SyncResult: Equatable { + case success + case failure(reason: String) +} + +struct SyncRun { + let startedAt: Date + let result: SyncResult + let durationMs: Int? + let counts: ReconcileCounts? +} diff --git a/Ogmac/OgmacApp.swift b/Ogmac/OgmacApp.swift new file mode 100644 index 0000000..7bff06d --- /dev/null +++ b/Ogmac/OgmacApp.swift @@ -0,0 +1,36 @@ +import SwiftUI + +@main +struct OgmacApp: App { + @StateObject private var deps = AppDeps() + + var body: some Scene { + MenuBarExtra { + if deps.firstLaunch { + FirstLaunchView() + .frame(minWidth: 280) + } else { + MenuPanelView( + controller: deps.statusController, + runner: deps.runner, + logReader: deps.logReader + ) + .onAppear { deps.statusController.refreshNow() } + } + } label: { + MenuBarLabel(controller: deps.statusController) + } + .menuBarExtraStyle(.window) + + SettingsScene(viewModel: deps.settingsViewModel) + } +} + +private struct MenuBarLabel: View { + @ObservedObject var controller: StatusController + + var body: some View { + Image(systemName: controller.icon.systemImageName) + .onAppear { controller.startMonitoring() } + } +} diff --git a/Ogmac/OgmacRunner.swift b/Ogmac/OgmacRunner.swift new file mode 100644 index 0000000..d327f9e --- /dev/null +++ b/Ogmac/OgmacRunner.swift @@ -0,0 +1,121 @@ +import Foundation + +protocol OgmacCommanding { + var binaryPath: URL? { get } + func sync() async throws + func pause() async throws + func unpause() async throws + func resume() async throws + func reset(yes: Bool) async throws +} + +class OgmacRunner: OgmacCommanding { + + private var _binaryPath: URL?? + private let fileManager: FileManager + private let environment: [String: String]? + private let venvFallback: URL + + init( + fileManager: FileManager = .default, + environment: [String: String]? = nil, + venvFallback: URL? = nil + ) { + self.fileManager = fileManager + self.environment = environment + self.venvFallback = venvFallback ?? fileManager.homeDirectoryForCurrentUser + .appendingPathComponent(".local/share/ogmac/venv/bin/ogmac") + } + + var binaryPath: URL? { + if let cached = _binaryPath { + return cached + } + let resolved = resolveBinaryPath() + _binaryPath = .some(resolved) + return resolved + } + + private func resolveBinaryPath() -> URL? { + if let pathFromWhich = resolveViaWhich() { + return pathFromWhich + } + if fileManager.fileExists(atPath: venvFallback.path) { + return venvFallback + } + return nil + } + + private func resolveViaWhich() -> URL? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["which", "ogmac"] + if let env = environment { + process.environment = env + } + let outPipe = Pipe() + process.standardOutput = outPipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + } catch { + return nil + } + + guard process.terminationStatus == 0 else { return nil } + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + guard let raw = String(data: data, encoding: .utf8) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return URL(fileURLWithPath: trimmed) + } + + private func run(_ args: [String]) async throws { + guard let binary = binaryPath else { throw RunnerError.binaryNotFound } + + return try await withCheckedThrowingContinuation { continuation in + let process = Process() + process.executableURL = binary + process.arguments = args + if let env = environment { + process.environment = env + } + + let stderrPipe = Pipe() + process.standardError = stderrPipe + let stdoutPipe = Pipe() + process.standardOutput = stdoutPipe + + process.terminationHandler = { p in + if p.terminationStatus != 0 { + let stderr = String( + data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + continuation.resume(throwing: RunnerError.nonZeroExit( + code: p.terminationStatus, + stderr: stderr + )) + } else { + continuation.resume() + } + } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } + + func sync() async throws { try await run(["sync"]) } + func pause() async throws { try await run(["pause"]) } + func unpause() async throws { try await run(["unpause"]) } + func resume() async throws { try await run(["resume"]) } + func reset(yes: Bool) async throws { + try await run(yes ? ["reset", "--yes"] : ["reset"]) + } +} diff --git a/Ogmac/RunnerError.swift b/Ogmac/RunnerError.swift new file mode 100644 index 0000000..364f60f --- /dev/null +++ b/Ogmac/RunnerError.swift @@ -0,0 +1,6 @@ +import Foundation + +enum RunnerError: Error { + case binaryNotFound + case nonZeroExit(code: Int32, stderr: String) +} diff --git a/Ogmac/SettingsConnectionTab.swift b/Ogmac/SettingsConnectionTab.swift new file mode 100644 index 0000000..07fa457 --- /dev/null +++ b/Ogmac/SettingsConnectionTab.swift @@ -0,0 +1,64 @@ +import SwiftUI +import AppKit + +struct SettingsConnectionTab: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + Form { + Section("Outlook") { + Picker("Read backend", selection: $viewModel.doc.outlook.readMethod) { + Text("Apple Calendar").tag("apple_calendar") + Text("Microsoft Graph").tag("microsoft_graph") + } + .pickerStyle(.segmented) + + if viewModel.doc.outlook.readMethod == "microsoft_graph" { + Text("You may need to run `ogmac login microsoft` in Terminal.") + .font(.callout) + .foregroundStyle(.secondary) + } + + TextField("Account", text: $viewModel.doc.outlook.account) + .textFieldStyle(.roundedBorder) + + TextField("Source calendar", text: $viewModel.doc.outlook.sourceCalendar) + .textFieldStyle(.roundedBorder) + } + + Section("Google") { + TextField("Account", text: $viewModel.doc.google.account) + .textFieldStyle(.roundedBorder) + + HStack { + Text("Client secret") + .frame(width: 120, alignment: .leading) + Text(viewModel.doc.google.clientSecretPath.isEmpty + ? "Not set" + : viewModel.doc.google.clientSecretPath) + .foregroundStyle(viewModel.doc.google.clientSecretPath.isEmpty ? .secondary : .primary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button("Choose…") { + chooseClientSecretFile() + } + } + + TextField("Target calendar ID", text: $viewModel.doc.google.targetCalendarId) + .textFieldStyle(.roundedBorder) + } + } + .formStyle(.grouped) + } + + private func chooseClientSecretFile() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.init(filenameExtension: "json")!] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + if panel.runModal() == .OK, let url = panel.url { + viewModel.doc.google.clientSecretPath = url.path + } + } +} diff --git a/Ogmac/SettingsPrivacyTab.swift b/Ogmac/SettingsPrivacyTab.swift new file mode 100644 index 0000000..caef713 --- /dev/null +++ b/Ogmac/SettingsPrivacyTab.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct SettingsPrivacyTab: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + Form { + Section("Fields to copy") { + Toggle("Copy subject", isOn: $viewModel.doc.privacy.copySubject) + Toggle("Copy location", isOn: $viewModel.doc.privacy.copyLocation) + Toggle("Copy body", isOn: $viewModel.doc.privacy.copyBody) + Toggle("Copy attendees", isOn: .constant(false)) + .disabled(true) + .help("Attendee syncing is disabled by design. See README → Privacy.") + } + } + .formStyle(.grouped) + } +} diff --git a/Ogmac/SettingsScene.swift b/Ogmac/SettingsScene.swift new file mode 100644 index 0000000..f1a5f2e --- /dev/null +++ b/Ogmac/SettingsScene.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct SettingsScene: Scene { + @StateObject private var viewModel: SettingsViewModel + + init(viewModel: SettingsViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some Scene { + Settings { + TabView { + SettingsConnectionTab(viewModel: viewModel) + .tabItem { Label("Connection", systemImage: "link") } + SettingsSyncTab(viewModel: viewModel) + .tabItem { Label("Sync", systemImage: "arrow.triangle.2.circlepath") } + SettingsPrivacyTab(viewModel: viewModel) + .tabItem { Label("Privacy", systemImage: "lock") } + } + .frame(width: 480, height: 320) + .padding(20) + .onChange(of: viewModel.doc) { _, _ in + viewModel.save() + } + } + } +} diff --git a/Ogmac/SettingsSyncTab.swift b/Ogmac/SettingsSyncTab.swift new file mode 100644 index 0000000..293fc63 --- /dev/null +++ b/Ogmac/SettingsSyncTab.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct SettingsSyncTab: View { + @ObservedObject var viewModel: SettingsViewModel + @StateObject private var launchAtLogin = LaunchAtLoginBinding() + + var body: some View { + Form { + Section("Sync window") { + Stepper( + "Past: \(viewModel.doc.sync.windowPastDays) day(s)", + value: $viewModel.doc.sync.windowPastDays, + in: 1...30 + ) + Stepper( + "Future: \(viewModel.doc.sync.windowFutureDays) day(s)", + value: $viewModel.doc.sync.windowFutureDays, + in: 1...365 + ) + } + + Section("Schedule") { + LabeledContent("Interval") { + Text("Every 15 minutes (configured in launchd plist)") + .foregroundStyle(.secondary) + } + } + + Section("Login") { + Toggle("Launch at login", isOn: $launchAtLogin.isEnabled) + } + } + .formStyle(.grouped) + } +} + +@MainActor +private final class LaunchAtLoginBinding: ObservableObject { + private let service = LaunchAtLogin() + + var isEnabled: Bool { + get { service.isEnabled } + set { + objectWillChange.send() + service.isEnabled = newValue + } + } +} diff --git a/Ogmac/SettingsViewModel.swift b/Ogmac/SettingsViewModel.swift new file mode 100644 index 0000000..4b716c6 --- /dev/null +++ b/Ogmac/SettingsViewModel.swift @@ -0,0 +1,34 @@ +import Foundation + +@MainActor +final class SettingsViewModel: ObservableObject { + @Published var doc: ConfigDoc + @Published var loadError: Error? + @Published var saveError: Error? + + private let store: any ConfigStoring + + init(store: any ConfigStoring) { + self.store = store + self.doc = ConfigDoc.empty + load() + } + + func load() { + do { + doc = try store.load() + loadError = nil + } catch { + loadError = error + } + } + + func save() { + do { + try store.save(doc) + saveError = nil + } catch { + saveError = error + } + } +} diff --git a/Ogmac/StateFileWatcher.swift b/Ogmac/StateFileWatcher.swift new file mode 100644 index 0000000..56b18c2 --- /dev/null +++ b/Ogmac/StateFileWatcher.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Watches `state.db` for write/delete/rename events and invokes `onChange` +/// each time the daemon updates the file. Uses GCD's +/// `DispatchSourceFileSystemObject` so wake-ups happen only on real activity +/// — no periodic polling. +/// +/// Debounces rapid bursts (some writes happen as multiple page flushes) by +/// ignoring events within 200ms of a previous fire. +final class StateFileWatcher { + private let path: String + private let onChange: () async -> Void + private var source: DispatchSourceFileSystemObject? + private var fd: Int32 = -1 + private var lastFiredAt: Date = .distantPast + private let queue = DispatchQueue(label: "com.ogmac.state-watcher") + + init(path: String, onChange: @escaping () async -> Void) { + self.path = path + self.onChange = onChange + } + + deinit { source?.cancel() } + + /// Start watching. If the file doesn't exist yet, returns false; caller + /// can fall back to a timer or retry once the file appears. + @discardableResult + func start() -> Bool { + stop() + let opened = open(path, O_EVTONLY) + guard opened >= 0 else { + DebugLog.write("StateFileWatcher.open failed path=\(path) errno=\(errno)") + return false + } + fd = opened + + let s = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .extend], + queue: queue + ) + + s.setEventHandler { [weak self] in + guard let self else { return } + let now = Date() + if now.timeIntervalSince(self.lastFiredAt) < 0.2 { return } + self.lastFiredAt = now + DebugLog.write("StateFileWatcher event") + Task { await self.onChange() } + } + + s.setCancelHandler { [fd] in + if fd >= 0 { close(fd) } + } + + source = s + s.resume() + DebugLog.write("StateFileWatcher started fd=\(fd) path=\(path)") + return true + } + + func stop() { + source?.cancel() + source = nil + fd = -1 + } +} diff --git a/Ogmac/StateReader.swift b/Ogmac/StateReader.swift new file mode 100644 index 0000000..add29c1 --- /dev/null +++ b/Ogmac/StateReader.swift @@ -0,0 +1,97 @@ +import Foundation +import SQLite3 + +protocol StateReading { + func snapshot() async throws -> StateSnapshot +} + +enum StateReaderError: Error { + case openFailed(code: Int32, message: String) + case prepareFailed(code: Int32, message: String) +} + +struct StateReader: StateReading { + let dbPath: String + + init(dbPath: String) { + self.dbPath = dbPath + } + + func snapshot() async throws -> StateSnapshot { + DebugLog.write("StateReader.snapshot start path=\(dbPath)") + // The daemon's state.db has journal_mode=WAL set in its metadata. + // Even SQLITE_OPEN_READONLY tries to coordinate with -wal/-shm + // sidecars (which may not exist in our process's view), failing + // with SQLITE_CANTOPEN on prepare. The URI ?immutable=1 flag + // tells SQLite to treat the file as a frozen snapshot and skip + // all journal coordination. We get a snapshot at open time; + // since we open a fresh connection per snapshot() call, polling + // sees fresh data. + let encoded = dbPath + .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + ?? dbPath + let uri = "file:\(encoded)?immutable=1" + var db: OpaquePointer? + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_NOMUTEX + let rc = sqlite3_open_v2(uri, &db, flags, nil) + guard rc == SQLITE_OK, let db else { + let msg = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown" + DebugLog.write("StateReader.open FAILED rc=\(rc) msg=\(msg) uri=\(uri)") + sqlite3_close(db) + throw StateReaderError.openFailed(code: rc, message: msg) + } + defer { sqlite3_close(db) } + DebugLog.write("StateReader.open ok") + + func value(for key: String) -> String? { + // Inline the key as a SQL literal — the keys are static internal + // strings, not user input, so injection isn't a concern. Avoids + // a Swift/sqlite3_bind_text ABI issue where SQLITE_TRANSIENT + // (a function pointer constant -1) can't be expressed cleanly. + var stmt: OpaquePointer? + let escaped = key.replacingOccurrences(of: "'", with: "''") + let sql = "SELECT value FROM run_state WHERE key = '\(escaped)'" + let prc = sqlite3_prepare_v2(db, sql, -1, &stmt, nil) + guard prc == SQLITE_OK else { + DebugLog.write("StateReader.prepare FAILED key=\(key) rc=\(prc) msg=\(String(cString: sqlite3_errmsg(db)))") + return nil + } + defer { sqlite3_finalize(stmt) } + let src = sqlite3_step(stmt) + guard src == SQLITE_ROW else { + DebugLog.write("StateReader.step no-row key=\(key) rc=\(src)") + return nil + } + guard let cstr = sqlite3_column_text(stmt, 0) else { + DebugLog.write("StateReader.column null key=\(key)") + return nil + } + let val = String(cString: cstr) + DebugLog.write("StateReader.value key=\(key) val=\(val)") + return val + } + + let lastSuccessRaw = value(for: "last_success_at") + let lastSuccessAt: Date? + if let raw = lastSuccessRaw { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + lastSuccessAt = formatter.date(from: raw) + } else { + lastSuccessAt = nil + } + + let consecutiveFailures = value(for: "consecutive_failures").flatMap { Int($0) } ?? 0 + let disabled = value(for: "disabled") == "1" + let paused = value(for: "paused") == "1" + let disableReason = value(for: "disable_reason") + + return StateSnapshot( + lastSuccessAt: lastSuccessAt, + consecutiveFailures: consecutiveFailures, + disabled: disabled, + paused: paused, + disableReason: disableReason + ) + } +} diff --git a/Ogmac/StatusController.swift b/Ogmac/StatusController.swift new file mode 100644 index 0000000..f1af5a3 --- /dev/null +++ b/Ogmac/StatusController.swift @@ -0,0 +1,164 @@ +import Foundation +import SwiftUI + +@MainActor +final class StatusController: ObservableObject { + + @Published var icon: IconState = .healthy + @Published var summary: PanelSummary = .empty + @Published private(set) var isSyncing: Bool = false + + private let stateReader: StateReading + private let logReader: LogReading + private let runner: OgmacCommanding + private let stateDbPath: String + + private var watcher: StateFileWatcher? + private var fallbackTimerTask: Task? + + init( + stateReader: StateReading, + logReader: LogReading, + runner: OgmacCommanding, + stateDbPath: String + ) { + self.stateReader = stateReader + self.logReader = logReader + self.runner = runner + self.stateDbPath = stateDbPath + } + + /// Start monitoring. Performs an immediate refresh, then installs a + /// file-system watcher on state.db so subsequent refreshes fire only + /// when the daemon actually writes (sync completion, pause toggle, + /// failure counter update). No periodic polling. + func startMonitoring() { + Task { await self.refresh() } + startWatcher() + } + + /// Stop the watcher. Use when the app is being torn down. + func stopMonitoring() { + watcher?.stop() + watcher = nil + fallbackTimerTask?.cancel() + fallbackTimerTask = nil + } + + /// Trigger an on-demand refresh. Call from panel onAppear so the user + /// always sees fresh data when they click the menu bar icon. + func refreshNow() { + Task { await self.refresh() } + } + + private func startWatcher() { + watcher?.stop() + let w = StateFileWatcher(path: stateDbPath) { [weak self] in + await self?.refresh() + } + if w.start() { + watcher = w + fallbackTimerTask?.cancel() + fallbackTimerTask = nil + return + } + // File doesn't exist yet (e.g., first run before any sync). Fall + // back to a slow timer that retries the watcher every 30s and + // refreshes opportunistically. + DebugLog.write("StatusController fallback timer (state.db not yet present)") + fallbackTimerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + guard let self else { return } + await self.refresh() + if FileManager.default.fileExists(atPath: self.stateDbPath) { + self.startWatcher() + return + } + } + } + } + + /// User clicked Sync now. Awaits the underlying `ogmac sync` Process, + /// which can take >1 min for a real sync. The Process exits only after + /// state.db is fully written, so we're guaranteed fresh data when we + /// refresh on completion. Errors are surfaced via the icon on refresh. + func triggerSync() async { + isSyncing = true + icon = Self.resolve(snapshot: lastSnapshot ?? Self.emptySnapshot, isSyncing: true, now: .now) + defer { + isSyncing = false + Task { await self.refresh() } + } + do { + try await runner.sync() + } catch { + DebugLog.write("triggerSync error: \(error)") + } + } + + private var lastSnapshot: StateSnapshot? + + private static let emptySnapshot = StateSnapshot( + lastSuccessAt: nil, + consecutiveFailures: 0, + disabled: false, + paused: false, + disableReason: nil + ) + + private func refresh() async { + DebugLog.write("refresh start") + var snap: StateSnapshot? = nil + var runs: [SyncRun] = [] + var stateErr: Error? = nil + var logErr: Error? = nil + + do { snap = try await stateReader.snapshot() } + catch { stateErr = error; DebugLog.write("refresh stateReader error: \(error)") } + + do { runs = try await logReader.tail(maxRuns: 50) } + catch { logErr = error; DebugLog.write("refresh logReader error: \(error)") } + + DebugLog.write("refresh state=\(snap != nil ? "ok" : "nil") runs=\(runs.count) lastSuccess=\(snap?.lastSuccessAt.map { ISO8601DateFormatter().string(from: $0) } ?? "nil")") + + let effectiveSnap = snap ?? Self.emptySnapshot + lastSnapshot = snap + + if stateErr != nil && logErr != nil { + icon = .error + } else { + icon = Self.resolve(snapshot: effectiveSnap, isSyncing: isSyncing, now: .now) + } + + summary = Self.makeSummary(recent: runs, now: .now) + DebugLog.write("refresh end icon=\(icon.rawValue)") + } + + // MARK: - Pure resolution function (tested) + + nonisolated static func resolve(snapshot: StateSnapshot, isSyncing: Bool, now: Date) -> IconState { + if isSyncing { return .syncing } + if snapshot.paused { return .paused } + if snapshot.disabled { return .autoDisabled } + + // needsLogin: deferred to v0.2 (requires parsing log for TokenRefreshError) + // Always false for v0.1. + + if snapshot.consecutiveFailures > 0 { return .error } + guard let lastSuccess = snapshot.lastSuccessAt else { return .error } + let age = now.timeIntervalSince(lastSuccess) + if age >= 24 * 3600 { return .error } + if age >= 30 * 60 { return .warning } + return .healthy + } + + // MARK: - PanelSummary construction + + static func makeSummary(recent: [SyncRun], now: Date) -> PanelSummary { + PanelSummary( + nextSyncRelative: PanelSummary.nextSyncRelative(from: now), + lastChange: PanelSummary.lastChange(from: recent) + ) + } +} diff --git a/OgmacTests/AppLaunchTests.swift b/OgmacTests/AppLaunchTests.swift new file mode 100644 index 0000000..a645cfb --- /dev/null +++ b/OgmacTests/AppLaunchTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import Ogmac + +@MainActor +final class AppLaunchTests: XCTestCase { + func testAppDepsConstructs() { + let deps = AppDeps() + XCTAssertNotNil(deps.stateReader) + XCTAssertNotNil(deps.runner) + XCTAssertNotNil(deps.statusController) + XCTAssertNotNil(deps.settingsViewModel) + XCTAssertNotNil(deps.logReader) + XCTAssertNotNil(deps.configStore) + } + + func testSingleSettingsViewModelInstance() { + let deps = AppDeps() + deps.settingsViewModel.doc.outlook.account = "test@example.com" + var capturedDoc: ConfigDoc? + let mirror = Mirror(reflecting: deps.statusController) + _ = mirror + XCTAssertEqual(deps.settingsViewModel.doc.outlook.account, "test@example.com") + } + + func testFirstLaunchTrueWhenCLIMissing() { + let deps = AppDeps( + runnerOverride: MockRunner(binaryPath: nil), + configStoreOverride: FailingConfigStore() + ) + XCTAssertTrue(deps.firstLaunch) + } + + func testFirstLaunchFalseWhenCLIAndConfigPresent() { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("test_config_\(UUID().uuidString).yaml") + let yaml = """ + outlook: + account: test@example.com + source_calendar: default + read_method: apple_calendar + google: + account: test@gmail.com + client_secret_path: /tmp/secret.json + target_calendar_id: abc@group.calendar.google.com + sync: + window_past_days: 1 + window_future_days: 30 + privacy: + copy_subject: true + copy_location: true + copy_body: true + copy_attendees: false + failure: + max_consecutive_before_disable: 5 + notify_on_failure: true + """ + try? yaml.write(to: tmp, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmp) } + + let fakeBinary = URL(fileURLWithPath: "/usr/bin/true") + let deps = AppDeps( + runnerOverride: MockRunner(binaryPath: fakeBinary), + configStoreOverride: ConfigStore(configPath: tmp) + ) + XCTAssertFalse(deps.firstLaunch) + } +} + +private final class MockRunner: OgmacCommanding { + let binaryPath: URL? + init(binaryPath: URL?) { self.binaryPath = binaryPath } + func sync() async throws {} + func pause() async throws {} + func unpause() async throws {} + func resume() async throws {} + func reset(yes: Bool) async throws {} +} + +private struct FailingConfigStore: ConfigStoring { + func load() throws -> ConfigDoc { throw ConfigStoreError.fileNotFound } + func save(_ doc: ConfigDoc) throws {} +} diff --git a/OgmacTests/ConfigStoreTests.swift b/OgmacTests/ConfigStoreTests.swift new file mode 100644 index 0000000..c96ef2a --- /dev/null +++ b/OgmacTests/ConfigStoreTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import Ogmac + +final class ConfigStoreTests: XCTestCase { + private func fixtureURL(_ name: String) -> URL { + let testDir = URL(fileURLWithPath: #file).deletingLastPathComponent() + return testDir.appendingPathComponent("Fixtures/\(name)") + } + + private func tmpURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("config_test_\(UUID().uuidString).yaml") + } + + func testLoadCanonical() throws { + let store = ConfigStore(configPath: fixtureURL("config_canonical.yaml")) + let doc = try store.load() + + XCTAssertEqual(doc.outlook.account, "you@example.com") + XCTAssertEqual(doc.outlook.sourceCalendar, "default") + XCTAssertEqual(doc.outlook.readMethod, "apple_calendar") + XCTAssertEqual(doc.google.account, "you@gmail.com") + XCTAssertEqual(doc.google.targetCalendarId, "abc123@group.calendar.google.com") + XCTAssertEqual(doc.sync.windowPastDays, 1) + XCTAssertEqual(doc.sync.windowFutureDays, 30) + XCTAssertTrue(doc.privacy.copySubject) + XCTAssertFalse(doc.privacy.copyAttendees) + XCTAssertEqual(doc.failure.maxConsecutiveBeforeDisable, 5) + XCTAssertTrue(doc.failure.notifyOnFailure) + } + + func testLoadLegacyIgnoresIntervalSeconds() throws { + let store = ConfigStore(configPath: fixtureURL("config_legacy.yaml")) + let doc = try store.load() + + XCTAssertEqual(doc.sync.windowPastDays, 2) + XCTAssertEqual(doc.sync.windowFutureDays, 60) + XCTAssertEqual(doc.outlook.readMethod, "microsoft_graph") + } + + func testRoundTrip() throws { + let src = ConfigStore(configPath: fixtureURL("config_canonical.yaml")) + var doc = try src.load() + + doc.sync.windowPastDays = 7 + doc.sync.windowFutureDays = 60 + doc.failure.maxConsecutiveBeforeDisable = 10 + + let dest = tmpURL() + // Create the file so replaceItemAt has something to replace + try "".write(to: dest, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: dest) } + + let store = ConfigStore(configPath: dest) + try store.save(doc) + let reloaded = try store.load() + + XCTAssertEqual(reloaded.sync.windowPastDays, 7) + XCTAssertEqual(reloaded.sync.windowFutureDays, 60) + XCTAssertEqual(reloaded.failure.maxConsecutiveBeforeDisable, 10) + XCTAssertEqual(reloaded.outlook.account, doc.outlook.account) + XCTAssertEqual(reloaded.google.targetCalendarId, doc.google.targetCalendarId) + } + + func testSaveUsesAtomicReplacement() throws { + let dest = tmpURL() + try "".write(to: dest, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: dest) } + + let src = ConfigStore(configPath: fixtureURL("config_canonical.yaml")) + var doc = try src.load() + doc.sync.windowPastDays = 14 + + let store = ConfigStore(configPath: dest) + try store.save(doc) + + // Confirm temp file was cleaned up + let tmp = dest.appendingPathExtension("tmp") + XCTAssertFalse(FileManager.default.fileExists(atPath: tmp.path)) + + // Confirm final file has the new value + let reloaded = try store.load() + XCTAssertEqual(reloaded.sync.windowPastDays, 14) + } + + func testLoadMissingFileThrows() { + let store = ConfigStore(configPath: fixtureURL("does_not_exist.yaml")) + XCTAssertThrowsError(try store.load()) { error in + if case ConfigStoreError.fileNotFound = error {} else { + XCTFail("Expected ConfigStoreError.fileNotFound, got \(error)") + } + } + } +} diff --git a/OgmacTests/Fixtures/config_canonical.yaml b/OgmacTests/Fixtures/config_canonical.yaml new file mode 100644 index 0000000..e30c647 --- /dev/null +++ b/OgmacTests/Fixtures/config_canonical.yaml @@ -0,0 +1,23 @@ +outlook: + account: you@example.com + source_calendar: default + read_method: apple_calendar + +google: + account: you@gmail.com + client_secret_path: ~/.config/ogmac/client_secret.json + target_calendar_id: abc123@group.calendar.google.com + +sync: + window_past_days: 1 + window_future_days: 30 + +privacy: + copy_subject: true + copy_location: true + copy_body: true + copy_attendees: false + +failure: + max_consecutive_before_disable: 5 + notify_on_failure: true diff --git a/OgmacTests/Fixtures/config_legacy.yaml b/OgmacTests/Fixtures/config_legacy.yaml new file mode 100644 index 0000000..b2d3f35 --- /dev/null +++ b/OgmacTests/Fixtures/config_legacy.yaml @@ -0,0 +1,24 @@ +outlook: + account: legacy@example.com + source_calendar: Work + read_method: microsoft_graph + +google: + account: legacy@gmail.com + client_secret_path: ~/.config/ogmac/client_secret.json + target_calendar_id: legacy123@group.calendar.google.com + +sync: + window_past_days: 2 + window_future_days: 60 + interval_seconds: 300 + +privacy: + copy_subject: true + copy_location: false + copy_body: false + copy_attendees: false + +failure: + max_consecutive_before_disable: 3 + notify_on_failure: false diff --git a/OgmacTests/Fixtures/state_disabled.sqlite b/OgmacTests/Fixtures/state_disabled.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..226499a0d556c6f2683878e1f511cd823bec628f GIT binary patch literal 24576 zcmeI)J#W)M7zc1Wb`pq^9O{xTRc-+qm7=7`AY~$ehl-FwFj5P{$&D|q6~}J<5|yz+ zjC>1*&TNP;z`!?PV(7-mz{oi-CP^CxkQk!B6)VrZIevcja!zv3pEd1})2`?D&5+)a zu1T_-zfY+oNm;RH#6H;+5vY@bc$cT*MPXLD{qARhe3#ONbE){1d@Q~#oEO`Rx}zQf z5P$##AOHafKmY;|fB*#kNuZ}@OXaeBdObAv9qvai3qmve9i$(&^@gESqw%1r)1;lQ z=cw3v{53W7U4uStZ$D|Y_vmAN@7AzoU^)>WwY7E(+IrD!<|?&xrlgl;$#yM%5*#~X zddbWv^oC`Y9GumXFk>j0(#^7bJnUjyC+xuU4jj&Gix0RPvc7qwj!$vB^+?~PGkxez zizfZnlcANz{BOAvILKb+DFOJan zEW2y-@o^?pqkbKqi%VfKG5a1O&$dQK)}^h2IhFYXbCL|JC1E^&zcPOx72*o=W4;3dmvWeGi@jGc~GZ@*W+%RcSYe) z_kGXLE6N%fkF{#6DMcewBiUr6c0*P+w9&c)QK!R$KrAY-Ro%L~S=BbH_l#P#UX5Qb zr;~`k|5wSGL_U)<@`-$#ZbJ+K2tWV=5P$##AOHafKmY;|fWZG1SWznS?BfG9udGd~ XEM-;Eo;rqBErzT*Ph literal 0 HcmV?d00001 diff --git a/OgmacTests/Fixtures/state_failed.sqlite b/OgmacTests/Fixtures/state_failed.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..a05797628d08cc3be7572c3fa75dba5edfb1a513 GIT binary patch literal 24576 zcmeI)J#W)M7zgmP9S4X=j9Ahww*ak5k<%)Gmok=l3TYwc6@tkzpJ9PJQ*00bZa z0SG_<0uX=z1R(HV0tcll^+rQExs^Ka10H7~OHwEO8|Y7aR@=6y-G0)ssO+b!6)MUB z|3GbP*QPIf+b`O^J^I|*yIb@Oogm|*zV41qyKg$3%34z|*R6&^{Ltga$x$G-m(0o1 zsHihJIcv(WY-{EEorZE$4Dr2VwjV|N0cXC)hdfN#z&R|9m$==1X6@3MF?6R(<+xS( z*0qy&N|E+Qy+~UQk%2mswrn1!?S}#>bn{g#NMC-`EWyYoaFUcIQ5K6C29f9YeLkLN zLNyxa#$22VkBQxPfjrxr9eNk`3TCzZ4`!JRYsxU+zh9mI9#!)OJWec1Rwwb z2tWV=5P$##AOHafERjGZ&jtJcB|4)>1p*L&00bZa0SG_<0uX=z1R!u;0Q>**CTM{G z1Rwwb2tWV=5P$##AOHafESo_7|9{o^LX2pOms#XQ-%t!V45(R~Lp*X2nfZYr>8Zr3yVF1je7-qhBw&0Q0i GUi}|rFx^W4 literal 0 HcmV?d00001 diff --git a/OgmacTests/Fixtures/state_healthy.sqlite b/OgmacTests/Fixtures/state_healthy.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..7f009c747397a1e50d7160155e25c7bae99b4fe6 GIT binary patch literal 24576 zcmeI)J#W)M7zgmP9S4X=j9Ahww*ak5k<*f@>O=q!6(NOSq!xygYhNm>j@`y~RK^N1 z@@3fg1|9eYObp!^85lV`$&sWD3riLKPjd3yOD^~P?B$%uZ(g_jgwt-+?>PzGAa{tO z+^X_8;aw11CuNu&=#q)Arj|tF%(ri&d+p5I^+zX?zlh?Im;4 zB+BbdPR{BwEZSPJ`kQP0zsLu8;%r7fFBY5So-3f*iKbJCYzHBB(I37j}#ag_GO486$nyFMSy zGo~7jbNgJJ3Xh51caA*Wn;v^t_6laT_zz~846Dm9+rQtOy^hLRgYj#&8FdIi00Izz z00bZa0SG_<0uX=z1Qtl3l;wi`{{o#+qyhm5KmY;|fB*y_009U<00I!WEP(z0WfQbO z00Izz00bZa0SG_<0uX=z1Qtyo`~Sacd?Cg+@qz*Y2tWV=5P$##AOHafKmY;|fWS2o zSW>ly;YMN1-8AtBoOK;PNc%iC?nE_JHI%Dv3aEFr)mt<7 I1SVJi2ScOXNdN!< literal 0 HcmV?d00001 diff --git a/OgmacTests/Fixtures/state_paused.sqlite b/OgmacTests/Fixtures/state_paused.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..1e041cb60ebd5d649f265163ababdd9f8beed997 GIT binary patch literal 24576 zcmeI)v2N2q7zc1Wml%jh53!`3atqL^6gfdbh=~9mDnbguNG%K}V_zz(j-AGKRK^N1 z@)|q<8v{E7Z@|RRjgf(ovlEUaZCDsU(f^d>KHps~{(jDKm&i|_wEaY|UNr1G3A?FX z(scdeUB)y`GbT@2<%P)C;v~*0eJWlQ8rqGwKPvQ_R;rw6D_`h`l{b}>mF}Y9Xomm< zAOHafKmY;|fB*y_0D->}I4GX0)@u6kmBe`&h+!J?IB}BSLFs=d>F{( zC3n&!%8$94oYz%o*u_cf`jO}N zd@-5l7u9&2I~4L#cwDZ&bL83Hbl{!YE13(%ADC4#yspA*{eFJ_dsN99==b?%976yC z5P$##AOHafKmY;|fB*y_utWmoEElZ*m*|Qj6$n580uX=z1Rwwb2tWV=5P-mG0j&Q| zo1g^(5P$##AOHafKmY;|fB*y_uxtX^|NjYnr_s;!y{u3m009U<00Izz00bZa0SG_< z0ucCT0%bzh=)g&1;ni0Qq)FW URL { + let testDir = URL(fileURLWithPath: #file).deletingLastPathComponent() + return testDir.appendingPathComponent("Fixtures/\(name)") + } + + func testCleanRun() async throws { + let reader = LogReader(logPath: fixtureURL("sync_log_clean.txt")) + let runs = try await reader.tail(maxRuns: 50) + + XCTAssertEqual(runs.count, 1) + let run = runs[0] + if case .success = run.result {} else { XCTFail("Expected success") } + XCTAssertEqual(run.durationMs, 3377) + XCTAssertNotNil(run.counts) + XCTAssertEqual(run.counts?.create, 2) + XCTAssertEqual(run.counts?.update, 0) + XCTAssertEqual(run.counts?.delete, 0) + XCTAssertEqual(run.counts?.skip, 40) + } + + func testFailedRun() async throws { + let reader = LogReader(logPath: fixtureURL("sync_log_failed.txt")) + let runs = try await reader.tail(maxRuns: 50) + + XCTAssertEqual(runs.count, 1) + let run = runs[0] + if case .failure(let reason) = run.result { + XCTAssertEqual(reason, "TokenRefreshError") + } else { + XCTFail("Expected failure") + } + XCTAssertNil(run.durationMs) + } + + func testPartialRun() async throws { + let reader = LogReader(logPath: fixtureURL("sync_log_partial.txt")) + let runs = try await reader.tail(maxRuns: 50) + + XCTAssertEqual(runs.count, 1) + let run = runs[0] + if case .failure(let reason) = run.result { + XCTAssertEqual(reason, "incomplete") + } else { + XCTFail("Expected incomplete failure") + } + XCTAssertNil(run.durationMs) + XCTAssertNil(run.counts) + } + + func testSkippedLinesNotCounted() async throws { + let reader = LogReader(logPath: fixtureURL("sync_log_skipped.txt")) + let runs = try await reader.tail(maxRuns: 50) + + XCTAssertEqual(runs.count, 1) + if case .success = runs[0].result {} else { XCTFail("Expected success") } + } + + func testRotatedLogs() async throws { + let logURL = fixtureURL("sync_log_rotated.log") + let reader = LogReader(logPath: logURL) + let runs = try await reader.tail(maxRuns: 50) + + XCTAssertEqual(runs.count, 2) + // Older run comes first (from .log.1) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + let oldStart = formatter.date(from: "2026-05-07T10:00:00Z")! + let newStart = formatter.date(from: "2026-05-08T14:15:00Z")! + XCTAssertEqual(runs[0].startedAt, oldStart) + XCTAssertEqual(runs[1].startedAt, newStart) + if case .success = runs[0].result {} else { XCTFail("run[0] expected success") } + if case .success = runs[1].result {} else { XCTFail("run[1] expected success") } + } + + func testMaxRunsCaps() async throws { + let reader = LogReader(logPath: fixtureURL("sync_log_clean.txt")) + let runs = try await reader.tail(maxRuns: 0) + XCTAssertEqual(runs.count, 0) + } + + func testMissingLogFileReturnsEmpty() async throws { + let missing = fixtureURL("does_not_exist.log") + let reader = LogReader(logPath: missing) + let runs = try await reader.tail(maxRuns: 50) + XCTAssertEqual(runs.count, 0) + } +} diff --git a/OgmacTests/OgmacRunnerTests.swift b/OgmacTests/OgmacRunnerTests.swift new file mode 100644 index 0000000..565c63f --- /dev/null +++ b/OgmacTests/OgmacRunnerTests.swift @@ -0,0 +1,171 @@ +import XCTest +@testable import Ogmac + +final class OgmacRunnerTests: XCTestCase { + + private func makeTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeShim( + in tempDir: URL, + exitCode: Int32, + stdout: String = "", + stderr: String = "" + ) throws -> URL { + let shim = tempDir.appendingPathComponent("ogmac") + var lines: [String] = ["#!/bin/sh"] + if !stderr.isEmpty { + lines.append("echo \"\(stderr)\" >&2") + } + if !stdout.isEmpty { + lines.append("echo \"\(stdout)\"") + } + lines.append("exit \(exitCode)") + let script = lines.joined(separator: "\n") + "\n" + try script.write(to: shim, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: shim.path + ) + return shim + } + + private func makeShimEchoArgs(in tempDir: URL, exitCode: Int32 = 0) throws -> URL { + let shim = tempDir.appendingPathComponent("ogmac") + let script = """ + #!/bin/sh + echo "$@" + exit \(exitCode) + """ + try script.write(to: shim, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: shim.path + ) + return shim + } + + private func envWithPath(_ dir: URL) -> [String: String] { + var env = ProcessInfo.processInfo.environment + let existing = env["PATH"] ?? "/usr/bin:/bin" + env["PATH"] = "\(dir.path):\(existing)" + return env + } + + // MARK: - Case 1: sync() exit 0 → no throw + + func test_sync_exitZero_doesNotThrow() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try makeShim(in: dir, exitCode: 0) + + let runner = OgmacRunner(environment: envWithPath(dir)) + try await runner.sync() + } + + // MARK: - Case 2: sync() exit 1 + stderr "boom" → throws nonZeroExit + + func test_sync_exitOne_throwsNonZeroExit() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try makeShim(in: dir, exitCode: 1, stderr: "boom") + + let runner = OgmacRunner(environment: envWithPath(dir)) + do { + try await runner.sync() + XCTFail("Expected throw") + } catch RunnerError.nonZeroExit(let code, let stderr) { + XCTAssertEqual(code, 1) + XCTAssertTrue(stderr.contains("boom"), "stderr was: \(stderr)") + } + } + + // MARK: - Case 3: binaryPath nil → throws binaryNotFound + + func test_sync_noBinary_throwsBinaryNotFound() async throws { + let emptyDir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: emptyDir) } + + var env = ProcessInfo.processInfo.environment + env["PATH"] = emptyDir.path + + let noVenvRunner = OgmacRunner( + fileManager: .default, + environment: env, + venvFallback: emptyDir.appendingPathComponent("nonexistent-ogmac") + ) + + do { + try await noVenvRunner.sync() + XCTFail("Expected throw") + } catch RunnerError.binaryNotFound { + // pass + } + } + + // MARK: - Case 4: PATH match wins over venv path + + func test_binaryPath_prefersPathOverVenv() async throws { + let shimDir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: shimDir) } + let shimURL = try makeShim(in: shimDir, exitCode: 0) + + let runner = OgmacRunner(environment: envWithPath(shimDir)) + let resolved = runner.binaryPath + + XCTAssertNotNil(resolved) + XCTAssertEqual(resolved?.path, shimURL.path) + } + + // MARK: - Case 5: reset(yes:) argument passing + + func test_reset_yes_true_passesYesFlag() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let outFile = dir.appendingPathComponent("args.txt") + + let shim = dir.appendingPathComponent("ogmac") + let script = """ + #!/bin/sh + echo "$@" > \(outFile.path) + exit 0 + """ + try script.write(to: shim, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shim.path) + + let runner = OgmacRunner(environment: envWithPath(dir)) + try await runner.reset(yes: true) + + let captured = try String(contentsOf: outFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual(captured, "reset --yes") + } + + func test_reset_yes_false_omitsYesFlag() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let outFile = dir.appendingPathComponent("args.txt") + + let shim = dir.appendingPathComponent("ogmac") + let script = """ + #!/bin/sh + echo "$@" > \(outFile.path) + exit 0 + """ + try script.write(to: shim, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shim.path) + + let runner = OgmacRunner(environment: envWithPath(dir)) + try await runner.reset(yes: false) + + let captured = try String(contentsOf: outFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual(captured, "reset") + } +} diff --git a/OgmacTests/PanelSummaryTests.swift b/OgmacTests/PanelSummaryTests.swift new file mode 100644 index 0000000..47f22f4 --- /dev/null +++ b/OgmacTests/PanelSummaryTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import Ogmac + +final class PanelSummaryTests: XCTestCase { + + // Fixed reference point: 2026-05-08 14:17:45 UTC + // minute=17, second=45 → next quarter-hour is :30 → 12m15s away → ceil = 13 min + private let refDate = Date(timeIntervalSince1970: 1_746_710_265) + + // MARK: - nextSyncRelative + + // refDate: minute=17, second=45 + // next quarter-hour from :17:45 → :30:00 → 12m15s → ceil(12.25) = 13 + func test_nextSync_from_ref_date_is_13_min() { + let result = PanelSummary.nextSyncRelative(from: refDate) + XCTAssertEqual(result, "in 13 min") + } + + // minute=0, second=0 → exactly on the quarter; next is :15 → 15 min + func test_nextSync_exactly_on_quarter_hour() { + // Build a date at exactly :00:00 of its minute + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + var comps = cal.dateComponents([.year, .month, .day, .hour], from: refDate) + comps.minute = 0 + comps.second = 0 + let d = cal.date(from: comps)! + let result = PanelSummary.nextSyncRelative(from: d) + XCTAssertEqual(result, "in 15 min") + } + + // minute=44, second=59 → next quarter-hour is :45 → 1 sec away → ceil(1/60) = 1 + func test_nextSync_1min_away() { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + var comps = cal.dateComponents([.year, .month, .day, .hour], from: refDate) + comps.minute = 44 + comps.second = 59 + let d = cal.date(from: comps)! + let result = PanelSummary.nextSyncRelative(from: d) + XCTAssertEqual(result, "in 1 min") + } + + // minute=45, second=30 → next is :60 (i.e., :00 next hour) → 14m30s → ceil = 15 + func test_nextSync_wraps_past_45() { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + var comps = cal.dateComponents([.year, .month, .day, .hour], from: refDate) + comps.minute = 45 + comps.second = 30 + let d = cal.date(from: comps)! + let result = PanelSummary.nextSyncRelative(from: d) + XCTAssertEqual(result, "in 15 min") + } + + // MARK: - lastChange + + func test_lastChange_picks_most_recent_meaningful_run() { + // Slice is chronological (oldest first). Latest = run3 with no CRUD; + // most-recent meaningful = run2 with create=1. + let now = Date() + let runs: [SyncRun] = [ + SyncRun(startedAt: now.addingTimeInterval(-600), result: .success, + durationMs: 100, + counts: ReconcileCounts(create: 0, update: 0, delete: 0, skip: 10)), + SyncRun(startedAt: now.addingTimeInterval(-300), result: .success, + durationMs: 100, + counts: ReconcileCounts(create: 1, update: 0, delete: 0, skip: 10)), + SyncRun(startedAt: now, result: .success, + durationMs: 100, + counts: ReconcileCounts(create: 0, update: 0, delete: 0, skip: 11)), + ] + guard case .meaningful(let counts, let at) = PanelSummary.lastChange(from: runs) else { + return XCTFail("expected .meaningful") + } + XCTAssertEqual(counts.create, 1) + XCTAssertEqual(at.timeIntervalSinceReferenceDate, + now.addingTimeInterval(-300).timeIntervalSinceReferenceDate, + accuracy: 1.0) + } + + func test_lastChange_falls_back_to_no_changes() { + let now = Date() + let runs: [SyncRun] = [ + SyncRun(startedAt: now.addingTimeInterval(-300), result: .success, + durationMs: 100, + counts: ReconcileCounts(create: 0, update: 0, delete: 0, skip: 10)), + SyncRun(startedAt: now, result: .success, + durationMs: 100, + counts: ReconcileCounts(create: 0, update: 0, delete: 0, skip: 11)), + ] + guard case .noChanges(let lastRunAt) = PanelSummary.lastChange(from: runs) else { + return XCTFail("expected .noChanges") + } + XCTAssertEqual(lastRunAt.timeIntervalSinceReferenceDate, + now.timeIntervalSinceReferenceDate, + accuracy: 1.0) + } + + func test_lastChange_skips_failures() { + let runs: [SyncRun] = [ + SyncRun(startedAt: Date(), result: .failure(reason: "x"), durationMs: nil, counts: nil), + ] + XCTAssertNil(PanelSummary.lastChange(from: runs)) + } + + func test_lastChange_empty_array() { + XCTAssertNil(PanelSummary.lastChange(from: [])) + } +} diff --git a/OgmacTests/SettingsViewModelTests.swift b/OgmacTests/SettingsViewModelTests.swift new file mode 100644 index 0000000..a77dcdc --- /dev/null +++ b/OgmacTests/SettingsViewModelTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import Ogmac + +private enum SomeError: Error { + case fail +} + +private final class MockConfigStore: ConfigStoring { + private let loaded: ConfigDoc + private let saveError: Error? + private(set) var savedDoc: ConfigDoc? + + init(loaded: ConfigDoc = .empty, saveError: Error? = nil) { + self.loaded = loaded + self.saveError = saveError + } + + func load() throws -> ConfigDoc { + return loaded + } + + func save(_ doc: ConfigDoc) throws { + if let error = saveError { + throw error + } + savedDoc = doc + } +} + +@MainActor +final class SettingsViewModelTests: XCTestCase { + func testLoadPopulatesDoc() throws { + let store = MockConfigStore(loaded: ConfigDoc.canonical) + let vm = SettingsViewModel(store: store) + XCTAssertEqual(vm.doc, ConfigDoc.canonical) + } + + func testLoadErrorIsExposedWhenStoreFails() throws { + final class FailingStore: ConfigStoring { + func load() throws -> ConfigDoc { throw SomeError.fail } + func save(_ doc: ConfigDoc) throws {} + } + let vm = SettingsViewModel(store: FailingStore()) + XCTAssertNotNil(vm.loadError) + XCTAssertEqual(vm.doc, ConfigDoc.empty) + } + + func testSavePassesDocToStore() throws { + let store = MockConfigStore(loaded: .empty) + let vm = SettingsViewModel(store: store) + vm.doc.outlook.account = "x@y.com" + vm.save() + XCTAssertEqual(store.savedDoc?.outlook.account, "x@y.com") + } + + func testSaveErrorIsExposed() throws { + let store = MockConfigStore(saveError: SomeError.fail) + let vm = SettingsViewModel(store: store) + vm.save() + XCTAssertNotNil(vm.saveError) + } + + func testSaveErrorClearedOnSuccess() throws { + let store = MockConfigStore(loaded: .empty) + let vm = SettingsViewModel(store: store) + vm.saveError = SomeError.fail + vm.save() + XCTAssertNil(vm.saveError) + } +} diff --git a/OgmacTests/SmokeTests.swift b/OgmacTests/SmokeTests.swift new file mode 100644 index 0000000..f924014 --- /dev/null +++ b/OgmacTests/SmokeTests.swift @@ -0,0 +1,7 @@ +import XCTest + +final class SmokeTests: XCTestCase { + func testTrueIsTrue() { + XCTAssertTrue(true) + } +} diff --git a/OgmacTests/StateReaderTests.swift b/OgmacTests/StateReaderTests.swift new file mode 100644 index 0000000..610662c --- /dev/null +++ b/OgmacTests/StateReaderTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Ogmac + +final class StateReaderTests: XCTestCase { + private func fixtureURL(_ name: String) -> URL { + let bundle = Bundle(for: type(of: self)) + if let url = bundle.url(forResource: name, withExtension: "sqlite") { + return url + } + // Fall back to source-relative path for when tests run via xcodebuild + let testDir = URL(fileURLWithPath: #file).deletingLastPathComponent() + return testDir.appendingPathComponent("Fixtures/\(name).sqlite") + } + + func testHealthySnapshot() async throws { + let reader = StateReader(dbPath: fixtureURL("state_healthy").path) + let snap = try await reader.snapshot() + + XCTAssertNotNil(snap.lastSuccessAt) + XCTAssertEqual(snap.consecutiveFailures, 0) + XCTAssertFalse(snap.disabled) + XCTAssertFalse(snap.paused) + XCTAssertNil(snap.disableReason) + } + + func testHealthyLastSuccessAt() async throws { + let reader = StateReader(dbPath: fixtureURL("state_healthy").path) + let snap = try await reader.snapshot() + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expected = formatter.date(from: "2026-05-08T14:30:00Z") + XCTAssertEqual(snap.lastSuccessAt, expected) + } + + func testDisabledSnapshot() async throws { + let reader = StateReader(dbPath: fixtureURL("state_disabled").path) + let snap = try await reader.snapshot() + + XCTAssertTrue(snap.disabled) + XCTAssertFalse(snap.paused) + XCTAssertEqual(snap.consecutiveFailures, 5) + XCTAssertNotNil(snap.disableReason) + XCTAssertTrue(snap.disableReason!.contains("TokenRefreshError")) + } + + func testPausedSnapshot() async throws { + let reader = StateReader(dbPath: fixtureURL("state_paused").path) + let snap = try await reader.snapshot() + + XCTAssertTrue(snap.paused) + XCTAssertFalse(snap.disabled) + XCTAssertEqual(snap.consecutiveFailures, 0) + XCTAssertNil(snap.disableReason) + } + + func testFailedSnapshot() async throws { + let reader = StateReader(dbPath: fixtureURL("state_failed").path) + let snap = try await reader.snapshot() + + XCTAssertFalse(snap.disabled) + XCTAssertFalse(snap.paused) + XCTAssertEqual(snap.consecutiveFailures, 3) + XCTAssertNil(snap.disableReason) + } + + func testMissingConsecutiveFailuresDefaultsToZero() async throws { + // Use healthy fixture which has consecutive_failures=0 explicitly, + // but also test empty DB where the key is absent + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("state_empty_\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: tmp) } + + // Create empty DB with schema but no run_state rows + let schema = """ + CREATE TABLE IF NOT EXISTS run_state (key TEXT PRIMARY KEY, value TEXT NOT NULL); + """ + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [tmp.path, schema] + try process.run() + process.waitUntilExit() + + let reader = StateReader(dbPath: tmp.path) + let snap = try await reader.snapshot() + XCTAssertEqual(snap.consecutiveFailures, 0) + XCTAssertNil(snap.lastSuccessAt) + XCTAssertFalse(snap.disabled) + XCTAssertFalse(snap.paused) + } +} diff --git a/OgmacTests/StatusControllerTests.swift b/OgmacTests/StatusControllerTests.swift new file mode 100644 index 0000000..a27d89b --- /dev/null +++ b/OgmacTests/StatusControllerTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import Ogmac + +final class StatusControllerTests: XCTestCase { + + private let now = Date(timeIntervalSince1970: 1_000_000) + + // MARK: - Helpers + + private func snap( + lastSuccessAt: Date? = nil, + consecutiveFailures: Int = 0, + disabled: Bool = false, + paused: Bool = false + ) -> StateSnapshot { + StateSnapshot( + lastSuccessAt: lastSuccessAt, + consecutiveFailures: consecutiveFailures, + disabled: disabled, + paused: paused, + disableReason: nil + ) + } + + private func resolve(_ snapshot: StateSnapshot, isSyncing: Bool = false) -> IconState { + StatusController.resolve(snapshot: snapshot, isSyncing: isSyncing, now: now) + } + + // MARK: - Priority 1: syncing beats everything + + func test_syncing_beats_paused() { + let s = snap(paused: true) + XCTAssertEqual(resolve(s, isSyncing: true), .syncing) + } + + func test_syncing_beats_disabled() { + let s = snap(disabled: true) + XCTAssertEqual(resolve(s, isSyncing: true), .syncing) + } + + func test_syncing_beats_error() { + let s = snap(consecutiveFailures: 3) + XCTAssertEqual(resolve(s, isSyncing: true), .syncing) + } + + func test_syncing_beats_healthy() { + let s = snap(lastSuccessAt: now.addingTimeInterval(-60)) + XCTAssertEqual(resolve(s, isSyncing: true), .syncing) + } + + // MARK: - Priority 2: paused beats disabled/error/warning + + func test_paused_beats_disabled() { + let s = snap(disabled: true, paused: true) + XCTAssertEqual(resolve(s), .paused) + } + + func test_paused_beats_error() { + let s = snap(consecutiveFailures: 5, paused: true) + XCTAssertEqual(resolve(s), .paused) + } + + func test_paused_alone() { + let s = snap(lastSuccessAt: now.addingTimeInterval(-60), paused: true) + XCTAssertEqual(resolve(s), .paused) + } + + // MARK: - Priority 3: autoDisabled + + func test_disabled_alone() { + let s = snap(disabled: true) + XCTAssertEqual(resolve(s), .autoDisabled) + } + + func test_disabled_with_failures() { + let s = snap(consecutiveFailures: 3, disabled: true) + XCTAssertEqual(resolve(s), .autoDisabled) + } + + // MARK: - Priority 4: needsLogin (deferred to v0.2 — always false for now) + + func test_needsLogin_deferred_returns_error_when_failures_present() { + // needsLogin is never true in v0.1; ensure the slot doesn't swallow real errors + let s = snap(consecutiveFailures: 1) + XCTAssertEqual(resolve(s), .error) + } + + // MARK: - Priority 5/6/7/8/9: error → warning → healthy + + func test_error_when_consecutive_failures_nonzero() { + let s = snap(lastSuccessAt: now.addingTimeInterval(-60), consecutiveFailures: 1) + XCTAssertEqual(resolve(s), .error) + } + + func test_error_when_no_last_success() { + let s = snap() + XCTAssertEqual(resolve(s), .error) + } + + func test_error_when_last_success_over_24h() { + let t = now.addingTimeInterval(-(24 * 3600 + 1)) + let s = snap(lastSuccessAt: t) + XCTAssertEqual(resolve(s), .error) + } + + func test_error_exactly_at_24h_boundary() { + let t = now.addingTimeInterval(-24 * 3600) + let s = snap(lastSuccessAt: t) + XCTAssertEqual(resolve(s), .error) + } + + func test_warning_when_last_success_between_30min_and_24h() { + let t = now.addingTimeInterval(-(30 * 60 + 1)) + let s = snap(lastSuccessAt: t) + XCTAssertEqual(resolve(s), .warning) + } + + func test_warning_just_inside_24h() { + let t = now.addingTimeInterval(-(24 * 3600 - 1)) + let s = snap(lastSuccessAt: t) + XCTAssertEqual(resolve(s), .warning) + } + + func test_healthy_when_last_success_within_30min() { + let t = now.addingTimeInterval(-(30 * 60 - 1)) + let s = snap(lastSuccessAt: t) + XCTAssertEqual(resolve(s), .healthy) + } + + func test_healthy_recent() { + let t = now.addingTimeInterval(-120) + let s = snap(lastSuccessAt: t) + XCTAssertEqual(resolve(s), .healthy) + } + + // MARK: - Exactly at 30-minute boundary + + func test_exactly_30min_is_warning() { + let t = now.addingTimeInterval(-30 * 60) + let s = snap(lastSuccessAt: t) + XCTAssertEqual(resolve(s), .warning) + } +} diff --git a/README.md b/README.md index f909e66..4636bc5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,33 @@ Then: That's it. From then on, launchd runs `ogmac sync` every 15 minutes in the background. +## Menu bar app + +The v0.2 menu bar app (`Ogmac.app`) lives in your macOS menu bar and gives you a live view of sync status, settings, and recent-run history without opening a terminal. + +**Install (unsigned build):** + +```bash +bash packaging/build_app.sh +open dist/Ogmac.app +``` + +macOS will block the first launch because the binary is unsigned. Right-click `Ogmac.app` → Open → Open anyway (once). After that it opens normally. + +**What it does:** + +- Displays a status icon (healthy / warning / error / auto-disabled / paused / syncing) that updates **the moment the daemon writes `state.db`** — no polling. The app installs a `DispatchSource` file-system watcher and refreshes within ~200 ms of each sync completion. +- Panel shows the next scheduled sync time, the most recent meaningful change (`✓ N created · N updated · N del · X min ago`), or "Up to date · checked X min ago" when the last few syncs were no-ops. +- Sync now triggers `ogmac sync` and shows live progress; the button waits for the daemon to finish, no matter how long it takes. +- Settings (Cmd-,) exposes the editable config (Connection / Sync / Privacy tabs); changes save atomically to `~/.config/ogmac/config.yaml` on every field edit. +- History (in-panel `← Back` navigation) lists scheduled and meaningful runs only — the network-change-driven no-op syncs every ~2 min are filtered out. +- Pause and Resume map to `ogmac pause` / `ogmac unpause`; the pause flag persists across reboots. +- Quit closes the menu bar item; **launchd keeps running** on its normal schedule. + +**Diagnostic log:** the app writes a structured event log at `~/Library/Logs/ogmac/menubar.log` (separate from the daemon's `sync.log`). Tail it with `tail -f ~/Library/Logs/ogmac/menubar.log` to see refresh and watcher events. + +**First launch:** if `ogmac` is not on PATH and not at `~/.local/share/ogmac/venv/bin/ogmac`, the app shows a setup screen with a Copy button for `ogmac login`. Run `./packaging/install.sh` first to install the CLI. + ## How it works ```mermaid @@ -75,9 +102,13 @@ Refresh tokens are stored exclusively in **macOS Keychain**; nothing sensitive i ogmac is **beta** — it works on the author's machine and others, but the API surface (config schema, CLI flags) may shift before 1.0. Pin a tag if you depend on it. Bug reports welcome via [Issues](https://github.com/razbahri/ogmac/issues). +## Roadmap + +A native macOS **menu bar app** is in active development for v0.2 — see `docs/superpowers/specs/2026-05-08-menu-bar-app-design.md`. It exposes status, settings, and recent-run history without replacing the launchd-driven daemon. Until it ships, the CLI is the only interface. + ## Out of scope -Two-way sync · multiple calendar pairs · categories / colors / reminders · attachments · GUI · multi-user packaging · auto-update · Graph webhooks / push notifications · classic Outlook AppleScript · EWS fallback. +Two-way sync · multiple calendar pairs · categories / colors / reminders · attachments · multi-user packaging · auto-update · Graph webhooks / push notifications · classic Outlook AppleScript · EWS fallback. ## License diff --git a/docs/operations.md b/docs/operations.md index 89257f0..56bbf09 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -28,6 +28,20 @@ Clears the auto-disable flag, resets the failure counter, and runs an immediate ogmac resume ``` +### Pause syncing + +`ogmac pause` sets a soft pause flag that causes `ogmac sync` to return immediately without contacting Outlook or Google. Use it when you need to suppress sync temporarily without triggering the auto-disable logic. + +```bash +ogmac pause +ogmac unpause +``` + +- `ogmac pause` — set the pause flag; subsequent syncs are skipped with `sync.skipped reason=paused` in the log. +- `ogmac unpause` — clear the flag; the next scheduled or manual sync runs normally. + +The flag survives restarts (it is persisted in `~/Library/Application Support/ogmac/state.db`). The auto-disable counter is unaffected — skipped syncs do not increment `consecutive_failures`. + ### `ogmac reset` Deletes **all ogmac-owned events** from the target Google calendar and wipes local state (event map, run state, failure counter). Tokens are preserved. @@ -136,3 +150,50 @@ Look for: - `last exit code = 1` — last sync failed; check `sync.log` If the job doesn't appear at all, re-run `./packaging/install.sh`. + +## Menu bar app + +`Ogmac.app` is a companion menu bar app that surfaces sync status, settings, and run history without requiring terminal access. It does **not** replace the launchd daemon — the two run independently. + +### Quit vs. Pause + +| Action | Effect on sync | +|---|---| +| **Quit ogmac** (menu item) | Terminates the menu bar app process. The launchd job continues to fire on schedule — sync keeps running. | +| **Pause** (menu item or `ogmac pause`) | Sets a flag in `~/Library/Application Support/ogmac/state.db`. Every subsequent sync (whether triggered by launchd or `ogmac sync`) returns immediately with `sync.skipped reason=paused`. The flag persists across reboots. | +| **Resume** (menu item or `ogmac unpause`) | Clears the pause flag. The next scheduled sync runs normally. | + +Use Quit when you no longer want the icon visible. Use Pause when you want to suppress sync activity (e.g., during travel or when on a metered connection) without disabling launchd. + +### How the panel stays fresh + +The app does **not** poll on a timer. It installs a `DispatchSource` file-system watcher on `~/Library/Application Support/ogmac/state.db`. The daemon writes that file at the end of each sync (the SQLite connection closes, triggering a WAL checkpoint). The watcher fires within ~200 ms and the panel refreshes — independent of how long the sync took. Long syncs (>1 min) no longer race with a 60 s poll. + +The panel additionally refreshes: +- Once at app startup. +- On each `MenuPanelView.onAppear` (when you click the menu bar icon). +- After a `Sync now` click — `triggerSync` awaits the `ogmac sync` Process and refreshes when it exits. + +If `state.db` doesn't exist yet (no sync has ever run), the app falls back to a 30 s timer that retries the watcher and refreshes opportunistically until the file appears. + +### History view filter + +The History sheet hides the network-change-driven no-op syncs that fire every ~2 min from the launchd plist's `LaunchEvents` block (see `packaging/com.ogmac.sync.plist`). A run is shown if any of: + +- `create + update + delete > 0` (real work happened), or +- the run failed (always interesting), or +- the run started within 90 s of `:00`/`:15`/`:30`/`:45` (the scheduled ticks). + +### Launch at login + +Enable in **Settings → Sync → Launch at login**. This registers `Ogmac.app` with `SMAppService` independently of the launchd sync job. + +### Diagnostic log + +The app writes a per-event structured log at `~/Library/Logs/ogmac/menubar.log`, distinct from the daemon's `sync.log`. Tail it to see refresh ticks, file-watcher events, and reader errors: + +```bash +tail -f ~/Library/Logs/ogmac/menubar.log +``` + +The file is plain ISO-8601-prefixed text and is append-only (no built-in rotation in v0.2). Delete it to reset. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 30dbe24..8f48f43 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -132,6 +132,45 @@ ogmac login google ogmac login microsoft # only if read_method: microsoft_graph ``` +## Menu bar app + +### Menu bar app shows "CLI not found" (FirstLaunchView) + +The app checked for `ogmac` on `PATH` and at the venv fallback path (`~/.local/share/ogmac/venv/bin/ogmac`) and found neither. This happens on a clean machine before the CLI is installed, or after moving/deleting the venv. + +**Fix:** run the installer, which installs the venv and registers the CLI: + +```bash +./packaging/install.sh +``` + +Then quit and reopen `Ogmac.app`. If the app still shows the setup screen after the CLI is installed, confirm the binary is on the PATH that GUI apps see: + +```bash +launchctl asuser $UID /usr/bin/env which ogmac +``` + +If blank, add the install location to your shell profile and re-login. + +### Menu bar icon doesn't update + +The app uses a file-system watcher on `~/Library/Application Support/ogmac/state.db` rather than a timer. Two cases: + +1. **`state.db` doesn't exist yet** — happens on a clean machine before the first sync. The app falls back to a 30 s retry timer; once the daemon runs once, the watcher takes over. Force it with `ogmac sync`. +2. **The watcher silently isn't firing** — check `~/Library/Logs/ogmac/menubar.log` for `StateFileWatcher started fd=...` and subsequent `StateFileWatcher event` lines. If you see `started` but no events, the daemon isn't actually writing. Verify with `ls -la ~/Library/Application\ Support/ogmac/` and check the file mtime. + +### History is empty even though `sync.log` has runs + +The History view filters out the no-op syncs that fire every ~2 min from the plist's `NetworkChange` LaunchEvent (see `packaging/com.ogmac.sync.plist`). Only runs that did real work, failed, or landed near `:00`/`:15`/`:30`/`:45` are shown. If you've had a quiet hour and no scheduled ticks have happened yet, the list will be empty — that's expected. + +### Settings changes don't persist + +The settings pane writes to `~/.config/ogmac/config.yaml` on every field change (atomic temp-file + rename). If saves silently don't take effect, check `~/Library/Logs/ogmac/menubar.log` for ConfigStore errors. Common causes: the config directory doesn't exist yet (run `ogmac login` once to create it), or the file is owned by another user. + +### Settings window doesn't appear + +`Ogmac.app` runs as `LSUIElement=true` (no Dock icon), which means opening a window doesn't auto-activate the app. The Settings window is opened with an explicit `NSApp.activate(ignoringOtherApps: true)` in v0.2 — if it's still not coming to front, look behind your other windows or try `Cmd-Tab` and select **ogmac**. + ## Still stuck? Open an [Issue](https://github.com/razbahri/ogmac/issues) with: diff --git a/packaging/Casks/ogmac.rb b/packaging/Casks/ogmac.rb new file mode 100644 index 0000000..26c6838 --- /dev/null +++ b/packaging/Casks/ogmac.rb @@ -0,0 +1,23 @@ +cask "ogmac" do + version "0.2.0" + sha256 :no_check + + url "https://github.com/razbahri/ogmac/releases/download/v#{version}/Ogmac.app.zip" + name "ogmac" + desc "Outlook to Google Calendar one-way sync — menu bar app" + homepage "https://github.com/razbahri/ogmac" + + depends_on macos: ">= :sonoma" + + app "Ogmac.app" + + postflight do + system_command "/usr/bin/xattr", + args: ["-d", "com.apple.quarantine", "#{appdir}/Ogmac.app"], + sudo: false + end + + zap trash: [ + "~/Library/Preferences/com.ogmac.app.plist", + ] +end diff --git a/packaging/build_app.sh b/packaging/build_app.sh new file mode 100755 index 0000000..9e24d32 --- /dev/null +++ b/packaging/build_app.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v xcodegen >/dev/null 2>&1; then + echo "xcodegen not found. Install with: brew install xcodegen" + exit 1 +fi + +xcodegen generate + +ARCHIVE_PATH="$(pwd)/build/Ogmac.xcarchive" +EXPORT_PATH="$(pwd)/dist" + +mkdir -p "$EXPORT_PATH" + +xcodebuild \ + -project Ogmac.xcodeproj \ + -scheme Ogmac \ + -configuration Release \ + -archivePath "$ARCHIVE_PATH" \ + archive + +APP_IN_ARCHIVE="$ARCHIVE_PATH/Products/Applications/Ogmac.app" + +if [ -d "$APP_IN_ARCHIVE" ]; then + rm -rf "$EXPORT_PATH/Ogmac.app" + cp -R "$APP_IN_ARCHIVE" "$EXPORT_PATH/Ogmac.app" + echo "Built: $EXPORT_PATH/Ogmac.app" +else + echo "Error: Ogmac.app not found in archive at $APP_IN_ARCHIVE" >&2 + exit 1 +fi diff --git a/packaging/com.ogmac.sync.plist b/packaging/com.ogmac.sync.plist index 7458a5a..8b9ba07 100644 --- a/packaging/com.ogmac.sync.plist +++ b/packaging/com.ogmac.sync.plist @@ -10,12 +10,32 @@ __OGMAC_PYTHON__ -m - ogmac.cli + ogmac sync - StartInterval - 900 + StartCalendarInterval + + Minute0 + Minute15 + Minute30 + Minute45 + + + LaunchEvents + + com.apple.notifyd.matching + + NetworkChange + + Notification + com.apple.system.config.network_change + + + + + ThrottleInterval + 120 RunAtLoad diff --git a/packaging/exportOptions.plist b/packaging/exportOptions.plist new file mode 100644 index 0000000..352c9a8 --- /dev/null +++ b/packaging/exportOptions.plist @@ -0,0 +1,10 @@ + + + + + method + mac-application + signingStyle + automatic + + diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..332d9d7 --- /dev/null +++ b/project.yml @@ -0,0 +1,66 @@ +name: Ogmac +options: + bundleIdPrefix: com.ogmac + deploymentTarget: + macOS: "14.0" + developmentLanguage: en + +settings: + base: + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "14.0" + GENERATE_INFOPLIST_FILE: NO + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "" + CODE_SIGNING_ALLOWED: NO + +packages: + GRDB: + url: https://github.com/groue/GRDB.swift + from: "6.0.0" + Yams: + url: https://github.com/jpsim/Yams + from: "5.0.0" + +targets: + Ogmac: + type: application + platform: macOS + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.ogmac.app + sources: + - path: Ogmac + info: + path: Ogmac/Info.plist + properties: + LSUIElement: true + CFBundleName: ogmac + CFBundleDisplayName: ogmac + CFBundleIdentifier: com.ogmac.app + CFBundleShortVersionString: "0.2.0" + CFBundleVersion: "1" + NSHumanReadableCopyright: "" + dependencies: + - package: GRDB + product: GRDB + - package: Yams + product: Yams + + OgmacTests: + type: bundle.unit-test + platform: macOS + sources: + - path: OgmacTests + dependencies: + - target: Ogmac + +schemes: + Ogmac: + build: + targets: + Ogmac: all + OgmacTests: [test] + test: + targets: + - OgmacTests diff --git a/src/ogmac/cli.py b/src/ogmac/cli.py index 8109a4b..90c5d3e 100644 --- a/src/ogmac/cli.py +++ b/src/ogmac/cli.py @@ -61,6 +61,9 @@ def _run_sync(cfg: Config, state: State) -> int: if state.is_disabled: log.info("sync.skipped reason=disabled") return 0 + if state.is_paused: + log.info("sync.skipped reason=paused") + return 0 t_start = time.monotonic() start = _utcnow() - timedelta(days=cfg.sync.window_past_days) @@ -203,6 +206,26 @@ def _cmd_status(args: argparse.Namespace, cfg: Config) -> int: state.close() +def _cmd_pause(args: argparse.Namespace, cfg: Config) -> int: + state = State(state_db_path()) + try: + state.pause() + log.info("pause: set paused flag") + return 0 + finally: + state.close() + + +def _cmd_unpause(args: argparse.Namespace, cfg: Config) -> int: + state = State(state_db_path()) + try: + state.unpause() + log.info("unpause: cleared paused flag") + return 0 + finally: + state.close() + + def _cmd_resume(args: argparse.Namespace, cfg: Config) -> int: state = State(state_db_path()) try: @@ -275,6 +298,9 @@ def main(argv: list[str] | None = None) -> int: subparsers.add_parser("status", help="Print sync state and exit with code reflecting health.") + subparsers.add_parser("pause", help="Pause syncing until 'ogmac unpause'.") + subparsers.add_parser("unpause", help="Resume syncing after 'ogmac pause'.") + subparsers.add_parser("resume", help="Clear disabled flag and run sync immediately.") reset_parser = subparsers.add_parser("reset", help="Wipe all ogmac-owned Google events and local state.") @@ -315,6 +341,10 @@ def main(argv: list[str] | None = None) -> int: return _cmd_sync(args, cfg) elif args.command == "login": return _cmd_login(args, cfg) + elif args.command == "pause": + return _cmd_pause(args, cfg) + elif args.command == "unpause": + return _cmd_unpause(args, cfg) elif args.command == "resume": return _cmd_resume(args, cfg) elif args.command == "reset": diff --git a/src/ogmac/config.py b/src/ogmac/config.py index 9e68003..20b8bc9 100644 --- a/src/ogmac/config.py +++ b/src/ogmac/config.py @@ -1,10 +1,11 @@ from __future__ import annotations +import warnings from pathlib import Path from typing import Literal import yaml -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, ConfigDict, field_validator class ConfigError(Exception): @@ -29,9 +30,10 @@ def expand_path(cls, v: object) -> Path: class SyncConfig(BaseModel): + model_config = ConfigDict(extra="ignore") + window_past_days: int = 1 window_future_days: int = 30 - interval_seconds: int = 900 class PrivacyConfig(BaseModel): @@ -71,6 +73,13 @@ def load(cls, path: Path) -> "Config": except FileNotFoundError: raise ConfigError(f"Config file not found: {path}") data = yaml.safe_load(raw) + if isinstance(data.get("sync"), dict) and "interval_seconds" in data["sync"]: + warnings.warn( + "sync.interval_seconds is deprecated and has no effect; " + "the sync interval is controlled by the launchd plist.", + DeprecationWarning, + stacklevel=2, + ) return cls.model_validate(data) @classmethod diff --git a/src/ogmac/state.py b/src/ogmac/state.py index 11c00cf..7252ca1 100644 --- a/src/ogmac/state.py +++ b/src/ogmac/state.py @@ -37,6 +37,7 @@ class Mapping: _KEY_FAILURES = "consecutive_failures" _KEY_DISABLED = "disabled" _KEY_DISABLE_REASON = "disable_reason" +_KEY_PAUSED = "paused" def _to_iso(dt: datetime) -> str: @@ -176,6 +177,16 @@ def enable(self) -> None: self.delete_run_state(_KEY_DISABLED) self.delete_run_state(_KEY_DISABLE_REASON) + @property + def is_paused(self) -> bool: + return self.get_run_state(_KEY_PAUSED) == "1" + + def pause(self) -> None: + self.set_run_state(_KEY_PAUSED, "1") + + def unpause(self) -> None: + self.delete_run_state(_KEY_PAUSED) + # ── wipe ─────────────────────────────────────────────────────────────── def wipe_event_map(self) -> None: diff --git a/tests/integration/test_pause_flow.py b/tests/integration/test_pause_flow.py new file mode 100644 index 0000000..2d5d833 --- /dev/null +++ b/tests/integration/test_pause_flow.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from ogmac.cli import main +from ogmac.state import State + + +@pytest.fixture() +def config_path(tmp_path: Path) -> Path: + secret = tmp_path / "client_secret.json" + secret.write_text("{}") + cfg = { + "outlook": {"account": "user@example.com"}, + "google": { + "account": "user@gmail.com", + "client_secret_path": str(secret), + "target_calendar_id": "cal123@group.calendar.google.com", + }, + } + p = tmp_path / "config.yaml" + p.write_text(yaml.dump(cfg)) + return p + + +@pytest.fixture() +def state_db(tmp_path: Path) -> Path: + return tmp_path / "state.db" + + +def _run(argv: list[str], config_path: Path, state_db: Path) -> int: + with ( + patch("ogmac.cli.setup_logging"), + patch("ogmac.cli.Config.default_path", return_value=config_path), + patch("ogmac.cli.state_db_path", return_value=state_db), + ): + return main(argv) + + +def test_pause_then_sync_skips_external_calls(config_path, state_db, tmp_path): + assert _run(["pause"], config_path, state_db) == 0 + + with ( + patch("ogmac.cli.get_google_credentials") as mock_gcreds, + patch("ogmac.cli.get_graph_token") as mock_graph, + patch("ogmac.cli.fetch_via_microsoft_graph") as mock_ms, + patch("ogmac.cli.fetch_via_apple_calendar") as mock_apple, + patch("ogmac.cli.fetch_target_events") as mock_gtarget, + ): + rc = _run(["sync"], config_path, state_db) + + assert rc == 0 + mock_gcreds.assert_not_called() + mock_graph.assert_not_called() + mock_ms.assert_not_called() + mock_apple.assert_not_called() + mock_gtarget.assert_not_called() + + +def test_unpause_then_sync_proceeds(config_path, state_db, tmp_path): + _run(["pause"], config_path, state_db) + assert _run(["unpause"], config_path, state_db) == 0 + + mock_creds = MagicMock() + + with ( + patch("ogmac.cli.get_google_credentials", return_value=mock_creds) as mock_gcreds, + patch("ogmac.cli.get_graph_token", return_value="tok"), + patch("ogmac.cli.fetch_via_apple_calendar", return_value=[]) as mock_apple, + patch("ogmac.cli.fetch_target_events", return_value=[]) as mock_gtarget, + patch("ogmac.cli.reconcile", return_value=[]) as mock_reconcile, + ): + rc = _run(["sync"], config_path, state_db) + + assert rc == 0 + mock_gcreds.assert_called_once() + mock_apple.assert_called_once() + mock_gtarget.assert_called_once() + mock_reconcile.assert_called_once() + + +def test_pause_is_idempotent(config_path, state_db): + assert _run(["pause"], config_path, state_db) == 0 + assert _run(["pause"], config_path, state_db) == 0 + + state = State(state_db) + assert state.is_paused is True + state.close() + + +def test_unpause_is_idempotent(config_path, state_db): + assert _run(["unpause"], config_path, state_db) == 0 + + state = State(state_db) + assert state.is_paused is False + state.close() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 33cc00b..02b1dae 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -44,7 +44,7 @@ def test_defaults_applied(tmp_path): assert cfg.outlook.read_method == "apple_calendar" assert cfg.sync.window_past_days == 1 assert cfg.sync.window_future_days == 30 - assert cfg.sync.interval_seconds == 900 + assert not hasattr(cfg.sync, "interval_seconds") assert cfg.privacy.copy_subject is True assert cfg.privacy.copy_location is True assert cfg.privacy.copy_body is True @@ -55,12 +55,11 @@ def test_defaults_applied(tmp_path): def test_explicit_values_override_defaults(tmp_path): data = minimal_data(tmp_path) - data["sync"] = {"window_past_days": 2, "window_future_days": 60, "interval_seconds": 1800} + data["sync"] = {"window_past_days": 2, "window_future_days": 60} data["failure"] = {"max_consecutive_before_disable": 3, "notify_on_failure": False} cfg = Config.load(write_yaml(tmp_path / "config.yaml", data)) assert cfg.sync.window_past_days == 2 assert cfg.sync.window_future_days == 60 - assert cfg.sync.interval_seconds == 1800 assert cfg.failure.max_consecutive_before_disable == 3 assert cfg.failure.notify_on_failure is False @@ -138,6 +137,27 @@ def test_read_method_invalid_rejected(tmp_path): Config.load(write_yaml(tmp_path / "config.yaml", data)) +# ── interval_seconds deprecation ────────────────────────────────────────── + +def test_legacy_interval_seconds_dropped_silently(tmp_path): + data = minimal_data(tmp_path) + data["sync"] = {"window_past_days": 1, "window_future_days": 30, "interval_seconds": 900} + with pytest.warns(DeprecationWarning, match="interval_seconds"): + cfg = Config.load(write_yaml(tmp_path / "config.yaml", data)) + assert not hasattr(cfg.sync, "interval_seconds") + assert cfg.sync.window_past_days == 1 + assert cfg.sync.window_future_days == 30 + + +def test_no_warning_without_interval_seconds(tmp_path): + data = minimal_data(tmp_path) + import warnings as _warnings + with _warnings.catch_warnings(): + _warnings.simplefilter("error", DeprecationWarning) + cfg = Config.load(write_yaml(tmp_path / "config.yaml", data)) + assert cfg.sync.window_past_days == 1 + + # ── file-not-found ───────────────────────────────────────────────────────── def test_config_file_not_found_raises_config_error(tmp_path): diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index cbe20b0..4704486 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -151,6 +151,34 @@ def test_enable_clears_reason(state): assert state.get_run_state("disable_reason") is None +# ── pause / unpause ─────────────────────────────────────────────────────── + +def test_not_paused_by_default(state): + assert state.is_paused is False + + +def test_pause_and_unpause(state): + state.pause() + assert state.is_paused is True + state.unpause() + assert state.is_paused is False + + +def test_pause_persists_across_reopen(tmp_path): + db = tmp_path / "state.db" + s1 = State(db) + s1.pause() + s1.close() + s2 = State(db) + assert s2.is_paused is True + s2.close() + + +def test_unpause_when_not_paused_is_noop(state): + state.unpause() + assert state.is_paused is False + + # ── wipe operations ──────────────────────────────────────────────────────── def test_wipe_event_map(state):