From 71437bb103fc93b6836602e3a76fda21b637abb8 Mon Sep 17 00:00:00 2001 From: Ahmed Ramy Date: Wed, 29 Jul 2026 00:12:54 +0400 Subject: [PATCH 1/5] Convert test suite to Swift Testing with BDD scenarios Rewrites NightcapAppTests from XCTest to Swift Testing, organised as one @Suite per feature with numbered Given/When/Then scenarios. Behaviour is characterised rather than changed: no production code is touched. Notable additions beyond a straight translation: - removeAppRequested had no coverage at all. Three scenarios now cover removing a running app, removing an idle one, and removing one of two running apps (the assertion must survive for the other). - The assertion reason string is asserted when a second watched app launches, so the pmset-visible reason stays truthful. - A scenario covers IOKit refusing the assertion, where the app must not claim the Mac is being kept awake. - test_terminate_event_keeps_assertion_when_another_instance_still_running previously asserted nothing. It now checks that release() is not called. Tests are given an in-memory file storage dependency. Swift Testing runs suites in parallel, and @Shared(.fileStorage) would otherwise be shared mutable state across scenarios. scripts/check-domain-coverage.sh enforces a floor on pure-domain coverage from an .xcresult bundle. Live adapters (NSWorkspace, IOKit, SMAppService, StoreKit) and SwiftUI views are excluded by design; covering those means integration tests, not characterisation. 20 scenarios, all passing. Domain coverage 96.04% (291/303), up from 93.70% (284/303). Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw --- .gitignore | 3 + NightcapTests/NightcapAppTests.swift | 485 ++++++++++++++++++++------- docs/roadmap/S1-coverage-baseline.md | 51 +++ scripts/check-domain-coverage.sh | 57 ++++ 4 files changed, 466 insertions(+), 130 deletions(-) create mode 100644 docs/roadmap/S1-coverage-baseline.md create mode 100755 scripts/check-domain-coverage.sh diff --git a/.gitignore b/.gitignore index 3461466..334b05c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist/ *.ipa *.dSYM.zip *.dSYM + +## local-only spec override +project.local.yml diff --git a/NightcapTests/NightcapAppTests.swift b/NightcapTests/NightcapAppTests.swift index 91fcfe0..3385d14 100644 --- a/NightcapTests/NightcapAppTests.swift +++ b/NightcapTests/NightcapAppTests.swift @@ -1,233 +1,417 @@ import ComposableArchitecture import ConcurrencyExtras import Foundation -import XCTest +import Sharing +import Testing + @testable import Nightcap -@MainActor -final class NightcapAppTests: XCTestCase { - func test_launched_event_for_watched_app_triggers_acquire() async { - let env = makeEnv(running: []) - let store = makeStore(env: env) +// MARK: - Feature: Holding the sleep assertion +@MainActor +@Suite("Feature: Holding the sleep assertion") +struct SleepAssertionFeature { + @Test("Scenario 1: a watched app launches, so the Mac is kept awake") + func watchedAppLaunchAcquiresAssertion() async { + // Given no watched app is running + let env = TestEnv(running: []) + let store = env.makeStore() await store.send(.onAppear) { $0.launchAtLoginStatus = .disabled } - await store.send(.lifecycleEvent(.launched(bundleID: "com.mitchellh.ghostty"))) { - $0.runningWatchedIDs = ["com.mitchellh.ghostty"] + // When Ghostty launches + await store.send(.lifecycleEvent(.launched(bundleID: .ghostty))) { + // Then it is tracked and the assertion is held + $0.runningWatchedIDs = [.ghostty] $0.assertionHeld = true } - XCTAssertEqual(env.acquired.value, ["Nightcap: Ghostty"]) + // And the assertion names the app, so the user can see why in pmset + #expect(env.acquired.value == ["Nightcap: Ghostty"]) } - func test_terminate_event_releases_when_no_other_instances_running() async { - let env = makeEnv(running: ["com.mitchellh.ghostty"]) - let store = makeStore(env: env) - + @Test("Scenario 2: the last instance quits, so the Mac may sleep again") + func lastInstanceTerminationReleasesAssertion() async { + // Given Ghostty is running and the assertion is held + let env = TestEnv(running: [.ghostty]) + let store = env.makeStore() await store.send(.onAppear) { - $0.runningWatchedIDs = ["com.mitchellh.ghostty"] + $0.runningWatchedIDs = [.ghostty] $0.assertionHeld = true $0.launchAtLoginStatus = .disabled } + // When Ghostty terminates and no instance remains env.running.setValue([]) - await store.send(.lifecycleEvent(.terminated(bundleID: "com.mitchellh.ghostty"))) { + await store.send(.lifecycleEvent(.terminated(bundleID: .ghostty))) { + // Then tracking clears and the assertion is released $0.runningWatchedIDs = [] $0.assertionHeld = false } - XCTAssertGreaterThanOrEqual(env.released.value, 1) + #expect(env.released.value >= 1) } - func test_terminate_event_keeps_assertion_when_another_instance_still_running() async { - let env = makeEnv(running: ["com.mitchellh.ghostty"]) - let store = makeStore(env: env) - + @Test("Scenario 3: one instance quits while another still runs, so the Mac stays awake") + func terminationWithSurvivingInstanceKeepsAssertion() async { + // Given Ghostty is running and the assertion is held + let env = TestEnv(running: [.ghostty]) + let store = env.makeStore() await store.send(.onAppear) { - $0.runningWatchedIDs = ["com.mitchellh.ghostty"] + $0.runningWatchedIDs = [.ghostty] $0.assertionHeld = true $0.launchAtLoginStatus = .disabled } - await store.send(.lifecycleEvent(.terminated(bundleID: "com.mitchellh.ghostty"))) - } + // When a terminate event arrives but another instance is still alive + // (env.running still reports Ghostty) + await store.send(.lifecycleEvent(.terminated(bundleID: .ghostty))) - func test_wake_event_reconciles_running_apps() async { - let env = makeEnv(running: []) - let store = makeStore(env: env) + // Then no state change occurs and the assertion is never released + #expect(env.released.value == 0) + } + @Test("Scenario 4: the Mac wakes, so state is reconciled from reality") + func wakeReconcilesAgainstRunningApps() async { + // Given nothing is running + let env = TestEnv(running: []) + let store = env.makeStore() await store.send(.onAppear) { $0.launchAtLoginStatus = .disabled } - env.running.setValue(["com.mitchellh.ghostty"]) + // When the Mac wakes and Ghostty is now running (its launch event was missed) + env.running.setValue([.ghostty]) await store.send(.lifecycleEvent(.wake)) { - $0.runningWatchedIDs = ["com.mitchellh.ghostty"] + // Then the assertion is acquired without a launch event + $0.runningWatchedIDs = [.ghostty] $0.assertionHeld = true } + // When the Mac wakes again and Ghostty has since died env.running.setValue([]) await store.send(.lifecycleEvent(.wake)) { + // Then the stale assertion is released $0.runningWatchedIDs = [] $0.assertionHeld = false } } +} - func test_on_appear_loads_running_app_candidates() async { - let xcode = WatchedApp(bundleID: "com.apple.dt.Xcode", displayName: "Xcode") - let zoom = WatchedApp(bundleID: "us.zoom.xos", displayName: "zoom.us") - let env = makeEnv(running: [], runningApps: [xcode, zoom]) - let store = makeStore(env: env) +// MARK: - Feature: Managing the watched list +@MainActor +@Suite("Feature: Managing the watched list") +struct WatchedListFeature { + @Test("Scenario 1: adding an app that is already running starts watching immediately") + func addingRunningAppAcquiresWithoutWaitingForLaunch() async { + // Given Xcode is running but unwatched + let env = TestEnv(running: [.xcode], runningApps: [.xcodeApp]) + let store = env.makeStore() await store.send(.onAppear) { + $0.runningAppCandidates = [.xcodeApp] $0.launchAtLoginStatus = .disabled - $0.runningAppCandidates = [xcode, zoom] } - } - func test_launch_event_for_unwatched_app_refreshes_running_app_candidates() async { - let xcode = WatchedApp(bundleID: "com.apple.dt.Xcode", displayName: "Xcode") - let env = makeEnv(running: [], runningApps: []) - let store = makeStore(env: env) + // When the user adds it + await store.send(.addAppRequested(.xcodeApp)) { + // Then it is watched and the assertion is held right away + $0.$watchedApps.withLock { $0.append(.xcodeApp) } + $0.runningWatchedIDs = [.xcode] + $0.assertionHeld = true + } + #expect(env.acquired.value == ["Nightcap: Xcode"]) + } + + @Test("Scenario 2: adding an app already on the list changes nothing") + func duplicateAddIsANoOp() async { + // Given Ghostty is already the default watched app + let env = TestEnv(running: []) + let store = env.makeStore() await store.send(.onAppear) { $0.launchAtLoginStatus = .disabled } - env.runningApps.setValue([xcode]) - await store.send(.lifecycleEvent(.launched(bundleID: xcode.bundleID))) { - $0.runningAppCandidates = [xcode] - } - } + // When the user adds Ghostty again + await store.send(.addAppRequested(.ghosttyApp)) - func test_adding_running_app_uses_existing_watch_flow() async { - let xcode = WatchedApp(bundleID: "com.apple.dt.Xcode", displayName: "Xcode") - let env = makeEnv(running: [xcode.bundleID], runningApps: [xcode]) - let store = makeStore(env: env) + // Then no state changes and no review prompt fires + #expect(env.reviewPrompts.value == 0) + } + @Test("Scenario 3: adding a genuinely new app asks for a review at the value moment") + func firstSuccessfulAddRequestsReview() async { + // Given a fresh list + let env = TestEnv(running: []) + let store = env.makeStore() await store.send(.onAppear) { - $0.runningAppCandidates = [xcode] $0.launchAtLoginStatus = .disabled } - await store.send(.addAppRequested(xcode)) { - $0.$watchedApps.withLock { $0.append(xcode) } - $0.runningWatchedIDs = [xcode.bundleID] - $0.assertionHeld = true + // When the user adds an app not already watched + let writer = WatchedApp(bundleID: "com.example.writer", displayName: "Writer") + await store.send(.addAppRequested(writer)) { + // Then it joins the list + $0.$watchedApps.withLock { $0.append(writer) } } - XCTAssertEqual(env.acquired.value, ["Nightcap: Xcode"]) + // And exactly one review prompt is requested + #expect(env.reviewPrompts.value == 1) } - func test_duplicate_add_is_a_no_op() async { - let env = makeEnv(running: []) - let store = makeStore(env: env) + @Test("Scenario 4: opening the menu lists the apps currently running") + func onAppearLoadsRunningAppCandidates() async { + // Given Xcode and Zoom are running + let env = TestEnv(running: [], runningApps: [.xcodeApp, .zoomApp]) + let store = env.makeStore() + + // When the menu appears + await store.send(.onAppear) { + // Then both are offered as candidates + $0.launchAtLoginStatus = .disabled + $0.runningAppCandidates = [.xcodeApp, .zoomApp] + } + } + @Test("Scenario 5: an unwatched app launching refreshes the candidate list") + func unwatchedLaunchRefreshesCandidates() async { + // Given nothing is running + let env = TestEnv(running: [], runningApps: []) + let store = env.makeStore() await store.send(.onAppear) { $0.launchAtLoginStatus = .disabled } - let duplicate = WatchedApp(bundleID: "com.mitchellh.ghostty", displayName: "Ghostty") - await store.send(.addAppRequested(duplicate)) - XCTAssertEqual(env.reviewPrompts.value, 0) + // When Xcode launches, though it is not watched + env.runningApps.setValue([.xcodeApp]) + await store.send(.lifecycleEvent(.launched(bundleID: .xcode))) { + // Then it appears as a candidate to add + $0.runningAppCandidates = [.xcodeApp] + } } +} - func test_add_new_app_requests_review_after_value_moment() async { - let env = makeEnv(running: []) - let store = makeStore(env: env) +// MARK: - Feature: Pausing and resuming a watched app +@MainActor +@Suite("Feature: Pausing and resuming a watched app") +struct PauseResumeFeature { + @Test("Scenario 1: pausing a running app releases the Mac without forgetting the app") + func pausingReleasesButKeepsTheEntry() async { + // Given Ghostty is watched, running, and holding the assertion + let env = TestEnv(running: [.ghostty]) + let store = env.makeStore() await store.send(.onAppear) { + $0.runningWatchedIDs = [.ghostty] + $0.assertionHeld = true $0.launchAtLoginStatus = .disabled } - let app = WatchedApp(bundleID: "com.example.writer", displayName: "Writer") - await store.send(.addAppRequested(app)) { - $0.$watchedApps.withLock { $0.append(app) } + // When the user pauses watching + await store.send(.observationToggled(.ghostty, false)) { + // Then the assertion drops but the app stays on the list + $0.$watchedApps.withLock { $0[0].isObserved = false } + $0.runningWatchedIDs = [] + $0.assertionHeld = false } - XCTAssertEqual(env.reviewPrompts.value, 1) + #expect(env.released.value >= 1) } - func test_observation_toggle_off_releases_running_app_without_removing_it() async { - let env = makeEnv(running: ["com.mitchellh.ghostty"]) - let store = makeStore(env: env) - + @Test("Scenario 2: resuming re-acquires when the app is still running") + func resumingReacquiresForAStillRunningApp() async { + // Given Ghostty is watched, running, and then paused + let env = TestEnv(running: [.ghostty]) + let store = env.makeStore() await store.send(.onAppear) { - $0.runningWatchedIDs = ["com.mitchellh.ghostty"] + $0.runningWatchedIDs = [.ghostty] $0.assertionHeld = true $0.launchAtLoginStatus = .disabled } - - await store.send(.observationToggled("com.mitchellh.ghostty", false)) { + await store.send(.observationToggled(.ghostty, false)) { $0.$watchedApps.withLock { $0[0].isObserved = false } $0.runningWatchedIDs = [] $0.assertionHeld = false } - XCTAssertGreaterThanOrEqual(env.released.value, 1) + // When the user resumes watching + await store.send(.observationToggled(.ghostty, true)) { + // Then the assertion comes back without needing a relaunch + $0.$watchedApps.withLock { $0[0].isObserved = true } + $0.runningWatchedIDs = [.ghostty] + $0.assertionHeld = true + } + + #expect(env.acquired.value == ["Nightcap: Ghostty", "Nightcap: Ghostty"]) } - func test_observation_toggle_on_acquires_if_app_is_running() async { - let env = makeEnv(running: ["com.mitchellh.ghostty"]) - let store = makeStore(env: env) + @Test("Scenario 3: a paused app launching does not wake-lock the Mac") + func pausedAppLaunchDoesNotAcquire() async { + // Given Ghostty is watched but paused + let env = TestEnv(running: []) + let store = env.makeStore() + await store.send(.onAppear) { + $0.launchAtLoginStatus = .disabled + } + await store.send(.observationToggled(.ghostty, false)) { + $0.$watchedApps.withLock { $0[0].isObserved = false } + } + + // When Ghostty launches + await store.send(.lifecycleEvent(.launched(bundleID: .ghostty))) + + // Then no assertion is taken + #expect(env.acquired.value.isEmpty) + } +} + +// MARK: - Feature: Removing a watched app +@MainActor +@Suite("Feature: Removing a watched app") +struct RemoveAppFeature { + @Test("Scenario 1: removing a running app releases the Mac") + func removingARunningAppReleasesAssertion() async { + // Given Ghostty is watched, running, and holding the assertion + let env = TestEnv(running: [.ghostty]) + let store = env.makeStore() await store.send(.onAppear) { - $0.runningWatchedIDs = ["com.mitchellh.ghostty"] + $0.runningWatchedIDs = [.ghostty] $0.assertionHeld = true $0.launchAtLoginStatus = .disabled } - await store.send(.observationToggled("com.mitchellh.ghostty", false)) { - $0.$watchedApps.withLock { $0[0].isObserved = false } + // When the user removes it from the list + await store.send(.removeAppRequested(.ghostty)) { + // Then it is gone and the assertion drops + $0.$watchedApps.withLock { $0.removeAll { $0.bundleID == .ghostty } } $0.runningWatchedIDs = [] $0.assertionHeld = false } - await store.send(.observationToggled("com.mitchellh.ghostty", true)) { - $0.$watchedApps.withLock { $0[0].isObserved = true } - $0.runningWatchedIDs = ["com.mitchellh.ghostty"] + #expect(env.released.value >= 1) + } + + @Test("Scenario 2: removing an app that is not running touches no assertion") + func removingAnIdleAppDoesNotRelease() async { + // Given Ghostty is watched but not running + let env = TestEnv(running: []) + let store = env.makeStore() + await store.send(.onAppear) { + $0.launchAtLoginStatus = .disabled + } + + // Note: onAppear already reconciled to "nothing running" and called + // release() once unconditionally. That is a no-op inside AssertionHolder, + // which guards on `held` — but it is visible here, so measure the delta. + let releasesBeforeRemoval = env.released.value + + // When the user removes it + await store.send(.removeAppRequested(.ghostty)) { + $0.$watchedApps.withLock { $0.removeAll { $0.bundleID == .ghostty } } + } + + // Then removal itself attempts no release, since the app held nothing + #expect(env.released.value == releasesBeforeRemoval) + } + + @Test("Scenario 3: removing one of two running apps keeps the Mac awake for the other") + func removingOneOfTwoKeepsAssertion() async { + // Given Ghostty and Xcode are both watched and running + let env = TestEnv(running: [.ghostty, .xcode]) + let store = env.makeStore() + await store.send(.onAppear) { + $0.runningWatchedIDs = [.ghostty] + $0.assertionHeld = true + $0.launchAtLoginStatus = .disabled + } + await store.send(.addAppRequested(.xcodeApp)) { + $0.$watchedApps.withLock { $0.append(.xcodeApp) } + $0.runningWatchedIDs = [.ghostty, .xcode] + $0.assertionHeld = true + } + + // When Ghostty is removed + await store.send(.removeAppRequested(.ghostty)) { + // Then the assertion survives for Xcode alone + $0.$watchedApps.withLock { $0.removeAll { $0.bundleID == .ghostty } } + $0.runningWatchedIDs = [.xcode] $0.assertionHeld = true } - XCTAssertEqual(env.acquired.value, ["Nightcap: Ghostty", "Nightcap: Ghostty"]) + // And the reason is re-stated without Ghostty, so pmset shows the truth + #expect(env.acquired.value.last == "Nightcap: Xcode") } +} - func test_unobserved_app_launch_does_not_acquire() async { - let env = makeEnv(running: []) - let store = makeStore(env: env) +// MARK: - Feature: Reporting why the Mac is awake +@MainActor +@Suite("Feature: Reporting why the Mac is awake") +struct AssertionReasonFeature { + @Test("Scenario 1: a second watched app launching re-states the reason with both names") + func secondAppLaunchIncludesBothNamesInReason() async { + // Given Ghostty is watched and running, and Xcode is watched but idle + let env = TestEnv(running: [.ghostty]) + let store = env.makeStore() await store.send(.onAppear) { + $0.runningWatchedIDs = [.ghostty] + $0.assertionHeld = true $0.launchAtLoginStatus = .disabled } + await store.send(.addAppRequested(.xcodeApp)) { + $0.$watchedApps.withLock { $0.append(.xcodeApp) } + } - await store.send(.observationToggled("com.mitchellh.ghostty", false)) { - $0.$watchedApps.withLock { $0[0].isObserved = false } + // When Xcode launches too + env.running.setValue([.ghostty, .xcode]) + await store.send(.lifecycleEvent(.launched(bundleID: .xcode))) { + $0.runningWatchedIDs = [.ghostty, .xcode] + $0.assertionHeld = true } - await store.send(.lifecycleEvent(.launched(bundleID: "com.mitchellh.ghostty"))) - XCTAssertEqual(env.acquired.value, []) + // Then the newest reason names both apps, in watch-list order + #expect(env.acquired.value.last == "Nightcap: Ghostty, Xcode") } - func test_legacy_watched_app_records_decode_as_observed() throws { - let data = #"{"bundleID":"com.example.app","displayName":"Example"}"# - .data(using: .utf8)! + @Test("Scenario 2: the Mac is reported as not held when IOKit refuses the assertion") + func failedAcquireLeavesAssertionUnheld() async { + // Given IOKit will reject IOPMAssertionCreateWithName + let env = TestEnv(running: []) + let store = env.makeStore(acquireReturns: false) + await store.send(.onAppear) { + $0.launchAtLoginStatus = .disabled + } - let app = try JSONDecoder().decode(WatchedApp.self, from: data) + // When a watched app launches + await store.send(.lifecycleEvent(.launched(bundleID: .ghostty))) { + // Then the app is tracked but the UI must not claim the Mac is awake + $0.runningWatchedIDs = [.ghostty] + } - XCTAssertTrue(app.isObserved) + #expect(env.acquired.value == ["Nightcap: Ghostty"]) } +} - func test_launch_at_login_failure_rolls_back() async { +// MARK: - Feature: Launch at login + +@MainActor +@Suite("Feature: Launch at login") +struct LaunchAtLoginFeature { + @Test("Scenario 1: the toggle rolls back when the system refuses to register") + func failedRegistrationRollsBackTheToggle() async { + // Given launch at login is off and SMAppService will throw let store = TestStore(initialState: AppFeature.State()) { AppFeature() } withDependencies: { + $0.defaultFileStorage = .inMemory $0.appLifecycleClient.runningBundleIDs = { [] } $0.appLifecycleClient.runningApps = { [] } $0.appLifecycleClient.events = { .finished } $0.launchAtLoginClient.status = { .disabled } - $0.launchAtLoginClient.setEnabled = { _ in throw TestError.simulated } + $0.launchAtLoginClient.setEnabled = { _ in throw TestEnv.SimulatedError() } $0.powerAssertionClient.acquire = { _ in true } $0.powerAssertionClient.release = {} } @@ -236,72 +420,113 @@ final class NightcapAppTests: XCTestCase { $0.launchAtLoginStatus = .disabled } + // When the user flips it on and registration fails await store.send(.launchAtLoginToggled(true)) { + // Then the UI flips optimistically first $0.launchAtLoginStatus = .enabled } + // And then reverts, so the toggle never lies about the real state await store.receive(\.launchAtLoginStatusUpdated) { $0.launchAtLoginStatus = .disabled } } +} + +// MARK: - Feature: Persisting the watched list + +@Suite("Feature: Persisting the watched list") +struct PersistenceFeature { + @Test("Scenario 1: a list saved before pause/resume existed still loads as watched") + func legacyRecordsDecodeAsObserved() throws { + // Given a JSON record written before `isObserved` existed + let data = #"{"bundleID":"com.example.app","displayName":"Example"}"# + .data(using: .utf8)! + + // When it is decoded + let app = try JSONDecoder().decode(WatchedApp.self, from: data) + + // Then it defaults to watched, so upgrading never silently stops working + #expect(app.isObserved) + } +} - func test_quit_releases_assertion_before_terminate() async { - let env = makeEnv(running: []) - let store = makeStore(env: env) +// MARK: - Feature: Quitting Nightcap +@MainActor +@Suite("Feature: Quitting Nightcap") +struct QuitFeature { + @Test("Scenario 1: quitting releases the assertion before terminating") + func quitReleasesAssertionBeforeTerminate() async { + // Given Nightcap is running + let env = TestEnv(running: []) + let store = env.makeStore() await store.send(.onAppear) { $0.launchAtLoginStatus = .disabled } + // When the user quits await store.send(.quitTapped) - XCTAssertGreaterThanOrEqual(env.released.value, 1) + + // Then the kernel assertion is released, so no wake-lock outlives the app + #expect(env.released.value >= 1) } +} - // MARK: - Helpers +// MARK: - Test support - private enum TestError: Error { case simulated } +extension String { + fileprivate static let ghostty = "com.mitchellh.ghostty" + fileprivate static let xcode = "com.apple.dt.Xcode" +} - private struct TestEnv { - let running: LockIsolated> - let runningApps: LockIsolated<[WatchedApp]> - let acquired: LockIsolated<[String]> - let released: LockIsolated - let reviewPrompts: LockIsolated - } +extension WatchedApp { + fileprivate static let ghosttyApp = WatchedApp(bundleID: .ghostty, displayName: "Ghostty") + fileprivate static let xcodeApp = WatchedApp(bundleID: .xcode, displayName: "Xcode") + fileprivate static let zoomApp = WatchedApp(bundleID: "us.zoom.xos", displayName: "zoom.us") +} - private func makeEnv( - running: Set, - runningApps: [WatchedApp] = [] - ) -> TestEnv { - TestEnv( - running: LockIsolated(running), - runningApps: LockIsolated(runningApps), - acquired: LockIsolated([]), - released: LockIsolated(0), - reviewPrompts: LockIsolated(0) - ) +/// Records what the reducer asked the outside world to do, so scenarios can +/// assert on effects (assertion reasons, release counts, review prompts) rather +/// than only on state. +private struct TestEnv { + struct SimulatedError: Error {} + + let running: LockIsolated> + let runningApps: LockIsolated<[WatchedApp]> + let acquired = LockIsolated<[String]>([]) + let released = LockIsolated(0) + let reviewPrompts = LockIsolated(0) + + init(running: Set, runningApps: [WatchedApp] = []) { + self.running = LockIsolated(running) + self.runningApps = LockIsolated(runningApps) } - private func makeStore( - env: TestEnv, + @MainActor + func makeStore( acquireReturns: Bool = true ) -> TestStore { TestStore(initialState: AppFeature.State()) { AppFeature() } withDependencies: { - $0.appLifecycleClient.runningBundleIDs = { env.running.value } - $0.appLifecycleClient.runningApps = { env.runningApps.value } + // In-memory, not the real container JSON: Swift Testing runs suites in + // parallel and @Shared(.fileStorage) would otherwise be shared mutable + // state across scenarios. + $0.defaultFileStorage = .inMemory + $0.appLifecycleClient.runningBundleIDs = { running.value } + $0.appLifecycleClient.runningApps = { runningApps.value } $0.appLifecycleClient.events = { .finished } $0.launchAtLoginClient.status = { .disabled } $0.powerAssertionClient.acquire = { reason in - env.acquired.withValue { $0.append(reason) } + acquired.withValue { $0.append(reason) } return acquireReturns } $0.powerAssertionClient.release = { - env.released.withValue { $0 += 1 } + released.withValue { $0 += 1 } } $0.reviewPromptClient.requestIfAppropriate = { - env.reviewPrompts.withValue { $0 += 1 } + reviewPrompts.withValue { $0 += 1 } } } } diff --git a/docs/roadmap/S1-coverage-baseline.md b/docs/roadmap/S1-coverage-baseline.md new file mode 100644 index 0000000..5f3c60c --- /dev/null +++ b/docs/roadmap/S1-coverage-baseline.md @@ -0,0 +1,51 @@ +# S1 — Coverage Baseline + +Captured 2026-07-29, commit `25ee63a` + local signing override. +Toolchain: Xcode 27.0 via Xcode MCP (`windowtab3`). No `xcodebuild`. + +## Test run + +15 tests, **15 passed**, 0 failed, 0 skipped. Scheme `Nightcap`, test plan `Nightcap`. + +## Per-file coverage, `Nightcap.app` target (52.83% overall, 486/920) + +| File | Coverage | Classification | +|---|---|---| +| `Domain/WatchedApp.swift` | **100.00%** (13/13) | pure domain | +| `Domain/LaunchAtLoginStatus.swift` | **100.00%** (13/13) | pure domain | +| `AppFeature.swift` | **93.14%** (258/277) | domain logic (reducer) | +| `NightcapApp.swift` | 100.00% (25/25) | composition root | +| `SharedUI/WatchedAppsMenuSection.swift` | 82.35% (84/102) | UI | +| `SharedUI/MenuContentView.swift` | 58.54% (48/82) | UI | +| `SharedUI/MenuStatusSection.swift` | 57.14% (8/14) | UI | +| `SharedUI/MenuActionsSection.swift` | 28.95% (11/38) | UI | +| `SharedUI/MenuAppPicker.swift` | 0.00% (0/63) | UI | +| `SharedUI/AddRunningAppMenu.swift` | 0.00% (0/64) | UI | +| `Services/PowerAssertionClient.swift` | 31.48% (17/54) | live IOKit adapter | +| `Services/LaunchAtLoginClient.swift` | 30.00% (3/10) | live SMAppService adapter | +| `Services/AppQuitterClient.swift` | 18.18% (2/11) | live NSApp adapter | +| `Services/ReviewPromptClient.swift` | 14.29% (2/14) | live StoreKit adapter | +| `Services/AppLifecycleClient.swift` | 1.43% (2/140) | live NSWorkspace adapter | + +## Domain-logic total + +`WatchedApp` + `LaunchAtLoginStatus` + `AppFeature` = **284/303 = 93.7%** + +**The S4 goal of 80% pure-domain coverage is already met, before any work.** +S4 therefore becomes: encode the gate so it cannot regress, not chase a number. + +## S2 acceptance gate + +The BDD rewrite must hold every number above. Specifically: +- 15 → at least 15 scenarios, all passing +- `WatchedApp` and `LaunchAtLoginStatus` stay at 100% +- `AppFeature` ≥ 93.14% + +Any drop means the translation lost a case. + +## Notes for later slices + +- Uncovered code is concentrated in live adapters (`AppLifecycleClient` 1.43%) and + SwiftUI menu views. Neither is domain logic; both are correctly excluded from the gate. +- `AppFeature.swift:1` imports AppKit but uses **no** AppKit symbol. S5's domain + extraction starts by deleting that import — the reducer is already pure. diff --git a/scripts/check-domain-coverage.sh b/scripts/check-domain-coverage.sh new file mode 100755 index 0000000..91909bb --- /dev/null +++ b/scripts/check-domain-coverage.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Fails if pure-domain coverage regresses below the threshold. +# +# Domain = the files that hold logic independent of AppKit/IOKit/SwiftUI. +# Live adapters (NSWorkspace, IOKit, SMAppService, StoreKit bridges) and SwiftUI +# views are deliberately excluded: covering them means UI/integration tests, not +# characterization. +# +# Usage: scripts/check-domain-coverage.sh [threshold] +set -euo pipefail + +XCRESULT="${1:?usage: $0 [threshold]}" +THRESHOLD="${2:-80}" + +DOMAIN_FILES=( + "Nightcap/Domain/WatchedApp.swift" + "Nightcap/Domain/LaunchAtLoginStatus.swift" + "Nightcap/AppFeature.swift" +) + +report=$(xcrun xccov view --report --files-for-target Nightcap.app "$XCRESULT") + +covered=0 +total=0 +echo "Domain coverage:" +for file in "${DOMAIN_FILES[@]}"; do + # xccov prints e.g. "93.14% (258/277)" — take the last (n/m) on the line. + line=$(grep -F "/$file " <<<"$report" || true) + if [[ -z "$line" ]]; then + echo " MISSING $file — not in coverage report" >&2 + exit 1 + fi + # xccov pads columns with trailing spaces, so match the last (n/m) anywhere. + fraction=$(grep -oE '\([0-9]+/[0-9]+\)' <<<"$line" | tail -1 | tr -d '()') + c=${fraction%/*} + t=${fraction#*/} + covered=$((covered + c)) + total=$((total + t)) + filepct=$(awk -v c="$c" -v t="$t" 'BEGIN{printf "%.2f", c*100/t}') + printf ' %-40s %6s%% (%s/%s)\n' "$(basename "$file")" "$filepct" "$c" "$t" +done + +if (( total == 0 )); then + echo "No domain lines found — coverage not enabled on the test plan?" >&2 + exit 1 +fi + +pct=$(awk -v c="$covered" -v t="$total" 'BEGIN{printf "%.2f", c*100/t}') +echo " ----" +printf ' %-40s %s%% (%d/%d)\n' TOTAL "$pct" "$covered" "$total" + +if awk "BEGIN{exit !($pct < $THRESHOLD)}"; then + echo "FAIL: domain coverage $pct% is below the $THRESHOLD% threshold" >&2 + exit 1 +fi + +echo "PASS: domain coverage $pct% meets the $THRESHOLD% threshold" From ddad2ab63f42dd0a571d9d2559c5603060147999 Mon Sep 17 00:00:00 2001 From: Ahmed Ramy Date: Wed, 29 Jul 2026 00:32:17 +0400 Subject: [PATCH 2/5] WIP: extract NightcapDomain/Clients/UI into NightcapKit package Splits the app into three modules inside one local SwiftPM package: - NightcapDomain: WatchedApp, LaunchAtLoginStatus, AppFeature, and all dependency-client interfaces. No AppKit, IOKit, ServiceManagement or StoreKit, so it can build for iOS and watchOS. - NightcapClients: the live DependencyKey conformances. The only module that touches platform frameworks. - NightcapUI: the SwiftUI menu views. AppFeature.swift previously imported AppKit without using a single AppKit symbol; the reducer was already pure and that import is now gone. The package builds standalone (`swift build` in Packages/NightcapKit succeeds). The generated Xcode project does NOT yet consume it: Xcode never registers the XCLocalSwiftPackageReference, and NightcapKit is absent from SourcePackages/workspace-state.json, so all three products report as "Missing package product". Committed as WIP so the extraction is not lost while that is resolved. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw --- .gitignore | 3 + .mcp.json | 8 ++ Nightcap/NightcapApp.swift | 3 + Nightcap/Services/AppQuitterClient.swift | 27 ----- Nightcap/Services/LaunchAtLoginClient.swift | 29 ----- NightcapTests/NightcapAppTests.swift | 2 +- Packages/NightcapKit/Package.swift | 48 +++++++++ .../AppLifecycleClient+Live.swift | 26 +---- .../AppQuitterClient+Live.swift | 13 +++ .../LaunchAtLoginClient+Live.swift | 16 +++ .../PowerAssertionClient+Live.swift | 17 +-- .../ReviewPromptClient+Live.swift | 18 +--- .../Sources/NightcapDomain}/AppFeature.swift | 23 ++-- .../Sources/NightcapDomain/Clients.swift | 100 ++++++++++++++++++ .../NightcapDomain}/LaunchAtLoginStatus.swift | 13 ++- .../Sources/NightcapDomain}/WatchedApp.swift | 16 +-- .../NightcapUI}/AddRunningAppMenu.swift | 1 + .../NightcapUI}/MenuActionsSection.swift | 1 + .../Sources/NightcapUI}/MenuAppPicker.swift | 1 + .../Sources/NightcapUI}/MenuContentView.swift | 11 +- .../NightcapUI}/MenuStatusSection.swift | 1 + .../NightcapUI}/WatchedAppsMenuSection.swift | 1 + docs/roadmap/expansion-roadmap.md | 91 ++++++++++++++++ project.yml | 10 ++ 24 files changed, 343 insertions(+), 136 deletions(-) create mode 100644 .mcp.json delete mode 100644 Nightcap/Services/AppQuitterClient.swift delete mode 100644 Nightcap/Services/LaunchAtLoginClient.swift create mode 100644 Packages/NightcapKit/Package.swift rename Nightcap/Services/AppLifecycleClient.swift => Packages/NightcapKit/Sources/NightcapClients/AppLifecycleClient+Live.swift (80%) create mode 100644 Packages/NightcapKit/Sources/NightcapClients/AppQuitterClient+Live.swift create mode 100644 Packages/NightcapKit/Sources/NightcapClients/LaunchAtLoginClient+Live.swift rename Nightcap/Services/PowerAssertionClient.swift => Packages/NightcapKit/Sources/NightcapClients/PowerAssertionClient+Live.swift (80%) rename Nightcap/Services/ReviewPromptClient.swift => Packages/NightcapKit/Sources/NightcapClients/ReviewPromptClient+Live.swift (54%) rename {Nightcap => Packages/NightcapKit/Sources/NightcapDomain}/AppFeature.swift (93%) create mode 100644 Packages/NightcapKit/Sources/NightcapDomain/Clients.swift rename {Nightcap/Domain => Packages/NightcapKit/Sources/NightcapDomain}/LaunchAtLoginStatus.swift (67%) rename {Nightcap/Domain => Packages/NightcapKit/Sources/NightcapDomain}/WatchedApp.swift (59%) rename {Nightcap/SharedUI => Packages/NightcapKit/Sources/NightcapUI}/AddRunningAppMenu.swift (98%) rename {Nightcap/SharedUI => Packages/NightcapKit/Sources/NightcapUI}/MenuActionsSection.swift (97%) rename {Nightcap/SharedUI => Packages/NightcapKit/Sources/NightcapUI}/MenuAppPicker.swift (99%) rename {Nightcap/SharedUI => Packages/NightcapKit/Sources/NightcapUI}/MenuContentView.swift (87%) rename {Nightcap/SharedUI => Packages/NightcapKit/Sources/NightcapUI}/MenuStatusSection.swift (96%) rename {Nightcap/SharedUI => Packages/NightcapKit/Sources/NightcapUI}/WatchedAppsMenuSection.swift (99%) create mode 100644 docs/roadmap/expansion-roadmap.md diff --git a/.gitignore b/.gitignore index 334b05c..7214656 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ dist/ ## local-only spec override project.local.yml + +## SwiftPM local build artifacts +Packages/*/.build/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..67cc0ee --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "xcode": { + "command": "xcrun", + "args": ["mcpbridge"] + } + } +} diff --git a/Nightcap/NightcapApp.swift b/Nightcap/NightcapApp.swift index 080b188..a87a6fb 100644 --- a/Nightcap/NightcapApp.swift +++ b/Nightcap/NightcapApp.swift @@ -1,4 +1,7 @@ import ComposableArchitecture +import NightcapClients +import NightcapDomain +import NightcapUI import SwiftUI @main diff --git a/Nightcap/Services/AppQuitterClient.swift b/Nightcap/Services/AppQuitterClient.swift deleted file mode 100644 index 80c87ab..0000000 --- a/Nightcap/Services/AppQuitterClient.swift +++ /dev/null @@ -1,27 +0,0 @@ -import AppKit -import Dependencies -import DependenciesMacros - -@DependencyClient -struct AppQuitterClient: Sendable { - var quit: @Sendable () -> Void -} - -extension AppQuitterClient: DependencyKey { - static let liveValue: AppQuitterClient = .init( - quit: { - DispatchQueue.main.async { - NSApplication.shared.terminate(nil) - } - } - ) - - static let testValue = AppQuitterClient(quit: {}) -} - -extension DependencyValues { - var appQuitterClient: AppQuitterClient { - get { self[AppQuitterClient.self] } - set { self[AppQuitterClient.self] = newValue } - } -} diff --git a/Nightcap/Services/LaunchAtLoginClient.swift b/Nightcap/Services/LaunchAtLoginClient.swift deleted file mode 100644 index 9bfdaab..0000000 --- a/Nightcap/Services/LaunchAtLoginClient.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Dependencies -import DependenciesMacros -import ServiceManagement - -@DependencyClient -struct LaunchAtLoginClient: Sendable { - var status: @Sendable () -> LaunchAtLoginStatus = { .unknown } - var setEnabled: @Sendable (Bool) throws -> Void -} - -extension LaunchAtLoginClient: DependencyKey { - static let liveValue: LaunchAtLoginClient = .init( - status: { LaunchAtLoginStatus(SMAppService.mainApp.status) }, - setEnabled: { enable in - if enable { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - } - ) -} - -extension DependencyValues { - var launchAtLoginClient: LaunchAtLoginClient { - get { self[LaunchAtLoginClient.self] } - set { self[LaunchAtLoginClient.self] = newValue } - } -} diff --git a/NightcapTests/NightcapAppTests.swift b/NightcapTests/NightcapAppTests.swift index 3385d14..3d00d52 100644 --- a/NightcapTests/NightcapAppTests.swift +++ b/NightcapTests/NightcapAppTests.swift @@ -4,7 +4,7 @@ import Foundation import Sharing import Testing -@testable import Nightcap +import NightcapDomain // MARK: - Feature: Holding the sleep assertion diff --git a/Packages/NightcapKit/Package.swift b/Packages/NightcapKit/Package.swift new file mode 100644 index 0000000..89d98d9 --- /dev/null +++ b/Packages/NightcapKit/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// One package, three targets. The boundary that matters is the module boundary: +// NightcapDomain cannot import AppKit/IOKit/SwiftUI, and the compiler enforces it. +// Separate packages would add a manifest each and duplicate package identity in +// the generated Xcode project for no extra isolation. +let package = Package( + name: "NightcapKit", + platforms: [.macOS(.v14), .iOS(.v17), .watchOS(.v10)], + products: [ + .library(name: "NightcapDomain", targets: ["NightcapDomain"]), + .library(name: "NightcapClients", targets: ["NightcapClients"]), + .library(name: "NightcapUI", targets: ["NightcapUI"]), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.12.0"), + .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.8.0"), + ], + targets: [ + // Pure. No platform frameworks, so it builds for watchOS too. + .target( + name: "NightcapDomain", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "Sharing", package: "swift-sharing"), + ] + ), + // The only target allowed to touch AppKit, IOKit, ServiceManagement, StoreKit. + .target( + name: "NightcapClients", + dependencies: [ + "NightcapDomain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .target( + name: "NightcapUI", + dependencies: [ + "NightcapDomain", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + ] +) diff --git a/Nightcap/Services/AppLifecycleClient.swift b/Packages/NightcapKit/Sources/NightcapClients/AppLifecycleClient+Live.swift similarity index 80% rename from Nightcap/Services/AppLifecycleClient.swift rename to Packages/NightcapKit/Sources/NightcapClients/AppLifecycleClient+Live.swift index 4daac35..93e7ace 100644 --- a/Nightcap/Services/AppLifecycleClient.swift +++ b/Packages/NightcapKit/Sources/NightcapClients/AppLifecycleClient+Live.swift @@ -1,22 +1,9 @@ import AppKit import Dependencies -import DependenciesMacros - -@DependencyClient -struct AppLifecycleClient: Sendable { - var runningBundleIDs: @Sendable () -> Set = { [] } - var runningApps: @Sendable () -> [WatchedApp] = { [] } - var events: @Sendable () -> AsyncStream = { .finished } - - enum Event: Sendable, Equatable { - case launched(bundleID: String) - case terminated(bundleID: String) - case wake - } -} +import NightcapDomain extension AppLifecycleClient: DependencyKey { - static let liveValue: AppLifecycleClient = .init( + public static let liveValue: AppLifecycleClient = .init( runningBundleIDs: { Set(NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier)) }, @@ -80,13 +67,4 @@ extension AppLifecycleClient: DependencyKey { } } ) - - static let testValue = AppLifecycleClient() -} - -extension DependencyValues { - var appLifecycleClient: AppLifecycleClient { - get { self[AppLifecycleClient.self] } - set { self[AppLifecycleClient.self] = newValue } - } } diff --git a/Packages/NightcapKit/Sources/NightcapClients/AppQuitterClient+Live.swift b/Packages/NightcapKit/Sources/NightcapClients/AppQuitterClient+Live.swift new file mode 100644 index 0000000..a0f2e26 --- /dev/null +++ b/Packages/NightcapKit/Sources/NightcapClients/AppQuitterClient+Live.swift @@ -0,0 +1,13 @@ +import AppKit +import Dependencies +import NightcapDomain + +extension AppQuitterClient: DependencyKey { + public static let liveValue: AppQuitterClient = .init( + quit: { + DispatchQueue.main.async { + NSApplication.shared.terminate(nil) + } + } + ) +} diff --git a/Packages/NightcapKit/Sources/NightcapClients/LaunchAtLoginClient+Live.swift b/Packages/NightcapKit/Sources/NightcapClients/LaunchAtLoginClient+Live.swift new file mode 100644 index 0000000..b3d9c8d --- /dev/null +++ b/Packages/NightcapKit/Sources/NightcapClients/LaunchAtLoginClient+Live.swift @@ -0,0 +1,16 @@ +import Dependencies +import NightcapDomain +import ServiceManagement + +extension LaunchAtLoginClient: DependencyKey { + public static let liveValue: LaunchAtLoginClient = .init( + status: { LaunchAtLoginStatus(SMAppService.mainApp.status) }, + setEnabled: { enable in + if enable { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } + ) +} diff --git a/Nightcap/Services/PowerAssertionClient.swift b/Packages/NightcapKit/Sources/NightcapClients/PowerAssertionClient+Live.swift similarity index 80% rename from Nightcap/Services/PowerAssertionClient.swift rename to Packages/NightcapKit/Sources/NightcapClients/PowerAssertionClient+Live.swift index 4669a4b..8b47fc7 100644 --- a/Nightcap/Services/PowerAssertionClient.swift +++ b/Packages/NightcapKit/Sources/NightcapClients/PowerAssertionClient+Live.swift @@ -1,18 +1,12 @@ import Dependencies -import DependenciesMacros import Foundation import IOKit import IOKit.pwr_mgt +import NightcapDomain import os -@DependencyClient -struct PowerAssertionClient: Sendable { - var acquire: @Sendable (_ reason: String) -> Bool = { _ in false } - var release: @Sendable () -> Void -} - extension PowerAssertionClient: DependencyKey { - static let liveValue: PowerAssertionClient = { + public static let liveValue: PowerAssertionClient = { let holder = AssertionHolder() return Self( acquire: { holder.acquire(reason: $0) }, @@ -21,13 +15,6 @@ extension PowerAssertionClient: DependencyKey { }() } -extension DependencyValues { - var powerAssertionClient: PowerAssertionClient { - get { self[PowerAssertionClient.self] } - set { self[PowerAssertionClient.self] = newValue } - } -} - private let logger = Logger(subsystem: "com.abdocodes.nightcap", category: "PowerAssertion") private final class AssertionHolder: @unchecked Sendable { diff --git a/Nightcap/Services/ReviewPromptClient.swift b/Packages/NightcapKit/Sources/NightcapClients/ReviewPromptClient+Live.swift similarity index 54% rename from Nightcap/Services/ReviewPromptClient.swift rename to Packages/NightcapKit/Sources/NightcapClients/ReviewPromptClient+Live.swift index ad908cb..352315c 100644 --- a/Nightcap/Services/ReviewPromptClient.swift +++ b/Packages/NightcapKit/Sources/NightcapClients/ReviewPromptClient+Live.swift @@ -1,15 +1,10 @@ import Dependencies -import DependenciesMacros import Foundation +import NightcapDomain import StoreKit -@DependencyClient -struct ReviewPromptClient: Sendable { - var requestIfAppropriate: @Sendable () -> Void -} - extension ReviewPromptClient: DependencyKey { - static let liveValue: ReviewPromptClient = .init( + public static let liveValue: ReviewPromptClient = .init( requestIfAppropriate: { let defaults = UserDefaults.standard let key = "reviewPrompt.lastRequestDate" @@ -22,13 +17,4 @@ extension ReviewPromptClient: DependencyKey { SKStoreReviewController.requestReview() } ) - - static let testValue = ReviewPromptClient(requestIfAppropriate: {}) -} - -extension DependencyValues { - var reviewPromptClient: ReviewPromptClient { - get { self[ReviewPromptClient.self] } - set { self[ReviewPromptClient.self] = newValue } - } } diff --git a/Nightcap/AppFeature.swift b/Packages/NightcapKit/Sources/NightcapDomain/AppFeature.swift similarity index 93% rename from Nightcap/AppFeature.swift rename to Packages/NightcapKit/Sources/NightcapDomain/AppFeature.swift index b2462fb..3ea1243 100644 --- a/Nightcap/AppFeature.swift +++ b/Packages/NightcapKit/Sources/NightcapDomain/AppFeature.swift @@ -1,21 +1,22 @@ -import AppKit import ComposableArchitecture import Foundation import Sharing @Reducer -struct AppFeature { +public struct AppFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { @Shared(.fileStorage(.documentsDirectory.appending(component: "watched-apps.json"))) - var watchedApps: [WatchedApp] = [.ghostty] - var runningWatchedIDs: Set = [] - var runningAppCandidates: [WatchedApp] = [] - var launchAtLoginStatus: LaunchAtLoginStatus = .unknown - var assertionHeld = false + public var watchedApps: [WatchedApp] = [.ghostty] + public var runningWatchedIDs: Set = [] + public var runningAppCandidates: [WatchedApp] = [] + public var launchAtLoginStatus: LaunchAtLoginStatus = .unknown + public var assertionHeld = false + + public init() {} } - enum Action { + public enum Action { case onAppear case lifecycleEvent(AppLifecycleClient.Event) case reconcile @@ -36,7 +37,9 @@ struct AppFeature { @Dependency(\.appQuitterClient) var quitter @Dependency(\.reviewPromptClient) var reviewPrompt - var body: some ReducerOf { + public init() {} + + public var body: some ReducerOf { Reduce { state, action in switch action { case .onAppear: diff --git a/Packages/NightcapKit/Sources/NightcapDomain/Clients.swift b/Packages/NightcapKit/Sources/NightcapDomain/Clients.swift new file mode 100644 index 0000000..2a48ec9 --- /dev/null +++ b/Packages/NightcapKit/Sources/NightcapDomain/Clients.swift @@ -0,0 +1,100 @@ +import Dependencies +import DependenciesMacros + +// Interfaces only. Live implementations live in NightcapClients, which is the +// only module allowed to import AppKit, IOKit, ServiceManagement or StoreKit. +// This keeps NightcapDomain buildable on every platform, including watchOS. + +// MARK: - App lifecycle + +@DependencyClient +public struct AppLifecycleClient: Sendable { + public var runningBundleIDs: @Sendable () -> Set = { [] } + public var runningApps: @Sendable () -> [WatchedApp] = { [] } + public var events: @Sendable () -> AsyncStream = { .finished } + + public enum Event: Sendable, Equatable { + case launched(bundleID: String) + case terminated(bundleID: String) + case wake + } +} + +extension AppLifecycleClient: TestDependencyKey { + public static let testValue = AppLifecycleClient() +} + +// MARK: - Power assertion + +@DependencyClient +public struct PowerAssertionClient: Sendable { + public var acquire: @Sendable (_ reason: String) -> Bool = { _ in false } + public var release: @Sendable () -> Void +} + +extension PowerAssertionClient: TestDependencyKey { + public static let testValue = PowerAssertionClient() +} + +// MARK: - Launch at login + +@DependencyClient +public struct LaunchAtLoginClient: Sendable { + public var status: @Sendable () -> LaunchAtLoginStatus = { .unknown } + public var setEnabled: @Sendable (Bool) throws -> Void +} + +extension LaunchAtLoginClient: TestDependencyKey { + public static let testValue = LaunchAtLoginClient() +} + +// MARK: - Quitting + +@DependencyClient +public struct AppQuitterClient: Sendable { + public var quit: @Sendable () -> Void +} + +extension AppQuitterClient: TestDependencyKey { + public static let testValue = AppQuitterClient(quit: {}) +} + +// MARK: - Review prompt + +@DependencyClient +public struct ReviewPromptClient: Sendable { + public var requestIfAppropriate: @Sendable () -> Void +} + +extension ReviewPromptClient: TestDependencyKey { + public static let testValue = ReviewPromptClient(requestIfAppropriate: {}) +} + +// MARK: - Registration + +extension DependencyValues { + public var appLifecycleClient: AppLifecycleClient { + get { self[AppLifecycleClient.self] } + set { self[AppLifecycleClient.self] = newValue } + } + + public var powerAssertionClient: PowerAssertionClient { + get { self[PowerAssertionClient.self] } + set { self[PowerAssertionClient.self] = newValue } + } + + public var launchAtLoginClient: LaunchAtLoginClient { + get { self[LaunchAtLoginClient.self] } + set { self[LaunchAtLoginClient.self] = newValue } + } + + public var appQuitterClient: AppQuitterClient { + get { self[AppQuitterClient.self] } + set { self[AppQuitterClient.self] = newValue } + } + + public var reviewPromptClient: ReviewPromptClient { + get { self[ReviewPromptClient.self] } + set { self[ReviewPromptClient.self] = newValue } + } +} diff --git a/Nightcap/Domain/LaunchAtLoginStatus.swift b/Packages/NightcapKit/Sources/NightcapDomain/LaunchAtLoginStatus.swift similarity index 67% rename from Nightcap/Domain/LaunchAtLoginStatus.swift rename to Packages/NightcapKit/Sources/NightcapDomain/LaunchAtLoginStatus.swift index 958bd0b..e9eddb3 100644 --- a/Nightcap/Domain/LaunchAtLoginStatus.swift +++ b/Packages/NightcapKit/Sources/NightcapDomain/LaunchAtLoginStatus.swift @@ -1,19 +1,25 @@ import Foundation + +#if canImport(ServiceManagement) import ServiceManagement +#endif -enum LaunchAtLoginStatus: Equatable, Sendable { +public enum LaunchAtLoginStatus: Equatable, Sendable { case unknown case disabled case enabled case requiresApproval case error(String) - var isOn: Bool { + public var isOn: Bool { if case .enabled = self { return true } return false } +} - init(_ status: SMAppService.Status) { +#if canImport(ServiceManagement) +extension LaunchAtLoginStatus { + public init(_ status: SMAppService.Status) { switch status { case .notRegistered: self = .disabled case .enabled: self = .enabled @@ -23,3 +29,4 @@ enum LaunchAtLoginStatus: Equatable, Sendable { } } } +#endif diff --git a/Nightcap/Domain/WatchedApp.swift b/Packages/NightcapKit/Sources/NightcapDomain/WatchedApp.swift similarity index 59% rename from Nightcap/Domain/WatchedApp.swift rename to Packages/NightcapKit/Sources/NightcapDomain/WatchedApp.swift index fd4c57c..bda8ae0 100644 --- a/Nightcap/Domain/WatchedApp.swift +++ b/Packages/NightcapKit/Sources/NightcapDomain/WatchedApp.swift @@ -1,28 +1,28 @@ import Foundation -struct WatchedApp: Codable, Equatable, Identifiable, Sendable { - var bundleID: String - var displayName: String - var isObserved: Bool +public struct WatchedApp: Codable, Equatable, Identifiable, Sendable { + public var bundleID: String + public var displayName: String + public var isObserved: Bool - init(bundleID: String, displayName: String, isObserved: Bool = true) { + public init(bundleID: String, displayName: String, isObserved: Bool = true) { self.bundleID = bundleID self.displayName = displayName self.isObserved = isObserved } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) bundleID = try container.decode(String.self, forKey: .bundleID) displayName = try container.decode(String.self, forKey: .displayName) isObserved = try container.decodeIfPresent(Bool.self, forKey: .isObserved) ?? true } - var id: String { bundleID } + public var id: String { bundleID } } extension WatchedApp { - static let ghostty = WatchedApp( + public static let ghostty = WatchedApp( bundleID: "com.mitchellh.ghostty", displayName: "Ghostty" ) diff --git a/Nightcap/SharedUI/AddRunningAppMenu.swift b/Packages/NightcapKit/Sources/NightcapUI/AddRunningAppMenu.swift similarity index 98% rename from Nightcap/SharedUI/AddRunningAppMenu.swift rename to Packages/NightcapKit/Sources/NightcapUI/AddRunningAppMenu.swift index 72e12b6..241383e 100644 --- a/Nightcap/SharedUI/AddRunningAppMenu.swift +++ b/Packages/NightcapKit/Sources/NightcapUI/AddRunningAppMenu.swift @@ -1,3 +1,4 @@ +import NightcapDomain import SwiftUI struct AddRunningAppMenu: View { diff --git a/Nightcap/SharedUI/MenuActionsSection.swift b/Packages/NightcapKit/Sources/NightcapUI/MenuActionsSection.swift similarity index 97% rename from Nightcap/SharedUI/MenuActionsSection.swift rename to Packages/NightcapKit/Sources/NightcapUI/MenuActionsSection.swift index 8ff8d46..28964fc 100644 --- a/Nightcap/SharedUI/MenuActionsSection.swift +++ b/Packages/NightcapKit/Sources/NightcapUI/MenuActionsSection.swift @@ -1,3 +1,4 @@ +import NightcapDomain import AppKit import ServiceManagement import SwiftUI diff --git a/Nightcap/SharedUI/MenuAppPicker.swift b/Packages/NightcapKit/Sources/NightcapUI/MenuAppPicker.swift similarity index 99% rename from Nightcap/SharedUI/MenuAppPicker.swift rename to Packages/NightcapKit/Sources/NightcapUI/MenuAppPicker.swift index 3cdc816..2a3f75b 100644 --- a/Nightcap/SharedUI/MenuAppPicker.swift +++ b/Packages/NightcapKit/Sources/NightcapUI/MenuAppPicker.swift @@ -1,3 +1,4 @@ +import NightcapDomain import AppKit import UniformTypeIdentifiers diff --git a/Nightcap/SharedUI/MenuContentView.swift b/Packages/NightcapKit/Sources/NightcapUI/MenuContentView.swift similarity index 87% rename from Nightcap/SharedUI/MenuContentView.swift rename to Packages/NightcapKit/Sources/NightcapUI/MenuContentView.swift index 2b825cf..2b8874c 100644 --- a/Nightcap/SharedUI/MenuContentView.swift +++ b/Packages/NightcapKit/Sources/NightcapUI/MenuContentView.swift @@ -1,11 +1,16 @@ +import NightcapDomain import ComposableArchitecture import Foundation import SwiftUI -struct MenuContentView: View { - @Bindable var store: StoreOf +public struct MenuContentView: View { + @Bindable public var store: StoreOf - var body: some View { + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { MenuStatusSection( assertionHeld: store.assertionHeld, activeAppCount: store.runningWatchedIDs.count diff --git a/Nightcap/SharedUI/MenuStatusSection.swift b/Packages/NightcapKit/Sources/NightcapUI/MenuStatusSection.swift similarity index 96% rename from Nightcap/SharedUI/MenuStatusSection.swift rename to Packages/NightcapKit/Sources/NightcapUI/MenuStatusSection.swift index dfb5d71..2b7af3b 100644 --- a/Nightcap/SharedUI/MenuStatusSection.swift +++ b/Packages/NightcapKit/Sources/NightcapUI/MenuStatusSection.swift @@ -1,3 +1,4 @@ +import NightcapDomain import SwiftUI struct MenuStatusSection: View { diff --git a/Nightcap/SharedUI/WatchedAppsMenuSection.swift b/Packages/NightcapKit/Sources/NightcapUI/WatchedAppsMenuSection.swift similarity index 99% rename from Nightcap/SharedUI/WatchedAppsMenuSection.swift rename to Packages/NightcapKit/Sources/NightcapUI/WatchedAppsMenuSection.swift index a475672..5d0be7c 100644 --- a/Nightcap/SharedUI/WatchedAppsMenuSection.swift +++ b/Packages/NightcapKit/Sources/NightcapUI/WatchedAppsMenuSection.swift @@ -1,3 +1,4 @@ +import NightcapDomain import SwiftUI struct WatchedAppsMenuSection: View { diff --git a/docs/roadmap/expansion-roadmap.md b/docs/roadmap/expansion-roadmap.md new file mode 100644 index 0000000..a03d0ac --- /dev/null +++ b/docs/roadmap/expansion-roadmap.md @@ -0,0 +1,91 @@ +# Nightcap Expansion — SPDD Roadmap + +Repo: `~/Developer/Nightcap` @ `25ee63a` (clean, 12 commits). + +**You are here:** S0. Nothing started. + +## Reality check + +| | | +|---|---| +| Real size today | 1,281 lines Swift, 1 module, 1 target | +| Pure domain | 54 lines (`WatchedApp` 29, `LaunchAtLoginStatus` 25) | +| Logic under test | `AppFeature.swift` 171 lines, 15 XCTest cases | +| Gate to done | 2 shipping companion apps + snapshot + Maestro, all green via Xcode MCP | +| Hard blocker now | Xcode MCP not configured for this repo → cannot build or test | + +## Dependency DAG + +```mermaid +flowchart TD + S0[S0 Xcode MCP wiring]:::todo --> S1[S1 Baseline build + xccov]:::todo + S1 --> S2[S2 BDD rewrite in place]:::todo + S2 --> S3[S3 Edge-case scenarios]:::todo + S2 --> S4[S4 Coverage gate 80% domain]:::todo + S3 --> S5[S5 Extract NightcapDomain]:::todo + S4 --> S5 + S5 --> S6[S6 Extract NightcapClients]:::todo + S6 --> S7[S7 Extract NightcapUI]:::todo + S7 --> S8[S8 Mac app thin target + MAS parity]:::todo + S5 --> S9[S9 Transport client + stub]:::todo + S7 --> S10[S10 iOS app over stub]:::todo + S9 --> S10 + S10 --> S11[S11 watchOS app over stub]:::todo + S10 --> S12[S12 Snapshot tests iOS]:::todo + S10 --> S13[S13 Maestro e2e]:::todo + S12 --> S14[S14 CI wiring]:::todo + S13 --> S14 + S11 --> S15 + S10 --> S15[S15 CloudKit transport - GATED]:::blocked + S15 --> S16[S16 Hotspot reminder - GATED]:::blocked + + classDef todo fill:#e8eef7,stroke:#5b7fa6,color:#1b2a3a + classDef blocked fill:#f7e8e8,stroke:#a65b5b,stroke-dasharray:4 3,color:#3a1b1b +``` + +## Parallel tracks + +| Track | Slices, in order | +|---|---| +| **A — Tests** | S0 → S1 → S2 → (S3 ∥ S4) | +| **B — Packaging** | S5 → S6 → S7 → S8 | +| **C — Apps** | S9 *(parallel to S6/S7)* → S10 → S11 | +| **D — Verification** | (S12 ∥ S13) → S14 | +| **E — Gated** | S15 → S16 — needs entitlement + privacy sign-off | + +Real concurrency wins: **S9 alongside S6/S7**, and **S11 ∥ S12 ∥ S13** once S10 lands. Everything else is genuinely sequential — packaging can't start before characterization tests exist, apps can't start before the UI package does. + +## Backlog + +| ID | Slice | Blocked by | Unblocks | Est | +|---|---|---|---|---| +| S0 | Write `.mcp.json`, restart session, verify Xcode MCP tools | — | all | 10 min + restart | +| S1 | Build + run 15 tests via MCP, capture per-file xccov | S0 | S2 | 0.5 d | +| S2 | Rewrite `NightcapAppTests.swift` → Swift Testing `spec/Scenario` BDD | S1 | S3, S4 | 1 d | +| S3 | Edge cases: assertion swap-before-release, duplicate add no-op, legacy decode default, launch-at-login rollback, terminate-with-second-instance | S2 | S5 | 1 d | +| S4 | xccov script + 80% pure-domain gate | S2 | S5 | 0.5 d | +| S5 | Extract `NightcapDomain` — no AppKit import | S3, S4 | S6, S9 | 1 d | +| S6 | Extract `NightcapClients`, per-OS `#if` | S5 | S7 | 1 d | +| S7 | Extract `NightcapUI` shared views | S6 | S8, S10 | 1 d | +| S8 | Mac app → thin target, verify entitlements/LSUIElement parity | S7 | — | 0.5 d | +| S9 | `MacStateTransportClient` protocol + stub live value | S5 | S10 | 1 d | +| S10 | iOS app target, TCA over stub | S7, S9 | S11–S13 | 2 d | +| S11 | watchOS app target over stub | S10 | S15 | 2 d | +| S12 | Snapshot tests, iOS | S10 | S14 | 1 d | +| S13 | Maestro e2e flows | S10 | S14 | 1 d | +| S14 | CI runs macOS + iOS + watch + Maestro | S12, S13 | — | 1 d | +| S15 | **GATED** CloudKit transport, entitlements, PRIVACY.md rewrite | S10, S11 | S16 | 3 d | +| S16 | **GATED** Hotspot reminder — `NWPathMonitor` → companion | S15 | — | 1 d | + +## PR stack + +Each independently green and reviewable: + +1. **PR1** = S0–S4 — test rewrite + coverage gate. Zero production-code change. The one worth upstreaming. +2. **PR2** = S5–S8 — package extraction. Behavior-identical Mac app. +3. **PR3** = S9–S10 — iOS app on stub. +4. **PR4** = S11 — watchOS. +5. **PR5** = S12–S14 — verification infra. +6. **PR6** = S15–S16 — only after explicit sign-off. + +S15/S16 dashed for a reason: they add a network entitlement to a sandboxed App Store app whose listing promises zero network calls. Separate decision, last. diff --git a/project.yml b/project.yml index f073b01..8e8c411 100644 --- a/project.yml +++ b/project.yml @@ -6,6 +6,8 @@ options: macOS: 14.0 packages: + NightcapKit: + path: Packages/NightcapKit ComposableArchitecture: url: https://github.com/pointfreeco/swift-composable-architecture from: 1.0.0 @@ -66,6 +68,12 @@ targets: sources: - path: Nightcap dependencies: + - package: NightcapKit + product: NightcapDomain + - package: NightcapKit + product: NightcapClients + - package: NightcapKit + product: NightcapUI - package: ComposableArchitecture - package: swift-dependencies product: Dependencies @@ -113,6 +121,8 @@ targets: - path: NightcapTests dependencies: - target: Nightcap + - package: NightcapKit + product: NightcapDomain - package: ComposableArchitecture - package: swift-dependencies product: Dependencies From fc6acf372f748f5b9edeefbc0b112a4f70a4a1a5 Mon Sep 17 00:00:00 2001 From: Ahmed Ramy Date: Wed, 29 Jul 2026 00:42:17 +0400 Subject: [PATCH 3/5] Extract domain, clients and UI into separate modules Splits the single app target into three modules with compiler-enforced boundaries: - NightcapDomain: WatchedApp, LaunchAtLoginStatus, AppFeature, and the dependency-client interfaces. Imports no platform frameworks, so it can be reused by iOS and watchOS targets later. - NightcapClients: the live DependencyKey conformances. The only module that touches AppKit, IOKit, ServiceManagement and StoreKit. - NightcapUI: the SwiftUI menu views. Client interfaces are separated from implementations using the standard swift-dependencies split: the @DependencyClient struct and its TestDependencyKey live in the domain, the liveValue conformance lives in NightcapClients. AppFeature.swift imported AppKit without using a single AppKit symbol. The reducer was already pure, so the extraction started by deleting that import. Modules are built as static framework targets rather than local SwiftPM packages. Xcode would not register a local package reference for this project: NightcapKit never appeared in SourcePackages/workspace-state.json and all products reported "Missing package product", despite a manifest that builds fine under `swift build` and matches a working setup elsewhere. Static linking also avoids embedding and signing three extra dynamic frameworks. Package.swift manifests are kept alongside so the modules remain consumable by SwiftPM directly. Shipping parity verified on the built app: LSUIElement true, category unchanged, app-sandbox and files.user-selected.read-only intact, and no Nightcap frameworks embedded (statically linked). All 20 scenarios still pass. Domain coverage is 93.11%, down from 96.04%, entirely because LaunchAtLoginStatus.init(SMAppService.Status) was previously counted as covered by the test host app exercising the live code path at launch, not by any test. That code now lives in NightcapClients, so the number reflects real test coverage. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw --- Packages/NightcapClients/Package.swift | 24 ++++ .../AppLifecycleClient+Live.swift | 0 .../AppQuitterClient+Live.swift | 0 .../LaunchAtLoginClient+Live.swift | 0 .../PowerAssertionClient+Live.swift | 0 .../ReviewPromptClient+Live.swift | 0 Packages/NightcapDomain/Package.resolved | 131 ++++++++++++++++++ Packages/NightcapDomain/Package.swift | 28 ++++ .../Sources/NightcapDomain/AppFeature.swift | 0 .../Sources/NightcapDomain/Clients.swift | 0 .../NightcapDomain/LaunchAtLoginStatus.swift | 0 .../Sources/NightcapDomain/WatchedApp.swift | 0 Packages/NightcapKit/Package.swift | 48 ------- Packages/NightcapUI/Package.swift | 23 +++ .../NightcapUI/AddRunningAppMenu.swift | 0 .../NightcapUI/MenuActionsSection.swift | 0 .../Sources/NightcapUI/MenuAppPicker.swift | 0 .../Sources/NightcapUI/MenuContentView.swift | 0 .../NightcapUI/MenuStatusSection.swift | 0 .../NightcapUI/WatchedAppsMenuSection.swift | 0 project.yml | 99 +++++++++++-- scripts/check-domain-coverage.sh | 8 +- 22 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 Packages/NightcapClients/Package.swift rename Packages/{NightcapKit => NightcapClients}/Sources/NightcapClients/AppLifecycleClient+Live.swift (100%) rename Packages/{NightcapKit => NightcapClients}/Sources/NightcapClients/AppQuitterClient+Live.swift (100%) rename Packages/{NightcapKit => NightcapClients}/Sources/NightcapClients/LaunchAtLoginClient+Live.swift (100%) rename Packages/{NightcapKit => NightcapClients}/Sources/NightcapClients/PowerAssertionClient+Live.swift (100%) rename Packages/{NightcapKit => NightcapClients}/Sources/NightcapClients/ReviewPromptClient+Live.swift (100%) create mode 100644 Packages/NightcapDomain/Package.resolved create mode 100644 Packages/NightcapDomain/Package.swift rename Packages/{NightcapKit => NightcapDomain}/Sources/NightcapDomain/AppFeature.swift (100%) rename Packages/{NightcapKit => NightcapDomain}/Sources/NightcapDomain/Clients.swift (100%) rename Packages/{NightcapKit => NightcapDomain}/Sources/NightcapDomain/LaunchAtLoginStatus.swift (100%) rename Packages/{NightcapKit => NightcapDomain}/Sources/NightcapDomain/WatchedApp.swift (100%) delete mode 100644 Packages/NightcapKit/Package.swift create mode 100644 Packages/NightcapUI/Package.swift rename Packages/{NightcapKit => NightcapUI}/Sources/NightcapUI/AddRunningAppMenu.swift (100%) rename Packages/{NightcapKit => NightcapUI}/Sources/NightcapUI/MenuActionsSection.swift (100%) rename Packages/{NightcapKit => NightcapUI}/Sources/NightcapUI/MenuAppPicker.swift (100%) rename Packages/{NightcapKit => NightcapUI}/Sources/NightcapUI/MenuContentView.swift (100%) rename Packages/{NightcapKit => NightcapUI}/Sources/NightcapUI/MenuStatusSection.swift (100%) rename Packages/{NightcapKit => NightcapUI}/Sources/NightcapUI/WatchedAppsMenuSection.swift (100%) diff --git a/Packages/NightcapClients/Package.swift b/Packages/NightcapClients/Package.swift new file mode 100644 index 0000000..2586922 --- /dev/null +++ b/Packages/NightcapClients/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "NightcapClients", + platforms: [.macOS(.v14), .iOS(.v17), .watchOS(.v10)], + products: [ + .library(name: "NightcapClients", targets: ["NightcapClients"]) + ], + dependencies: [ + .package(path: "../NightcapDomain"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.12.0"), + ], + targets: [ + // The only module allowed to import platform frameworks. + .target( + name: "NightcapClients", + dependencies: [ + .product(name: "NightcapDomain", package: "NightcapDomain"), + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ) + ] +) diff --git a/Packages/NightcapKit/Sources/NightcapClients/AppLifecycleClient+Live.swift b/Packages/NightcapClients/Sources/NightcapClients/AppLifecycleClient+Live.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapClients/AppLifecycleClient+Live.swift rename to Packages/NightcapClients/Sources/NightcapClients/AppLifecycleClient+Live.swift diff --git a/Packages/NightcapKit/Sources/NightcapClients/AppQuitterClient+Live.swift b/Packages/NightcapClients/Sources/NightcapClients/AppQuitterClient+Live.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapClients/AppQuitterClient+Live.swift rename to Packages/NightcapClients/Sources/NightcapClients/AppQuitterClient+Live.swift diff --git a/Packages/NightcapKit/Sources/NightcapClients/LaunchAtLoginClient+Live.swift b/Packages/NightcapClients/Sources/NightcapClients/LaunchAtLoginClient+Live.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapClients/LaunchAtLoginClient+Live.swift rename to Packages/NightcapClients/Sources/NightcapClients/LaunchAtLoginClient+Live.swift diff --git a/Packages/NightcapKit/Sources/NightcapClients/PowerAssertionClient+Live.swift b/Packages/NightcapClients/Sources/NightcapClients/PowerAssertionClient+Live.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapClients/PowerAssertionClient+Live.swift rename to Packages/NightcapClients/Sources/NightcapClients/PowerAssertionClient+Live.swift diff --git a/Packages/NightcapKit/Sources/NightcapClients/ReviewPromptClient+Live.swift b/Packages/NightcapClients/Sources/NightcapClients/ReviewPromptClient+Live.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapClients/ReviewPromptClient+Live.swift rename to Packages/NightcapClients/Sources/NightcapClients/ReviewPromptClient+Live.swift diff --git a/Packages/NightcapDomain/Package.resolved b/Packages/NightcapDomain/Package.resolved new file mode 100644 index 0000000..fad02de --- /dev/null +++ b/Packages/NightcapDomain/Package.resolved @@ -0,0 +1,131 @@ +{ + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "794f4b0a9cf32042592388d014f6a1ea987d323a", + "version" : "1.9.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "72d749bf341b78851203066ab421869b783ec42a", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "ead11e04e5011c437722c1990d22f80d87056978", + "version" : "1.26.1" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5fa253428866f2360c3754e88537f700ed2656b5", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "fad75807c596fecd724b0fc81cd61c94008faad4", + "version" : "2.10.3" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "de219a1cf34e958134e75a9ebb134cf09bf52fc6", + "version" : "2.0.11" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "8f6abcf4c8950e2679d5b2fee4ca284fd7c34886", + "version" : "1.11.0" + } + } + ], + "version" : 2 +} diff --git a/Packages/NightcapDomain/Package.swift b/Packages/NightcapDomain/Package.swift new file mode 100644 index 0000000..03d5787 --- /dev/null +++ b/Packages/NightcapDomain/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "NightcapDomain", + platforms: [.macOS(.v14), .iOS(.v17), .watchOS(.v10)], + products: [ + .library(name: "NightcapDomain", targets: ["NightcapDomain"]) + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.12.0"), + .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.8.0"), + ], + targets: [ + // Pure: no AppKit, IOKit, ServiceManagement or StoreKit, so this builds + // for iOS and watchOS as well as macOS. + .target( + name: "NightcapDomain", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "Sharing", package: "swift-sharing"), + ] + ) + ] +) diff --git a/Packages/NightcapKit/Sources/NightcapDomain/AppFeature.swift b/Packages/NightcapDomain/Sources/NightcapDomain/AppFeature.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapDomain/AppFeature.swift rename to Packages/NightcapDomain/Sources/NightcapDomain/AppFeature.swift diff --git a/Packages/NightcapKit/Sources/NightcapDomain/Clients.swift b/Packages/NightcapDomain/Sources/NightcapDomain/Clients.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapDomain/Clients.swift rename to Packages/NightcapDomain/Sources/NightcapDomain/Clients.swift diff --git a/Packages/NightcapKit/Sources/NightcapDomain/LaunchAtLoginStatus.swift b/Packages/NightcapDomain/Sources/NightcapDomain/LaunchAtLoginStatus.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapDomain/LaunchAtLoginStatus.swift rename to Packages/NightcapDomain/Sources/NightcapDomain/LaunchAtLoginStatus.swift diff --git a/Packages/NightcapKit/Sources/NightcapDomain/WatchedApp.swift b/Packages/NightcapDomain/Sources/NightcapDomain/WatchedApp.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapDomain/WatchedApp.swift rename to Packages/NightcapDomain/Sources/NightcapDomain/WatchedApp.swift diff --git a/Packages/NightcapKit/Package.swift b/Packages/NightcapKit/Package.swift deleted file mode 100644 index 89d98d9..0000000 --- a/Packages/NightcapKit/Package.swift +++ /dev/null @@ -1,48 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -// One package, three targets. The boundary that matters is the module boundary: -// NightcapDomain cannot import AppKit/IOKit/SwiftUI, and the compiler enforces it. -// Separate packages would add a manifest each and duplicate package identity in -// the generated Xcode project for no extra isolation. -let package = Package( - name: "NightcapKit", - platforms: [.macOS(.v14), .iOS(.v17), .watchOS(.v10)], - products: [ - .library(name: "NightcapDomain", targets: ["NightcapDomain"]), - .library(name: "NightcapClients", targets: ["NightcapClients"]), - .library(name: "NightcapUI", targets: ["NightcapUI"]), - ], - dependencies: [ - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.12.0"), - .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.8.0"), - ], - targets: [ - // Pure. No platform frameworks, so it builds for watchOS too. - .target( - name: "NightcapDomain", - dependencies: [ - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "Dependencies", package: "swift-dependencies"), - .product(name: "DependenciesMacros", package: "swift-dependencies"), - .product(name: "Sharing", package: "swift-sharing"), - ] - ), - // The only target allowed to touch AppKit, IOKit, ServiceManagement, StoreKit. - .target( - name: "NightcapClients", - dependencies: [ - "NightcapDomain", - .product(name: "Dependencies", package: "swift-dependencies"), - ] - ), - .target( - name: "NightcapUI", - dependencies: [ - "NightcapDomain", - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), - ] -) diff --git a/Packages/NightcapUI/Package.swift b/Packages/NightcapUI/Package.swift new file mode 100644 index 0000000..31918c8 --- /dev/null +++ b/Packages/NightcapUI/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "NightcapUI", + platforms: [.macOS(.v14), .iOS(.v17), .watchOS(.v10)], + products: [ + .library(name: "NightcapUI", targets: ["NightcapUI"]) + ], + dependencies: [ + .package(path: "../NightcapDomain"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"), + ], + targets: [ + .target( + name: "NightcapUI", + dependencies: [ + .product(name: "NightcapDomain", package: "NightcapDomain"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ) + ] +) diff --git a/Packages/NightcapKit/Sources/NightcapUI/AddRunningAppMenu.swift b/Packages/NightcapUI/Sources/NightcapUI/AddRunningAppMenu.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapUI/AddRunningAppMenu.swift rename to Packages/NightcapUI/Sources/NightcapUI/AddRunningAppMenu.swift diff --git a/Packages/NightcapKit/Sources/NightcapUI/MenuActionsSection.swift b/Packages/NightcapUI/Sources/NightcapUI/MenuActionsSection.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapUI/MenuActionsSection.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuActionsSection.swift diff --git a/Packages/NightcapKit/Sources/NightcapUI/MenuAppPicker.swift b/Packages/NightcapUI/Sources/NightcapUI/MenuAppPicker.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapUI/MenuAppPicker.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuAppPicker.swift diff --git a/Packages/NightcapKit/Sources/NightcapUI/MenuContentView.swift b/Packages/NightcapUI/Sources/NightcapUI/MenuContentView.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapUI/MenuContentView.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuContentView.swift diff --git a/Packages/NightcapKit/Sources/NightcapUI/MenuStatusSection.swift b/Packages/NightcapUI/Sources/NightcapUI/MenuStatusSection.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapUI/MenuStatusSection.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuStatusSection.swift diff --git a/Packages/NightcapKit/Sources/NightcapUI/WatchedAppsMenuSection.swift b/Packages/NightcapUI/Sources/NightcapUI/WatchedAppsMenuSection.swift similarity index 100% rename from Packages/NightcapKit/Sources/NightcapUI/WatchedAppsMenuSection.swift rename to Packages/NightcapUI/Sources/NightcapUI/WatchedAppsMenuSection.swift diff --git a/project.yml b/project.yml index 8e8c411..6bc1791 100644 --- a/project.yml +++ b/project.yml @@ -6,8 +6,6 @@ options: macOS: 14.0 packages: - NightcapKit: - path: Packages/NightcapKit ComposableArchitecture: url: https://github.com/pointfreeco/swift-composable-architecture from: 1.0.0 @@ -68,12 +66,9 @@ targets: sources: - path: Nightcap dependencies: - - package: NightcapKit - product: NightcapDomain - - package: NightcapKit - product: NightcapClients - - package: NightcapKit - product: NightcapUI + - target: NightcapDomain + - target: NightcapClients + - target: NightcapUI - package: ComposableArchitecture - package: swift-dependencies product: Dependencies @@ -113,6 +108,89 @@ targets: Release: ENABLE_HARDENED_RUNTIME: YES + NightcapDomain: + type: framework + platform: macOS + deploymentTarget: 14.0 + sources: + - path: Packages/NightcapDomain/Sources/NightcapDomain + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.abdocodes.nightcap.domain + SWIFT_VERSION: 5.0 + GENERATE_INFOPLIST_FILE: YES + # Static: avoids embedding/signing three dynamic frameworks and the + # duplicate-symbol problems from mixing them with SPM PackageFrameworks. + MACH_O_TYPE: staticlib + SKIP_INSTALL: YES + dependencies: + - package: ComposableArchitecture + - package: swift-dependencies + product: Dependencies + - package: swift-dependencies + product: DependenciesMacros + - package: swift-sharing + product: Sharing + - package: swift-case-paths + product: CasePaths + - package: swift-case-paths + product: CasePathsCore + - package: swift-concurrency-extras + product: ConcurrencyExtras + - package: swift-perception + product: Perception + - package: swift-perception + product: PerceptionCore + - package: xctest-dynamic-overlay + product: IssueReporting + - package: swift-custom-dump + product: CustomDump + - package: swift-identified-collections + product: IdentifiedCollections + - package: swift-navigation + product: SwiftNavigation + - package: combine-schedulers + product: CombineSchedulers + + NightcapClients: + type: framework + platform: macOS + deploymentTarget: 14.0 + sources: + - path: Packages/NightcapClients/Sources/NightcapClients + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.abdocodes.nightcap.clients + SWIFT_VERSION: 5.0 + GENERATE_INFOPLIST_FILE: YES + # Static: avoids embedding/signing three dynamic frameworks and the + # duplicate-symbol problems from mixing them with SPM PackageFrameworks. + MACH_O_TYPE: staticlib + SKIP_INSTALL: YES + dependencies: + - target: NightcapDomain + - package: swift-dependencies + product: Dependencies + + NightcapUI: + type: framework + platform: macOS + deploymentTarget: 14.0 + sources: + - path: Packages/NightcapUI/Sources/NightcapUI + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.abdocodes.nightcap.ui + SWIFT_VERSION: 5.0 + GENERATE_INFOPLIST_FILE: YES + # Static: avoids embedding/signing three dynamic frameworks and the + # duplicate-symbol problems from mixing them with SPM PackageFrameworks. + MACH_O_TYPE: staticlib + SKIP_INSTALL: YES + dependencies: + - target: NightcapDomain + - package: ComposableArchitecture + NightcapTests: type: bundle.unit-test platform: macOS @@ -121,11 +199,12 @@ targets: - path: NightcapTests dependencies: - target: Nightcap - - package: NightcapKit - product: NightcapDomain + - target: NightcapDomain - package: ComposableArchitecture - package: swift-dependencies product: Dependencies + - package: swift-dependencies + product: DependenciesMacros - package: swift-sharing product: Sharing - package: swift-concurrency-extras diff --git a/scripts/check-domain-coverage.sh b/scripts/check-domain-coverage.sh index 91909bb..14da42f 100755 --- a/scripts/check-domain-coverage.sh +++ b/scripts/check-domain-coverage.sh @@ -13,12 +13,12 @@ XCRESULT="${1:?usage: $0 [threshold]}" THRESHOLD="${2:-80}" DOMAIN_FILES=( - "Nightcap/Domain/WatchedApp.swift" - "Nightcap/Domain/LaunchAtLoginStatus.swift" - "Nightcap/AppFeature.swift" + "Packages/NightcapDomain/Sources/NightcapDomain/WatchedApp.swift" + "Packages/NightcapDomain/Sources/NightcapDomain/LaunchAtLoginStatus.swift" + "Packages/NightcapDomain/Sources/NightcapDomain/AppFeature.swift" ) -report=$(xcrun xccov view --report --files-for-target Nightcap.app "$XCRESULT") +report=$(xcrun xccov view --report --files-for-target NightcapDomain.framework "$XCRESULT") covered=0 total=0 From ab0e7a0c50085feaa88f210ce7109f09daae54ba Mon Sep 17 00:00:00 2001 From: Ahmed Ramy Date: Wed, 29 Jul 2026 00:48:34 +0400 Subject: [PATCH 4/5] Add iPhone companion app over a stubbed Mac transport Introduces the companion surface, built inside-out from the domain: - MacState: the entire contract between the Mac and a companion. A plain value type, so companions can be built and tested long before a real transport exists. - MacStateTransportClient: transport-agnostic interface with a working in-memory stub as its live value. Shipping a real transport means adding a network entitlement to a sandboxed App Store app whose listing promises zero network calls, so that stays a separate decision. - CompanionFeature: one reducer driving both iPhone and Watch. Neither platform holds logic of its own. - NightcapCompanionUI: shared SwiftUI for iOS and watchOS. The existing NightcapUI stays macOS-only because it is AppKit menu-bar code. - NightcapPhone: the iOS app target. NightcapDomain is now a multi-platform target (macOS, iOS, watchOS), which is what the earlier purity work was for. CompanionFeature.State distinguishes "waiting for the Mac" from "the Mac is idle", so the UI never claims the Mac is asleep before any snapshot has arrived. Transport failures surface a message rather than failing silently. onDisappear cancels the subscription so a watch app is not holding a stream open in the background. ComposableArchitecture is linked once, via NightcapDomain. The companion UI and phone app take it with link: false; linking it into each static framework produced 7674 duplicate symbols. Verified on the iPhone 17 Pro simulator via the accessibility tree: the app shows "Keeping Mac Awake. 1 app active", and tapping Ghostty's toggle moves it to "Idle. Sleep allowed" with Ghostty marked Paused. 24 scenarios pass, including 4 new companion scenarios. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw --- Apps/Phone/NightcapPhoneApp.swift | 17 +++ NightcapTests/NightcapAppTests.swift | 86 +++++++++++ Packages/NightcapCompanionUI/Package.swift | 23 +++ .../NightcapCompanionUI/CompanionView.swift | 137 ++++++++++++++++++ .../NightcapDomain/CompanionFeature.swift | 82 +++++++++++ .../Sources/NightcapDomain/MacState.swift | 29 ++++ .../MacStateTransportClient.swift | 111 ++++++++++++++ project.yml | 57 +++++++- 8 files changed, 536 insertions(+), 6 deletions(-) create mode 100644 Apps/Phone/NightcapPhoneApp.swift create mode 100644 Packages/NightcapCompanionUI/Package.swift create mode 100644 Packages/NightcapCompanionUI/Sources/NightcapCompanionUI/CompanionView.swift create mode 100644 Packages/NightcapDomain/Sources/NightcapDomain/CompanionFeature.swift create mode 100644 Packages/NightcapDomain/Sources/NightcapDomain/MacState.swift create mode 100644 Packages/NightcapDomain/Sources/NightcapDomain/MacStateTransportClient.swift diff --git a/Apps/Phone/NightcapPhoneApp.swift b/Apps/Phone/NightcapPhoneApp.swift new file mode 100644 index 0000000..2ea5862 --- /dev/null +++ b/Apps/Phone/NightcapPhoneApp.swift @@ -0,0 +1,17 @@ +import ComposableArchitecture +import NightcapCompanionUI +import NightcapDomain +import SwiftUI + +@main +struct NightcapPhoneApp: App { + @State private var store = Store(initialState: CompanionFeature.State()) { + CompanionFeature() + } + + var body: some Scene { + WindowGroup { + CompanionView(store: store) + } + } +} diff --git a/NightcapTests/NightcapAppTests.swift b/NightcapTests/NightcapAppTests.swift index 3d00d52..a5805dd 100644 --- a/NightcapTests/NightcapAppTests.swift +++ b/NightcapTests/NightcapAppTests.swift @@ -531,3 +531,89 @@ private struct TestEnv { } } } + +// MARK: - Feature: Watching the Mac from a companion app + +@MainActor +@Suite("Feature: Watching the Mac from a companion app") +struct CompanionFeatureTests { + @Test("Scenario 1: the app opens and shows the Mac's current state") + func openingSubscribesAndReceivesState() async { + // Given a Mac holding the assertion for Ghostty + let store = TestStore(initialState: CompanionFeature.State()) { + CompanionFeature() + } withDependencies: { + $0.macStateTransportClient = .stub(initial: .preview) + } + + // When the companion appears + await store.send(.onAppear) + + // Then the first snapshot arrives and the app stops saying "waiting" + await store.receive(\.macStateReceived) { + $0.macState = .preview + $0.hasConnected = true + } + #expect(store.state.isWaitingForMac == false) + #expect(store.state.macState.activeApps.map(\.displayName) == ["Ghostty"]) + + await store.send(.onDisappear) + } + + @Test("Scenario 2: before any snapshot arrives the app does not claim the Mac is idle") + func waitingStateIsDistinctFromIdle() { + // Given a companion that has heard nothing yet + let state = CompanionFeature.State() + + // Then it reports waiting, not "asleep" + #expect(state.isWaitingForMac) + #expect(state.macState.isAwakeHeld == false) + } + + @Test("Scenario 3: pausing an app from the phone releases the Mac") + func pausingFromCompanionUpdatesMacState() async { + // Given a Mac kept awake by Ghostty + let store = TestStore(initialState: CompanionFeature.State()) { + CompanionFeature() + } withDependencies: { + $0.macStateTransportClient = .stub(initial: .preview) + } + await store.send(.onAppear) + await store.receive(\.macStateReceived) { + $0.macState = .preview + $0.hasConnected = true + } + + // When the user pauses Ghostty from the companion + await store.send(.observationToggled("com.mitchellh.ghostty", false)) + + // Then the Mac reports itself no longer held + await store.receive(\.macStateReceived) { + $0.macState.watchedApps[0].isObserved = false + $0.macState.runningWatchedIDs = [] + $0.macState.isAwakeHeld = false + } + + await store.send(.onDisappear) + } + + @Test("Scenario 4: a transport failure surfaces a message instead of failing silently") + func transportFailureSurfacesMessage() async { + // Given a Mac that cannot be reached + struct Unreachable: Error {} + let store = TestStore(initialState: CompanionFeature.State()) { + CompanionFeature() + } withDependencies: { + $0.macStateTransportClient.states = { .finished } + $0.macStateTransportClient.setObservation = { _, _ in throw Unreachable() } + } + + // When the user toggles an app + await store.send(.observationToggled("com.mitchellh.ghostty", false)) + + // Then the failure is shown rather than swallowed + await store.receive(\.failed) { + $0.failureMessage = "Couldn't reach your Mac." + } + } +} diff --git a/Packages/NightcapCompanionUI/Package.swift b/Packages/NightcapCompanionUI/Package.swift new file mode 100644 index 0000000..514ae10 --- /dev/null +++ b/Packages/NightcapCompanionUI/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "NightcapCompanionUI", + platforms: [.iOS(.v17), .watchOS(.v10)], + products: [ + .library(name: "NightcapCompanionUI", targets: ["NightcapCompanionUI"]) + ], + dependencies: [ + .package(path: "../NightcapDomain"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"), + ], + targets: [ + .target( + name: "NightcapCompanionUI", + dependencies: [ + .product(name: "NightcapDomain", package: "NightcapDomain"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ) + ] +) diff --git a/Packages/NightcapCompanionUI/Sources/NightcapCompanionUI/CompanionView.swift b/Packages/NightcapCompanionUI/Sources/NightcapCompanionUI/CompanionView.swift new file mode 100644 index 0000000..4b4bb34 --- /dev/null +++ b/Packages/NightcapCompanionUI/Sources/NightcapCompanionUI/CompanionView.swift @@ -0,0 +1,137 @@ +import ComposableArchitecture +import NightcapDomain +import SwiftUI + +/// The companion screen, shared by iPhone and Watch. Platform differences are +/// handled by SwiftUI's own adaptation rather than by branching here. +public struct CompanionView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + NavigationStack { + List { + Section { + MacStatusRow( + isAwakeHeld: store.macState.isAwakeHeld, + isWaiting: store.isWaitingForMac, + activeCount: store.macState.activeApps.count + ) + } + + if let failureMessage = store.failureMessage { + Section { + Label(failureMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + + Section("Watched Apps") { + if store.macState.watchedApps.isEmpty { + Text(store.isWaitingForMac ? "Waiting for your Mac…" : "No apps added") + .foregroundStyle(.secondary) + } else { + ForEach(store.macState.watchedApps) { app in + WatchedAppRow( + app: app, + isRunning: store.macState.runningWatchedIDs.contains(app.bundleID), + onToggle: { isObserved in + store.send(.observationToggled(app.bundleID, isObserved)) + } + ) + } + } + } + } + .navigationTitle("Nightcap") + .refreshable { store.send(.refreshRequested) } + } + .onAppear { store.send(.onAppear) } + .onDisappear { store.send(.onDisappear) } + } +} + +struct MacStatusRow: View { + let isAwakeHeld: Bool + let isWaiting: Bool + let activeCount: Int + + var body: some View { + HStack { + Image(systemName: iconName) + .font(.title2) + .foregroundStyle(iconColor) + .accessibilityHidden(true) + VStack(alignment: .leading) { + Text(title).font(.headline) + Text(subtitle).font(.subheadline).foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title). \(subtitle)") + } + + private var iconName: String { + if isWaiting { return "questionmark.circle" } + return isAwakeHeld ? "cup.and.saucer.fill" : "moon.zzz" + } + + private var iconColor: Color { + if isWaiting { return .secondary } + return isAwakeHeld ? .green : .secondary + } + + private var title: String { + if isWaiting { return "Waiting for your Mac" } + return isAwakeHeld ? "Keeping Mac Awake" : "Idle" + } + + private var subtitle: String { + if isWaiting { return "No status received yet" } + guard isAwakeHeld else { return "Sleep allowed" } + return activeCount == 1 ? "1 app active" : "\(activeCount) apps active" + } +} + +struct WatchedAppRow: View { + let app: WatchedApp + let isRunning: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack { + Image(systemName: iconName) + .foregroundStyle(iconColor) + .accessibilityHidden(true) + VStack(alignment: .leading) { + Text(app.displayName) + if !app.isObserved { + Text("Paused").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + Toggle("", isOn: Binding(get: { app.isObserved }, set: onToggle)) + .labelsHidden() + .accessibilityLabel("Watch \(app.displayName)") + } + } + + private var iconName: String { + guard app.isObserved else { return "pause.circle" } + return isRunning ? "circle.fill" : "circle" + } + + private var iconColor: Color { + guard app.isObserved else { return .secondary } + return isRunning ? .green : .secondary + } +} + +#Preview { + CompanionView( + store: Store(initialState: CompanionFeature.State()) { CompanionFeature() } + ) +} diff --git a/Packages/NightcapDomain/Sources/NightcapDomain/CompanionFeature.swift b/Packages/NightcapDomain/Sources/NightcapDomain/CompanionFeature.swift new file mode 100644 index 0000000..fa7d427 --- /dev/null +++ b/Packages/NightcapDomain/Sources/NightcapDomain/CompanionFeature.swift @@ -0,0 +1,82 @@ +import ComposableArchitecture +import Foundation + +/// Drives both the iPhone and the Watch app. Neither platform owns any logic of +/// its own: they differ only in how they render this state. +@Reducer +public struct CompanionFeature { + @ObservableState + public struct State: Equatable { + public var macState: MacState = MacState() + public var hasConnected = false + public var failureMessage: String? + + public init() {} + + /// Nothing has arrived yet, so the UI should say so rather than claim the + /// Mac is idle. + public var isWaitingForMac: Bool { !hasConnected } + } + + public enum Action { + case onAppear + case onDisappear + case macStateReceived(MacState) + case observationToggled(String, Bool) + case refreshRequested + case failed(String) + } + + private enum CancelID { case states } + + @Dependency(\.macStateTransportClient) var transport + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + return .run { send in + for await macState in transport.states() { + await send(.macStateReceived(macState)) + } + } + .cancellable(id: CancelID.states, cancelInFlight: true) + + case .onDisappear: + // Stop listening when the companion goes to the background, so a + // watch app is not holding a stream open behind the user's back. + return .cancel(id: CancelID.states) + + case let .macStateReceived(macState): + state.macState = macState + state.hasConnected = true + state.failureMessage = nil + return .none + + case let .observationToggled(bundleID, isObserved): + return .run { send in + do { + try await transport.setObservation(bundleID, isObserved) + } catch { + await send(.failed("Couldn't reach your Mac.")) + } + } + + case .refreshRequested: + return .run { send in + do { + try await transport.refresh() + } catch { + await send(.failed("Couldn't reach your Mac.")) + } + } + + case let .failed(message): + state.failureMessage = message + return .none + } + } + } +} diff --git a/Packages/NightcapDomain/Sources/NightcapDomain/MacState.swift b/Packages/NightcapDomain/Sources/NightcapDomain/MacState.swift new file mode 100644 index 0000000..fb3ffef --- /dev/null +++ b/Packages/NightcapDomain/Sources/NightcapDomain/MacState.swift @@ -0,0 +1,29 @@ +import Foundation + +/// A snapshot of what the Mac is doing, as seen by a companion app. +/// +/// This is the whole contract between the Mac and the phone/watch. Keeping it a +/// plain value type means the companions can be built and tested against a stub +/// long before any real transport exists. +public struct MacState: Codable, Equatable, Sendable { + public var isAwakeHeld: Bool + public var watchedApps: [WatchedApp] + public var runningWatchedIDs: Set + public var lastUpdated: Date + + public init( + isAwakeHeld: Bool = false, + watchedApps: [WatchedApp] = [], + runningWatchedIDs: Set = [], + lastUpdated: Date = .distantPast + ) { + self.isAwakeHeld = isAwakeHeld + self.watchedApps = watchedApps + self.runningWatchedIDs = runningWatchedIDs + self.lastUpdated = lastUpdated + } + + public var activeApps: [WatchedApp] { + watchedApps.filter { runningWatchedIDs.contains($0.bundleID) } + } +} diff --git a/Packages/NightcapDomain/Sources/NightcapDomain/MacStateTransportClient.swift b/Packages/NightcapDomain/Sources/NightcapDomain/MacStateTransportClient.swift new file mode 100644 index 0000000..48dd398 --- /dev/null +++ b/Packages/NightcapDomain/Sources/NightcapDomain/MacStateTransportClient.swift @@ -0,0 +1,111 @@ +import Dependencies +import DependenciesMacros +import Foundation + +/// How a companion app reads and influences the Mac. +/// +/// Deliberately transport-agnostic. The live value today is a stub: shipping a +/// real transport means adding a network entitlement to a sandboxed App Store +/// app whose listing promises zero network calls, which is a separate decision. +@DependencyClient +public struct MacStateTransportClient: Sendable { + /// A stream of Mac snapshots. Emits the current state immediately. + public var states: @Sendable () -> AsyncStream = { .finished } + /// Ask the Mac to pause or resume watching an app. + public var setObservation: @Sendable (_ bundleID: String, _ isObserved: Bool) async throws -> Void + /// Ask the Mac for a fresh snapshot. + public var refresh: @Sendable () async throws -> Void +} + +extension MacStateTransportClient: TestDependencyKey { + public static let testValue = MacStateTransportClient() + + /// An in-memory Mac that behaves plausibly, so the companion apps can be + /// built and demoed end to end without a transport. + public static func stub(initial: MacState = .preview) -> MacStateTransportClient { + let box = StubBox(state: initial) + return MacStateTransportClient( + states: { box.stream() }, + setObservation: { bundleID, isObserved in + box.setObservation(bundleID: bundleID, isObserved: isObserved) + }, + refresh: { box.republish() } + ) + } +} + +extension MacStateTransportClient: DependencyKey { + public static let liveValue = MacStateTransportClient.stub() +} + +extension DependencyValues { + public var macStateTransportClient: MacStateTransportClient { + get { self[MacStateTransportClient.self] } + set { self[MacStateTransportClient.self] = newValue } + } +} + +extension MacState { + public static let preview = MacState( + isAwakeHeld: true, + watchedApps: [ + WatchedApp(bundleID: "com.mitchellh.ghostty", displayName: "Ghostty"), + WatchedApp(bundleID: "com.apple.dt.Xcode", displayName: "Xcode"), + WatchedApp(bundleID: "us.zoom.xos", displayName: "zoom.us", isObserved: false), + ], + runningWatchedIDs: ["com.mitchellh.ghostty"], + lastUpdated: Date(timeIntervalSince1970: 1_800_000_000) + ) +} + +private final class StubBox: @unchecked Sendable { + private let lock = NSLock() + private var state: MacState + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + init(state: MacState) { + self.state = state + } + + func stream() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + lock.lock() + continuations[id] = continuation + continuation.yield(state) + lock.unlock() + continuation.onTermination = { [weak self] _ in + guard let self else { return } + lock.lock() + continuations[id] = nil + lock.unlock() + } + } + } + + func setObservation(bundleID: String, isObserved: Bool) { + lock.lock() + if let index = state.watchedApps.firstIndex(where: { $0.bundleID == bundleID }) { + state.watchedApps[index].isObserved = isObserved + if isObserved { + // The stub cannot know if the app is running, so treat a resume as + // "running" only when it was already known to be running. + } else { + state.runningWatchedIDs.remove(bundleID) + } + state.isAwakeHeld = !state.runningWatchedIDs.isEmpty + } + let snapshot = state + let targets = Array(continuations.values) + lock.unlock() + targets.forEach { $0.yield(snapshot) } + } + + func republish() { + lock.lock() + let snapshot = state + let targets = Array(continuations.values) + lock.unlock() + targets.forEach { $0.yield(snapshot) } + } +} diff --git a/project.yml b/project.yml index 6bc1791..11938dd 100644 --- a/project.yml +++ b/project.yml @@ -66,7 +66,7 @@ targets: sources: - path: Nightcap dependencies: - - target: NightcapDomain + - target: NightcapDomain_macOS - target: NightcapClients - target: NightcapUI - package: ComposableArchitecture @@ -110,8 +110,11 @@ targets: NightcapDomain: type: framework - platform: macOS - deploymentTarget: 14.0 + platform: [macOS, iOS, watchOS] + deploymentTarget: + macOS: 14.0 + iOS: 17.0 + watchOS: 10.0 sources: - path: Packages/NightcapDomain/Sources/NightcapDomain settings: @@ -168,7 +171,7 @@ targets: MACH_O_TYPE: staticlib SKIP_INSTALL: YES dependencies: - - target: NightcapDomain + - target: NightcapDomain_macOS - package: swift-dependencies product: Dependencies @@ -188,8 +191,50 @@ targets: MACH_O_TYPE: staticlib SKIP_INSTALL: YES dependencies: - - target: NightcapDomain + - target: NightcapDomain_macOS + - package: ComposableArchitecture + + NightcapCompanionUI: + type: framework + platform: [iOS, watchOS] + deploymentTarget: + iOS: 17.0 + watchOS: 10.0 + sources: + - path: Packages/NightcapCompanionUI/Sources/NightcapCompanionUI + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.abdocodes.nightcap.companionui + SWIFT_VERSION: 5.0 + GENERATE_INFOPLIST_FILE: YES + MACH_O_TYPE: staticlib + SKIP_INSTALL: YES + dependencies: + - target: NightcapDomain_${platform} + - package: ComposableArchitecture + link: false + + NightcapPhone: + type: application + platform: iOS + deploymentTarget: 17.0 + sources: + - path: Apps/Phone + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.abdocodes.nightcap.phone + PRODUCT_NAME: Nightcap + SWIFT_VERSION: 5.0 + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_CFBundleDisplayName: Nightcap + CODE_SIGN_STYLE: Automatic + TARGETED_DEVICE_FAMILY: "1,2" + dependencies: + - target: NightcapDomain_iOS + - target: NightcapCompanionUI_iOS - package: ComposableArchitecture + link: false NightcapTests: type: bundle.unit-test @@ -199,7 +244,7 @@ targets: - path: NightcapTests dependencies: - target: Nightcap - - target: NightcapDomain + - target: NightcapDomain_macOS - package: ComposableArchitecture - package: swift-dependencies product: Dependencies From c7de7901e9e1847938aa997538d804489ce705de Mon Sep 17 00:00:00 2001 From: Ahmed Ramy Date: Wed, 29 Jul 2026 00:51:32 +0400 Subject: [PATCH 5/5] Add watchOS companion app Adds the watchOS app target. It needed no new logic and no new views: CompanionFeature and NightcapCompanionUI were already built for both iOS and watchOS, so this is an entry point plus target configuration. Runs independently of a companion iPhone app (WKRunsIndependentlyOfCompanionApp), since the companion talks to a Mac rather than to the phone. Verified: builds and links cleanly against the watchOS simulator SDK. NOT verified at runtime: this machine has no watchOS simulator runtime installed, so no watch simulator can be created and the app has not been launched. Installing a runtime is a multi-gigabyte download and is left as a deliberate decision. The UI is shared with the iPhone app, which has been driven end to end on a simulator, so the untested surface is the watchOS shell rather than the screen itself. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw --- Apps/Watch/NightcapWatchApp.swift | 17 +++++++++++++++++ project.yml | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 Apps/Watch/NightcapWatchApp.swift diff --git a/Apps/Watch/NightcapWatchApp.swift b/Apps/Watch/NightcapWatchApp.swift new file mode 100644 index 0000000..9497d6f --- /dev/null +++ b/Apps/Watch/NightcapWatchApp.swift @@ -0,0 +1,17 @@ +import ComposableArchitecture +import NightcapCompanionUI +import NightcapDomain +import SwiftUI + +@main +struct NightcapWatchApp: App { + @State private var store = Store(initialState: CompanionFeature.State()) { + CompanionFeature() + } + + var body: some Scene { + WindowGroup { + CompanionView(store: store) + } + } +} diff --git a/project.yml b/project.yml index 11938dd..aa7b469 100644 --- a/project.yml +++ b/project.yml @@ -236,6 +236,28 @@ targets: - package: ComposableArchitecture link: false + NightcapWatch: + type: application + platform: watchOS + deploymentTarget: 10.0 + sources: + - path: Apps/Watch + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.abdocodes.nightcap.watch + PRODUCT_NAME: Nightcap + SWIFT_VERSION: 5.0 + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Nightcap + INFOPLIST_KEY_WKApplication: YES + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp: YES + CODE_SIGN_STYLE: Automatic + dependencies: + - target: NightcapDomain_watchOS + - target: NightcapCompanionUI_watchOS + - package: ComposableArchitecture + link: false + NightcapTests: type: bundle.unit-test platform: macOS