diff --git a/Sources/Companion/CompanionSnapshot.swift b/Sources/Companion/CompanionSnapshot.swift index 61c1c42..9e15dce 100644 --- a/Sources/Companion/CompanionSnapshot.swift +++ b/Sources/Companion/CompanionSnapshot.swift @@ -34,6 +34,15 @@ enum CompanionService { static let version = 1 } +/// Address-backed companion saves (Tailscale MagicDNS, LAN IP, custom domain) are not +/// discovered via `_hoghunter._tcp`. Bonjour browse churn must not mark them offline. +enum CompanionReach { + static func keepsManualHost(_ remoteHost: String?) -> Bool { + let host = remoteHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !host.isEmpty + } +} + /// Response returned when the iOS companion triggers a safe remote clean on the Mac. struct CompanionCleanResponse: Codable, Equatable, Sendable { var status: String diff --git a/Tests/HogHunterTests/CompanionTests.swift b/Tests/HogHunterTests/CompanionTests.swift index fd9e5ee..689fe4d 100644 --- a/Tests/HogHunterTests/CompanionTests.swift +++ b/Tests/HogHunterTests/CompanionTests.swift @@ -7,6 +7,14 @@ final class CompanionTests: XCTestCase { static let deviceToken = "hh1_TESTDEVICETOKEN" static func isDevice(_ presented: String) -> Bool { presented == deviceToken } + func testManualHostStaysPutWhenBonjourCannotSeeIt() { + XCTAssertTrue(CompanionReach.keepsManualHost("my-mac.tailnet.ts.net")) + XCTAssertTrue(CompanionReach.keepsManualHost(" 192.0.2.1 ")) // RFC 5737 documentation address + XCTAssertFalse(CompanionReach.keepsManualHost(nil)) + XCTAssertFalse(CompanionReach.keepsManualHost("")) + XCTAssertFalse(CompanionReach.keepsManualHost(" ")) + } + func testAuthorizedResponseReturnsTheSnapshotBody() { let body = Data("{\"secret\":true}".utf8) let response = CompanionHTTP.response( diff --git a/docs/EFFORT-LOG.md b/docs/EFFORT-LOG.md index 76c1bad..5a6c925 100644 --- a/docs/EFFORT-LOG.md +++ b/docs/EFFORT-LOG.md @@ -21,6 +21,7 @@ Mirror of the live fleet board rows for this app. Reserve a row before starting | Mon, Oct 5, 2026 | AG | ag/issue-78-clean-progress-relay | Landed (#80) | Issue #78. Stream live 'Safe Mac Clean' status and progress to iOS companion during remote execution, including APFS snapshot creation, per-category item cleaning, and completion telemetry. | | Sun, Oct 4, 2026 | AG | ag/group-containers-and-docker-cleaner | In Progress | Issue #73. Attribute App Group containers via code signing entitlements in StorageScanner and surface OrbStack/container caches in DiskCleaner. | | Sat, Oct 4, 2026 | GROK-BUILD | grok-build/hh-log-truncate | Open (PR #70) | Issue #66. `hoghunter-clean` no longer tail-truncates logs under the source and apps workspaces. `find -print0` keeps a newline inside a path. | +| Sat, Oct 4, 2026 | GROK-BUILD | grok-build/hh-remote-offline | Open (PR #69) | Issue #64. Saved Tailscale, IP, or domain host stays online when Bonjour browse updates; `pairingDismissed` stops the pairing sheet from re-presenting every 3s after Cancel on 401. Fleet recall query *HogHunter Bonjour Tailscale manual host* confirms address-backed transport is intentional. Rollout `docs/rollouts/2026-10-04-remote-host-bonjour.md`. macOS `CompanionTests` and full `HogHunter` test job: pending CI on this push. iOS `HogHunterIOS` simulator build: pending CI. | | Sat, Oct 4, 2026 | GROK-BUILD | grok-build/hh-vendor-folders | Open (PR #72) | Issue #67. Extreme Clean no longer offers Google, Mozilla, Microsoft, MobileSync, or CrashReporter as orphaned app leftovers. A folder that still holds an installed product is skipped too. | | Fri, Oct 9, 2026 | CURSOR | grok-build/hh-vendor-folders | Open (PR #72) | Kodus follow-up on #67. AGENT-SYNC.md (AFC mirror) read; pre-work `#agent-sync` claim process failure (relay `/post` 404, no Zulip bot env) — no invented claim id. Confirmed Swift+shell orphan parity, normalized `holdsInstalledProduct`, vendor `strip()` trim, rollout headings + verification commands. Python helper syntax/behavior checks exit 0; full `xcodebuild` blocked on Linux (hosted macOS CI). | | Sat, Oct 4, 2026 | GROK-BUILD | grok-build/hh-safety-and-settings | Landed (#68) | Top-to-bottom review, then the bugs that can delete or mis-control a machine today. Companion parser waits for Content-Length. A failed APFS snapshot aborts the clean in the app and in scripts/clean.sh. Finder trash no longer interpolates the path into AppleScript, and a Finder failure no longer rmtree's the file. Settings Cleaner tab uses the SettingsTab tag so the page actually opens. Remote tame uses the same opt-in as remote quit. An empty regimen rule set skips instead of cleaning every scanned item. Full Disk Access copy says Hog Hunter. Squash `3b61d03`. | diff --git a/docs/rollouts/2026-10-04-remote-host-bonjour.md b/docs/rollouts/2026-10-04-remote-host-bonjour.md new file mode 100644 index 0000000..1c007f6 --- /dev/null +++ b/docs/rollouts/2026-10-04-remote-host-bonjour.md @@ -0,0 +1,48 @@ +# Remote host stays online when Bonjour updates + +Sat, Oct 4, 2026. Seat GROK-BUILD. Branch `grok-build/hh-remote-offline`. Pull request #69. Issue #64. + +## Context & Objective + +repo: `Simple-With-Us/HogHunter`. Pre-work claim posted to `#agent-sync` (topic HogHunter issue #64 manual host offline on Bonjour). + +On the same Wi-Fi, the iPhone companion discovers the Mac with Bonjour (`_hoghunter._tcp`). A phone paired over Tailscale, LAN IP, or custom domain stores `remoteHost` / `remotePort` and reaches the Mac with `NWEndpoint.hostPort` (shipped in PR #75; see `AGENTS.md`). Every Bonjour browse tick called `reconcile()`, which treated a missing Bonjour peer as offline and cleared the live dashboard—even while fetches over the saved address still worked. Issue #64. A second defect re-presented the pairing sheet every three seconds after Cancel when the Mac returned 401 for a rotated code. + +Fleet recall (2026-10-09, query *HogHunter iOS companion Bonjour Tailscale manual host*): board lesson `636727035baf4d3dac0beb2c7ebfb578` and contrib `contrib/AG/2026-10-01/6dd52f92` confirm Tailscale / `hostPort` transport is intentional; issue #64 is the Bonjour-flap bug, not a mandate for Bonjour-only transport. + +## Changes Made + +- `Sources/Companion/CompanionSnapshot.swift` — `CompanionReach.keepsManualHost` helper shared with the iOS target. +- `ios/Sources/CompanionModel.swift` — skip offline reconciliation for address-backed saves in `noteDiscovery` / `reconcile`; `pairingDismissed` so `refresh()` does not re-enter `.code` after Cancel; `cancelCode()` paths for manual host vs Wi-Fi-only saved Mac. +- `Tests/HogHunterTests/CompanionTests.swift` — `testManualHostStaysPutWhenBonjourCannotSeeIt` (RFC 5737 addresses only). +- `docs/EFFORT-LOG.md` — in-progress row for this branch. +- `docs/rollouts/2026-10-04-remote-host-bonjour.md` — this note. + +## Decisions & Trade-offs + +- **Keep manual hosts.** Bonjour browse results are not ground truth for a saved `remoteHost`. `forget()` still clears address-backed saves when the user chooses Forget Mac. +- **Wi-Fi-only saves stay strict.** When `remoteHost` is nil, losing Bonjour still marks the Mac offline. +- **Pairing sheet loop.** `pairingDismissed` is set on Cancel and cleared on successful pair or remote connect; while set, 401 responses land in `.offline` with copy instead of re-opening the sheet. +- **Stale snapshot on transient errors.** After at least one successful fetch, a later network error keeps the last snapshot on screen (unchanged from main). + +## Verification State + +| Command | Result | +|---------|--------| +| `xcodegen generate` | NOT RUN on Linux cloud agent (macOS CI runs this) | +| `xcodebuild -scheme HogHunter -destination 'platform=macOS' test CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY="" -only-testing:HogHunterTests/CompanionTests/testManualHostStaysPutWhenBonjourCannotSeeIt` | PENDING — requires GitHub Actions `test` job on push | +| `xcodebuild -scheme HogHunter -destination 'platform=macOS' test` (full macOS unit suite) | PENDING — same CI job | +| `xcodebuild -scheme HogHunterIOS -destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NO` | PENDING — CI `build-ios` job | + +Last known green (pre-`pairingDismissed` follow-up, local macOS seat): `CompanionTests` filter above exited 0. + +## Next Steps & Blockers + +- Merge after CI green on macOS unit tests and iOS simulator build. +- Owner smoke on a Tailscale-paired phone: confirm dashboard stays live through Bonjour browse churn; rotate pairing code, tap Cancel once, confirm sheet stays dismissed and status shows code mismatch. +- No TestFlight or credential changes in this pull request. + +## Zero-Code Findings + +- Kodus “Bonjour-only” findings conflict with product contract (`AGENTS.md`, PR #75, fleet recall above). This change preserves address-backed transport and only stops Bonjour from falsely marking those sessions offline. +- iOS companion UI has no XCTest target; behavioral fixes are validated via macOS `CompanionReach` tests plus CI iOS compile. diff --git a/ios/Sources/CompanionModel.swift b/ios/Sources/CompanionModel.swift index 77db354..8060786 100644 --- a/ios/Sources/CompanionModel.swift +++ b/ios/Sources/CompanionModel.swift @@ -158,6 +158,24 @@ final class CompanionModel { private var poll: Task? private var started = false private var didBrowse = false + // Set when the user cancels the pairing sheet. The 3s poll must not + // re-enter .code on the next 401 after an explicit cancel; it stays + // offline until the user starts pairing again. + private var pairingDismissed = false + /// Why the last pairing attempt failed. `codeError` carries the words to + /// show, but its presence alone does not say why: the short-code guard and + /// an unreachable Mac set it too, and neither means the code was rejected. + private enum CodeFailure { + /// The Mac answered and rejected the credential (401). + case unauthorized + /// A short code, an unreachable Mac, or anything else. + case other + } + private var codeFailure: CodeFailure? + + private static func failureKind(for error: Error) -> CodeFailure { + (error as? CompanionClientError) == .unauthorized ? .unauthorized : .other + } private let defaultsKey = "hoghunter.companion.saved" private let appGroupSuite = "group.com.simplewithus.hoghunter" @@ -193,12 +211,66 @@ final class CompanionModel { func select(_ mac: DiscoveredMac) { codeDraft = "" codeError = nil + codeFailure = nil + pairingDismissed = false phase = .code(mac.id) } func cancelCode() { + // Capture the 401 diagnosis before clearing it: after a pairing-code + // rotation the Mac answers (with 401), so "did not answer" would be + // the wrong message. Read the recorded kind, not `codeError`'s mere + // presence — a short code and an unreachable Mac set that too, and + // calling either of those "the code no longer matches" misleads. + let wasUnauthorized = codeFailure == .unauthorized codeError = nil + codeFailure = nil + guard case .code = phase else { + reconcile() + return + } + // Only a real cancel of the pairing sheet. SwiftUI also calls this + // when submitCode() flips phase to .live and the sheet dismisses. + pairingDismissed = true + // A manual host is not in the Bonjour list, so reconcile would return + // without leaving the pairing sheet. + if let saved, CompanionReach.keepsManualHost(saved.remoteHost) { + if wasUnauthorized { + phase = .offline + statusLine = "The code for \(saved.name) no longer matches.\u{00A0} Reconnect to pair again." + } else if snapshot != nil { + phase = .live + statusLine = "\(saved.name) (Remote)" + } else { + phase = .offline + statusLine = "The Mac at \(saved.remoteHost ?? "that address") did not answer." + } + return + } + if saved == nil { + phase = discovered.isEmpty ? .looking : .choose + statusLine = discovered.isEmpty + ? "Looking for Hog Hunter on this Wi-Fi." + : "Pick the Mac you want to watch." + return + } reconcile() + if case .code = phase { + if snapshot != nil { + phase = .live + statusLine = saved?.name ?? "Hog Hunter" + } else if let saved, discovered.contains(where: { $0.id == saved.peerID }) { + // The Mac is still on this Wi-Fi. Cancel means go back to + // the picker, not "the Mac did not answer". + phase = .choose + statusLine = "Pick the Mac you want to watch." + } else { + phase = .offline + statusLine = wasUnauthorized + ? "The code for \(saved?.name ?? "your Mac") no longer matches.\u{00A0} Reconnect to pair again." + : "The Mac did not answer." + } + } } func submitCode() async { @@ -206,6 +278,7 @@ final class CompanionModel { let token = codeDraft.uppercased().filter { CompanionToken.alphabet.contains($0) } guard token.count >= 8 else { codeError = "Enter the 8 character code from Hog Hunter Settings on your Mac." + codeFailure = .other return } isSubmittingCode = true @@ -217,9 +290,12 @@ final class CompanionModel { persistSaved() snapshot = next codeError = nil + codeFailure = nil + pairingDismissed = false phase = .live } catch { codeError = Self.codeMessage(for: error) + codeFailure = Self.failureKind(for: error) } } @@ -257,13 +333,16 @@ final class CompanionModel { func requestApproval() async { guard case let .code(peerID) = phase else { codeError = "Pick a Mac first." + codeFailure = .other return } guard let mac = discovered.first(where: { $0.id == peerID }) else { codeError = "That Mac is no longer on this Wi-Fi. Keep Hog Hunter open on it, or use Connect by Tailscale or Address." + codeFailure = .other return } codeError = nil + codeFailure = nil isWaitingForApproval = true defer { isWaitingForApproval = false } do { @@ -275,6 +354,7 @@ final class CompanionModel { phase = .live } catch { codeError = Self.approvalMessage(for: error) + codeFailure = Self.failureKind(for: error) } } @@ -326,6 +406,8 @@ final class CompanionModel { snapshot = nil codeDraft = "" codeError = nil + codeFailure = nil + pairingDismissed = false UserDefaults.standard.removeObject(forKey: defaultsKey) reconcile() } @@ -343,6 +425,15 @@ final class CompanionModel { resetCleaner() isDemoMode = false snapshot = nil + // Only say "looking" when nothing is saved. For a saved Mac that page + // tells the person to open Hog Hunter and turn on Share With iPhone, + // which is false: the Mac is still paired and the next poll refills + // the dashboard. Its statusLine would not show either, because the + // page renders a fixed message and only .offline uses statusLine. + if saved == nil { + phase = .looking + statusLine = "Looking for Hog Hunter on this Wi-Fi." + } reconcile() } @@ -728,6 +819,8 @@ final class CompanionModel { let peerID = "remote-\(host):\(portNum)" saved = SavedMac(peerID: peerID, name: displayName, token: token, remoteHost: host, remotePort: portNum) persistSaved() + pairingDismissed = false + codeFailure = nil snapshot = fetched phase = .live statusLine = "\(displayName) (Remote)" @@ -758,6 +851,8 @@ final class CompanionModel { if case .code = phase { return } do { let fetched = try await Self.fetch(endpoint: endpoint, token: saved.token) + pairingDismissed = false + codeFailure = nil snapshot = fetched phase = .live statusLine = saved.remoteHost != nil ? "\(saved.name) (Remote)" : saved.name @@ -767,11 +862,21 @@ final class CompanionModel { } Task { await upgradeToDeviceTokenIfNeeded() } } catch CompanionClientError.unauthorized { - codeError = CompanionToken.isDeviceToken(saved.token) - ? "This iPhone is no longer paired with the Mac.\u{00A0} Enter the code from Hog Hunter Settings to pair it again." - : "The code no longer matches.\u{00A0} Enter the code from Hog Hunter Settings on your Mac." - phase = .code(saved.peerID) snapshot = nil + if pairingDismissed { + // The user already cancelled this pairing attempt; the next + // poll must not re-present the sheet on every 401. + phase = .offline + statusLine = "The code for \(saved.name) no longer matches.\u{00A0} Reconnect to pair again." + } else { + codeError = CompanionToken.isDeviceToken(saved.token) + ? "This iPhone is no longer paired with the Mac.\u{00A0} Enter the code from Hog Hunter Settings to pair it again." + : "The code no longer matches.\u{00A0} Enter the code from Hog Hunter Settings on your Mac." + // Only a 401 gets here, so this is the case cancelCode() must + // report as a code problem rather than a connectivity one. + codeFailure = .unauthorized + phase = .code(saved.peerID) + } } catch { if snapshot == nil { phase = .offline @@ -947,6 +1052,7 @@ final class CompanionModel { private func noteDiscovery() { if case .code = phase { return } + if CompanionReach.keepsManualHost(saved?.remoteHost) { return } if phase == .live, saved != nil, discovered.contains(where: { $0.id == saved?.peerID }) { return } @@ -961,6 +1067,7 @@ final class CompanionModel { : "Pick the Mac you want to watch." return } + if CompanionReach.keepsManualHost(saved?.remoteHost) { return } if discovered.contains(where: { $0.id == saved?.peerID }) { return }