diff --git a/.gitignore b/.gitignore index 3461466..7214656 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ dist/ *.ipa *.dSYM.zip *.dSYM + +## 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/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/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/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 91fcfe0..a5805dd 100644 --- a/NightcapTests/NightcapAppTests.swift +++ b/NightcapTests/NightcapAppTests.swift @@ -1,233 +1,417 @@ import ComposableArchitecture import ConcurrencyExtras import Foundation -import XCTest -@testable import Nightcap +import Sharing +import Testing -@MainActor -final class NightcapAppTests: XCTestCase { - func test_launched_event_for_watched_app_triggers_acquire() async { - let env = makeEnv(running: []) - let store = makeStore(env: env) +import NightcapDomain + +// 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"]) + } + + @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) } +} - func test_observation_toggle_on_acquires_if_app_is_running() async { - let env = makeEnv(running: ["com.mitchellh.ghostty"]) - let store = makeStore(env: env) +// 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 } - XCTAssertEqual(env.acquired.value, ["Nightcap: Ghostty", "Nightcap: Ghostty"]) + // 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 + } + + // 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,73 +420,200 @@ 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 } } } } } + +// 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/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/Nightcap/Services/AppLifecycleClient.swift b/Packages/NightcapClients/Sources/NightcapClients/AppLifecycleClient+Live.swift similarity index 80% rename from Nightcap/Services/AppLifecycleClient.swift rename to Packages/NightcapClients/Sources/NightcapClients/AppLifecycleClient+Live.swift index 4daac35..93e7ace 100644 --- a/Nightcap/Services/AppLifecycleClient.swift +++ b/Packages/NightcapClients/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/NightcapClients/Sources/NightcapClients/AppQuitterClient+Live.swift b/Packages/NightcapClients/Sources/NightcapClients/AppQuitterClient+Live.swift new file mode 100644 index 0000000..a0f2e26 --- /dev/null +++ b/Packages/NightcapClients/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/NightcapClients/Sources/NightcapClients/LaunchAtLoginClient+Live.swift b/Packages/NightcapClients/Sources/NightcapClients/LaunchAtLoginClient+Live.swift new file mode 100644 index 0000000..b3d9c8d --- /dev/null +++ b/Packages/NightcapClients/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/NightcapClients/Sources/NightcapClients/PowerAssertionClient+Live.swift similarity index 80% rename from Nightcap/Services/PowerAssertionClient.swift rename to Packages/NightcapClients/Sources/NightcapClients/PowerAssertionClient+Live.swift index 4669a4b..8b47fc7 100644 --- a/Nightcap/Services/PowerAssertionClient.swift +++ b/Packages/NightcapClients/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/NightcapClients/Sources/NightcapClients/ReviewPromptClient+Live.swift similarity index 54% rename from Nightcap/Services/ReviewPromptClient.swift rename to Packages/NightcapClients/Sources/NightcapClients/ReviewPromptClient+Live.swift index ad908cb..352315c 100644 --- a/Nightcap/Services/ReviewPromptClient.swift +++ b/Packages/NightcapClients/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/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/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/Nightcap/AppFeature.swift b/Packages/NightcapDomain/Sources/NightcapDomain/AppFeature.swift similarity index 93% rename from Nightcap/AppFeature.swift rename to Packages/NightcapDomain/Sources/NightcapDomain/AppFeature.swift index b2462fb..3ea1243 100644 --- a/Nightcap/AppFeature.swift +++ b/Packages/NightcapDomain/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/NightcapDomain/Sources/NightcapDomain/Clients.swift b/Packages/NightcapDomain/Sources/NightcapDomain/Clients.swift new file mode 100644 index 0000000..2a48ec9 --- /dev/null +++ b/Packages/NightcapDomain/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/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/Nightcap/Domain/LaunchAtLoginStatus.swift b/Packages/NightcapDomain/Sources/NightcapDomain/LaunchAtLoginStatus.swift similarity index 67% rename from Nightcap/Domain/LaunchAtLoginStatus.swift rename to Packages/NightcapDomain/Sources/NightcapDomain/LaunchAtLoginStatus.swift index 958bd0b..e9eddb3 100644 --- a/Nightcap/Domain/LaunchAtLoginStatus.swift +++ b/Packages/NightcapDomain/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/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/Nightcap/Domain/WatchedApp.swift b/Packages/NightcapDomain/Sources/NightcapDomain/WatchedApp.swift similarity index 59% rename from Nightcap/Domain/WatchedApp.swift rename to Packages/NightcapDomain/Sources/NightcapDomain/WatchedApp.swift index fd4c57c..bda8ae0 100644 --- a/Nightcap/Domain/WatchedApp.swift +++ b/Packages/NightcapDomain/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/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/Nightcap/SharedUI/AddRunningAppMenu.swift b/Packages/NightcapUI/Sources/NightcapUI/AddRunningAppMenu.swift similarity index 98% rename from Nightcap/SharedUI/AddRunningAppMenu.swift rename to Packages/NightcapUI/Sources/NightcapUI/AddRunningAppMenu.swift index 72e12b6..241383e 100644 --- a/Nightcap/SharedUI/AddRunningAppMenu.swift +++ b/Packages/NightcapUI/Sources/NightcapUI/AddRunningAppMenu.swift @@ -1,3 +1,4 @@ +import NightcapDomain import SwiftUI struct AddRunningAppMenu: View { diff --git a/Nightcap/SharedUI/MenuActionsSection.swift b/Packages/NightcapUI/Sources/NightcapUI/MenuActionsSection.swift similarity index 97% rename from Nightcap/SharedUI/MenuActionsSection.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuActionsSection.swift index 8ff8d46..28964fc 100644 --- a/Nightcap/SharedUI/MenuActionsSection.swift +++ b/Packages/NightcapUI/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/NightcapUI/Sources/NightcapUI/MenuAppPicker.swift similarity index 99% rename from Nightcap/SharedUI/MenuAppPicker.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuAppPicker.swift index 3cdc816..2a3f75b 100644 --- a/Nightcap/SharedUI/MenuAppPicker.swift +++ b/Packages/NightcapUI/Sources/NightcapUI/MenuAppPicker.swift @@ -1,3 +1,4 @@ +import NightcapDomain import AppKit import UniformTypeIdentifiers diff --git a/Nightcap/SharedUI/MenuContentView.swift b/Packages/NightcapUI/Sources/NightcapUI/MenuContentView.swift similarity index 87% rename from Nightcap/SharedUI/MenuContentView.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuContentView.swift index 2b825cf..2b8874c 100644 --- a/Nightcap/SharedUI/MenuContentView.swift +++ b/Packages/NightcapUI/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/NightcapUI/Sources/NightcapUI/MenuStatusSection.swift similarity index 96% rename from Nightcap/SharedUI/MenuStatusSection.swift rename to Packages/NightcapUI/Sources/NightcapUI/MenuStatusSection.swift index dfb5d71..2b7af3b 100644 --- a/Nightcap/SharedUI/MenuStatusSection.swift +++ b/Packages/NightcapUI/Sources/NightcapUI/MenuStatusSection.swift @@ -1,3 +1,4 @@ +import NightcapDomain import SwiftUI struct MenuStatusSection: View { diff --git a/Nightcap/SharedUI/WatchedAppsMenuSection.swift b/Packages/NightcapUI/Sources/NightcapUI/WatchedAppsMenuSection.swift similarity index 99% rename from Nightcap/SharedUI/WatchedAppsMenuSection.swift rename to Packages/NightcapUI/Sources/NightcapUI/WatchedAppsMenuSection.swift index a475672..5d0be7c 100644 --- a/Nightcap/SharedUI/WatchedAppsMenuSection.swift +++ b/Packages/NightcapUI/Sources/NightcapUI/WatchedAppsMenuSection.swift @@ -1,3 +1,4 @@ +import NightcapDomain import SwiftUI struct WatchedAppsMenuSection: View { 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/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..aa7b469 100644 --- a/project.yml +++ b/project.yml @@ -66,6 +66,9 @@ targets: sources: - path: Nightcap dependencies: + - target: NightcapDomain_macOS + - target: NightcapClients + - target: NightcapUI - package: ComposableArchitecture - package: swift-dependencies product: Dependencies @@ -105,6 +108,156 @@ targets: Release: ENABLE_HARDENED_RUNTIME: YES + NightcapDomain: + type: framework + platform: [macOS, iOS, watchOS] + deploymentTarget: + macOS: 14.0 + iOS: 17.0 + watchOS: 10.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_macOS + - 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_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 + + 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 @@ -113,9 +266,12 @@ targets: - path: NightcapTests dependencies: - target: Nightcap + - target: NightcapDomain_macOS - 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 new file mode 100755 index 0000000..14da42f --- /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=( + "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 NightcapDomain.framework "$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"