Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Sources/Companion/CompanionSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Tests/HogHunterTests/CompanionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions docs/EFFORT-LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-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`. |
| Sun, Oct 4, 2026 | AG | ag/ui-polish-2 | Landed (#75) | Tailscale MagicDNS and custom IP address connection screen with default port 24240 and offline Wi-Fi prompt. Approve-on-Mac pairing flow with NSAlert approval dialog and granular checkboxes for quitting processes and running disk cleaner. New "Allow iPhone to Run Disk Cleaner" toggle in Mac Settings > iPhone. Settings window elevation on top of menu bar panel with auto-hiding panel on focus change. Remote tame confirmation dialogs, server pairing cooldown, iOS process icon changed to cpu, link icon for forget Mac with confirmation alert, panel Settings button and Activity Monitor icon polish. Interactive click-to-copy pairing code hover affordance with iCloud Passwords parity, tame error/confirm text refinements, CI screenshot capture window layer fix, separated control toggles into 3 labeled groups (TIME, SHOW, SORT), added bidirectional sorting across all macOS and iOS companion views, and added display memory tiebreaker. PR #75. |
| Fri, Oct 2, 2026 | AG | ag/ios-parity-exclusions-and-controls | Landed (#52) | Deep iOS companion parity with macOS: interactive cleaner exclusions (toggle cleanable categories & manage custom excluded folders remotely), time lookback switcher (Now, 1h, 24h), process grouping switcher (Apps vs Processes), secured /v1/exclusions and /v1/view endpoints, and visual verification. Issue #51. PR #52. |
Expand Down
48 changes: 48 additions & 0 deletions docs/rollouts/2026-10-04-remote-host-bonjour.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Remote host stays online when Bonjour updates
Comment thread
jaywedgeworth22 marked this conversation as resolved.

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.
115 changes: 111 additions & 4 deletions ios/Sources/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ final class CompanionModel {
private var poll: Task<Void, Never>?
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"

Expand Down Expand Up @@ -193,19 +211,74 @@ 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
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Comment thread
jaywedgeworth22 marked this conversation as resolved.
codeFailure = nil
guard case .code = phase else {
reconcile()
return
}
Comment thread
jaywedgeworth22 marked this conversation as resolved.
// 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) {
Comment thread
jaywedgeworth22 marked this conversation as resolved.
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."
}
Comment thread
jaywedgeworth22 marked this conversation as resolved.
}
Comment thread
kody-ai[bot] marked this conversation as resolved.
}

func submitCode() async {
guard case let .code(peerID) = phase, let mac = discovered.first(where: { $0.id == peerID }) else { return }
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
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -275,6 +354,7 @@ final class CompanionModel {
phase = .live
} catch {
codeError = Self.approvalMessage(for: error)
codeFailure = Self.failureKind(for: error)
}
}

Expand Down Expand Up @@ -326,6 +406,8 @@ final class CompanionModel {
snapshot = nil
codeDraft = ""
codeError = nil
codeFailure = nil
pairingDismissed = false
UserDefaults.standard.removeObject(forKey: defaultsKey)
reconcile()
}
Expand All @@ -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()
}
Comment thread
jaywedgeworth22 marked this conversation as resolved.

Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -947,6 +1052,7 @@ final class CompanionModel {

private func noteDiscovery() {
if case .code = phase { return }
if CompanionReach.keepsManualHost(saved?.remoteHost) { return }
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Comment thread
jaywedgeworth22 marked this conversation as resolved.
if phase == .live, saved != nil, discovered.contains(where: { $0.id == saved?.peerID }) {
return
}
Expand All @@ -961,6 +1067,7 @@ final class CompanionModel {
: "Pick the Mac you want to watch."
return
}
if CompanionReach.keepsManualHost(saved?.remoteHost) { return }
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Comment thread
jaywedgeworth22 marked this conversation as resolved.
if discovered.contains(where: { $0.id == saved?.peerID }) {
return
}
Expand Down
Loading