Compare commits

..
Author SHA1 Message Date
jack 6d373100d9 Keep share extension helpers target-local 2026-07-10 15:11:12 -04:00
jack 19c06f360d Require review before importing shared content 2026-07-10 20:34:59 +02:00
34 changed files with 818 additions and 3818 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ bitchat is designed for private, account-free communication. This policy describ
2. **Nickname, preferences, and relationships** 2. **Nickname, preferences, and relationships**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally. - Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it. - The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
3. **Private group state** 3. **Private group state**
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support. - Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
+1 -1
View File
@@ -70,6 +70,7 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift, Services/TransportConfig.swift,
); );
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
@@ -337,7 +338,6 @@
es, es,
ar, ar,
de, de,
fa,
fr, fr,
he, he,
id, id,
+1 -6
View File
@@ -2,11 +2,6 @@ import BitFoundation
import Combine import Combine
import Foundation import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable { enum RuntimeScenePhase: String, Sendable, Equatable {
case active case active
case inactive case inactive
@@ -25,7 +20,7 @@ enum AppEvent: Sendable, Equatable {
case startupCompleted case startupCompleted
case scenePhaseChanged(RuntimeScenePhase) case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String) case openedURL(String)
case sharedContentAccepted(SharedContentKind) case sharedContentReadyForReview(SharedContentKind)
case notificationOpened(peerID: PeerID?) case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String) case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent) case torLifecycleChanged(TorLifecycleEvent)
+8 -1
View File
@@ -20,13 +20,19 @@ final class AppChromeModel: ObservableObject {
@Published var showScreenshotPrivacyWarning = false @Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
/// Bulletin-board coordinator, created on first use of the board sheet. /// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { init(
chatViewModel: ChatViewModel,
privateInboxModel: PrivateInboxModel,
onPanicWipe: @escaping () -> Void = {}
) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.onPanicWipe = onPanicWipe
self.nickname = chatViewModel.nickname self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel) bind(privateInboxModel: privateInboxModel)
@@ -98,6 +104,7 @@ final class AppChromeModel: ObservableObject {
} }
func panicClearAllData() { func panicClearAllData() {
onPanicWipe()
chatViewModel.panicClearAllData() chatViewModel.panicClearAllData()
} }
+29 -39
View File
@@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject {
let peerListModel: PeerListModel let peerListModel: PeerListModel
let appChromeModel: AppChromeModel let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject {
init( init(
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
idBridge: NostrIdentityBridge = NostrIdentityBridge() idBridge: NostrIdentityBridge = NostrIdentityBridge(),
sharedContentStore: SharedContentStore? = nil
) { ) {
self.idBridge = idBridge self.idBridge = idBridge
let conversations = ConversationStore() let conversations = ConversationStore()
@@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject {
peerIdentityStore: peerIdentityStore, peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore locationPresenceStore: locationPresenceStore
) )
let resolvedSharedContentStore: SharedContentStore?
if let sharedContentStore {
resolvedSharedContentStore = sharedContentStore
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
} else {
resolvedSharedContentStore = nil
}
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
self.sharedContentImportModel = sharedContentImportModel
self.appChromeModel = AppChromeModel( self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel privateInboxModel: self.privateInboxModel,
onPanicWipe: { sharedContentImportModel.discardAll() }
) )
let chatViewModel = self.chatViewModel let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel( self.boardAlertsModel = BoardAlertsModel(
@@ -106,7 +119,6 @@ final class AppRuntime: ObservableObject {
} }
) )
) )
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers() bindRuntimeObservers()
NotificationDelegate.shared.runtime = self NotificationDelegate.shared.runtime = self
@@ -313,44 +325,22 @@ private extension AppRuntime {
} }
func checkForSharedContent() { func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return } let previousID = sharedContentImportModel.offer?.id
let clearSharedContent = { guard let payload = sharedContentImportModel.refresh(
userDefaults.removeObject(forKey: "sharedContent") destination: currentSharedContentDestination
userDefaults.removeObject(forKey: "sharedContentType") ) else { return }
userDefaults.removeObject(forKey: "sharedContentDate")
if previousID != payload.id {
record(.sharedContentReadyForReview(payload.kind))
}
} }
guard let sharedContent = userDefaults.string(forKey: "sharedContent"), var currentSharedContentDestination: SharedContentDestination {
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { SharedContentDestination.resolve(
// A partial or malformed handoff must not linger in the shared selectedPrivatePeerID: privateConversationModel.selectedPeerID,
// app-group container indefinitely. privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
clearSharedContent() activeChannel: locationChannelsModel.selectedChannel
return )
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
clearSharedContent()
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
clearSharedContent()
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
} }
func handleNostrRelayConnectionChanged(_ isConnected: Bool) { func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
+9 -110
View File
@@ -7,146 +7,45 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:] @Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = [] @Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
private let geoNicknameCapacity: Int
private var geoNicknameOrder: [String] = []
init(
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
}
func setCurrentGeohash(_ geohash: String?) { func setCurrentGeohash(_ geohash: String?) {
let normalized = geohash?.lowercased() currentGeohash = geohash?.lowercased()
if currentGeohash != normalized {
// Presence markers are scoped to the active geohash channel.
clearTeleportedGeo()
clearGeoNicknames()
}
currentGeohash = normalized
} }
func setNickname(_ nickname: String, for pubkeyHex: String) { func setNickname(_ nickname: String, for pubkeyHex: String) {
guard geoNicknameCapacity > 0 else { geoNicknames[pubkeyHex.lowercased()] = nickname
clearGeoNicknames()
return
}
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
return
}
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
geoNicknameOrder.removeFirst()
geoNicknames.removeValue(forKey: oldest)
}
geoNicknames[key] = nickname
geoNicknameOrder.append(key)
} }
func replaceGeoNicknames(_ nicknames: [String: String]) { func replaceGeoNicknames(_ nicknames: [String: String]) {
guard geoNicknameCapacity > 0 else { geoNicknames = Dictionary(
clearGeoNicknames() uniqueKeysWithValues: nicknames.map { key, value in
return (key.lowercased(), value)
} }
)
var seen: Set<String> = []
var ordered: [String] = []
var normalized: [String: String] = [:]
for (key, value) in nicknames {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))
ordered = kept
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
normalized[key].map { (key, $0) }
})
}
geoNicknameOrder = ordered
geoNicknames = normalized
} }
func clearGeoNicknames() { func clearGeoNicknames() {
geoNicknames.removeAll() geoNicknames.removeAll()
geoNicknameOrder.removeAll()
}
func retainGeoNicknames(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
} }
func markTeleported(_ pubkeyHex: String) { func markTeleported(_ pubkeyHex: String) {
guard teleportedGeoCapacity > 0 else { teleportedGeo.insert(pubkeyHex.lowercased())
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
} }
func clearTeleported(_ pubkeyHex: String) { func clearTeleported(_ pubkeyHex: String) {
let key = pubkeyHex.lowercased() teleportedGeo.remove(pubkeyHex.lowercased())
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
} }
func replaceTeleportedGeo(_ pubkeys: Set<String>) { func replaceTeleportedGeo(_ pubkeys: Set<String>) {
guard teleportedGeoCapacity > 0 else { teleportedGeo = Set(pubkeys.map { $0.lowercased() })
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
} }
func clearTeleportedGeo() { func clearTeleportedGeo() {
teleportedGeo.removeAll() teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
} }
func reset() { func reset() {
currentGeohash = nil currentGeohash = nil
geoNicknames.removeAll() geoNicknames.removeAll()
geoNicknameOrder.removeAll()
teleportedGeo.removeAll() teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
} }
} }
+119
View File
@@ -0,0 +1,119 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentDestination: Sendable, Equatable {
case mesh
case geohash(String)
case privateConversation(peerID: PeerID, displayName: String)
static func resolve(
selectedPrivatePeerID: PeerID?,
privateDisplayName: String?,
activeChannel: ChannelID
) -> SharedContentDestination {
if let selectedPrivatePeerID {
let fallback = String(selectedPrivatePeerID.id.prefix(12))
return .privateConversation(
peerID: selectedPrivatePeerID,
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
)
}
switch activeChannel {
case .mesh:
return .mesh
case .location(let channel):
return .geohash(channel.geohash.lowercased())
}
}
var displayName: String {
switch self {
case .mesh:
return "#mesh"
case .geohash(let geohash):
return "#\(geohash)"
case .privateConversation(_, let displayName):
return displayName
}
}
}
struct SharedContentOffer: Identifiable, Sendable, Equatable {
let payload: SharedContentPayload
let destination: SharedContentDestination
var id: UUID { payload.id }
}
/// Holds a pending extension handoff until the user chooses a destination and
/// explicitly adds it to the composer. This type has no send dependency by
/// design: confirming an import can never transmit a message.
@MainActor
final class SharedContentImportModel: ObservableObject {
@Published private(set) var offer: SharedContentOffer?
private let store: SharedContentStore?
init(store: SharedContentStore?) {
self.store = store
}
@discardableResult
func refresh(
destination: SharedContentDestination,
now: Date = Date()
) -> SharedContentPayload? {
guard let payload = store?.pending(now: now) else {
offer = nil
return nil
}
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
if offer != nextOffer {
offer = nextOffer
}
return payload
}
func updateDestination(_ destination: SharedContentDestination) {
guard let offer, offer.destination != destination else { return }
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
}
/// Returns composer text only when the currently displayed destination is
/// still current and the reviewed envelope is still the stored envelope.
/// A destination change updates the prompt and requires another tap.
func confirm(
destination: SharedContentDestination,
now: Date = Date()
) -> String? {
guard let offer else { return nil }
guard offer.destination == destination else {
updateDestination(destination)
return nil
}
guard let payload = store?.consume(id: offer.id, now: now) else {
_ = refresh(destination: destination, now: now)
return nil
}
self.offer = nil
return payload.composerText
}
func cancel(destination: SharedContentDestination, now: Date = Date()) {
guard let offer else { return }
store?.discard(id: offer.id)
self.offer = nil
// If a newer share replaced the reviewed envelope, surface it rather
// than losing it with the older cancellation.
_ = refresh(destination: destination, now: now)
}
func discardAll() {
store?.discardAll()
offer = nil
}
}
+1
View File
@@ -41,6 +41,7 @@ struct BitchatApp: App {
.environmentObject(runtime.peerListModel) .environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel) .environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel) .environmentObject(runtime.boardAlertsModel)
.environmentObject(runtime.sharedContentImportModel)
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -66,10 +66,7 @@ class NoiseSession {
// Only initiator writes the first message // Only initiator writes the first message
if role == .initiator { if role == .initiator {
guard let handshake = handshakeState else { let message = try handshakeState!.writeMessage()
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message) sentHandshakeMessages.append(message)
return message return message
} else { } else {
-19
View File
@@ -701,10 +701,6 @@ struct NostrEvent: Codable {
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? "" self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey self.pubkey = pubkey
self.created_at = createdAt self.created_at = createdAt
@@ -714,21 +710,6 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String self.sig = dict["sig"] as? String
} }
/// Bounds untrusted relay tag arrays so attackers cannot force large
/// allocations or expensive joins on the inbound hot path.
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
for tag in tags {
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
return false
}
}
return true
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId() let (eventId, eventIdHash) = try calculateEventId()
+5 -13
View File
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
case notice(String) case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) { init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.dataWithinInboundLimit, guard let data = message.data,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any], let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2, array.count >= 2,
let type = array[0] as? String else { let type = array[0] as? String else {
@@ -1525,19 +1525,11 @@ private enum ParsedInbound {
} }
private extension URLSessionWebSocketTask.Message { private extension URLSessionWebSocketTask.Message {
/// Prefer rejecting oversized frames before UTF-8/Data materialization var data: Data? {
/// where we can (string length), and always before JSON parse.
var dataWithinInboundLimit: Data? {
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
switch self { switch self {
case .string(let text): case .string(let text): text.data(using: .utf8)
guard text.utf8.count <= maxBytes else { return nil } case .data(let data): data
return text.data(using: .utf8) @unknown default: nil
case .data(let data):
guard data.count <= maxBytes else { return nil }
return data
@unknown default:
return nil
} }
} }
} }
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
isSelf: Bool, isSelf: Bool,
isMentioned: Bool isMentioned: Bool
) -> AttributedString { ) -> AttributedString {
// For very long content, use plain formatting to avoid expensive // For very long content without special tokens, use plain formatting
// regex/detector work. Cashu presence must not disable this guard. let containsCashu = containsCashuToken(content)
if content.isOversizedForRichFormatting() { if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf) return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
} }
+212
View File
@@ -0,0 +1,212 @@
import Foundation
enum SharedContentKind: String, Codable, Sendable, Equatable {
case text
case url
}
/// The single, bounded payload handed from the share extension to the app.
///
/// The app-group store intentionally contains at most one envelope. A newer
/// share replaces an older one, which prevents unbounded shared-container
/// growth while still surviving suspension and a later app launch.
struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable {
static let currentVersion = 1
static let maxContentBytes = 16_000
static let maxTitleBytes = 512
static let maxEnvelopeBytes = 24_000
static let retentionSeconds: TimeInterval = 24 * 60 * 60
static let allowedFutureSkewSeconds: TimeInterval = 5 * 60
let version: Int
let id: UUID
let kind: SharedContentKind
let content: String
let title: String?
let createdAt: Date
init(
version: Int = Self.currentVersion,
id: UUID = UUID(),
kind: SharedContentKind,
content: String,
title: String? = nil,
createdAt: Date = Date()
) {
self.version = version
self.id = id
self.kind = kind
self.content = content
self.title = title
self.createdAt = createdAt
}
static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload {
SharedContentPayload(kind: .text, content: content, createdAt: createdAt)
}
var composerText: String { content }
var preview: String {
let normalized = content
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
guard normalized.count > 240 else { return normalized }
return String(normalized.prefix(240)) + ""
}
func validate(now: Date = Date()) throws {
guard version == Self.currentVersion else {
throw SharedContentHandoffError.unsupportedVersion
}
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw SharedContentHandoffError.emptyContent
}
guard content.utf8.count <= Self.maxContentBytes else {
throw SharedContentHandoffError.contentTooLarge
}
if let title {
guard title.utf8.count <= Self.maxTitleBytes else {
throw SharedContentHandoffError.titleTooLarge
}
guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else {
throw SharedContentHandoffError.invalidCharacters
}
}
let age = now.timeIntervalSince(createdAt)
guard age >= -Self.allowedFutureSkewSeconds,
age <= Self.retentionSeconds else {
throw SharedContentHandoffError.expired
}
switch kind {
case .text:
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else {
throw SharedContentHandoffError.invalidCharacters
}
case .url:
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false),
let components = URLComponents(string: content),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
components.host?.isEmpty == false else {
throw SharedContentHandoffError.unsupportedURL
}
}
}
private static func containsDisallowedControl(
in value: String,
allowsTextLayout: Bool
) -> Bool {
value.unicodeScalars.contains { scalar in
guard CharacterSet.controlCharacters.contains(scalar) else { return false }
if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" {
return false
}
return true
}
}
}
enum SharedContentHandoffError: Error, Equatable {
case unsupportedVersion
case emptyContent
case contentTooLarge
case titleTooLarge
case invalidCharacters
case expired
case unsupportedURL
case envelopeTooLarge
case encodingFailed
}
/// Durable, single-item app-group storage used by both the extension and app.
final class SharedContentStore {
static let storageKey = "sharedContentEnvelopeV1"
private static let legacyKeys = [
"sharedContent",
"sharedContentType",
"sharedContentDate"
]
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
init(defaults: UserDefaults) {
self.defaults = defaults
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
}
/// Replaces any older pending share with a validated, bounded envelope.
func stage(_ payload: SharedContentPayload, now: Date = Date()) throws {
try payload.validate(now: now)
guard let encoded = try? encoder.encode(payload) else {
throw SharedContentHandoffError.encodingFailed
}
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else {
throw SharedContentHandoffError.envelopeTooLarge
}
defaults.set(encoded, forKey: Self.storageKey)
clearLegacyKeys()
}
/// Reads the pending share without consuming it. Invalid and expired data
/// is removed immediately so malformed app-group state cannot linger.
func pending(now: Date = Date()) -> SharedContentPayload? {
clearLegacyKeys()
guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil }
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes,
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else {
defaults.removeObject(forKey: Self.storageKey)
return nil
}
do {
try payload.validate(now: now)
return payload
} catch {
defaults.removeObject(forKey: Self.storageKey)
return nil
}
}
/// Consumes only the envelope the user actually reviewed. If a newer share
/// already replaced it, the newer content remains pending.
func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? {
guard let payload = pending(now: now), payload.id == id else { return nil }
defaults.removeObject(forKey: Self.storageKey)
return payload
}
/// Explicit cancellation has the same identity guard as consumption so it
/// can never discard a newer share that arrived while a prompt was open.
func discard(id: UUID) {
guard let encoded = defaults.data(forKey: Self.storageKey),
encoded.count <= SharedContentPayload.maxEnvelopeBytes,
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded),
payload.id == id else {
return
}
defaults.removeObject(forKey: Self.storageKey)
}
func discardAll() {
defaults.removeObject(forKey: Self.storageKey)
clearLegacyKeys()
}
private func clearLegacyKeys() {
for key in Self.legacyKeys {
defaults.removeObject(forKey: key)
}
}
}
-13
View File
@@ -46,7 +46,6 @@ enum TransportConfig {
static let privateChatCap: Int = 1337 static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337 static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337 static let geoTimelineCap: Int = 1337
static let geoNicknameParticipantsCap: Int = 1337
static let contentLRUCap: Int = 2000 static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000 static let geoSamplingEventLRUCap: Int = 2000
@@ -82,11 +81,6 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50 static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path. // Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100 static let nostrInboundEventLogInterval: Int = 100
// Reject oversized/untrusted relay frames before JSON parse / store.
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
static let nostrMaxEventTags: Int = 64
static let nostrMaxEventTagValues: Int = 16
static let nostrMaxEventTagValueBytes: Int = 1024
// Conversation store diagnostics (field observability) // Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line // Sample interval for the periodic store-audit "OK" heartbeat line
@@ -104,12 +98,6 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0 static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3 static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5 static let uiContentRateBucketRefillPerSec: Double = 0.5
// Bound attacker-keyed bucket maps (sender IDs / content digests).
static let uiSenderRateBucketMaxEntries: Int = 2000
static let uiContentRateBucketMaxEntries: Int = 2000
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
// Cap teleported-participant markers so remote events cannot grow the set.
static let geoTeleportedParticipantsCap: Int = 1337
// UI sleeps/delays // UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -326,7 +314,6 @@ enum TransportConfig {
// Share extension // Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration // Gossip Sync Configuration
-40
View File
@@ -1,40 +0,0 @@
import Foundation
/// In-app override for the UI language, on top of the system per-app
/// language. Apple resolves localization from the AppleLanguages default at
/// process start, so a new choice takes effect on the next launch callers
/// surface a "restart to apply" note after changing it.
enum AppLanguageSettings {
/// "" means no override: follow the device (or per-app system) language.
static let overrideKey = "app.languageOverride"
private static let appleLanguagesKey = "AppleLanguages"
/// Language codes the app ships translations for, straight from the
/// built bundle so this never drifts from the string catalog.
static var availableLanguages: [String] {
Bundle.main.localizations
.filter { $0 != "Base" }
.sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending }
}
/// The language's name in that language ("فارسی", "") so every user
/// can find their own entry regardless of the current UI language.
static func endonym(for code: String) -> String {
let locale = Locale(identifier: code)
let name = locale.localizedString(forIdentifier: code) ?? code
return name.lowercased(with: locale)
}
/// Persists the override (nil clears it). AppleLanguages drives the
/// actual localization lookup on next launch.
static func setOverride(_ code: String?) {
let defaults = UserDefaults.standard
if let code, !code.isEmpty {
defaults.set(code, forKey: overrideKey)
defaults.set([code], forKey: appleLanguagesKey)
} else {
defaults.removeObject(forKey: overrideKey)
defaults.removeObject(forKey: appleLanguagesKey)
}
}
}
@@ -71,8 +71,12 @@ final class ChatMessageFormatter {
let content = message.content let content = message.content
let nsContent = content as NSString let nsContent = content as NSString
let nsLen = nsContent.length let nsLen = nsContent.length
let containsCashuEarly: Bool = {
let regex = Patterns.quickCashuPresence
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}()
if content.isOversizedForRichFormatting() { if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf plainStyle.font = isSelf
-1
View File
@@ -1183,7 +1183,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
identityManager.clearAllIdentityData() identityManager.clearAllIdentityData()
peerIdentityStore.clearAll() peerIdentityStore.clearAll()
locationPresenceStore.reset() locationPresenceStore.reset()
publicRateLimiter.reset()
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
@@ -156,17 +156,6 @@ private extension ChatViewModelBootstrapper {
viewModel?.objectWillChange.send() viewModel?.objectWillChange.send()
} }
.store(in: &viewModel.cancellables) .store(in: &viewModel.cancellables)
viewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] people in
Task { @MainActor [weak viewModel] in
let visible = Set(people.map { $0.id })
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
}
}
.store(in: &viewModel.cancellables)
} }
func loadPersistedViewState() { func loadPersistedViewState() {
+8 -79
View File
@@ -26,10 +26,6 @@ struct MessageRateLimiter {
} }
return false return false
} }
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
} }
private var senderBuckets: [String: TokenBucket] = [:] private var senderBuckets: [String: TokenBucket] = [:]
@@ -39,26 +35,17 @@ struct MessageRateLimiter {
private let senderRefill: Double private let senderRefill: Double
private let contentCapacity: Double private let contentCapacity: Double
private let contentRefill: Double private let contentRefill: Double
private let maxSenderBuckets: Int
private let maxContentBuckets: Int
private let bucketIdleTTL: TimeInterval
init( init(
senderCapacity: Double, senderCapacity: Double,
senderRefillPerSec: Double, senderRefillPerSec: Double,
contentCapacity: Double, contentCapacity: Double,
contentRefillPerSec: Double, contentRefillPerSec: Double
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
) { ) {
self.senderCapacity = senderCapacity self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec self.contentRefill = contentRefillPerSec
self.maxSenderBuckets = max(1, maxSenderBuckets)
self.maxContentBuckets = max(1, maxContentBuckets)
self.bucketIdleTTL = bucketIdleTTL
} }
/// - Parameter powBits: validated NIP-13 difficulty of the event /// - Parameter powBits: validated NIP-13 difficulty of the event
@@ -71,83 +58,25 @@ struct MessageRateLimiter {
if powBits >= NostrPoW.rateLimitBypassBits { if powBits >= NostrPoW.rateLimitBypassBits {
senderAllowed = true senderAllowed = true
} else { } else {
var senderBucket = Self.bucket( var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
for: senderKey,
in: &senderBuckets,
capacity: senderCapacity, capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill, refillPerSec: senderRefill,
maxBuckets: maxSenderBuckets, lastRefill: now
idleTTL: bucketIdleTTL,
now: now
) )
senderAllowed = senderBucket.allow(now: now) senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket senderBuckets[senderKey] = senderBucket
} }
// Rejected senders must not mint attacker-keyed content entries. var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
guard senderAllowed else { return false }
var contentBucket = Self.bucket(
for: contentKey,
in: &contentBuckets,
capacity: contentCapacity, capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill, refillPerSec: contentRefill,
maxBuckets: maxContentBuckets, lastRefill: now
idleTTL: bucketIdleTTL,
now: now
) )
let contentAllowed = contentBucket.allow(now: now) let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket contentBuckets[contentKey] = contentBucket
return contentAllowed return senderAllowed && contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
var bucketCountsForTesting: (sender: Int, content: Int) {
(senderBuckets.count, contentBuckets.count)
}
// Static so we can take `inout` on a stored dictionary without overlapping
// exclusive access through a mutating method on `self`.
private static func bucket(
for key: String,
in buckets: inout [String: TokenBucket],
capacity: Double,
refillPerSec: Double,
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) -> TokenBucket {
if let existing = buckets[key] {
return existing
}
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
return TokenBucket(
capacity: capacity,
tokens: capacity,
refillPerSec: refillPerSec,
lastRefill: now
)
}
private static func evictIfNeeded(
from buckets: inout [String: TokenBucket],
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) {
guard buckets.count >= maxBuckets else { return }
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
guard buckets.count >= maxBuckets else { return }
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
buckets.removeValue(forKey: oldestKey)
}
} }
} }
@@ -196,10 +196,7 @@ final class NostrInboundPipeline {
// Sampled: fires for every geo event and floods dev logs in busy geohashes. // Sampled: fires for every geo event and floods dev logs in busy geohashes.
geoEventLogCount += 1 geoEventLogCount += 1
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug( SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
category: .session
)
} }
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
-73
View File
@@ -26,10 +26,6 @@ struct AppInfoView: View {
/// introduction), and afterwards the sheet reopens wherever it was left. /// introduction), and afterwards the sheet reopens wherever it was left.
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info @AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
@State private var showPanicConfirmation = false @State private var showPanicConfirmation = false
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
/// The override changed this session; localization resolves at process
/// start, so surface the restart hint.
@State private var showLanguageRestartNote = false
private enum Pane: String { private enum Pane: String {
case settings case settings
@@ -59,11 +55,6 @@ struct AppInfoView: View {
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing") static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings")
static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings")
static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language")
static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch")
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings") static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does") static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
static func bridgeCell(_ cell: String) -> String { static func bridgeCell(_ cell: String) -> String {
@@ -322,52 +313,6 @@ struct AppInfoView: View {
} }
} }
// Language an in-app override so the UI language can differ
// from the device language (takes effect on next launch).
VStack(alignment: .leading, spacing: 12) {
SectionHeader(verbatim: Strings.Settings.languageTitle)
settingsCard {
Menu {
Button {
selectLanguage(nil)
} label: {
menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty)
}
Divider()
ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in
Button {
selectLanguage(code)
} label: {
menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code)
}
}
} label: {
HStack {
Text(Strings.Settings.languagePickerLabel)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(textColor)
Spacer()
Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride))
.bitchatFont(size: 12)
.foregroundColor(palette.accent)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if showLanguageRestartNote {
Text(Strings.Settings.languageRestartNote)
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// Voice same card + IRC pill as every other toggle setting. // Voice same card + IRC pill as every other toggle setting.
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(Strings.Voice.title) SectionHeader(Strings.Voice.title)
@@ -513,24 +458,6 @@ struct AppInfoView: View {
.padding() .padding()
} }
private func selectLanguage(_ code: String?) {
let previous = languageOverride
AppLanguageSettings.setOverride(code)
languageOverride = code ?? ""
if languageOverride != previous {
showLanguageRestartNote = true
}
}
private func menuItemLabel(_ title: String, isSelected: Bool) -> some View {
HStack {
Text(title)
if isSelected {
Image(systemName: "checkmark")
}
}
}
private var bridgeToggleBinding: Binding<Bool> { private var bridgeToggleBinding: Binding<Bool> {
Binding( Binding(
get: { bridgeService.isEnabled }, get: { bridgeService.isEnabled },
@@ -41,7 +41,7 @@ struct TextMessageView: View {
// first text line; a fixed top padding left the lock's solid body // first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center. // hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 0) {
let isLong = message.content.isLongForDisplay() let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate { if message.isPrivate {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
@@ -103,7 +103,7 @@ struct TextMessageView: View {
} }
// Expand/Collapse for very long messages // Expand/Collapse for very long messages
if message.content.isLongForDisplay() { if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) { Button(labelKey) {
+42
View File
@@ -36,6 +36,7 @@ struct ContentView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = "" @State private var messageText = ""
@@ -69,6 +70,14 @@ struct ContentView: View {
privateConversationModel.selectedPeerID privateConversationModel.selectedPeerID
} }
private var sharedContentDestination: SharedContentDestination {
SharedContentDestination.resolve(
selectedPrivatePeerID: selectedPrivatePeerID,
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
activeChannel: locationChannelsModel.selectedChannel
)
}
private var usesGlassLayout: Bool { appTheme.usesGlassChrome } private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
var body: some View { var body: some View {
@@ -85,6 +94,7 @@ struct ContentView: View {
isTextFieldFocused = true isTextFieldFocused = true
} }
#endif #endif
sharedContentImportModel.updateDestination(sharedContentDestination)
} }
.onChange(of: colorScheme) { newValue in .onChange(of: colorScheme) { newValue in
conversationUIModel.setCurrentColorScheme(newValue) conversationUIModel.setCurrentColorScheme(newValue)
@@ -101,6 +111,10 @@ struct ContentView: View {
if newValue != nil { if newValue != nil {
showSidebar = true showSidebar = true
} }
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
sharedContentImportModel.updateDestination(sharedContentDestination)
} }
.sheet( .sheet(
isPresented: Binding( isPresented: Binding(
@@ -227,6 +241,34 @@ struct ContentView: View {
} message: { } message: {
Text(appChromeModel.bluetoothAlertMessage) Text(appChromeModel.bluetoothAlertMessage)
} }
.alert(
String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"),
isPresented: Binding(
get: { sharedContentImportModel.offer != nil },
set: { _ in }
),
presenting: sharedContentImportModel.offer
) { _ in
Button("common.cancel", role: .cancel) {
sharedContentImportModel.cancel(destination: sharedContentDestination)
}
Button("share_import.review.use_in_composer") {
guard let importedText = sharedContentImportModel.confirm(
destination: sharedContentDestination
) else { return }
// Replacing is deliberate and called out in the prompt. It
// avoids combining a stale draft from another conversation
// with newly shared content.
messageText = importedText
isTextFieldFocused = true
}
} message: { offer in
let format = String(
localized: "share_import.review.message",
comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically"
)
Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview)
}
.onDisappear { .onDisappear {
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
} }
-20
View File
@@ -21,26 +21,6 @@ extension String {
return current >= threshold return current >= threshold
} }
/// True when the message should collapse behind Show more in the UI.
/// Length alone decides this embedding a Cashu-looking token must not
/// disable the guard (remote DoS via unbounded layout).
func isLongForDisplay(
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
/// True when rich formatting (regex / link detectors) should be skipped.
/// Cashu presence used to exempt oversized content from the plain path;
/// that let untrusted input force expensive formatting work.
func isOversizedForRichFormatting(
lengthThreshold: Int = 4000,
tokenThreshold: Int = 1024
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare // Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI // bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match. // form matches too the token embedded after the scheme is the match.
@@ -38,12 +38,6 @@
"comment" : "Fallback title when saving a shared link" "comment" : "Fallback title when saving a shared link"
} }
}, },
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیوند اشتراک‌گذاری‌شده"
}
},
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "needs_review",
@@ -203,7 +197,7 @@
} }
}, },
"share.status.failed_to_encode" : { "share.status.failed_to_encode" : {
"extractionState" : "manual", "extractionState" : "stale",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@@ -239,12 +233,6 @@
"comment" : "Shown when the share payload cannot be encoded" "comment" : "Shown when the share payload cannot be encoded"
} }
}, },
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "کدگذاری پیوند ناموفق بود"
}
},
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "needs_review",
@@ -440,12 +428,6 @@
"comment" : "Shown when provided content cannot be shared" "comment" : "Shown when provided content cannot be shared"
} }
}, },
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "محتوای قابل اشتراک‌گذاری وجود ندارد"
}
},
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "needs_review",
@@ -641,12 +623,6 @@
"comment" : "Shown when the share extension receives no content" "comment" : "Shown when the share extension receives no content"
} }
}, },
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "چیزی برای اشتراک‌گذاری نیست"
}
},
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "needs_review",
@@ -806,7 +782,7 @@
} }
}, },
"share.status.shared_link" : { "share.status.shared_link" : {
"extractionState" : "manual", "extractionState" : "stale",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@@ -842,12 +818,6 @@
"comment" : "Confirmation after successfully sharing a link" "comment" : "Confirmation after successfully sharing a link"
} }
}, },
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
}
},
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "needs_review",
@@ -1007,7 +977,7 @@
} }
}, },
"share.status.shared_text" : { "share.status.shared_text" : {
"extractionState" : "manual", "extractionState" : "stale",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@@ -1043,12 +1013,6 @@
"comment" : "Confirmation after successfully sharing text" "comment" : "Confirmation after successfully sharing text"
} }
}, },
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
}
},
"fil" : { "fil" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "needs_review",
@@ -1206,6 +1170,76 @@
} }
} }
} }
},
"share.status.failed_to_save" : {
"comment" : "Shown when content cannot be staged for the main app",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible denregistrer dans bitchat" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchate kaydedilemedi" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } }
}
},
"share.status.saved_for_review" : {
"comment" : "Shown after content is staged for review in the main app",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez lapp pour vérifier" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri lapp per controllare" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchate kaydedildi — incelemek için uygulamayı açın" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } }
}
} }
}, },
"version" : "1.0" "version" : "1.0"
+25 -25
View File
@@ -19,9 +19,8 @@ final class ShareViewController: UIViewController {
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content") static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared") static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link") static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link") static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app")
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text") static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app")
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
} }
private let statusLabel: UILabel = { private let statusLabel: UILabel = {
@@ -44,9 +43,7 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
]) ])
DispatchQueue.global().async { processShare()
self.processShare()
}
} }
// MARK: - Processing // MARK: - Processing
@@ -151,30 +148,33 @@ final class ShareViewController: UIViewController {
// MARK: - Save + Finish // MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) { private func saveAndFinish(url: URL, title: String?) {
let payload: [String: String] = [ let payload = SharedContentPayload(
"url": url.absoluteString, kind: .url,
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback content: url.absoluteString,
] title: title ?? url.host ?? Strings.sharedLinkTitleFallback
if let json = try? JSONSerialization.data(withJSONObject: payload), )
let s = String(data: json, encoding: .utf8) { stageAndFinish(payload)
saveToSharedDefaults(content: s, type: "url")
finishWithMessage(Strings.sharedLinkConfirmation)
} else {
finishWithMessage(Strings.failedToEncode)
}
} }
private func saveAndFinish(text: String) { private func saveAndFinish(text: String) {
saveToSharedDefaults(content: text, type: "text") stageAndFinish(.text(text))
finishWithMessage(Strings.sharedTextConfirmation)
} }
private func saveToSharedDefaults(content: String, type: String) { private func stageAndFinish(_ payload: SharedContentPayload) {
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return } guard let defaults = UserDefaults(suiteName: Self.groupID) else {
userDefaults.set(content, forKey: "sharedContent") finishWithMessage(Strings.failedToSave)
userDefaults.set(type, forKey: "sharedContentType") return
userDefaults.set(Date(), forKey: "sharedContentDate") }
// No need to force synchronize; the system persists changes let store = SharedContentStore(defaults: defaults)
do {
try store.stage(payload)
// Staging is not sending. The main app will require a second,
// destination-labelled confirmation before filling its composer.
finishWithMessage(Strings.savedForReview)
} catch {
finishWithMessage(Strings.failedToSave)
}
} }
private func finishWithMessage(_ msg: String) { private func finishWithMessage(_ msg: String) {
-38
View File
@@ -147,44 +147,6 @@ struct AppArchitectureTests {
#expect(store.teleportedGeo.isEmpty) #expect(store.teleportedGeo.isEmpty)
} }
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
@MainActor
func locationPresenceStoreBoundsTeleportedParticipants() {
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.markTeleported("AAAAAA")
store.markTeleported("BBBBBB")
store.markTeleported("CCCCCC")
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
#expect(store.teleportedGeo == Set(["cccccc"]))
store.setCurrentGeohash("u4pruz")
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
@MainActor
func locationPresenceStoreBoundsGeoNicknames() {
let store = LocationPresenceStore(geoNicknameCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.setNickname("alice", for: "AAAAAA")
store.setNickname("bob", for: "BBBBBB")
store.setNickname("carol", for: "CCCCCC")
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
#expect(store.geoNicknames == ["cccccc": "carol"])
store.setCurrentGeohash("u4pruz")
#expect(store.geoNicknames.isEmpty)
}
@Test("PeerHandle equality and hashing use the canonical identity only") @Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() { func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a")) let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
-19
View File
@@ -646,25 +646,6 @@ struct ChatViewModelFormattingTests {
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]") #expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
} }
@Test @MainActor
func formatMessageAsText_longCashuFallsBackToPlain() async {
let (viewModel, _) = makeTestableViewModel()
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "fmt-long-cashu",
sender: "Alice#a1b2",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
isRelay: false,
senderPeerID: PeerID(str: "00000000000000b3")
)
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
}
@Test @MainActor @Test @MainActor
func formatMessageHeader_formatsSenderHeader() async { func formatMessageHeader_formatsSenderHeader() async {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
@@ -323,32 +323,6 @@ struct MessageFormattingEngineTests {
// Exactly at threshold DOES trigger (uses >= comparison) // Exactly at threshold DOES trigger (uses >= comparison)
#expect(content.hasVeryLongToken(threshold: 50)) #expect(content.hasVeryLongToken(threshold: 50))
} }
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
let cashu = "cashuA" + String(repeating: "a", count: 40)
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
#expect(content.extractCashuLinks().count == 1)
#expect(content.isLongForDisplay())
}
@MainActor
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
let context = MockMessageFormattingContext(nickname: "carol")
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "long-cashu",
sender: "alice",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
}
} }
@MainActor @MainActor
@@ -116,87 +116,4 @@ struct MessageRateLimiterTests {
#expect(plain) #expect(plain)
#expect(!plainExhausted) #expect(!plainExhausted)
} }
@Test("Content buckets do not grow when sender is rate limited")
func contentBucketsDoNotGrowAfterSenderLimit() {
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: 10,
maxContentBuckets: 10,
bucketIdleTTL: 60
)
let now = Date()
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
var rejected = true
for index in 1...100 {
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
rejected = false
}
}
#expect(first)
#expect(rejected)
#expect(limiter.bucketCountsForTesting.sender == 1)
#expect(limiter.bucketCountsForTesting.content == 1)
}
@Test("Bucket maps evict entries at configured caps")
func bucketMapsEvictAtConfiguredCaps() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
for index in 0..<25 {
_ = limiter.allow(
senderKey: "sender-\(index)",
contentKey: "content-\(index)",
now: now.addingTimeInterval(TimeInterval(index))
)
}
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
@Test("PoW bypass still creates content buckets under the cap")
func powBypassCreatesBoundedContentBuckets() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 100,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
var allAllowed = true
for index in 0..<10 {
let allowed = limiter.allow(
senderKey: "sender",
contentKey: "content-\(index)",
powBits: NostrPoW.rateLimitBypassBits,
now: now.addingTimeInterval(TimeInterval(index))
)
if !allowed { allAllowed = false }
}
#expect(allAllowed)
#expect(limiter.bucketCountsForTesting.sender == 0)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
} }
-58
View File
@@ -290,65 +290,7 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42) #expect(object["limit"] as? Int == 42)
} }
@Test func inboundNostrEventRejectsTooManyTags() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = Array(
repeating: ["g", "u4pruyd"],
count: TransportConfig.nostrMaxEventTags + 1
)
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [Array(
repeating: "value",
count: TransportConfig.nostrMaxEventTagValues + 1
)]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [[
"g",
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
]]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
let event = try NostrEvent(from: eventDict)
#expect(event.tags.count == 2)
}
// MARK: - Helpers // MARK: - Helpers
private static func validInboundEventDict() -> [String: Any] {
[
"id": String(repeating: "0", count: 64),
"pubkey": String(repeating: "1", count: 64),
"created_at": 1_234_567,
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
"tags": [["g", "u4pruyd"]],
"content": "hello",
"sig": String(repeating: "2", count: 128)
]
}
private static func base64URLDecode(_ s: String) -> Data? { private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4 let rem = str.count % 4
@@ -0,0 +1,173 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("Share extension handoff", .serialized)
struct SharedContentHandoffTests {
private func makeStore() -> (suite: String, defaults: UserDefaults, store: SharedContentStore) {
let suite = "SharedContentHandoffTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return (suite, defaults, SharedContentStore(defaults: defaults))
}
@Test("A staged share survives an inactive app and a late open")
func stagedShareSurvivesLateOpen() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let stagedAt = Date(timeIntervalSince1970: 1_000_000)
let payload = SharedContentPayload.text("review me later", createdAt: stagedAt)
try context.store.stage(payload, now: stagedAt)
#expect(context.store.pending(now: stagedAt.addingTimeInterval(60 * 60)) == payload)
#expect(context.defaults.data(forKey: SharedContentStore.storageKey) != nil)
}
@Test("Malformed, oversized, unsupported, and expired payloads are rejected and cleared")
func invalidPayloadsAreRejectedAndCleared() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 2_000_000)
context.defaults.set(Data("not-json".utf8), forKey: SharedContentStore.storageKey)
#expect(context.store.pending(now: now) == nil)
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
context.defaults.set(
Data(repeating: 0x41, count: SharedContentPayload.maxEnvelopeBytes + 1),
forKey: SharedContentStore.storageKey
)
#expect(context.store.pending(now: now) == nil)
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
let oversized = SharedContentPayload.text(
String(repeating: "x", count: SharedContentPayload.maxContentBytes + 1),
createdAt: now
)
#expect(throws: SharedContentHandoffError.contentTooLarge) {
try context.store.stage(oversized, now: now)
}
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
let unsupportedURL = SharedContentPayload(
kind: .url,
content: "file:///private/tmp/secret.txt",
createdAt: now
)
#expect(throws: SharedContentHandoffError.unsupportedURL) {
try context.store.stage(unsupportedURL, now: now)
}
let misleadingControl = SharedContentPayload.text("safe\u{202E}txt", createdAt: now)
#expect(throws: SharedContentHandoffError.invalidCharacters) {
try context.store.stage(misleadingControl, now: now)
}
let expired = SharedContentPayload.text(
"too old",
createdAt: now.addingTimeInterval(-SharedContentPayload.retentionSeconds - 1)
)
context.defaults.set(try JSONEncoder().encode(expired), forKey: SharedContentStore.storageKey)
#expect(context.store.pending(now: now) == nil)
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
}
@Test("Mesh, geohash, and stale private selections resolve to explicit destinations")
func destinationsAreExplicit() {
let geohashChannel = ChannelID.location(
GeohashChannel(level: .city, geohash: "9Q8YY")
)
let stalePeer = PeerID(str: "0011223344556677")
#expect(SharedContentDestination.resolve(
selectedPrivatePeerID: nil,
privateDisplayName: nil,
activeChannel: .mesh
) == .mesh)
#expect(SharedContentDestination.resolve(
selectedPrivatePeerID: nil,
privateDisplayName: nil,
activeChannel: geohashChannel
) == .geohash("9q8yy"))
#expect(SharedContentDestination.resolve(
selectedPrivatePeerID: stalePeer,
privateDisplayName: "alice",
activeChannel: geohashChannel
) == .privateConversation(peerID: stalePeer, displayName: "alice"))
}
@Test("A destination change requires a new confirmation and never consumes on the stale tap")
@MainActor
func staleDestinationCannotBeConfirmed() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 3_000_000)
let payload = SharedContentPayload.text("do not auto-send", createdAt: now)
let peer = PeerID(str: "8899aabbccddeeff")
let privateDestination = SharedContentDestination.privateConversation(
peerID: peer,
displayName: "alice"
)
let model = SharedContentImportModel(store: context.store)
try context.store.stage(payload, now: now)
model.refresh(destination: privateDestination, now: now)
#expect(model.confirm(destination: .mesh, now: now) == nil)
#expect(model.offer?.destination == .mesh)
#expect(context.store.pending(now: now) == payload)
#expect(model.confirm(destination: .mesh, now: now) == payload.content)
#expect(model.offer == nil)
#expect(context.store.pending(now: now) == nil)
}
@Test("Confirmation consumes once and cancellation explicitly clears without producing composer text")
@MainActor
func oneTimeConfirmationAndCancellation() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 4_000_000)
let model = SharedContentImportModel(store: context.store)
let first = SharedContentPayload.text("confirmed", createdAt: now)
try context.store.stage(first, now: now)
model.refresh(destination: .geohash("u4pruy"), now: now)
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == "confirmed")
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == nil)
let second = SharedContentPayload.text("cancelled", createdAt: now)
try context.store.stage(second, now: now)
model.refresh(destination: .mesh, now: now)
model.cancel(destination: .mesh, now: now)
#expect(model.offer == nil)
#expect(context.store.pending(now: now) == nil)
let third = SharedContentPayload.text("panic-wiped", createdAt: now)
try context.store.stage(third, now: now)
model.refresh(destination: .mesh, now: now)
model.discardAll()
#expect(model.offer == nil)
#expect(context.store.pending(now: now) == nil)
}
@Test("Cancelling an old review never deletes a newer staged share")
@MainActor
func cancellationPreservesNewerShare() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 5_000_000)
let model = SharedContentImportModel(store: context.store)
let old = SharedContentPayload.text("old", createdAt: now)
let newer = SharedContentPayload.text("new", createdAt: now)
try context.store.stage(old, now: now)
model.refresh(destination: .mesh, now: now)
try context.store.stage(newer, now: now)
model.cancel(destination: .mesh, now: now)
#expect(model.offer?.payload == newer)
#expect(context.store.pending(now: now) == newer)
}
}
+4 -1
View File
@@ -41,6 +41,7 @@ private struct SmokeFeatureModels {
let conversationUIModel: ConversationUIModel let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel let peerListModel: PeerListModel
let boardAlertsModel: BoardAlertsModel let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
} }
@MainActor @MainActor
@@ -99,7 +100,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
verificationModel: verificationModel, verificationModel: verificationModel,
conversationUIModel: conversationUIModel, conversationUIModel: conversationUIModel,
peerListModel: peerListModel, peerListModel: peerListModel,
boardAlertsModel: boardAlertsModel boardAlertsModel: boardAlertsModel,
sharedContentImportModel: SharedContentImportModel(store: nil)
) )
} }
@@ -118,6 +120,7 @@ private func installSmokeEnvironment<V: View>(
.environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel) .environmentObject(featureModels.peerListModel)
.environmentObject(featureModels.boardAlertsModel) .environmentObject(featureModels.boardAlertsModel)
.environmentObject(featureModels.sharedContentImportModel)
} }
@MainActor @MainActor