Review fixes: pin the screenshot routing table

The geohash warn path lived only in AppRuntime with no coverage. The
decision is now a pure nonisolated table
(AppRuntime.resolveScreenshotResponse) pinned by tests: location sheet
or geohash timeline → local privacy alert (nothing sent), App Info →
nothing, mesh/DM → chat layer (where public channels stay silent).

Mesh deliberately gets no local alert: a mesh screenshot reveals no
place and triggers no send, so there is nothing actionable to warn
about — alerting on every screenshot would train people to dismiss the
one alert that matters. Rationale now documented on the response enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-09 11:53:20 +02:00
parent 435fa945d6
commit c9c1ebcbad
2 changed files with 109 additions and 18 deletions

View File

@ -424,26 +424,24 @@ private extension AppRuntime {
}
func handleScreenshotCaptured() {
if appChromeModel.isLocationChannelsSheetPresented {
let isLocationChannelActive: Bool = {
if case .location = chatViewModel.activeChannel { return true }
return false
}()
switch Self.resolveScreenshotResponse(
isLocationChannelsSheetPresented: appChromeModel.isLocationChannelsSheetPresented,
isAppInfoPresented: appChromeModel.isAppInfoPresented,
hasPrivateChatOpen: chatViewModel.selectedPrivateChatPeer != nil,
isLocationChannelActive: isLocationChannelActive
) {
case .warnLocally:
appChromeModel.triggerScreenshotPrivacyWarning()
return
case .ignore:
break
case .forwardToChat:
chatViewModel.handleScreenshotCaptured()
}
if appChromeModel.isAppInfoPresented {
return
}
// Screenshots are never announced to public channels (see
// ChatLifecycleCoordinator.handleScreenshotCaptured). A geohash
// timeline screenshot still reveals a place, so warn the person
// taking it — locally, the same alert the channel sheet uses.
if chatViewModel.selectedPrivateChatPeer == nil,
case .location = chatViewModel.activeChannel {
appChromeModel.triggerScreenshotPrivacyWarning()
return
}
chatViewModel.handleScreenshotCaptured()
}
func openExternalURL(_ url: URL) {
@ -460,3 +458,40 @@ private extension AppRuntime {
}
}
}
// MARK: - Screenshot routing
extension AppRuntime {
/// What a screenshot triggers. Nothing on this table sends anything to
/// a public channel (see `ChatLifecycleCoordinator.handleScreenshotCaptured`).
enum ScreenshotCaptureResponse: Equatable {
/// Show the local location-privacy alert; nothing is sent anywhere.
case warnLocally
/// Do nothing. App Info holds no conversation or location content.
case ignore
/// Hand to the chat layer: a DM notice goes to the peer when a
/// secure session exists; public timelines stay silent. Mesh
/// deliberately gets no local alert either — a mesh screenshot
/// reveals no place and triggers no send, so there is nothing to
/// warn about, and alerting on every screenshot would train people
/// to dismiss the one alert that matters (the location one).
case forwardToChat
}
/// Pure decision table so the screenshot routing is testable without a
/// runtime.
nonisolated static func resolveScreenshotResponse(
isLocationChannelsSheetPresented: Bool,
isAppInfoPresented: Bool,
hasPrivateChatOpen: Bool,
isLocationChannelActive: Bool
) -> ScreenshotCaptureResponse {
if isLocationChannelsSheetPresented { return .warnLocally }
if isAppInfoPresented { return .ignore }
// A geohash timeline screenshot still reveals a place — warn the
// person taking it, locally, with the same alert the channel sheet
// uses.
if !hasPrivateChatOpen, isLocationChannelActive { return .warnLocally }
return .forwardToChat
}
}

View File

@ -350,3 +350,59 @@ struct ChatLifecycleCoordinatorContextTests {
}
}
// MARK: - Screenshot Routing
/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path,
/// the App Info exemption, and the deliberate mesh silence. Combined with
/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves
/// location + screenshot → local privacy alert, and nothing sent anywhere.
struct ScreenshotCaptureRoutingTests {
@Test("Location channels warn locally; the sheet warns everywhere")
func locationScreenshotsWarnLocally() {
// Geohash timeline, no DM open: warn the person, tell no one.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: false,
isLocationChannelActive: true
) == .warnLocally)
// The channel sheet reveals location regardless of active channel.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: true,
isAppInfoPresented: false,
hasPrivateChatOpen: true,
isLocationChannelActive: false
) == .warnLocally)
}
@Test("App Info is exempt; mesh and DMs forward to the chat layer")
func nonLocationScreenshotsForwardOrIgnore() {
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: true,
hasPrivateChatOpen: false,
isLocationChannelActive: true
) == .ignore)
// Mesh timeline: forwarded, where the coordinator stays silent for
// public channels — no local alert either (nothing is sent and no
// place is revealed; see ScreenshotCaptureResponse docs).
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: false,
isLocationChannelActive: false
) == .forwardToChat)
// An open DM keeps its peer notice even from a location channel.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: true,
isLocationChannelActive: true
) == .forwardToChat)
}
}