Compare commits

..
102 Commits
Author SHA1 Message Date
7040d9ecb0 Add plus-minus feature to location notes (± 1 grid) (#821)
Expands location notes coverage to include 8 neighboring geohash cells,
creating a 3×3 grid around the user's current building-level location.

Changes:
- Add Geohash.neighbors() method to calculate 8 surrounding cells
- Update LocationNotesManager to subscribe to center + neighbors (9 cells total)
- Add NostrFilter.geohashNotes([String]) overload for multi-cell subscriptions
- Display '± 1' indicator in LocationNotesView header

Coverage:
- Single cell: ~38m × 19m
- 3×3 grid: ~114m × 57m total area
- Better discovery of nearby activity

Behavior:
- Subscribe: Fetches notes from all 9 cells (single efficient subscription)
- Post: Still uses center geohash only (correct privacy-preserving behavior)
- UI: Shows 'geohash ± 1' to indicate expanded coverage

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:44:06 +02:00
88bafb41cc Remove LocationNotesCounter for privacy-first approach (#820)
Simplifies geonotes architecture by eliminating all background subscriptions:

- Delete LocationNotesCounter.swift (104 lines) and related tests (58 lines)
- Remove background subscription system entirely
- Icon is now constant darker orange (indicates availability, not state)
- Subscription ONLY happens when user explicitly opens the sheet
- Total: ~220 lines of code removed

Privacy Benefits:
- Zero network traffic until user action
- No location leaking to relays via background polling
- User must explicitly opt-in to discover notes

The icon (note.text in orange) simply indicates the feature is available
when location permission is granted. Notes are only fetched when the
sheet is opened, prioritizing privacy over convenience.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:06:31 +02:00
jackandGitHub fb43a8b0f5 Reuse cached mention regex in parseMentions (#812) 2025-10-17 23:02:58 +02:00
jackandGitHub eb35608fa1 Remove unused helpers and add cross-platform logging fallbacks (#811) 2025-10-17 22:58:56 +02:00
RedThoroughbredandGitHub b81ae0b4c0 fix: improve Xcode detection in Justfile (#814)
- Add check for full Xcode vs command line tools only
- Provide clearer error messages with setup instructions
- Verify Xcode is installed and properly configured
- Add better validation for development environment

Fixes issue where 'just run' fails with cryptic error when only
command line tools are installed. Now gives clear guidance on
installing full Xcode and configuring xcode-select properly.

Resolves #760
2025-10-17 21:37:51 +02:00
jackandGitHub b6d42261d0 Guard peer collision checks with snapshot (#810) 2025-10-15 16:12:16 +02:00
3d914dcf46 Convert the remaining tests to Swift Testing (#781)
* SwiftTesting: NoiseProtocolTests + BinaryProtocolPaddingTests

* SwiftTesting: `NotificationStreamAssemblerTests`

* SwiftTesting: `NostrProtocolTests`

* SwiftTesting: `BinaryProtocolTests`

* SwiftTesting: `PeerIDTests`

* SwiftTesting: `BLEServiceTests`

* SwiftTesting: `CommandProcessorTests`

* SwiftTesting: `GCSFilterTests`

* SwiftTesting: `GeohashBookmarksStoreTests`

* Remove `peerID` test constants

* Remove PeerID + String interop from tests

* Refactor IntegrationTests to extract state management

* Refactor global state management of MockBLEService

* NoiseProtocolSwiftTests: `actor` -> `struct`

* Remove measurement tests w/ no benchmark

* `NoiseProtocolSwiftTests` -> `NoiseProtocolTests`

* SwiftTesting: `LocationChannelsTests`

* SwiftTesting: `GossipSyncManagerTests`

* SwiftTesting: `LocationNotesManagerTests`

* Global `sleep` function for tests

* SwiftTesting: `IntegrationTests`

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-15 01:04:01 +02:00
b3ec5eeda0 Refactor Noise: Extract files and remove dead code (#806)
* Extract each type to a separate file

* NoiseSessionManager: Remove unused functions

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-15 00:32:44 +02:00
IslamandGitHub 47d75ab9d8 PeerID 23/n: ChatViewModel + its dependences (#801) 2025-10-15 00:20:19 +02:00
3479c7d5df Fix people sheet dismiss gestures (#803)
* Allow closing people sheet from X and swipe

* Swipe right to return from DM to people list

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 21:11:44 +02:00
8a0727fcf7 Guard BLE link state lookups on BLE queue (#805)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 21:11:07 +02:00
6588861e34 Align gossip sync stale cleanup with Android client (#798)
* Align DM sheet toolbar with people list

* Gate stale gossip announcements

* Remove stale peer messages during gossip cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 13:54:36 +02:00
a1647901e5 Add Turkish translations for share extension & Add Turkey to knownRegions (#787)
* Add Turkish translations for share extension

* Add Turkey to knownRegions

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-14 12:45:23 +02:00
IslamandGitHub 23249f3e41 PeerID 22/n: PrivateChatManager (#800)
* PrivateChatManager: functions to use `PeerID`

* PrivateChatManager: properties to use `PeerID`
2025-10-14 12:30:18 +02:00
ad4103bacc Refactor Nostr ID Bridge & Keychain Helper (#796)
* Extract each type to a separate file

* Nostr ID Bridge: Convert static func/vars to instance

* `KeychainHelper` behind a protocol to easily mock

* Update tests with ID Bridge and MockKeychainHelper

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-14 12:26:18 +02:00
e3149fa098 Process incoming fragments on message queue (#804)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 12:20:39 +02:00
jack 987ba2e694 Fix people sheet close button 2025-10-12 20:18:29 +02:00
615273a63e Align DM sheet toolbar with people list (#795)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-12 19:53:08 +02:00
IslamandGitHub 4563c22d2d Fix hidden source of deadlock (#794) 2025-10-12 12:27:33 +02:00
IslamandGitHub 9f74266527 Centralize repeated queue-checking logic (#791) 2025-10-11 21:38:04 +02:00
IslamandGitHub 239064e4eb Cleanup (#793)
* Remove and ignore `.cache/`

* Optimize debug logo + remove its duplicates
2025-10-11 21:28:28 +02:00
IslamandGitHub d371131ad5 Remove MockBluetoothMeshService (#777) 2025-10-09 23:47:01 +02:00
d994ccf012 Fix send button tap responsiveness and sidebar drag jitter (#783)
Restructured ContentView layout to prevent sidebar from covering input box
and removed gesture conflicts that caused jitter during slow drags.

Changes:
- Moved sidebar overlay to only cover messages area, not input box
- Input box now always accessible below sidebar (not covered by overlay)
- Removed blocking drag gesture from mainChatView
- Changed sidebar gesture from simultaneousGesture to gesture for priority
- Removed animation-disabling transactions that amplified touch noise
- Removed 2pt threshold checks that caused visible jumps

Result: Send button taps immediately, sidebar slides smoothly without jitter.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-09 23:45:05 +02:00
IslamandGitHub 302c741d58 PeerID 21/n: UnifiedPeerService (#772) 2025-10-07 16:37:47 +02:00
IslamandGitHub 7ec84857eb PeerID 20/n: BLEService’s private functions (#771) 2025-10-07 16:26:17 +02:00
IslamandGitHub 68482c67d7 PeerID 19/n: BLEService’s private properties (#770) 2025-10-07 16:17:29 +02:00
IslamandGitHub 551a843691 PeerID 18/n: BitchatDelegate + Tests (#769) 2025-10-07 16:01:53 +02:00
IslamandGitHub fbc15ea08f SwiftTesting: Enable in GitHubActions + peer uuids (#767) 2025-10-07 12:40:20 +02:00
0f23ed0a99 Location notes: fix performance and UI issues (#774)
* Location notes: fix performance and UI issues

Performance fixes:
- Add Set-based duplicate detection (O(1) vs O(n) lookup)
- Eliminates lag when receiving 200+ notes during EOSE

Correctness fixes:
- Fix optimistic echo timestamp to match signed event timestamp
- Add echo IDs to noteIDs set for consistency

UI improvements:
- Remove duplicate "loading recent notes" text in header
- Simplify toolbar icon color logic for immediate green indication
- Icon now reliably turns green when notes exist in geohash

Tests: All 3 LocationNotes tests passing

* Location notes: add remaining robustness fixes

Additional improvements:
- Align counter/manager limits to 200 (prevents showing count higher than displayable)
- Set loading state before clearing notes to eliminate UI flicker on geohash change
- Add geohash validation for building-level precision (8 valid base32 chars)
- Add defensive 500-note memory cap with automatic trimming
- Clear stale notesGeohash state on sheet dismiss

Tests:
- Fix test geohashes to use valid base32 characters
- All 3 LocationNotes tests passing

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-07 12:36:54 +02:00
c583949031 Fix QR verification sending multiple notifications (#773)
The camera scanner was continuously detecting the same QR code (10-30+ times/second), causing multiple verification notifications to be sent. This happened because:

1. AVCaptureMetadataOutput fires repeatedly while QR is visible
2. Each detection triggered a new verification flow
3. After receiving response, pendingQRVerifications was cleared, allowing duplicate scans

Changes:
- Add deduplication using lastValid state to ignore re-scans of same QR code
- Only set lastValid after successful verification initiation
- Add onSuccess callback to close scanner after successful scan
- Automatically return to "My QR" view after verification starts
- Apply same logic to both iOS camera and macOS manual input paths

This ensures exactly one verification request per scan session.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-07 11:32:57 +02:00
IslamandGitHub d75700fa2b Add Tor’s xcframework and select “Do not embed” (#768) 2025-10-07 02:01:24 +02:00
7149182c56 Fix ghost peers and stale messages from gossip sync (#766)
Problem:
- Ghost peers from yesterday appeared on app restart
- Old messages resurfaced after restarting
- Peers running 24+ hours synced stale data to restarting peers

Root causes:
1. GossipSyncManager stored packets indefinitely (no time limit)
2. handleAnnounce/handleMessage had no timestamp validation
3. Relayed announces from other peers could be arbitrarily old

Solution (defense in depth):
- Added 15-minute age window to GossipSyncManager config
- Gossip storage rejects expired packets at ingestion
- Gossip sync responses filter out expired packets
- GCS payload building excludes expired packets
- Periodic cleanup removes expired announcements/messages
- handleAnnounce rejects stale announces before processing
- handleMessage rejects stale broadcast messages before processing

This prevents ghost peers from appearing on restart and ensures
only recent mesh state is synchronized between peers.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-07 00:56:25 +02:00
13b58aeaa9 Swiping to close the sidebar (#678)
* enabled drag gesture to close the sidebar view

* removed extraneous onChanged block

* Fix sidebar swipe gestures to work with ScrollView

Improvements:
- Use simultaneousGesture() instead of gesture() to work alongside ScrollView
- Add horizontal-only detection (width > height * 1.5) to prevent interfering with vertical scrolling
- Add visual feedback during drag with sidebarDragOffset updates
- Add 20pt right edge zone for easier swipe-to-open activation (iOS-native behavior)
- Lower threshold for edge swipe (-50pt instead of -100pt)

Both swipe-to-open and swipe-to-close now work reliably:
- Swipe left from anywhere (or from right edge) to open sidebar
- Swipe right on sidebar to close it
- Vertical scrolling unaffected

* Fix sidebar drag offset direction

Changed offset calculation from:
  showSidebar ? -dragOffset : width - dragOffset
To:
  showSidebar ? dragOffset : width + dragOffset

This fixes the issue where dragging right to close the sidebar would
make it fly to the left side of the screen before closing.

Now the sidebar correctly follows the finger during drag:
- When closing: moves right (toward off-screen)
- When opening: moves left (toward on-screen)

* Optimize sidebar drag performance for smooth 60fps

Performance improvements:
- Remove .animation() modifier that was conflicting with drag updates
- Use Transaction with disablesAnimations during drag for instant updates
- Throttle state updates to only fire when offset changes >2pt
- Always render sidebar (avoid conditional view creation overhead)
- Only animate on gesture end, not during drag

This eliminates the lag/jank during swipe by ensuring:
1. No animation conflicts during manual drag
2. Direct offset updates follow finger immediately
3. Stable view hierarchy (no conditional rendering)
4. Reduced state update frequency

The sidebar now feels buttery smooth at 60fps.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 23:24:24 +02:00
b5b05977fb Show Bluetooth permission alerts on launch and foreground (#765)
* Fix test suite peer ID collisions

Use unique peer IDs for each test suite to prevent global registry
collisions when Swift Testing runs suites in parallel.

- PrivateChatE2ETests: PRIV_* prefix
- PublicChatE2ETests: PUB_* prefix
- Update all peer ID references to use actual instance IDs

This fixes the race condition where simplePublicMessage() was receiving
duplicate deliveries due to registry contamination.

* Add Bluetooth permission & state alerts on launch and foreground

Wire up existing Bluetooth alert infrastructure to show notifications
when Bluetooth is off, unauthorized, or unsupported.

Changes:
- Add didUpdateBluetoothState() to BitchatDelegate protocol
- BLEService now notifies delegate when Bluetooth state changes
- ChatViewModel implements delegate method to show alerts
- Check Bluetooth state on app launch (after 100ms delay)
- Check Bluetooth state when app comes to foreground
- Add getCurrentBluetoothState() method to BLEService

The UI alert already existed but wasn't wired up. Now users will see
appropriate alerts for:
- Bluetooth turned off
- Bluetooth permission denied
- Bluetooth unsupported on device

Alert includes a button to open Settings on iOS.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 22:47:11 +02:00
af6703cf24 Fix test suite peer ID collisions (#764)
Use unique peer IDs for each test suite to prevent global registry
collisions when Swift Testing runs suites in parallel.

- PrivateChatE2ETests: PRIV_* prefix
- PublicChatE2ETests: PUB_* prefix
- Update all peer ID references to use actual instance IDs

This fixes the race condition where simplePublicMessage() was receiving
duplicate deliveries due to registry contamination.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 22:46:45 +02:00
eb13eec4c5 Swift Testing (#748)
* Swift Testing: `PrivateChatE2ETests` + minor refactor

* Swift Testing: `PublicChatE2ETests`

* Swift Testing: `FragmentationTests`

* Fix MockBLEService init to accept PeerID and remove _testRegister call

* Add peerID property to MockBLEService and fix ttlDecrement test

* Remove duplicate peerID property and fix type comparisons

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 20:55:52 +02:00
IslamandGitHub 81c90b2924 Extract TextMessageView into a separate file (#759)
* Extract `TextMessageView` into a separate file

* Remove dead code
2025-10-06 17:42:06 +02:00
IslamandGitHub 8c368233c4 Extract and simplify PaymentChipView (#758)
* `cashuToken` -> `cashuLinks` + minor refaactor

* Extract and simplify `PaymentChipView`
2025-10-06 17:21:32 +02:00
IslamandGitHub 13ebaad42f PeerID 17/n: PeripheralState + centralToPeerID (#756)
* PeerID 15/n: Bitchat Message & Packet accept in init

* PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer

* PeerID 17/n: `PeripheralState` + `centralToPeerID`
2025-10-06 17:19:43 +02:00
IslamandGitHub 10e3f574ab PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer (#755)
* PeerID 15/n: Bitchat Message & Packet accept in init

* PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer
2025-10-06 17:17:57 +02:00
IslamandGitHub 5f44c56a90 PeerID 15/n: Bitchat Message & Packet accept in init (#754) 2025-10-06 17:16:11 +02:00
IslamandGitHub 01ec4573f8 Extract DeliveryStatusView into a separate file (#757) 2025-10-06 11:20:39 +02:00
IslamandGitHub f86ae5e2ea PeerID 14/n: Transport and its dependents (#753)
* Rearrange `Transport`’s properties and functions

* `NostrTransport`: group Transport-related code together

* `BLEService`: group Transport-related code together

* Extract `NotificationStreamAssembler` into a file

* Move private functions to a dedicated extension

* PeerID 14/n: `Transport` and its dependents
2025-10-06 11:02:20 +02:00
IslamandGitHub 2673d28686 PeerID 12/n: GossipSyncManager (#751)
* Noise types use PeerID

* Fix tests

* Extract `NoiseSessionManager` into a separate file

* Extract `NoiseSessionState` into a separate file

* Remove `failed` state from `NoiseSessionState`

* Extract `NoiseSessionError` into a separate file

* PeerID 12/n: `GossipSyncManager`
2025-10-05 15:53:57 +02:00
IslamandGitHub 03c357f048 PeerID 11/n: Noise types use PeerID + create separate files (#750)
* Noise types use PeerID

* Fix tests

* Extract `NoiseSessionManager` into a separate file

* Extract `NoiseSessionState` into a separate file

* Remove `failed` state from `NoiseSessionState`

* Extract `NoiseSessionError` into a separate file
2025-10-05 15:51:06 +02:00
IslamandGitHub 64f91bb1d6 PeerID 10/n: MessageRouter (#749)
* PeerID 9/n: `NoiseEncryptionService`

* PeerID 10/n: `MessageRouter`
2025-10-05 12:43:33 +02:00
IslamandGitHub 9ecda048e7 PeerID 9/n: NoiseEncryptionService (#747) 2025-10-05 12:39:16 +02:00
IslamandGitHub bdc31d3be3 Don’t auto-register mock BLE services on creation (#746) 2025-10-05 12:36:35 +02:00
GitHub Action 6846b19d83 Automated update of relay data - Sun Oct 5 06:04:21 UTC 2025 2025-10-05 06:04:21 +00:00
IslamandGitHub 524a7e916b PeerID 8/n: NoiseRateLimiter & FavoritesPersistenceService (#745) 2025-10-03 11:32:13 +02:00
IslamandGitHub f2473f857b PeerID 7/n: Unify PeerIDUtils & PeerIDResolver (#744) 2025-10-02 13:41:47 +02:00
IslamandGitHub 8cfee095d3 PeerID 6/n: Unifiy validation (#743) 2025-10-02 13:36:49 +02:00
IslamandGitHub e4ec2ef3fe PeerID 5/n: Ephemeral and Secure Identities (#742) 2025-10-02 13:29:48 +02:00
IslamandGitHub bd11940151 Injectable UserDefaults to fix race condition in tests (#741) 2025-10-02 12:59:48 +02:00
IslamandGitHub 90273cee2d PeerID 4/n: BitchatMessage.senderPeerID + String equality (#739) 2025-10-02 12:57:17 +02:00
jack faae625e53 Add shared macOS scheme to match iOS scheme 2025-10-02 11:59:46 +02:00
3a35b3acc2 Modularization: Extract Tor into a separate module (#602)
* Extract Tor into a separate module

* Add Tor package as a dependency for iOS & macOS targets

* Move `tor-nolzma.xcframework` inside Tor

* Remove `libz` from Frameworks as its linked in Tor

* Remove stray `.gitkeep` from macOS target membership

* Fix missing import and access control for modularized Tor

- Add import Tor to NetworkActivationService
- Make TorManager.shutdownCompletely() public for external access

* Fix tor-nolzma.xcframework structure for Xcode builds

- Add missing Info.plist files to all framework slices
- Restructure macOS framework to use deep bundle format (Versions/)
- Keep iOS frameworks as shallow bundles (standard for iOS)

This fixes the Xcode build errors while maintaining SPM compatibility.

* Remove stale xcframework references from Xcode project

Xcode cleaned up old direct references to tor-nolzma.xcframework
since it's now managed internally by the Tor Swift package.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-02 11:53:34 +02:00
IslamandGitHub 787386c66d Shared schemes (#738)
* Update .gitignore to not ignored shared settings

`xcshareddata/` should be added to the repo to sync the scheme settings (like running parallel tests or turning on code coverage…)

* Add `bitchat (iOS)` shared scheme

* Parallelized and randomized test execution

* Gather code coverage for `bitchat_iOS` target
2025-10-01 14:49:19 +02:00
IslamandGitHub aeec15f054 Temporarily disable broken tests to catch actual issues (#740) 2025-10-01 14:48:39 +02:00
IslamandGitHub f004f3e7ea PeerID 3/n: Remove dead code (#706)
* PeerID 2/n: Count and hex conversion done with `bare`

* ChatVM: remove dead code `registerPeerPublicKey`

* ChatVM: Remove dead `handlePeerFavoritedUs`

* ChatVM: remove dead functions

* PrivateChatManager: Remove dead code

* BLEService: Remove dead code
2025-10-01 13:38:28 +02:00
IslamandGitHub 8d938e8518 PeerID 2/n: Count and hex conversion done with bare (#705) 2025-10-01 13:33:39 +02:00
jackandGitHub ea72212f66 Prune unused validation helpers (#701) 2025-10-01 13:29:09 +02:00
IslamandGitHub 3183703a63 Fix broken build + uncover localization tests (#702)
* Move LocalizationCatalogTests out of Localization/

SPM is treating all the files under Localization as a resource as per the Package.swift, hence it’s not even building it

* Use a class object vs struct to fix build issue

* Explicitly check that the output is not a l10n key

* Remove skipped test
2025-10-01 13:16:59 +02:00
jack 2ffa709cca Bump version to 1.4.4 2025-09-30 13:14:26 +02:00
IslamandGitHub bf712a0610 Refactor peerID - 1/n: Add PeerID + Tests (#688)
* Unify SHA256 hash and hex usages

* Refactor `peerID` - 1/n: Add `PeerID` + Tests
2025-09-30 12:49:36 +02:00
IslamandGitHub 78fb3f1bf6 Unify SHA256 hash and hex usages (#687) 2025-09-30 12:47:16 +02:00
Jonathan BoiceandGitHub f5af00be88 test(localization): squash divergent history to restore clean commit (#695)
- Replace 474/474 sync divergence with a single descriptive commit
- Keep only intended localization test improvements vs main
- Ensure readable history for code review and future merges
2025-09-30 12:45:49 +02:00
jackandGitHub b2214e30f5 Remove unused favorites and notification helpers (#693) 2025-09-30 12:44:46 +02:00
jackandGitHub 85063e9359 Optimize private chat deduplication (#694) 2025-09-30 12:37:48 +02:00
Jonathan BoiceandGitHub f67f728d79 Fix broke tests with LocationNotesManagerTests to expect localization key (#699)
* fix(test): update LocationNotesManagerTests to expect localization key

The tests were failing because in the test environment, String(localized:) returns
the localization key instead of the actual localized value. Updated the assertions
to expect 'location_notes.error.no_relays' instead of the English translation
'no geo relays available near this location. try again soon.'

* Fix LocationNotesManager test assertions for localization bundle

- Update test assertions to use String(localized:) to match manager behavior in test environment
- Fixes issue where tests expected localized text but manager returns raw keys in SPM test environment
- Both manager and tests now consistently handle localization bundle differences
- Resolves P1 issue: Keep LocationNotes error messages localized
- All 124 tests now pass after clean build

* SPM: process bitchat/Localizable.xcstrings in main target to fix CLI warning; keep tests' Localization resource.
2025-09-30 12:32:53 +02:00
b873b19104 Add new languages (#700)
* Fix establishing encryption typo

* Add Bengali localizations

* Add Hindi localizations

* Add Turkish localizations

* Add Portuguese (Portugal) localizations

* Add widespread localization coverage

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-30 12:31:29 +02:00
0f7bcf17ff Refactor: Migrate to Swift String Catalogs (.xcstrings) (#691)
* Automated update of relay data - Sun Sep 21 06:26:33 UTC 2025

* chore(l10n): add empty string catalogs

* chore(l10n): populate string catalogs from legacy resources

* test(l10n): add catalog guardrail suite

* chore(l10n): remove legacy localization files

* fix: Add localization resources to Package.swift targets

- Add .process("Localization") to bitchat target resources
- Add .process("Localization") to bitchatTests target resources
- Resolves Bundle.module resource loading for localization files
- Enables proper localization testing in Swift Package Manager builds

* feat: Add Korean localization and convert to UTF-8 format

Korean Language Support:
- Add complete Korean (ko) localization with 191 strings from PR #686
- Include all app strings: UI, features, system messages, alerts
- Include all share extension strings: status messages, errors
- Verified 100% translation coverage for Korean locale

UTF-8 Format Conversion:
- Convert 23,047 Unicode escape sequences to readable UTF-8 characters
- Transform \u sequences (e.g. \u0625\u063a\u0644\u0627\u0642) to native text (إغلاق)
- Improve maintainability across all 15 supported locales
- Preserve all existing translations while enhancing readability

Locales supported: en, ar, de, es, fr, he, id, it, ja, ko, ne, pt-BR, ru, uk, zh-Hans

* test: Enhance dynamic localization test framework

Dynamic Test Framework:
- Replace hardcoded locale tests with data-driven approach
- Add testLocalizationExpectedValues() for dynamic locale validation
- Add testConfiguredLocalesCompleteness() for coverage verification
- Tests now read configuration from PrimaryLocalizationKeys.json

Expanded Test Coverage:
- Increase from 14 to 33 key validations (135% increase)
- Add critical UI strings: common actions, app info, security, sharing
- Cover 364 total string validations across 15 locales
- Include Korean validation with native Korean expected values

Test Categories Added:
- Common UI: cancel, close, copy actions
- App Info: encryption, offline features, app name
- Bluetooth: permission and settings alerts
- Security: verification badges and actions
- Share Extension: all status and error messages
- Content Actions: accessibility and user actions

Maintains 100% test success rate across all supported locales.

* fix: Convert plural strings to correct xcstrings format

Three plural strings (content.accessibility.people_count,
location_channels.row_title, location_notes.header) were using
incorrect format causing runtime String(format:) errors.

Migrated from stringUnit.variations structure to proper
substitutions.variations format across all 14 languages.

* refactor(l10n): migrate to native String(localized:) APIs

Remove L10n.string wrapper in favor of Swift's native localization APIs.
Migrate 100+ localization call sites to use String(localized:) and String(localized:defaultValue:) with string interpolation.

- Update catalog to use interpolation syntax (\(var)) instead of format specifiers (%@)
- Migrate simple strings to String(localized:)
- Migrate strings with arguments to String(localized:defaultValue:) with interpolation
- Keep format strings for plural substitutions (String(format:locale:))
- Remove bitchat/Utils/Localization.swift

Net result: -407 insertions, +130 deletions across 15 files

* fix(l10n): correct interpolation to use format strings

Interpolation in String(localized:defaultValue:) doesn't work as expected -
the interpolation happens at the call site before localization lookup.

Convert dynamic strings to use String(format:String(localized:),args) pattern:
- Update catalog entries from \(var) syntax to %@ placeholders
- Wrap String(localized:) calls with String(format:locale:) for dynamic values
- Affects 17 strings across 6 files

This fixes UI showing literal "\(geohash)" text instead of actual values.

* chore(l10n): remove invalid catalog entries

Remove auto-extracted literal strings (@, #, ✔︎, @%@, bitchat/) that were
generated without proper localization structure. These caused test
decoding failures.

* fix(l10n): remove unused auto-extracted format string

Remove '%@/%@' key that was auto-extracted by Xcode but never used.
This key only existed in English causing locale parity test failures
across all 13 other languages.

Fixes locale parity tests - all 8 localization tests now pass with
only expected failures (incomplete translations in some locales).

* fix(l10n): copy format string to all locales for 100% completion

Add %@/%@ format string to all 14 non-English locales. Format strings
are locale-independent so using the same value everywhere is correct.

This brings all locales to 100% completion (189/189 strings) to prevent
Xcode from reporting incomplete translations when building.

* fix(l10n): prevent auto-extraction of UI literals

Use Text(verbatim:) for non-localizable UI elements:
- App branding ("bitchat/")
- Symbols (@, #, ✔︎)
- Dynamic usernames (@username)
- Count ratios (reached/total)

This prevents Xcode from auto-extracting these literals into the
String Catalog when building through Xcode GUI, which was causing
locales to show 96% completion instead of 100%.

* chore(l10n): remove auto-extracted UI literal entries

Delete 5 auto-extracted keys from catalog that are now using Text(verbatim:):
- @, #, ✔︎, %@, %@/%@

These were showing as stale/incomplete in Xcode causing 97% completion.
All locales now at 100% (188/188 strings).

* fix(l10n): prevent AttributedString from extracting @ symbol

Use string interpolation "\\(at)" instead of literal "@" in
AttributedString to prevent Xcode from auto-extracting it to the
String Catalog during build.

This was the last string causing locales to show 99% instead of 100%.

* fix(l10n): add %@ as non-translatable key in all locales

Mark %@ as non-translatable and add to all 15 locales with same value.
This prevents Xcode from showing incomplete translations when it
auto-extracts this format specifier during GUI builds.

All locales remain at 100% (189/189 strings).

* refactor: move Localizable.xcstrings to bitchat root

Move bitchat/Localization/Localizable.xcstrings to bitchat/ (after LaunchScreen)
and remove empty Localization directory.

* fix(test): update catalog path in localization tests

Update test paths from bitchat/Localization/Localizable.xcstrings to
bitchat/Localizable.xcstrings after moving the file.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-29 22:31:59 +02:00
GitHub Action da2a12296e Automated update of relay data - Sun Sep 28 06:04:29 UTC 2025 2025-09-28 06:04:30 +00:00
sheepbellandGitHub 56bd100944 Fix a typo in base Localizable.strings (#685) 2025-09-27 11:27:26 +02:00
IslamandGitHub eb5bc96a3c Unify the usages of splitSuffix() (#683) 2025-09-26 11:13:18 +02:00
IslamandGitHub d01538a2ea Fix Localizations (#684)
* Update all `NSLocalizedString` with `L10n.string`

+ combine with `L10n.format`

* Handle Localizations + Swift Package
2025-09-26 11:09:10 +02:00
IslamandGitHub 9abfab3248 Comment out broken tests (#675) 2025-09-25 19:34:32 +02:00
IslamandGitHub 0ae09f73d8 Fix tests that were broken after localization integration (#677) 2025-09-25 19:34:08 +02:00
IslamandGitHub 56dd12f3b1 Add default localization to fix CI builds (#674) 2025-09-25 14:14:25 +02:00
f5caa1751a Add base localization infrastructure and externalize strings (#670)
* Add base localization infrastructure and externalize strings

* Add Spanish localization scaffolding with translations

* Add machine translations for expanded locales

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-24 15:12:42 +02:00
jack de906cb97c Refresh typography and channel sheet styling 2025-09-24 12:32:11 +02:00
Mattia MarcheseandGitHub a41ec65f58 Include mermaid diagrams for packet structures (#666)
Added mermaid diagrams for BitchatPacket and BitchatMessage structures.
2025-09-24 12:18:25 +02:00
1fd2da18f5 Improve BLE relay reliability (#665)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-23 21:09:22 +02:00
c49a1b264e Support Dynamic Type across chat surfaces (#664)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-23 19:56:16 +02:00
leoperegrinoandGitHub 10c0391eaf enforce https in noiseprotocol.org url (#661) 2025-09-23 14:08:48 +02:00
3a94b57341 Fix BLE stream crashes and gossip sync races (#663)
* Handle long BLE packets safely

* Keep BLE stream aligned after partial drops

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-23 14:06:13 +02:00
f8f780d2d6 Refine location notes UI and align sheet layouts (#660)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-21 14:56:20 +02:00
jackandGitHub c837afb818 Remove unused handshake coordinator and identity placeholders (#656) 2025-09-21 13:20:21 +02:00
IslamandGitHub 47ef82f01a Refactor 2/n: ChatViewModel's Geohash Subscription (#635)
* Extract `processNostrMessage` into a function

* `updateChannelActivityTimeThenSend` function

* Break down / flatten `beginGeohashSampling`

* Extract `subscribeNostrEvent` into a function

* Break down / flatten `resubscribeCurrentGeohash`
2025-09-21 12:46:28 +02:00
jack d1e5ce21a7 Enable default relays when location permission granted 2025-09-21 12:43:27 +02:00
GitHub Action f2c1bb2131 Automated update of relay data - Sun Sep 21 06:04:25 UTC 2025 2025-09-21 06:04:25 +00:00
RubensandGitHub 221819b591 fix: crash on shared extension (#621)
* fix: crash on shared extension

* fix: global(qos: .default) to global()

* fix: removed weak self
2025-09-17 08:03:07 -07:00
RubensandGitHub 4a21ab0531 chore: debug icon (#634)
* chore: add debug icon to make it easier to identify debug builds on developers' devices

* chore: add new AppIcon assets with 1024x1024
2025-09-17 08:00:06 -07:00
jack 50ae8da5f9 Bump marketing version to 1.4.2 2025-09-16 08:16:21 -07:00
54bb812469 Gate relays on mutual favorites and add Tor toggle (#631)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-16 08:12:51 -07:00
IslamandGitHub 482fca81ef Single source of truth for Marketing Version (#627) 2025-09-16 06:44:30 -07:00
IslamandGitHub b2e7d2d26e Set macOS App Category as Social Media as well (#628) 2025-09-16 06:43:23 -07:00
IslamandGitHub 6a2832d22b Prevent Github Languages stats skewing (#630) 2025-09-16 06:42:36 -07:00
jack 56e7324069 Improve Tor dormant resume and restart flow 2025-09-15 23:08:29 +02:00
1733dda6cd Ignore self when presenting message actions (#625)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 21:58:39 +02:00
00ff5fd31c Refine panic mode to regenerate identities immediately (#624)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 21:39:39 +02:00
RubensandGitHub f684c452b2 fix: adjust Justfile after removing XcodeGen from the project (#620) 2025-09-15 20:59:56 +02:00
2403 changed files with 47755 additions and 10406 deletions
+20
View File
@@ -0,0 +1,20 @@
# Prevent Github Languages stats skewing:
# Binaries and assets
**/*.xcframework/** linguist-vendored
**/*.xcassets/** linguist-vendored
# Generated files
**/*.pbxproj linguist-generated
**/*.storyboard linguist-generated
Package.resolved linguist-generated
# Downloaded CSVs
relays/online_relays_gps.csv linguist-vendored
# Docs
**/*.md linguist-documentation
# Configs
Configs/*.xcconfig linguist-documentation
**/*.plist linguist-documentation
+1 -1
View File
@@ -24,4 +24,4 @@ jobs:
run: swift build run: swift build
- name: Run Tests - name: Run Tests
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345 run: swift test --parallel
+5 -4
View File
@@ -9,9 +9,6 @@ plans/
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint *.xcscmblueprint
*.xccheckout *.xccheckout
@@ -57,7 +54,8 @@ iOSInjectionProject/
## Xcode project ## Xcode project
*.xcodeproj/project.xcworkspace/ *.xcodeproj/project.xcworkspace/
*.xcodeproj/xcshareddata/ ## Xcode User settings
xcuserdata/
## Python ## Python
__pycache__/ __pycache__/
@@ -68,6 +66,9 @@ __pycache__/
*.tmp *.tmp
*.temp *.temp
## Cache
.cache/
# Local build results # Local build results
.Result*/ .Result*/
.Result*.xcresult/ .Result*.xcresult/
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.4.0 MARKETING_VERSION = 1.4.4
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+9 -18
View File
@@ -14,16 +14,16 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1) @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met" @echo "✅ All prerequisites met"
# Backup original files # Backup original files
backup: backup:
@echo "Backing up original project configuration..." @echo "Backing up original project configuration..."
@cp project.yml project.yml.backup 2>/dev/null || true
@# Backup other files that get modified by xcodegen
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi @if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
@@ -44,15 +44,10 @@ patch-for-macos: backup
@# Move iOS-specific files out of the way temporarily @# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi @if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Generate Xcode project with patches
generate: patch-for-macos
@echo "Generating Xcode project..."
@xcodegen generate
# Build the macOS app # Build the macOS app
build: check generate build: #check generate
@echo "Building BitChat for macOS..." @echo "Building BitChat for macOS..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app # Run the macOS app
run: build run: build
@@ -75,9 +70,7 @@ clean: restore
# Quick run without cleaning (for development) # Quick run without cleaning (for development)
dev-run: check dev-run: check
@echo "Quick development build..." @echo "Quick development build..."
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@xcodegen generate
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Show app info # Show app info
@@ -106,11 +99,9 @@ nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..." @echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 2>/dev/null || true @rm -rf bitchat.xcodeproj 2>/dev/null || true
@rm -f project.yml.backup 2>/dev/null || true
@rm -f project-macos.yml 2>/dev/null || true
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true @rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true @rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved @# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore" @git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@echo "✅ Nuclear clean complete" @echo "✅ Nuclear clean complete"
+9 -14
View File
@@ -4,6 +4,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "bitchat", name: "bitchat",
defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v16),
.macOS(.v13) .macOS(.v13)
@@ -15,6 +16,7 @@ let package = Package(
), ),
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/Tor"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
], ],
@@ -24,8 +26,7 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "P256K", package: "swift-secp256k1"), .product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger"),
.target(name: "TorC"), .product(name: "Tor", package: "Tor")
.target(name: "tor-nolzma")
], ],
path: "bitchat", path: "bitchat",
exclude: [ exclude: [
@@ -33,21 +34,12 @@ let package = Package(
"Assets.xcassets", "Assets.xcassets",
"bitchat.entitlements", "bitchat.entitlements",
"bitchat-macOS.entitlements", "bitchat-macOS.entitlements",
"LaunchScreen.storyboard", "LaunchScreen.storyboard"
"Services/Tor/C/"
], ],
linkerSettings: [ resources: [
.linkedLibrary("z") .process("Localizable.xcstrings")
] ]
), ),
.target(
name: "TorC",
path: "bitchat/Services/Tor/C"
),
.binaryTarget(
name: "tor-nolzma",
path: "Frameworks/tor-nolzma.xcframework"
),
.testTarget( .testTarget(
name: "bitchatTests", name: "bitchatTests",
dependencies: ["bitchat"], dependencies: ["bitchat"],
@@ -55,6 +47,9 @@ let package = Package(
exclude: [ exclude: [
"Info.plist", "Info.plist",
"README.md" "README.md"
],
resources: [
.process("Localization")
] ]
) )
] ]
+9 -2
View File
@@ -9,7 +9,7 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING] > [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns. > Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License ## License
@@ -22,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr - **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -116,3 +116,10 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
Want to try this on macos: `just run` will set it up and run from source. Want to try this on macos: `just run` will set it up and run from source.
Run `just clean` afterwards to restore things to original state for mobile app building and development. Run `just clean` afterwards to restore things to original state for mobile app building and development.
## Localization
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
+41
View File
@@ -184,6 +184,28 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers. **Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
```mermaid
---
config:
theme: dark
---
---
title: "BitchatPacket"
---
packet
+8: "Version"
+8: "Type"
+8: "TTL"
+64: "Timestamp"
+8: "Flags"
+16: "Payload Length"
+64: "Sender ID"
+64: "Recipient ID (optional)"
+48: "Payload (variable)"
+64: "Signature (optional)"
```
_A representation of the sizes of the fields in `BitchatPacket`_
### 6.2. Application Message Format (`BitchatMessage`) ### 6.2. Application Message Format (`BitchatMessage`)
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content. For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
@@ -198,6 +220,25 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. | | Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. | | Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
```mermaid
---
config:
theme: dark
---
---
title: "BitchatMessage"
---
packet
+8: "Flags"
+64: "Timestamp"
+24: "ID (variable)"
+32: "Sender (variable)"
+32: "Content (variable)"
+32: "Original Sender (variable) (optional)"
+32: "Recipient Nickname (variable) (optional)"
```
_A representation of the sizes of the fields in `BitchatMessage`_
--- ---
## 7. Message Routing and Propagation ## 7. Message Routing and Propagation
+179 -31
View File
@@ -7,14 +7,14 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; }; A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -27,6 +27,13 @@
remoteGlobalIDString = AF077EA0474EDEDE2C72716C; remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
remoteInfo = bitchat_iOS; remoteInfo = bitchat_iOS;
}; };
E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
proxyType = 1;
remoteGlobalIDString = 57CA17A36A2532A6CFF367BB;
remoteInfo = bitchatShareExtension;
};
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = { FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */; containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
@@ -42,6 +49,7 @@
dstPath = ""; dstPath = "";
dstSubfolder = PlugIns; dstSubfolder = PlugIns;
files = ( files = (
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
}; };
@@ -49,15 +57,22 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; }; E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Services/TransportConfig.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = { A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
@@ -70,16 +85,23 @@
membershipExceptions = ( membershipExceptions = (
Info.plist, Info.plist,
LaunchScreen.storyboard, LaunchScreen.storyboard,
Services/Tor/C/include/.gitkeep,
); );
target = 0576A29205865664C0937536 /* bitchat_macOS */; target = 0576A29205865664C0937536 /* bitchat_macOS */;
}; };
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ShareViewController.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
A6E32C972E762EA70032EA8A /* bitchat */ = { A6E32C972E762EA70032EA8A /* bitchat */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */,
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */, A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */,
A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */, A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */,
); );
@@ -88,6 +110,9 @@
}; };
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = { A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */,
);
path = bitchatShareExtension; path = bitchatShareExtension;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -108,32 +133,22 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
); );
}; };
B5A5CC493FFB3D8966548140 /* Frameworks */ = { B5A5CC493FFB3D8966548140 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
); );
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0481A3542E6D877600FC845E /* Frameworks */ = {
isa = PBXGroup;
children = (
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
0481A35C2E6DA18600FC845E /* libz.tbd */,
);
path = Frameworks;
sourceTree = "<group>";
};
18198ED912AAF495D8AF7763 = { 18198ED912AAF495D8AF7763 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -142,8 +157,8 @@
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */, A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
A6E32D412E762EAE0032EA8A /* bitchatTests */, A6E32D412E762EAE0032EA8A /* bitchatTests */,
A6E367C92E76469E0032EA8A /* Configs */, A6E367C92E76469E0032EA8A /* Configs */,
0481A3542E6D877600FC845E /* Frameworks */,
9F37F9F2C353B58AC809E93B /* Products */, 9F37F9F2C353B58AC809E93B /* Products */,
A6F183FB2E948783006A9046 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -152,12 +167,21 @@
children = ( children = (
96D0D41CA19EE5A772AA8434 /* bitchat.app */, 96D0D41CA19EE5A772AA8434 /* bitchat.app */,
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */, 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */,
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */, C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */, 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A6F183FB2E948783006A9046 /* Frameworks */ = {
isa = PBXGroup;
children = (
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -178,6 +202,7 @@
packageProductDependencies = ( packageProductDependencies = (
B1D9136AA0083366353BFA2F /* P256K */, B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */, A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */,
); );
productName = bitchat_macOS; productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -202,6 +227,19 @@
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */; productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
buildPhases = (
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
);
buildRules = (
);
name = bitchatShareExtension;
productName = bitchatShareExtension;
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = { 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */; buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
@@ -232,6 +270,9 @@
); );
buildRules = ( buildRules = (
); );
dependencies = (
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A6E32C972E762EA70032EA8A /* bitchat */, A6E32C972E762EA70032EA8A /* bitchat */,
); );
@@ -239,6 +280,7 @@
packageProductDependencies = ( packageProductDependencies = (
4EB6BA1B8464F1EA38F4E286 /* P256K */, 4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */,
); );
productName = bitchat_iOS; productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -259,17 +301,33 @@
knownRegions = ( knownRegions = (
Base, Base,
en, en,
es,
ar,
de,
fr,
he,
id,
it,
ja,
ne,
"pt-BR",
ru,
tr,
uk,
"zh-Hans",
); );
mainGroup = 18198ED912AAF495D8AF7763; mainGroup = 18198ED912AAF495D8AF7763;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
); );
preferredProjectObjectVersion = 90; preferredProjectObjectVersion = 90;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */,
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */, 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */, 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
AF077EA0474EDEDE2C72716C /* bitchat_iOS */, AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
@@ -294,6 +352,11 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
0A08E70F08F55FD5BA8C7EF3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
files = (
);
};
137ABE739BF20ACDDF8CC605 /* Sources */ = { 137ABE739BF20ACDDF8CC605 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
files = ( files = (
@@ -322,6 +385,11 @@
target = 0576A29205865664C0937536 /* bitchat_macOS */; target = 0576A29205865664C0937536 /* bitchat_macOS */;
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */; targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
}; };
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */;
};
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = { D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */; target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
@@ -406,18 +474,49 @@
}; };
name = Release; name = Release;
}; };
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Release.xcconfig;
buildSettings = {
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = "$(SWIFT_VERSION)";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = { 702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig; baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = 66W2RU3J5T; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -429,7 +528,6 @@
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -478,7 +576,7 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = 66W2RU3J5T; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -490,7 +588,6 @@
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -515,10 +612,11 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 66W2RU3J5T; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -526,7 +624,6 @@
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = macosx; SDKROOT = macosx;
@@ -599,7 +696,7 @@
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */; baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig; baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
@@ -607,10 +704,11 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 66W2RU3J5T; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -618,7 +716,6 @@
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = macosx; SDKROOT = macosx;
@@ -693,6 +790,37 @@
}; };
name = Debug; name = Debug;
}; };
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = {
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = "$(SWIFT_VERSION)";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -736,6 +864,14 @@
); );
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */,
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */,
);
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
@@ -743,6 +879,10 @@
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitLogger; relativePath = localPackages/BitLogger;
}; };
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Tor;
};
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
@@ -770,6 +910,14 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = BitLogger; productName = BitLogger;
}; };
A6E3EA7E2E7706720032EA8A /* Tor */ = {
isa = XCSwiftPackageProductDependency;
productName = Tor;
};
A6E3EA802E7706A80032EA8A /* Tor */ = {
isa = XCSwiftPackageProductDependency;
productName = Tor;
};
B1D9136AA0083366353BFA2F /* P256K */ = { B1D9136AA0083366353BFA2F /* P256K */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
BuildableName = "bitchatShareExtension.appex"
BlueprintName = "bitchatShareExtension"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</MacroExpansion>
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
BuildableName = "bitchatTests_iOS.xctest"
BlueprintName = "bitchatTests_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
BuildableName = "bitchatTests_macOS.xctest"
BlueprintName = "bitchatTests_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "BITCHAT_LOG_LEVEL"
value = "debug"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,36 @@
{
"images" : [
{
"filename" : "image-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+7 -2
View File
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Tor
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
@@ -25,11 +26,15 @@ struct BitchatApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif #endif
private let idBridge = NostrIdentityBridge()
init() { init() {
let keychain = KeychainManager() let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject( _chatViewModel = StateObject(
wrappedValue: ChatViewModel( wrappedValue: ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain) identityManager: SecureIdentityStateManager(keychain)
) )
) )
@@ -49,7 +54,7 @@ struct BitchatApp: App {
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast // Prewarm Nostr identity and QR to make first VERIFY sheet fast
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub) _ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
} }
#if os(iOS) #if os(iOS)
@@ -216,7 +221,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo // Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String { if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async { DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: peerID) self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
} }
} }
} }
@@ -1,91 +0,0 @@
import Foundation
#if os(iOS)
import AVFoundation
import CoreGraphics
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
private var recorder: AVAudioRecorder?
private(set) var isRecording = false
func startRecording(to url: URL) throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 32000
]
recorder = try AVAudioRecorder(url: url, settings: settings)
recorder?.delegate = self
recorder?.isMeteringEnabled = true
guard recorder?.record() == true else { throw NSError(domain: "VoiceRecorder", code: -1) }
isRecording = true
}
func stopRecording(completion: @escaping () -> Void) {
guard isRecording else { completion(); return }
// Add 500ms padding to avoid clipping
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.recorder?.stop()
self?.isRecording = false
completion()
}
}
// MARK: - Live metrics for visualizer
/// Returns normalized amplitude [0,1] based on average power in dB.
/// Call periodically while recording to drive a live waveform.
func pollNormalizedAmplitude() -> CGFloat {
guard let r = recorder, isRecording else { return 0 }
r.updateMeters()
// Use peak power for responsiveness, map dB [-160,0] -> linear [0,1]
let db = r.peakPower(forChannel: 0)
let linear = pow(10.0, db / 20.0) // 0..1
if linear.isNaN || linear.isInfinite { return 0 }
// Keep linear to avoid globally scaling up low amplitudes
return CGFloat(max(0, min(1, linear)))
}
/// Current elapsed recording time in milliseconds.
func currentTimeMs() -> Int {
guard let r = recorder, isRecording else { return 0 }
return Int(r.currentTime * 1000)
}
}
enum VoiceRecorderPaths {
static func outgoingURL() throws -> URL {
let fm = FileManager.default
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
let ts = ISO8601DateFormatter()
ts.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime]
let name = "voice_\(Int(Date().timeIntervalSince1970)).m4a"
return folder.appendingPathComponent(name)
}
}
#else
/// Minimal macOS-compatible stubs so the macOS target builds without AVFAudio.
final class VoiceRecorder: NSObject {
private(set) var isRecording = false
func startRecording(to url: URL) throws {
// Voice recording is iOS-only in this project
throw NSError(domain: "VoiceRecorder", code: -1, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
}
func stopRecording(completion: @escaping () -> Void) { completion() }
}
enum VoiceRecorderPaths {
static func outgoingURL() throws -> URL {
throw NSError(domain: "VoiceRecorder", code: -2, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
}
}
#endif
-76
View File
@@ -1,76 +0,0 @@
import Foundation
import AVFoundation
import Accelerate
struct WaveformCache {
static var shared = WaveformCache()
private var cache: [String: [Float]] = [:]
mutating func set(_ bins: [Float], for path: String) { cache[path] = bins }
func get(_ path: String) -> [Float]? { cache[path] }
}
enum WaveformExtractor {
static func extractBins(url: URL, binCount: Int = 120) -> [Float] {
if let cached = WaveformCache.shared.get(url.path) { return cached }
let asset = AVURLAsset(url: url)
guard let track = asset.tracks(withMediaType: .audio).first else { return [] }
let reader: AVAssetReader
do { reader = try AVAssetReader(asset: asset) } catch { return [] }
let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsNonInterleaved: false
]
let output = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings)
output.alwaysCopiesSampleData = false
if reader.canAdd(output) { reader.add(output) } else { return [] }
guard reader.startReading() else { return [] }
var samples: [Float] = []
while reader.status == .reading {
guard let buffer = output.copyNextSampleBuffer() else { break }
if let block = CMSampleBufferGetDataBuffer(buffer) {
var length = 0
var dataPointer: UnsafeMutablePointer<Int8>?
if CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
let base = dataPointer {
// 16-bit signed little endian
let count = length / 2
var floats = [Float](repeating: 0, count: count)
base.withMemoryRebound(to: Int16.self, capacity: count) { ptr in
vDSP.convertElements(of: UnsafeBufferPointer(start: ptr, count: count), to: &floats)
}
// Normalize to [-1,1]
var maxVal: Float = 32768.0
vDSP.divide(floats, maxVal, result: &floats)
samples += floats
}
}
CMSampleBufferInvalidate(buffer)
}
guard !samples.isEmpty, reader.status == .completed || reader.status == .reading else { return [] }
// Reduce to bins by RMS per window
let window = max(1, samples.count / binCount)
var bins: [Float] = []
bins.reserveCapacity(binCount)
var i = 0
while i < samples.count && bins.count < binCount {
let end = min(i + window, samples.count)
let slice = samples[i..<end]
var sum: Float = 0
vDSP_svesq(slice.map { $0 }, 1, &sum, vDSP_Length(slice.count))
let rms = sqrtf(sum / Float(slice.count))
bins.append(min(1.0, rms))
i = end
}
if bins.count < binCount {
bins += Array(repeating: 0, count: binCount - bins.count)
}
WaveformCache.shared.set(bins, for: url.path)
return bins
}
}
+1 -18
View File
@@ -87,7 +87,7 @@ import Foundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity { struct EphemeralIdentity {
let peerID: String // 8 random bytes let peerID: PeerID // 8 random bytes
let sessionStart: Date let sessionStart: Date
var handshakeState: HandshakeState var handshakeState: HandshakeState
} }
@@ -158,23 +158,6 @@ struct IdentityCache: Codable {
var version: Int = 1 var version: Int = 1
} }
// MARK: - Identity Resolution
enum IdentityHint {
case unknown
case likelyKnown(fingerprint: String)
case ambiguous(candidates: Set<String>)
case verified(fingerprint: String)
}
// MARK: - Pending Actions
struct PendingActions {
var toggleFavorite: Bool?
var setTrustLevel: TrustLevel?
var setPetname: String?
}
// //
// MARK: - Migration Support // MARK: - Migration Support
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
// MARK: Cryptographic Identities // MARK: Cryptographic Identities
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
func updateSocialIdentity(_ identity: SocialIdentity) func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management // MARK: Favorites Management
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
func getBlockedNostrPubkeys() -> Set<String> func getBlockedNostrPubkeys() -> Set<String>
// MARK: Ephemeral Session Management // MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState) func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
func updateHandshakeState(peerID: String, state: HandshakeState) func updateHandshakeState(peerID: PeerID, state: HandshakeState)
// MARK: Cleanup // MARK: Cleanup
func clearAllIdentityData() func clearAllIdentityData()
func removeEphemeralSession(peerID: String) func removeEphemeralSession(peerID: PeerID)
// MARK: Verification // MARK: Verification
func setVerified(fingerprint: String, verified: Bool) func setVerified(fingerprint: String, verified: Bool)
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
private let encryptionKeyName = "identityCacheEncryptionKey" private let encryptionKeyName = "identityCacheEncryptionKey"
// In-memory state // In-memory state
private var ephemeralSessions: [String: EphemeralIdentity] = [:] private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] } guard peerID.isShort else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
} }
} }
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Ephemeral Session Management // MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity( self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID, peerID: peerID,
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
func updateHandshakeState(peerID: String, state: HandshakeState) { func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state self.ephemeralSessions[peerID]?.handshakeState = state
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
func removeEphemeralSession(peerID: String) { func removeEphemeralSession(peerID: PeerID) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
} }
-6
View File
@@ -33,18 +33,12 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string> <string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>bitchat saves received images to your photo library when you choose to save them.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat needs access to your photos to let you pick images to send.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string> <string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record and send short voice notes to nearby peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
File diff suppressed because it is too large Load Diff
-133
View File
@@ -1,133 +0,0 @@
//
// BitchatFilePacket.swift
// bitchat
//
// TLV encoder/decoder for file transfer payloads (images, audio, and generic files)
// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len)
//
import Foundation
import CryptoKit
struct BitchatFilePacket {
enum TLVType: UInt8 {
case fileName = 0x01
case fileSize = 0x02
case mimeType = 0x03
case content = 0x04
}
let fileName: String
let fileSize: UInt32
let mimeType: String
let content: Data
func encode() -> Data? {
var out = Data()
// Standard TLV helper for FILE_NAME and MIME_TYPE (2-byte length)
func appendStandardTLV(_ type: TLVType, _ value: Data) {
out.append(type.rawValue)
let len = UInt16(min(value.count, 0xFFFF))
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(value.prefix(Int(len)))
}
// FILE_NAME
if let nameData = fileName.data(using: .utf8) {
appendStandardTLV(.fileName, nameData)
}
// FILE_SIZE (4 bytes, UInt32 BE) - v2 spec
out.append(TLVType.fileSize.rawValue)
out.append(UInt8(0)) // Length high byte = 0
out.append(UInt8(4)) // Length low byte = 4
let size32 = UInt32(min(fileSize, UInt32.max))
out.append(UInt8((size32 >> 24) & 0xFF))
out.append(UInt8((size32 >> 16) & 0xFF))
out.append(UInt8((size32 >> 8) & 0xFF))
out.append(UInt8(size32 & 0xFF))
// MIME_TYPE
if let mimeData = mimeType.data(using: .utf8) {
appendStandardTLV(.mimeType, mimeData)
}
// CONTENT (4-byte length) - v2 spec
out.append(TLVType.content.rawValue)
let contentLen = UInt32(content.count)
out.append(UInt8((contentLen >> 24) & 0xFF))
out.append(UInt8((contentLen >> 16) & 0xFF))
out.append(UInt8((contentLen >> 8) & 0xFF))
out.append(UInt8(contentLen & 0xFF))
out.append(content)
return out
}
static func decode(from data: Data) -> BitchatFilePacket? {
var idx = 0
func read(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
defer { idx += n }
return data.subdata(in: idx..<(idx+n))
}
func read8() -> UInt8? { read(1)?.first }
func read16() -> UInt16? {
guard let d = read(2) else { return nil }
return (UInt16(d[0]) << 8) | UInt16(d[1])
}
func read32() -> UInt32? {
guard let d = read(4) else { return nil }
return (UInt32(d[0]) << 24) | (UInt32(d[1]) << 16) | (UInt32(d[2]) << 8) | UInt32(d[3])
}
var name: String?
var size: UInt32?
var mime: String?
var contentData = Data()
while idx < data.count {
guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil }
// CONTENT uses 4-byte length; others use 2-byte length
let len: Int
if tlvType == .content {
guard let len32 = read32() else { return nil }
len = Int(len32)
} else {
guard let len16 = read16() else { return nil }
len = Int(len16)
}
guard len >= 0, idx + len <= data.count else { return nil }
let val = data.subdata(in: idx..<(idx+len))
idx += len
switch tlvType {
case .fileName:
name = String(data: val, encoding: .utf8)
case .fileSize:
guard len == 4 else { return nil } // v2 spec: 4 bytes
var s: UInt32 = 0
for b in val { s = (s << 8) | UInt32(b) }
size = s
case .mimeType:
mime = String(data: val, encoding: .utf8)
case .content:
// Expect single CONTENT TLV; if multiple, concatenate defensively
contentData.append(val)
}
}
// Validate
guard !contentData.isEmpty else { return nil }
let finalSize = size ?? UInt32(contentData.count)
let finalName = name ?? "file"
let finalMime = mime ?? "application/octet-stream"
return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: contentData)
}
}
extension Data { func sha256Hex() -> String { SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined() } }
+17 -5
View File
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
let originalSender: String? let originalSender: String?
let isPrivate: Bool let isPrivate: Bool
let recipientNickname: String? let recipientNickname: String?
let senderPeerID: String? let senderPeerID: PeerID?
let mentions: [String]? // Array of mentioned nicknames let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking var deliveryStatus: DeliveryStatus? // Delivery tracking
@@ -42,7 +42,19 @@ final class BitchatMessage: Codable {
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
} }
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) { init(
id: String? = nil,
sender: String,
content: String,
timestamp: Date,
isRelay: Bool,
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeerID: PeerID? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
self.id = id ?? UUID().uuidString self.id = id ?? UUID().uuidString
self.sender = sender self.sender = sender
self.content = content self.content = content
@@ -151,7 +163,7 @@ extension BitchatMessage {
data.append(recipData.prefix(255)) data.append(recipData.prefix(255))
} }
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) { if let peerData = senderPeerID?.id.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255))) data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255)) data.append(peerData.prefix(255))
} }
@@ -264,11 +276,11 @@ extension BitchatMessage {
} }
} }
var senderPeerID: String? var senderPeerID: PeerID?
if hasSenderPeerID && offset < dataCopy.count { if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1 let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count { if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8) senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
offset += length offset += length
} }
} }
+2 -16
View File
@@ -22,19 +22,6 @@ struct BitchatPacket: Codable {
var signature: Data? var signature: Data?
var ttl: UInt8 var ttl: UInt8
// Full initialization with explicit version
init(version: UInt8, type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = version
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Backward compatible constructor (defaults to v1)
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) { init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1 self.version = 1
self.type = type self.type = type
@@ -47,12 +34,12 @@ struct BitchatPacket: Codable {
} }
// Convenience initializer for new binary format // Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) { init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
self.version = 1 self.version = 1
self.type = type self.type = type
// Convert hex string peer ID to binary data (8 bytes) // Convert hex string peer ID to binary data (8 bytes)
var senderData = Data() var senderData = Data()
var tempID = senderID var tempID = senderID.id
while tempID.count >= 2 { while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2)) let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) { if let byte = UInt8(hexByte, radix: 16) {
@@ -87,7 +74,6 @@ struct BitchatPacket: Codable {
// Create a copy without signature and with fixed TTL for signing // Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay // TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket( let unsignedPacket = BitchatPacket(
version: version, // Preserve packet version for signing
type: type, type: type,
senderID: senderID, senderID: senderID,
recipientID: recipientID, recipientID: recipientID,
+6 -8
View File
@@ -2,8 +2,8 @@ import Foundation
import CoreBluetooth import CoreBluetooth
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Identifiable, Equatable { struct BitchatPeer: Equatable {
let id: String // Hex-encoded peer ID let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data let noisePublicKey: Data
let nickname: String let nickname: String
let lastSeen: Date let lastSeen: Date
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
// Display helpers // Display helpers
var displayName: String { var displayName: String {
nickname.isEmpty ? String(id.prefix(8)) : nickname nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
} }
var statusIcon: String { var statusIcon: String {
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
// Initialize from mesh service data // Initialize from mesh service data
init( init(
id: String, peerID: PeerID,
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen: Date = Date(), lastSeen: Date = Date(),
isConnected: Bool = false, isConnected: Bool = false,
isReachable: Bool = false isReachable: Bool = false
) { ) {
self.id = id self.peerID = peerID
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen self.lastSeen = lastSeen
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
} }
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool { static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
lhs.id == rhs.id lhs.peerID == rhs.peerID
} }
} }
//
+217
View File
@@ -0,0 +1,217 @@
//
// PeerID.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct PeerID: Equatable, Hashable {
enum Prefix: String, CaseIterable {
/// When no prefix is provided
case empty = ""
/// `"mesh:"`
case mesh = "mesh:"
/// `"name:"`
case name = "name:"
/// `"noise:"` (+ 64 characters hex)
case noise = "noise:"
/// `"nostr_"` (+ 16 characters hex)
case geoDM = "nostr_"
/// `"nostr:"` (+ 8 characters hex)
case geoChat = "nostr:"
}
let prefix: Prefix
/// Returns the actual value without any prefix
let bare: String
/// Returns the full `id` value by combining `(prefix + bare)`
var id: String { prefix.rawValue + bare }
// Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) {
self.prefix = prefix
self.bare = String(bare)
}
}
// MARK: - Convenience Inits
extension PeerID {
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
init(nostr_ pubKey: String) {
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
}
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
init(nostr pubKey: String) {
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
}
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
init(str: any StringProtocol) {
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
} else {
self.init(prefix: .empty, bare: str)
}
}
/// Convenience init to handle `Optional<String>`
init?(str: (any StringProtocol)?) {
guard let str else { return nil }
self.init(str: str)
}
/// Convenience init to create PeerID by converting Data to String
init?(data: Data) {
self.init(str: String(data: data, encoding: .utf8))
}
/// Convenience init to "hide" hex-encoding implementation detail
init(hexData: Data) {
self.init(str: hexData.hexEncodedString())
}
}
// MARK: - Noise Public Key Helpers
extension PeerID {
/// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) {
self.init(str: publicKey.sha256Fingerprint().prefix(16))
}
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
func toShort() -> PeerID {
if let noiseKey {
return PeerID(publicKey: noiseKey)
}
return self
}
}
// MARK: - Codable
extension PeerID: Codable {
init(from decoder: any Decoder) throws {
self.init(str: try decoder.singleValueContainer().decode(String.self))
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(id)
}
}
// MARK: - Helpers
extension PeerID {
var isEmpty: Bool {
id.isEmpty
}
/// Returns true if `id` starts with "`nostr:`"
var isGeoChat: Bool {
prefix == .geoChat
}
/// Returns true if `id` starts with "`nostr_`"
var isGeoDM: Bool {
prefix == .geoDM
}
func toPercentEncoded() -> String {
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
}
}
// MARK: - Validation
extension PeerID {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool {
if prefix != .empty {
return PeerID(str: bare).isValid
}
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
if isShort || isNoiseKeyHex {
return true
}
// If length equals short or full but isn't valid hex, reject
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
return false
}
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !id.isEmpty &&
id.count < Constants.maxIDLength &&
id.rangeOfCharacter(from: validCharset.inverted) == nil
}
/// Returns true if the `bare` id is all hex
var isHex: Bool {
bare.allSatisfy { $0.isHexDigit }
}
/// Short routing IDs (exact 16-hex)
var isShort: Bool {
bare.count == Constants.hexIDLength && isHex
}
/// Full Noise key hex (exact 64-hex)
var isNoiseKeyHex: Bool {
noiseKey != nil
}
/// Full Noise key (exact 64-hex) as Data
var noiseKey: Data? {
guard bare.count == Constants.maxIDLength else { return nil }
return Data(hexString: bare)
}
}
// MARK: - Comparable
extension PeerID: Comparable {
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
lhs.id < rhs.id
}
}
// MARK: - String Interop Helpers
// MARK: CustomStringConvertible
extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String
var description: String {
id
}
}
// MARK: Custom Equatable w/ String & Optionality
// PeerID <> String
extension Optional where Wrapped == PeerID {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
}
// String <> PeerID
extension Optional where Wrapped == String {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
}
+1 -1
View File
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString() let readerID = readerIDData.hexEncodedString()
guard InputValidator.validatePeerID(readerID) else { return nil } guard PeerID(str: readerID).isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset), guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp), InputValidator.validateTimestamp(timestamp),
@@ -1,358 +0,0 @@
//
// NoiseHandshakeCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
final class NoiseHandshakeCoordinator {
// MARK: - Handshake State
enum HandshakeState: Equatable {
case idle
case waitingToInitiate(since: Date)
case initiating(attempt: Int, lastAttempt: Date)
case responding(since: Date)
case waitingForResponse(messagesSent: [Data], timeout: Date)
case established(since: Date)
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
var isActive: Bool {
switch self {
case .idle, .established, .failed:
return false
default:
return true
}
}
}
// MARK: - Properties
private var handshakeStates: [String: HandshakeState] = [:]
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
// Configuration
private let maxHandshakeAttempts = 3
private let handshakeTimeout: TimeInterval = 10.0
private let retryDelay: TimeInterval = 2.0
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
private let maxEstablishedSessions = 50 // Limit total established sessions
// Track handshake messages to detect duplicates
private var processedHandshakeMessages: Set<Data> = []
private let messageHistoryLimit = 100
// MARK: - Role Determination
/// Deterministically determine who should initiate the handshake
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
// Use simple string comparison for deterministic ordering
return myPeerID < remotePeerID ? .initiator : .responder
}
/// Check if we should initiate handshake with a peer
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
return handshakeQueue.sync {
// Check if we're already in an active handshake
if let state = handshakeStates[remotePeerID], state.isActive {
// Check if the handshake is stale and we should force a new one
if forceIfStale {
switch state {
case .initiating(_, let lastAttempt):
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
return true
}
default:
break
}
}
SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
return false
}
// Check role
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
if role != .initiator {
return false
}
// Check if we've failed recently and can't retry yet
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
if !canRetry {
return false
}
if Date().timeIntervalSince(lastAttempt) < retryDelay {
return false
}
}
return true
}
}
/// Record that we're initiating a handshake
func recordHandshakeInitiation(peerID: String) {
handshakeQueue.async(flags: .barrier) {
let attempt = self.getCurrentAttempt(for: peerID) + 1
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
}
}
/// Record that we're responding to a handshake
func recordHandshakeResponse(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .responding(since: Date())
SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
}
}
/// Record successful handshake completion
func recordHandshakeSuccess(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .established(since: Date())
SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
}
}
/// Record handshake failure
func recordHandshakeFailure(peerID: String, reason: String) {
handshakeQueue.async(flags: .barrier) {
let attempts = self.getCurrentAttempt(for: peerID)
let canRetry = attempts < self.maxHandshakeAttempts
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
}
}
/// Check if we should accept an incoming handshake initiation
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
return handshakeQueue.sync {
// If we're already established, reject new handshakes
if case .established = handshakeStates[remotePeerID] {
SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
return false
}
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
// If we're the initiator and already initiating, this is a race condition
if role == .initiator {
if case .initiating = handshakeStates[remotePeerID] {
// They shouldn't be initiating, but accept it to recover from race condition
SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
return true
}
}
// If we're the responder, we should accept
return true
}
}
/// Check if this is a duplicate handshake message
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
return handshakeQueue.sync {
if processedHandshakeMessages.contains(data) {
return true
}
// Add to processed messages with size limit
if processedHandshakeMessages.count >= messageHistoryLimit {
processedHandshakeMessages.removeAll()
}
processedHandshakeMessages.insert(data)
return false
}
}
/// Get time to wait before next handshake attempt
func getRetryDelay(for peerID: String) -> TimeInterval? {
return handshakeQueue.sync {
guard let state = handshakeStates[peerID] else { return nil }
switch state {
case .failed(_, let canRetry, let lastAttempt):
if !canRetry { return nil }
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
if timeSinceFailure >= retryDelay {
return 0
}
return retryDelay - timeSinceFailure
case .initiating(_, let lastAttempt):
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceAttempt >= minTimeBetweenHandshakes {
return 0
}
return minTimeBetweenHandshakes - timeSinceAttempt
default:
return nil
}
}
}
/// Reset handshake state for a peer
func resetHandshakeState(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates.removeValue(forKey: peerID)
SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
}
}
/// Clean up stale handshake states and old established sessions
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
return handshakeQueue.sync {
let now = Date()
var stalePeerIDs: [String] = []
var establishedSessions: [(peerID: String, since: Date)] = []
for (peerID, state) in handshakeStates {
var isStale = false
switch state {
case .initiating(_, let lastAttempt):
if now.timeIntervalSince(lastAttempt) > staleTimeout {
isStale = true
}
case .responding(let since):
if now.timeIntervalSince(since) > staleTimeout {
isStale = true
}
case .waitingForResponse(_, let timeout):
if now > timeout {
isStale = true
}
case .established(let since):
// Track established sessions for potential cleanup
establishedSessions.append((peerID, since))
// Clean up very old established sessions
if now.timeIntervalSince(since) > establishedSessionTTL {
isStale = true
}
default:
break
}
if isStale {
stalePeerIDs.append(peerID)
SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
}
}
// If we have too many established sessions, clean up the oldest ones
if establishedSessions.count > maxEstablishedSessions {
// Sort by age (oldest first)
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
for i in 0..<sessionsToRemove {
let peerID = sortedSessions[i].peerID
stalePeerIDs.append(peerID)
SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
}
}
// Clean up stale states
for peerID in stalePeerIDs {
handshakeStates.removeValue(forKey: peerID)
}
if !stalePeerIDs.isEmpty {
SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
}
return stalePeerIDs
}
}
/// Get current handshake state
func getHandshakeState(for peerID: String) -> HandshakeState {
return handshakeQueue.sync {
return handshakeStates[peerID] ?? .idle
}
}
/// Get current retry count for a peer
func getRetryCount(for peerID: String) -> Int {
return handshakeQueue.sync {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt - 1 // Attempts start at 1, retries start at 0
default:
return 0
}
}
}
/// Increment retry count for a peer
func incrementRetryCount(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
let currentAttempt = self.getCurrentAttempt(for: peerID)
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
}
}
// MARK: - Private Helpers
private func getCurrentAttempt(for peerID: String) -> Int {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt
case .failed(_, _, _):
// Count previous attempts
return 1 // Simplified for now
default:
return 0
}
}
/// Log current handshake states for debugging
func logHandshakeStates() {
handshakeQueue.sync {
SecureLogger.debug("=== Handshake States ===", category: .handshake)
for (peerID, state) in handshakeStates {
let stateDesc: String
switch state {
case .idle:
stateDesc = "idle"
case .waitingToInitiate(let since):
stateDesc = "waiting to initiate (since \(since))"
case .initiating(let attempt, let lastAttempt):
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
case .responding(let since):
stateDesc = "responding (since: \(since))"
case .waitingForResponse(let messages, let timeout):
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
case .established(let since):
stateDesc = "established (since \(since))"
case .failed(let reason, let canRetry, let lastAttempt):
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
}
SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
}
SecureLogger.debug("========================", category: .handshake)
}
}
/// Clear all handshake states - used during panic mode
func clearAllHandshakeStates() {
handshakeQueue.async(flags: .barrier) {
SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
self.handshakeStates.removeAll()
self.processedHandshakeMessages.removeAll()
}
}
}
+2 -2
View File
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
if nameData.count <= 32 { if nameData.count <= 32 {
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count) self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
} else { } else {
self.hash = Data(SHA256.hash(data: nameData)) self.hash = nameData.sha256Hash()
} }
self.chainingKey = self.hash self.chainingKey = self.hash
} }
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
} }
func mixHash(_ data: Data) { func mixHash(_ data: Data) {
hash = Data(SHA256.hash(data: hash + data)) hash = (hash + data).sha256Hash()
} }
func mixKeyAndHash(_ inputKeyMaterial: Data) { func mixKeyAndHash(_ inputKeyMaterial: Data) {
+95
View File
@@ -0,0 +1,95 @@
//
// NoiseRateLimiter.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
final class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:]
private var messageTimestamps: [PeerID: [Date]] = [:]
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
private var globalMessageTimestamps: [Date] = []
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
func allowMessage(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: PeerID) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
}
}
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
}
}
}
@@ -1,224 +0,0 @@
//
// NoiseSecurityConsiderations.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
import CryptoKit
// MARK: - Security Constants
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
static let maxGlobalHandshakesPerMinute = 30
static let maxGlobalMessagesPerSecond = 500
}
// MARK: - Security Validations
struct NoiseSecurityValidator {
/// Validate message size
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
/// Validate peer ID format using unified validator
static func validatePeerID(_ peerID: String) -> Bool {
return InputValidator.validatePeerID(peerID)
}
}
// MARK: - Enhanced Noise Session with Security
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Check message count
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
let encrypted = try super.encrypt(plaintext)
messageCount += 1
lastActivityTime = Date()
return encrypted
}
override func decrypt(_ ciphertext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
let decrypted = try super.decrypt(ciphertext)
lastActivityTime = Date()
return decrypted
}
func needsRenegotiation() -> Bool {
// Check if we've used more than 90% of message limit
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
if messageCount >= messageThreshold {
return true
}
// Check if last activity was more than 30 minutes ago
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
return true
}
return false
}
// MARK: - Testing Support
#if DEBUG
func setLastActivityTimeForTesting(_ date: Date) {
lastActivityTime = date
}
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
#endif
}
// MARK: - Rate Limiter
final class NoiseRateLimiter {
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
private var globalMessageTimestamps: [Date] = []
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
func allowMessage(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: String) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
}
}
}
// MARK: - Security Errors
enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
@@ -0,0 +1,37 @@
//
// NoiseSecurityConstants.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
static let maxGlobalHandshakesPerMinute = 30
static let maxGlobalMessagesPerSecond = 500
}
+18
View File
@@ -0,0 +1,18 @@
//
// NoiseSecurityError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
@@ -0,0 +1,22 @@
//
// NoiseSecurityValidator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct NoiseSecurityValidator {
/// Validate message size
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
}
+5 -267
View File
@@ -10,32 +10,8 @@ import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
// MARK: - Noise Session State
enum NoiseSessionState: Equatable {
case uninitialized
case handshaking
case established
case failed(Error)
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
switch (lhs, rhs) {
case (.uninitialized, .uninitialized),
(.handshaking, .handshaking),
(.established, .established):
return true
case (.failed, .failed):
return true // We don't compare the errors
default:
return false
}
}
}
// MARK: - Noise Session
class NoiseSession { class NoiseSession {
let peerID: String let peerID: PeerID
let role: NoiseRole let role: NoiseRole
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized private var state: NoiseSessionState = .uninitialized
@@ -55,7 +31,7 @@ class NoiseSession {
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent) private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
init( init(
peerID: String, peerID: PeerID,
role: NoiseRole, role: NoiseRole,
keychain: KeychainManagerProtocol, keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey, localStaticKey: Curve25519.KeyAgreement.PrivateKey,
@@ -141,7 +117,7 @@ class NoiseSession {
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established") SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
return nil return nil
} else { } else {
@@ -167,7 +143,7 @@ class NoiseSession {
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established") SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
} }
return response return response
@@ -220,12 +196,6 @@ class NoiseSession {
} }
} }
func getHandshakeHash() -> Data? {
return sessionQueue.sync {
return handshakeHash
}
}
func reset() { func reset() {
sessionQueue.sync(flags: .barrier) { sessionQueue.sync(flags: .barrier) {
let wasEstablished = state == .established let wasEstablished = state == .established
@@ -252,240 +222,8 @@ class NoiseSession {
handshakeHash = nil handshakeHash = nil
if wasEstablished { if wasEstablished {
SecureLogger.info(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID.id))
} }
} }
} }
} }
// MARK: - Session Manager
final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((String, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
}
// MARK: - Session Management
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
return managerQueue.sync(flags: .barrier) {
let session = SecureNoiseSession(
peerID: peerID,
role: role,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session
return session
}
}
func getSession(for peerID: String) -> NoiseSession? {
return managerQueue.sync {
return sessions[peerID]
}
}
func removeSession(for peerID: String) {
managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] {
if session.isEstablished() {
SecureLogger.info(.sessionExpired(peerID: peerID))
}
// Clear sensitive data before removing
session.reset()
}
_ = sessions.removeValue(forKey: peerID)
}
}
func getEstablishedSessions() -> [String: NoiseSession] {
return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() }
}
}
// MARK: - Handshake Helpers
func initiateHandshake(with peerID: String) throws -> Data {
return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() {
// Session already established, don't recreate
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
}
// Create new initiator session
let session = SecureNoiseSession(
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session
do {
let handshakeData = try session.startHandshake()
return handshakeData
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error
}
}
}
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = SecureNoiseSession(
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
}
return response
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionFailed?(peerID, error)
}
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error
}
}
}
// MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.encrypt(plaintext)
}
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.decrypt(ciphertext)
}
// MARK: - Key Management
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
func getHandshakeHash(for peerID: String) -> Data? {
return getSession(for: peerID)?.getHandshakeHash()
}
// MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
for (peerID, session) in sessions {
if let secureSession = session as? SecureNoiseSession,
secureSession.isEstablished(),
secureSession.needsRenegotiation() {
needingRekey.append((peerID: peerID, needsRekey: true))
}
}
return needingRekey
}
}
func initiateRekey(for peerID: String) throws {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
_ = try initiateHandshake(with: peerID)
}
}
// MARK: - Errors
enum NoiseSessionError: Error {
case invalidState
case notEstablished
case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished
}
+14
View File
@@ -0,0 +1,14 @@
//
// NoiseSessionError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum NoiseSessionError: Error, Equatable {
case invalidState
case notEstablished
case sessionNotFound
case alreadyEstablished
}
+211
View File
@@ -0,0 +1,211 @@
//
// NoiseSessionManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import CryptoKit
import Foundation
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
}
// MARK: - Session Management
func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync {
return sessions[peerID]
}
}
func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
}
}
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
sessions.removeAll()
}
}
// MARK: - Handshake Helpers
func initiateHandshake(with peerID: PeerID) throws -> Data {
return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() {
// Session already established, don't recreate
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
}
// Create new initiator session
let session = SecureNoiseSession(
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session
do {
let handshakeData = try session.startHandshake()
return handshakeData
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = SecureNoiseSession(
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
}
return response
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionFailed?(peerID, error)
}
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
// MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.encrypt(plaintext)
}
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.decrypt(ciphertext)
}
// MARK: - Key Management
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
// MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
for (peerID, session) in sessions {
if let secureSession = session as? SecureNoiseSession,
secureSession.isEstablished(),
secureSession.needsRenegotiation() {
needingRekey.append((peerID: peerID, needsRekey: true))
}
}
return needingRekey
}
}
func initiateRekey(for peerID: PeerID) throws {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
_ = try initiateHandshake(with: peerID)
}
}
+13
View File
@@ -0,0 +1,13 @@
//
// NoiseSessionState.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum NoiseSessionState: Equatable {
case uninitialized
case handshaking
case established
}
+81
View File
@@ -0,0 +1,81 @@
//
// SecureNoiseSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Check message count
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
let encrypted = try super.encrypt(plaintext)
messageCount += 1
lastActivityTime = Date()
return encrypted
}
override func decrypt(_ ciphertext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
let decrypted = try super.decrypt(ciphertext)
lastActivityTime = Date()
return decrypted
}
func needsRenegotiation() -> Bool {
// Check if we've used more than 90% of message limit
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
if messageCount >= messageThreshold {
return true
}
// Check if last activity was more than 30 minutes ago
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
return true
}
return false
}
// MARK: - Testing Support
#if DEBUG
func setLastActivityTimeForTesting(_ date: Date) {
lastActivityTime = date
}
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
#endif
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
+24 -4
View File
@@ -1,5 +1,6 @@
import BitLogger import BitLogger
import Foundation import Foundation
import Tor
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
@MainActor @MainActor
@@ -32,13 +33,32 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate. /// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] } guard !entries.isEmpty, count > 0 else { return [] }
let sorted = entries
if entries.count <= count {
return entries
.sorted { a, b in .sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon) haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
} }
.prefix(count) .map { "wss://\($0.host)" }
return sorted.map { "wss://\($0.host)" } }
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
}
}
return best.map { "wss://\($0.entry.host)" }
} }
// MARK: - Remote Fetch // MARK: - Remote Fetch
+50
View File
@@ -0,0 +1,50 @@
import Foundation
protocol KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
func delete(key: String, service: String)
}
/// Keychain helper for secure storage
struct KeychainHelper: KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
if let maybeData = Data(hexString: recipientPeerID) { if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 { if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint // Treat as Noise static public key; derive peerID from fingerprint
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData) return PeerID(publicKey: maybeData).id
} else if maybeData.count == 8 { } else if maybeData.count == 8 {
// Already an 8-byte peer ID // Already an 8-byte peer ID
return recipientPeerID return recipientPeerID
-293
View File
@@ -1,50 +1,5 @@
import Foundation import Foundation
import CryptoKit
import P256K import P256K
import Security
// Keychain helper for secure storage
struct KeychainHelper {
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
static func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
struct NostrIdentity: Codable { struct NostrIdentity: Codable {
@@ -103,251 +58,3 @@ struct NostrIdentity: Codable {
return publicKey.hexEncodedString() return publicKey.hexEncodedString()
} }
} }
/// Bridge between Noise and Nostr identities
struct NostrIdentityBridge {
private static let keychainService = "chat.bitchat.nostr"
private static let currentIdentityKey = "nostr-current-identity"
private static let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private static var deviceSeedCache: Data?
/// Get or create the current Nostr identity
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
KeychainHelper.save(key: key, data: data, service: keychainService)
}
}
/// Get Nostr public key associated with a Noise public key
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = KeychainHelper.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
return nil
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
static func clearAllAssociations() {
// Delete current Nostr identity
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
// Note: We can't efficiently delete all noise-nostr associations
// without tracking them, but they'll be orphaned and eventually cleaned up
// The important part is deleting the current identity so a new one is generated
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private static func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// Ensure availability after first unlock to prevent unintended rotation when locked
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
var combined = Data()
combined.append(seed)
combined.append(msg)
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
return try NostrIdentity(privateKeyData: fallback)
}
}
// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
+135
View File
@@ -0,0 +1,135 @@
import Foundation
import CryptoKit
/// Bridge between Noise and Nostr identities
final class NostrIdentityBridge {
private let keychainService = "chat.bitchat.nostr"
private let currentIdentityKey = "nostr-current-identity"
private let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
private let keychain: KeychainHelperProtocol
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
}
/// Get or create the current Nostr identity
func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = keychain.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
return nil
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// Ensure availability after first unlock to prevent unintended rotation when locked
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
return try NostrIdentity(privateKeyData: fallback)
}
}
+1 -4
View File
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
] as [Any] ] as [Any]
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
let hash = CryptoKit.SHA256.hash(data: data) return (data.sha256Fingerprint(), data.sha256Hash())
let hashData = Data(hash)
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
return (hashHex, hashData)
} }
func jsonString() throws -> String { func jsonString() throws -> String {
+121 -21
View File
@@ -2,6 +2,7 @@ import BitLogger
import Foundation import Foundation
import Network import Network
import Combine import Combine
import Tor
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
@@ -35,10 +36,14 @@ final class NostrRelayManager: ObservableObject {
"wss://nostr21.com" "wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080" // For local testing, you can add: "ws://localhost:8080"
] ]
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:] private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
@@ -65,6 +70,8 @@ final class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -78,16 +85,36 @@ final class NostrRelayManager: ObservableObject {
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
init() { init() {
// Initialize with default relays hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
self.relays = Self.defaultRelays.map { Relay(url: $0) } hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests // Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
} }
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return } guard networkService.activationAllowed else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect. // Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached { Task.detached {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
@@ -102,6 +129,12 @@ final class NostrRelayManager: ObservableObject {
} }
} }
} }
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
} }
/// Disconnect from all relays /// Disconnect from all relays
@@ -120,8 +153,10 @@ final class NostrRelayManager: ObservableObject {
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
// Global network policy gate // Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return } guard networkService.activationAllowed else { return }
if TorManager.shared.torEnforced && !TorManager.shared.isReady { let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early // Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -130,22 +165,21 @@ final class NostrRelayManager: ObservableObject {
} }
return return
} }
let existing = Set(relays.map { $0.url }) var existing = Set(relays.map { $0.url })
for url in Set(relayUrls) { for url in targets where !existing.contains(url) {
if !existing.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url)
} }
if connections[url] == nil { for url in targets where connections[url] == nil {
connectToRelay(url) connectToRelay(url)
} }
} }
}
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate // Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return } guard networkService.activationAllowed else { return }
if TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing // Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -154,7 +188,9 @@ final class NostrRelayManager: ObservableObject {
} }
return return
} }
let targetRelays = relayUrls ?? Self.defaultRelays let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest // Attempt immediate send to relays with active connections; queue the rest
@@ -220,7 +256,7 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate // Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return } guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists // Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date() let now = Date()
if messageHandlers[id] != nil { if messageHandlers[id] != nil {
@@ -229,7 +265,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
subscribeCoalesce[id] = now subscribeCoalesce[id] = now
if TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early // Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -257,19 +293,23 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? Self.defaultRelays
let urls = baseUrls.filter { !isPermanentlyFailed($0) } let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected // Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url }) let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) { for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
} }
for url in urls { for url in candidateUrls {
var map = self.pendingSubscriptions[url] ?? [:] var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
} }
// Initialize EOSE tracking if requested // Initialize EOSE tracking if requested
if let onEOSE = onEOSE { if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE // Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
@@ -284,6 +324,7 @@ final class NostrRelayManager: ObservableObject {
} }
eoseTrackers[id] = tracker eoseTrackers[id] = tracker
} }
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush // Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls) ensureConnections(to: urls)
@@ -298,6 +339,55 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func applyDefaultRelayPolicy(force: Bool = false) {
let shouldAllow = hasMutualFavorites || hasLocationPermission
if !force && shouldAllow == allowDefaultRelays { return }
allowDefaultRelays = shouldAllow
if shouldAllow {
var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays)
}
} else {
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
}
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
}
}
return result
}
/// Unsubscribe from a subscription /// Unsubscribe from a subscription
func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
@@ -326,14 +416,14 @@ final class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate // Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return } guard networkService.activationAllowed else { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return return
} }
@@ -348,7 +438,7 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session // Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is. // If Tor is enforced but not ready, delay connection until it is.
if TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
@@ -534,7 +624,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) { private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection // If networking is disallowed, do not schedule reconnection
if !TorManager.shared.isAutoStartAllowed() { if !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
@@ -816,6 +906,16 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter return filter
} }
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": geohashes]
filter.limit = limit
return filter
}
} }
// Dynamic coding key for tag filters // Dynamic coding key for tag filters
+1 -1
View File
@@ -197,7 +197,7 @@ extension Data {
offset += 16 offset += 16
// Convert 16 bytes to UUID string format // Convert 16 bytes to UUID string format
let uuid = uuidData.map { String(format: "%02x", $0) }.joined() let uuid = uuidData.hexEncodedString()
// Insert hyphens at proper positions: 8-4-4-4-12 // Insert hyphens at proper positions: 8-4-4-4-12
var result = "" var result = ""
+20 -52
View File
@@ -22,12 +22,11 @@
/// ///
/// ## Wire Format /// ## Wire Format
/// ``` /// ```
/// Header (13 bytes for v1, 15 bytes for v2): /// Header (Fixed 13 bytes):
/// +--------+------+-----+-----------+-------+---------------+ /// +--------+------+-----+-----------+-------+----------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength | /// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes (v1) | /// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
/// | | | | | | 4 bytes (v2) | /// +--------+------+-----+-----------+-------+----------------+
/// +--------+------+-----+-----------+-------+---------------+
/// ///
/// Variable sections: /// Variable sections:
/// +----------+-------------+---------+------------+ /// +----------+-------------+---------+------------+
@@ -57,10 +56,9 @@
/// - Bits 3-7: Reserved for future use /// - Bits 3-7: Reserved for future use
/// ///
/// ## Size Constraints /// ## Size Constraints
/// - Maximum packet size: 4.2GB (32-bit length field for v2+) /// - Maximum packet size: 65,535 bytes (16-bit length field)
/// - Typical packet size: < 512 bytes (BLE MTU) /// - Typical packet size: < 512 bytes (BLE MTU)
/// - Minimum packet size: 21 bytes (header + sender ID) /// - Minimum packet size: 21 bytes (header + sender ID)
/// - Legacy v1 packets: max 65,535 bytes (16-bit length field)
/// ///
/// ## Encoding Process /// ## Encoding Process
/// 1. Construct header with fixed fields /// 1. Construct header with fixed fields
@@ -107,8 +105,7 @@ extension Data {
/// their binary wire format representation. /// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian) /// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol { struct BinaryProtocol {
static let headerSizeV1 = 13 static let headerSize = 13
static let headerSizeV2 = 15
static let senderIDSize = 8 static let senderIDSize = 8
static let recipientIDSize = 8 static let recipientIDSize = 8
static let signatureSize = 64 static let signatureSize = 64
@@ -119,11 +116,6 @@ struct BinaryProtocol {
static let isCompressed: UInt8 = 0x04 static let isCompressed: UInt8 = 0x04
} }
/// Get header size based on protocol version
static func getHeaderSize(_ version: UInt8) -> Int {
return version >= 2 ? headerSizeV2 : headerSizeV1
}
// Encode BitchatPacket to binary format // Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? { static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
var data = Data() var data = Data()
@@ -146,10 +138,9 @@ struct BinaryProtocol {
} else { } else {
} }
// Header with version-dependent size // Header
let headerSize = getHeaderSize(packet.version)
// Reserve capacity to reduce reallocations. Estimate base size conservatively. // Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad // header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0) let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255 let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated) data.reserveCapacity(estimated)
@@ -175,20 +166,13 @@ struct BinaryProtocol {
} }
data.append(flags) data.append(flags)
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2) // Payload length (2 bytes, big-endian) - includes original size if compressed
let payloadDataSize = payload.count + (isCompressed ? 2 : 0) let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
if packet.version >= 2 { let payloadLength = UInt16(payloadDataSize)
// v2+: 4 bytes big-endian
data.append(UInt8((payloadDataSize >> 24) & 0xFF))
data.append(UInt8((payloadDataSize >> 16) & 0xFF)) data.append(UInt8((payloadLength >> 8) & 0xFF))
data.append(UInt8((payloadDataSize >> 8) & 0xFF)) data.append(UInt8(payloadLength & 0xFF))
data.append(UInt8(payloadDataSize & 0xFF))
} else {
// v1: 2 bytes big-endian
let payloadLength16 = UInt16(payloadDataSize)
data.append(UInt8((payloadLength16 >> 8) & 0xFF))
data.append(UInt8(payloadLength16 & 0xFF))
}
// SenderID (exactly 8 bytes) // SenderID (exactly 8 bytes)
let senderBytes = packet.senderID.prefix(senderIDSize) let senderBytes = packet.senderID.prefix(senderIDSize)
@@ -243,8 +227,8 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: smallest header (v1) + senderID // Minimum size: header + senderID
guard raw.count >= headerSizeV1 + senderIDSize else { return nil } guard raw.count >= headerSize + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil } guard let base = buf.baseAddress else { return nil }
@@ -265,14 +249,6 @@ struct BinaryProtocol {
offset += 2 offset += 2
return v return v
} }
// Read big-endian 32-bit
func read32() -> UInt32? {
guard require(4) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt32(p[0]) << 24) | (UInt32(p[1]) << 16) | (UInt32(p[2]) << 8) | UInt32(p[3])
offset += 4
return v
}
// Copy N bytes into Data // Copy N bytes into Data
func readData(_ n: Int) -> Data? { func readData(_ n: Int) -> Data? {
guard require(n) else { return nil } guard require(n) else { return nil }
@@ -282,8 +258,8 @@ struct BinaryProtocol {
return d return d
} }
// Version - accept both v1 and v2+ // Version
guard let version = read8(), version >= 1 else { return nil } guard let version = read8(), version == 1 else { return nil }
guard let type = read8() else { return nil } guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil } guard let ttl = read8() else { return nil }
@@ -301,15 +277,8 @@ struct BinaryProtocol {
let hasSignature = (flags & Flags.hasSignature) != 0 let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0 let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2+) // Payload length
let payloadLen: UInt32 guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
if version >= 2 {
guard let len32 = read32(), len32 <= 4_294_967_295 else { return nil }
payloadLen = len32
} else {
guard let len16 = read16(), len16 <= 65535 else { return nil }
payloadLen = UInt32(len16)
}
// SenderID // SenderID
guard let senderID = readData(senderIDSize) else { return nil } guard let senderID = readData(senderIDSize) else { return nil }
@@ -348,7 +317,6 @@ struct BinaryProtocol {
guard offset <= buf.count else { return nil } guard offset <= buf.count else { return nil }
return BitchatPacket( return BitchatPacket(
version: version,
type: type, type: type,
senderID: senderID, senderID: senderID,
recipientID: recipientID, recipientID: recipientID,
+12 -10
View File
@@ -59,6 +59,7 @@
/// ///
import Foundation import Foundation
import CoreBluetooth
// MARK: - Message Types // MARK: - Message Types
@@ -78,7 +79,6 @@ enum MessageType: UInt8 {
// Fragmentation (simplified) // Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // File transfer (audio/images) TLV payload
var description: String { var description: String {
switch self { switch self {
@@ -89,7 +89,6 @@ enum MessageType: UInt8 {
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
} }
} }
} }
@@ -133,7 +132,7 @@ enum LazyHandshakeState {
// MARK: - Delivery Status // MARK: - Delivery Status
// Delivery status for messages // Delivery status for messages
enum DeliveryStatus: Codable, Equatable { enum DeliveryStatus: Codable, Equatable, Hashable {
case sending case sending
case sent // Left our device case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient case delivered(to: String, at: Date) // Confirmed by recipient
@@ -163,9 +162,9 @@ enum DeliveryStatus: Codable, Equatable {
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
func didReceiveMessage(_ message: BitchatMessage) func didReceiveMessage(_ message: BitchatMessage)
func didConnectToPeer(_ peerID: String) func didConnectToPeer(_ peerID: PeerID)
func didDisconnectFromPeer(_ peerID: String) func didDisconnectFromPeer(_ peerID: PeerID)
func didUpdatePeerList(_ peers: [String]) func didUpdatePeerList(_ peers: [PeerID])
// Optional method to check if a fingerprint belongs to a favorite peer // Optional method to check if a fingerprint belongs to a favorite peer
func isFavorite(fingerprint: String) -> Bool func isFavorite(fingerprint: String) -> Bool
@@ -173,8 +172,11 @@ protocol BitchatDelegate: AnyObject {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
// Low-level events for better separation of concerns // Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
} }
// Provide default implementation to make it effectively optional // Provide default implementation to make it effectively optional
@@ -187,11 +189,11 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) { func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
// Default empty implementation // Default empty implementation
} }
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
// Default empty implementation // Default empty implementation
} }
} }
+61
View File
@@ -10,6 +10,14 @@ enum Geohash {
return map return map
}() }()
/// Validates a geohash string for building-level precision (8 characters).
/// - Parameter geohash: The geohash string to validate
/// - Returns: true if valid 8-character base32 geohash, false otherwise
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
guard geohash.count == 8 else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Encodes the provided coordinates into a geohash string. /// Encodes the provided coordinates into a geohash string.
/// - Parameters: /// - Parameters:
/// - latitude: Latitude in degrees (-90...90) /// - latitude: Latitude in degrees (-90...90)
@@ -111,4 +119,57 @@ enum Geohash {
} }
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1) return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
} }
/// Returns all 8 neighboring geohash cells at the same precision.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
static func neighbors(of geohash: String) -> [String] {
guard !geohash.isEmpty else { return [] }
let precision = geohash.count
let bounds = decodeBounds(geohash)
let center = decodeCenter(geohash)
// Calculate cell dimensions
let latHeight = bounds.latMax - bounds.latMin
let lonWidth = bounds.lonMax - bounds.lonMin
// Helper to wrap longitude around ±180
func wrapLongitude(_ lon: Double) -> Double {
var wrapped = lon
while wrapped > 180.0 { wrapped -= 360.0 }
while wrapped < -180.0 { wrapped += 360.0 }
return wrapped
}
// Helper to clamp latitude to ±90
func clampLatitude(_ lat: Double) -> Double {
return max(-90.0, min(90.0, lat))
}
// Calculate 8 neighbor centers
let neighbors: [(lat: Double, lon: Double)] = [
(center.lat + latHeight, center.lon), // N
(center.lat + latHeight, center.lon + lonWidth), // NE
(center.lat, center.lon + lonWidth), // E
(center.lat - latHeight, center.lon + lonWidth), // SE
(center.lat - latHeight, center.lon), // S
(center.lat - latHeight, center.lon - lonWidth), // SW
(center.lat, center.lon - lonWidth), // W
(center.lat + latHeight, center.lon - lonWidth) // NW
]
// Encode each neighbor, handling boundary conditions
return neighbors.compactMap { neighbor in
let lat = clampLatitude(neighbor.lat)
let lon = wrapLongitude(neighbor.lon)
// Skip if we've crossed a pole (latitude clamped to boundary)
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
return nil
}
return encode(latitude: lat, longitude: lon, precision: precision)
}
}
} }
+12 -6
View File
@@ -23,12 +23,18 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String { var displayName: String {
switch self { switch self {
case .building: return "Building" case .building:
case .block: return "Block" return String(localized: "location_levels.building", comment: "Name for building-level location channel")
case .neighborhood: return "Neighborhood" case .block:
case .city: return "City" return String(localized: "location_levels.block", comment: "Name for block-level location channel")
case .province: return "Province" case .neighborhood:
case .region: return "Region" return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
case .city:
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
case .province:
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
case .region:
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
} }
} }
} }
-14
View File
@@ -1,14 +0,0 @@
import Foundation
import CryptoKit
// MARK: - Peer ID Utilities
struct PeerIDUtils {
/// Derive the stable 16-hex peer ID from a Noise static public key
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(16))
}
}
File diff suppressed because it is too large Load Diff
+3 -21
View File
@@ -42,7 +42,7 @@ final class CommandProcessor {
case .location: return true case .location: return true
} }
}() }()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd { switch cmd {
case "/m", "/msg": case "/m", "/msg":
@@ -65,9 +65,6 @@ final class CommandProcessor {
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
default: default:
return .error(message: "unknown command: \(cmd)") return .error(message: "unknown command: \(cmd)")
} }
@@ -104,7 +101,7 @@ final class CommandProcessor {
case .location(let ch): case .location(let ch):
// Geohash context: show visible geohash participants (exclude self) // Geohash context: show visible geohash participants (exclude self)
guard let vm = chatViewModel else { return .success(message: "nobody around") } guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased() let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me } if let me = myHex { return person.id.lowercased() != me }
return true return true
@@ -285,7 +282,7 @@ final class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID) else { let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)") return .error(message: "can't find peer: \(nickname)")
} }
@@ -311,19 +308,4 @@ final class CommandProcessor {
} }
} }
private func handleHelp() -> CommandResult {
let helpText = """
commands:
/msg @name - start private chat
/who - list who's online
/clear - clear messages
/hug @name - send a hug
/slap @name - slap with a trout
/fav @name - add to favorites
/unfav @name - remove from favorites
/block @name - block
/unblock @name - unblock
"""
return .success(message: helpText)
}
} }
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites" private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainHelperProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = [] @Published private(set) var mutualFavorites: Set<Data> = []
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService() static let shared = FavoritesPersistenceService()
private init() { init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
loadFavorites() loadFavorites()
// Update mutual favorites when favorites change // Update mutual favorites when favorites change
@@ -179,125 +181,15 @@ final class FavoritesPersistenceService: ObservableObject {
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey) /// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
/// Falls back to scanning favorites and matching on derived peer ID. /// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? { func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes) // Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peerID.count == 16 else { return nil } guard peerID.isShort else { return nil }
for (pubkey, rel) in favorites { for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey) return rel
if derived == peerID { return rel }
} }
return nil return nil
} }
/// Update Nostr public key for a peer
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
guard let existing = favorites[peerNoisePublicKey] else { return }
let updated = FavoriteRelationship(
peerNoisePublicKey: existing.peerNoisePublicKey,
peerNostrPublicKey: nostrPubkey,
peerNickname: existing.peerNickname,
isFavorite: existing.isFavorite,
theyFavoritedUs: existing.theyFavoritedUs,
favoritedAt: existing.favoritedAt,
lastUpdated: Date()
)
favorites[peerNoisePublicKey] = updated
saveFavorites()
}
/// Update nickname for an existing favorite
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
guard let existing = favorites[peerNoisePublicKey] else { return }
// Skip if nickname hasn't changed
if existing.peerNickname == newNickname { return }
// Updating nickname for favorite
let updated = FavoriteRelationship(
peerNoisePublicKey: existing.peerNoisePublicKey,
peerNostrPublicKey: existing.peerNostrPublicKey,
peerNickname: newNickname,
isFavorite: existing.isFavorite,
theyFavoritedUs: existing.theyFavoritedUs,
favoritedAt: existing.favoritedAt,
lastUpdated: Date()
)
favorites[peerNoisePublicKey] = updated
saveFavorites()
// Notify observers
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil,
userInfo: ["peerPublicKey": peerNoisePublicKey]
)
}
/// Update noise public key when peer reconnects with new ID
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
guard let existing = favorites[oldKey] else {
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
return
}
// Check if we already have a favorite with the new key
if favorites[newKey] != nil {
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
favorites.removeValue(forKey: oldKey)
saveFavorites()
return
}
// Updating noise public key
// Remove old entry
favorites.removeValue(forKey: oldKey)
// Add with new key
let updated = FavoriteRelationship(
peerNoisePublicKey: newKey,
peerNostrPublicKey: existing.peerNostrPublicKey,
peerNickname: peerNickname,
isFavorite: existing.isFavorite,
theyFavoritedUs: existing.theyFavoritedUs,
favoritedAt: existing.favoritedAt,
lastUpdated: Date()
)
favorites[newKey] = updated
saveFavorites()
// Notify observers with both old and new keys
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil,
userInfo: [
"peerPublicKey": newKey,
"oldPeerPublicKey": oldKey,
"isKeyUpdate": true
]
)
}
/// Get all favorites (including non-mutual)
func getAllFavorites() -> [FavoriteRelationship] {
favorites.values.filter { $0.isFavorite }
}
/// Get only mutual favorites
func getMutualFavorites() -> [FavoriteRelationship] {
favorites.values.filter { $0.isMutual }
}
/// Get all favorite relationships (including where they favorited us)
func getAllRelationships() -> [FavoriteRelationship] {
Array(favorites.values)
}
/// Clear all favorites - used for panic mode /// Clear all favorites - used for panic mode
func clearAllFavorites() { func clearAllFavorites() {
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session) SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
@@ -306,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
saveFavorites() saveFavorites()
// Delete from keychain directly // Delete from keychain directly
KeychainHelper.delete( keychain.delete(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) )
@@ -326,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
let data = try encoder.encode(relationships) let data = try encoder.encode(relationships)
// Store in keychain for security // Store in keychain for security
KeychainHelper.save( keychain.save(
key: Self.storageKey, key: Self.storageKey,
data: data, data: data,
service: Self.keychainService service: Self.keychainService,
accessible: nil
) )
// Successfully saved favorites // Successfully saved favorites
@@ -341,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
private func loadFavorites() { private func loadFavorites() {
// Loading favorites from keychain // Loading favorites from keychain
guard let data = KeychainHelper.load( guard let data = keychain.load(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) else { ) else {
+8 -16
View File
@@ -21,7 +21,10 @@ final class GeohashBookmarksStore: ObservableObject {
private var resolving: Set<String> = [] private var resolving: Set<String> = []
#endif #endif
private init() { private let storage: UserDefaults
init(storage: UserDefaults = .standard) {
self.storage = storage
load() load()
} }
@@ -64,7 +67,7 @@ final class GeohashBookmarksStore: ObservableObject {
// MARK: - Persistence // MARK: - Persistence
private func load() { private func load() {
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return } guard let data = storage.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) { if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins) // Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>() var seen = Set<String>()
@@ -81,7 +84,7 @@ final class GeohashBookmarksStore: ObservableObject {
membership = seen membership = seen
} }
// Load any saved names // Load any saved names
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey), if let namesData = storage.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) { let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict bookmarkNames = dict
} }
@@ -89,13 +92,13 @@ final class GeohashBookmarksStore: ObservableObject {
private func persist() { private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) { if let data = try? JSONEncoder().encode(bookmarks) {
UserDefaults.standard.set(data, forKey: storeKey) storage.set(data, forKey: storeKey)
} }
} }
private func persistNames() { private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) { if let data = try? JSONEncoder().encode(bookmarkNames) {
UserDefaults.standard.set(data, forKey: namesStoreKey) storage.set(data, forKey: namesStoreKey)
} }
} }
@@ -213,15 +216,4 @@ final class GeohashBookmarksStore: ObservableObject {
} }
} }
#endif #endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
} }
+1 -28
View File
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
private let service = BitchatApp.bundleID private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)" private let appGroup = "group.\(BitchatApp.bundleID)"
private func isSandboxed() -> Bool {
#if os(macOS)
// More robust sandbox detection using multiple methods
// Method 1: Check environment variable (can be spoofed)
let environment = ProcessInfo.processInfo.environment
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
// Method 2: Check if we can access a path outside sandbox
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
if canWriteOutsideSandbox {
try? FileManager.default.removeItem(at: testPath)
}
// Method 3: Check container path
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
let hasContainerPath = containerPath.contains("/Containers/")
// If any method indicates sandbox, we consider it sandboxed
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
#else
// iOS is always sandboxed
return true
#endif
}
// MARK: - Identity Keys // MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
@@ -282,6 +254,7 @@ final class KeychainManager: KeychainManagerProtocol {
"com.bitchat.deviceidentity", "com.bitchat.deviceidentity",
"com.bitchat.noise.identity", "com.bitchat.noise.identity",
"chat.bitchat.passwords", "chat.bitchat.passwords",
"chat.bitchat.nostr",
"bitchat.keychain", "bitchat.keychain",
"bitchat", "bitchat",
"com.bitchat" "com.bitchat"
@@ -1,53 +0,0 @@
import Foundation
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
@MainActor
final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter()
@Published private(set) var geohash: String? = nil
@Published private(set) var count: Int? = 0
@Published private(set) var initialLoadComplete: Bool = false
private var subscriptionID: String? = nil
private var noteIDs = Set<String>()
private init() {}
func subscribe(geohash gh: String) {
let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return }
// Unsubscribe previous without clearing count to avoid flicker
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil
geohash = norm
noteIDs.removeAll()
initialLoadComplete = false
// Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
let relayUrls: [String]? = relays.isEmpty ? nil : relays
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
if !self.noteIDs.contains(event.id) {
self.noteIDs.insert(event.id)
self.count = self.noteIDs.count
}
}, onEOSE: { [weak self] in
self?.initialLoadComplete = true
})
}
func cancel() {
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil
geohash = nil
count = 0
noteIDs.removeAll()
}
}
+180 -22
View File
@@ -1,10 +1,59 @@
import BitLogger import BitLogger
import Foundation import Foundation
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7). /// Dependencies for location notes, allowing tests to stub relay/identity behavior.
struct LocationNotesDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
},
sendEvent: { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
},
deriveIdentity: { geohash in
try idBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
)
}
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
/// Subscribes to and publishes notes for a given geohash and provides a send API. /// Subscribes to and publishes notes for a given geohash and provides a send API.
@MainActor @MainActor
final class LocationNotesManager: ObservableObject { final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case ready
case noRelays
}
struct Note: Identifiable, Equatable { struct Note: Identifiable, Equatable {
let id: String let id: String
let pubkey: String let pubkey: String
@@ -24,46 +73,127 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var notes: [Note] = [] // reverse-chron sorted @Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var geohash: String @Published private(set) var geohash: String
@Published private(set) var initialLoadComplete: Bool = false @Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String?
private var subscriptionID: String? private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
init(geohash: String) { private enum Strings {
self.geohash = geohash.lowercased() static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
static func failedToSend(_ detail: String) -> String {
String(
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
locale: .current,
detail
)
}
}
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
let norm = geohash.lowercased()
self.geohash = norm
self.dependencies = dependencies
// Validate geohash (building-level precision: 8 chars)
if !Geohash.isValidBuildingGeohash(norm) {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
}
subscribe() subscribe()
} }
func setGeohash(_ newGeohash: String) { func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased() let norm = newGeohash.lowercased()
guard norm != geohash else { return } guard norm != geohash else { return }
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return
}
if let sub = subscriptionID { if let sub = subscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub) dependencies.unsubscribe(sub)
subscriptionID = nil subscriptionID = nil
} }
// Set loading state before clearing to prevent empty state flicker
state = .loading
initialLoadComplete = false
errorMessage = nil
geohash = norm geohash = norm
notes.removeAll() notes.removeAll()
noteIDs.removeAll()
subscribe() subscribe()
} }
private func subscribe() { func refresh() {
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))" if let sub = subscriptionID {
subscriptionID = subID dependencies.unsubscribe(sub)
// For persistent notes, allow relays to return recent history without an aggressive time cutoff subscriptionID = nil
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200) }
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) // Set loading state before clearing to prevent empty state flicker
let relayUrls: [String]? = relays.isEmpty ? nil : relays state = .loading
initialLoadComplete = false initialLoadComplete = false
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in errorMessage = nil
notes.removeAll()
noteIDs.removeAll()
subscribe()
}
func clearError() {
errorMessage = nil
}
private func subscribe() {
state = .loading
errorMessage = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
subscriptionID = nil
initialLoadComplete = true
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
return
}
subscriptionID = subID
initialLoadComplete = false
// Subscribe to center + 8 neighbors (± 1 grid)
let neighbors = Geohash.neighbors(of: geohash)
let allGeohashes = [geohash] + neighbors
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
// Build a set of valid geohashes for tag matching (includes all 9 cells)
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag // Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return } guard event.tags.contains(where: { tag in
if self.notes.contains(where: { $0.id == event.id }) { return } tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return }
guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note) self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt } self.notes.sort { $0.createdAt > $1.createdAt }
}, onEOSE: { [weak self] in self.enforceMemoryCap()
self?.initialLoadComplete = true self.state = .ready
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
self.state = .ready
}
}) })
} }
@@ -71,29 +201,57 @@ final class LocationNotesManager: ObservableObject {
func send(content: String, nickname: String) { func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
return
}
do { do {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash) let id = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote( let event = try NostrProtocol.createGeohashTextNote(
content: trimmed, content: trimmed,
geohash: geohash, geohash: geohash,
senderIdentity: id, senderIdentity: id,
nickname: nickname nickname: nickname
) )
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) dependencies.sendEvent(event, relays)
NostrRelayManager.shared.sendEvent(event, to: relays)
// Optimistic local-echo // Optimistic local-echo
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname) let echo = Note(
id: event.id,
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname
)
self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0) self.notes.insert(echo, at: 0)
self.enforceMemoryCap()
self.state = .ready
self.errorMessage = nil
} catch { } catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session) SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
errorMessage = Strings.failedToSend(error.localizedDescription)
}
}
/// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() {
if notes.count > maxNotesInMemory {
let removed = notes.count - maxNotesInMemory
notes = Array(notes.prefix(maxNotesInMemory))
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
} }
} }
/// Explicitly cancel subscription and release resources. /// Explicitly cancel subscription and release resources.
func cancel() { func cancel() {
if let sub = subscriptionID { if let sub = subscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub) dependencies.unsubscribe(sub)
subscriptionID = nil subscriptionID = nil
} }
state = .idle
errorMessage = nil
} }
} }
+20 -20
View File
@@ -6,7 +6,7 @@ import Foundation
final class MessageRouter { final class MessageRouter {
private let mesh: Transport private let mesh: Transport
private let nostr: NostrTransport private let nostr: NostrTransport
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrTransport) { init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh self.mesh = mesh
@@ -21,7 +21,7 @@ final class MessageRouter {
) { [weak self] note in ) { [weak self] note in
guard let self = self else { return } guard let self = self else { return }
if let data = note.userInfo?["peerPublicKey"] as? Data { if let data = note.userInfo?["peerPublicKey"] as? Data {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data) let peerID = PeerID(publicKey: data)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
} }
@@ -29,7 +29,7 @@ final class MessageRouter {
// Handle key updates // Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data, if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool { let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey) let peerID = PeerID(publicKey: newKey)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
} }
@@ -37,44 +37,44 @@ final class MessageRouter {
} }
} }
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID) let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh { if reachableMesh {
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// BLEService will initiate a handshake if needed and queue the message // BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID)) outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed // Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session) SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
mesh.sendReadReceipt(receipt, to: peerID) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session) SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
nostr.sendReadReceipt(receipt, to: peerID) nostr.sendReadReceipt(receipt, to: peerID)
} }
} }
func sendDeliveryAck(_ messageID: String, to peerID: String) { func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peerID) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peerID) nostr.sendDeliveryAck(for: messageID, to: peerID)
} }
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr // Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) { if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
@@ -84,16 +84,16 @@ final class MessageRouter {
} }
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool { private func canSendViaNostr(peerID: PeerID) -> Bool {
// Two forms are supported: // Two forms are supported:
// - 64-hex Noise public key (32 bytes) // - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey) // - 16-hex short peer ID (derived from Noise pubkey)
if peerID.count == 64, let noiseKey = Data(hexString: peerID) { if let noiseKey = peerID.noiseKey {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
} }
} else if peerID.count == 16 { } else if peerID.isShort {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
@@ -102,17 +102,17 @@ final class MessageRouter {
return false return false
} }
func flushOutbox(for peerID: String) { func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session) SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = [] var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
// Keep unsent items queued // Keep unsent items queued
+59 -15
View File
@@ -1,6 +1,7 @@
import Foundation import Foundation
import BitLogger import BitLogger
import Combine import Combine
import Tor
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays. /// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR /// Policy: permit start when either location permissions are authorized OR
@@ -10,9 +11,12 @@ final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService() static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false @Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var started = false private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private init() {} private init() {}
@@ -20,9 +24,23 @@ final class NetworkActivationService: ObservableObject {
guard !started else { return } guard !started else { return }
started = true started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
}
// Initial compute // Initial compute
activationAllowed = Self.computeAllowed() let allowed = basePolicyAllowed()
TorManager.shared.setAutoStartAllowed(activationAllowed) activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
NostrRelayManager.shared.connect()
} else {
NostrRelayManager.shared.disconnect()
}
// React to location permission changes // React to location permission changes
LocationChannelManager.shared.$permissionState LocationChannelManager.shared.$permissionState
@@ -41,30 +59,56 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
)
reevaluate()
}
private func reevaluate() { private func reevaluate() {
let allowed = Self.computeAllowed() let allowed = basePolicyAllowed()
if allowed != activationAllowed { let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired
if statusChanged {
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session) SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
activationAllowed = allowed activationAllowed = allowed
TorManager.shared.setAutoStartAllowed(allowed) }
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed { if allowed {
// Kick Tor + relays if we're now permitted if torChanged {
TorManager.shared.startIfNeeded() // Reset relay sockets when switching transport path (Tor direct)
// If app is in foreground, begin relay connections
if TorManager.shared.isForeground() {
NostrRelayManager.shared.connect()
}
} else {
// Transitioned to disallowed: disconnect relays and shut down Tor
NostrRelayManager.shared.disconnect() NostrRelayManager.shared.disconnect()
TorManager.shared.goDormantOnBackground()
} }
NostrRelayManager.shared.connect()
} else if statusChanged {
NostrRelayManager.shared.disconnect()
} }
} }
private static func computeAllowed() -> Bool { private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual return permOK || hasMutual
} }
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
}
}
} }
+47 -51
View File
@@ -62,7 +62,6 @@
/// ## Integration Points /// ## Integration Points
/// - **BLEService**: Calls this service for all private messages /// - **BLEService**: Calls this service for all private messages
/// - **ChatViewModel**: Monitors encryption status for UI indicators /// - **ChatViewModel**: Monitors encryption status for UI indicators
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
/// - **KeychainManager**: Secure storage for identity keys /// - **KeychainManager**: Secure storage for identity keys
/// ///
/// ## Thread Safety /// ## Thread Safety
@@ -116,15 +115,30 @@ enum EncryptionStatus: Equatable {
var description: String { var description: String {
switch self { switch self {
case .none: case .none:
return "Encryption failed" return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
case .noHandshake: case .noHandshake:
return "Not encrypted" return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
case .noiseHandshaking: case .noiseHandshaking:
return "Establishing encryption..." return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
case .noiseSecured: case .noiseSecured:
return "Encrypted" return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
case .noiseVerified: case .noiseVerified:
return "Encrypted & Verified" return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
}
}
var accessibilityDescription: String {
switch self {
case .none:
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
case .noHandshake:
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
case .noiseHandshaking:
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
case .noiseSecured:
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
case .noiseVerified:
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
} }
} }
} }
@@ -148,8 +162,8 @@ final class NoiseEncryptionService {
private let sessionManager: NoiseSessionManager private let sessionManager: NoiseSessionManager
// Peer fingerprints (SHA256 hash of static public key) // Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint private var peerFingerprints: [PeerID: String] = [:]
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID private var fingerprintToPeerID: [String: PeerID] = [:]
// Thread safety // Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
@@ -164,7 +178,7 @@ final class NoiseEncryptionService {
// Callbacks // Callbacks
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
// Add a handler for peer authentication // Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) { func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
@@ -258,12 +272,11 @@ final class NoiseEncryptionService {
/// Get our identity fingerprint /// Get our identity fingerprint
func getIdentityFingerprint() -> String { func getIdentityFingerprint() -> String {
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation) staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
return hash.map { String(format: "%02x", $0) }.joined()
} }
/// Get peer's public key data /// Get peer's public key data
func getPeerPublicKeyData(_ peerID: String) -> Data? { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
} }
@@ -391,11 +404,11 @@ final class NoiseEncryptionService {
// MARK: - Handshake Management // MARK: - Handshake Management
/// Initiate a Noise handshake with a peer /// Initiate a Noise handshake with a peer
func initiateHandshake(with peerID: String) throws -> Data { func initiateHandshake(with peerID: PeerID) throws -> Data {
// Validate peer ID // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID)) SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
@@ -405,7 +418,7 @@ final class NoiseEncryptionService {
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
SecureLogger.info(.handshakeStarted(peerID: peerID)) SecureLogger.info(.handshakeStarted(peerID: peerID.id))
// Return raw handshake data without wrapper // Return raw handshake data without wrapper
// The Noise protocol handles its own message format // The Noise protocol handles its own message format
@@ -414,17 +427,17 @@ final class NoiseEncryptionService {
} }
/// Process an incoming handshake message /// Process an incoming handshake message
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? { func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
// Validate peer ID // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID)) SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else { guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large")) SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
@@ -444,19 +457,19 @@ final class NoiseEncryptionService {
} }
/// Check if we have an established session with a peer /// Check if we have an established session with a peer
func hasEstablishedSession(with peerID: String) -> Bool { func hasEstablishedSession(with peerID: PeerID) -> Bool {
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
} }
/// Check if we have a session (established or handshaking) with a peer /// Check if we have a session (established or handshaking) with a peer
func hasSession(with peerID: String) -> Bool { func hasSession(with peerID: PeerID) -> Bool {
return sessionManager.getSession(for: peerID) != nil return sessionManager.getSession(for: peerID) != nil
} }
// MARK: - Encryption/Decryption // MARK: - Encryption/Decryption
/// Encrypt data for a specific peer /// Encrypt data for a specific peer
func encrypt(_ data: Data, for peerID: String) throws -> Data { func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else { guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
@@ -478,7 +491,7 @@ final class NoiseEncryptionService {
} }
/// Decrypt data from a specific peer /// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: String) throws -> Data { func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else { guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
@@ -500,38 +513,26 @@ final class NoiseEncryptionService {
// MARK: - Peer Management // MARK: - Peer Management
/// Get fingerprint for a peer /// Get fingerprint for a peer
func getPeerFingerprint(_ peerID: String) -> String? { func getPeerFingerprint(_ peerID: PeerID) -> String? {
return serviceQueue.sync { return serviceQueue.sync {
return peerFingerprints[peerID] return peerFingerprints[peerID]
} }
} }
/// Get peer ID for a fingerprint func clearEphemeralStateForPanic() {
func getPeerID(for fingerprint: String) -> String? { sessionManager.removeAllSessions()
return serviceQueue.sync {
return fingerprintToPeerID[fingerprint]
}
}
/// Remove a peer session
func removePeer(_ peerID: String) {
sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints[peerID] { peerFingerprints.removeAll()
fingerprintToPeerID.removeValue(forKey: fingerprint) fingerprintToPeerID.removeAll()
} }
peerFingerprints.removeValue(forKey: peerID) rateLimiter.resetAll()
}
SecureLogger.info(.sessionExpired(peerID: peerID))
} }
// MARK: - Private Helpers // MARK: - Private Helpers
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
// Calculate fingerprint // Calculate fingerprint
let fingerprint = calculateFingerprint(for: remoteStaticKey) let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
// Store fingerprint mapping // Store fingerprint mapping
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
@@ -540,21 +541,16 @@ final class NoiseEncryptionService {
} }
// Log security event // Log security event
SecureLogger.info(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
// Notify all handlers about authentication // Notify all handlers about authentication
serviceQueue.async { [weak self] in serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint) handler(peerID.id, fingerprint)
} }
} }
} }
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
let hash = SHA256.hash(data: publicKey.rawRepresentation)
return hash.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Session Maintenance // MARK: - Session Maintenance
private func startRekeyTimer() { private func startRekeyTimer() {
+137 -128
View File
@@ -4,50 +4,51 @@ import Combine
// Minimal Nostr transport conforming to Transport for offline sending // Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport { final class NostrTransport: Transport {
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
return
}
weak var delegate: BitchatDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just([]).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
// Provide BLE short peer ID for BitChat embedding // Provide BLE short peer ID for BitChat embedding
var senderPeerID: String = "" var senderPeerID = PeerID(str: "")
// Throttle READ receipts to avoid relay rate limits // Throttle READ receipts to avoid relay rate limits
private struct QueuedRead { private struct QueuedRead {
let receipt: ReadReceipt let receipt: ReadReceipt
let peerID: String let peerID: PeerID
} }
private var readQueue: [QueuedRead] = [] private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
var myPeerID: String { senderPeerID } init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
self.keychain = keychain
self.idBridge = idBridge
}
// MARK: - Transport Protocol Conformance
weak var delegate: BitchatDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just([]).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
var myPeerID: PeerID { senderPeerID }
var myNickname: String { "" } var myNickname: String { "" }
func setNickname(_ nickname: String) { /* not used for Nostr */ } func setNickname(_ nickname: String) { /* not used for Nostr */ }
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
}
func startServices() { /* no-op */ } func startServices() { /* no-op */ }
func stopServices() { /* no-op */ } func stopServices() { /* no-op */ }
func emergencyDisconnectAll() { /* no-op */ } func emergencyDisconnectAll() { /* no-op */ }
func isPeerConnected(_ peerID: String) -> Bool { false } func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func isPeerReachable(_ peerID: String) -> Bool { false } func isPeerReachable(_ peerID: PeerID) -> Bool { false }
func peerNickname(peerID: String) -> String? { nil } func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [String : String] { [:] } func getPeerNicknames() -> [PeerID : String] { [:] }
func getFingerprint(for peerID: String) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: String) { /* no-op */ } func triggerHandshake(with peerID: PeerID) { /* no-op */ }
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService? private static var cachedNoiseService: NoiseEncryptionService?
@@ -63,11 +64,11 @@ final class NostrTransport: Transport {
// Public broadcast not supported over Nostr here // Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only) // Convert recipient npub -> hex (x-only)
let recipientHex: String let recipientHex: String
do { do {
@@ -81,7 +82,7 @@ final class NostrTransport: Transport {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session) SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return return
} }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return return
} }
@@ -94,12 +95,113 @@ final class NostrTransport: Transport {
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits // Enqueue and process with throttling to avoid relay rate limits
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
processReadQueueIfNeeded() processReadQueueIfNeeded()
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
}
// MARK: - Geohash Helpers
extension NostrTransport {
// MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
}
// MARK: - Private Helpers
extension NostrTransport {
private func processReadQueueIfNeeded() { private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return } guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return } guard !readQueue.isEmpty else { return }
@@ -112,7 +214,7 @@ final class NostrTransport: Transport {
let item = readQueue.removeFirst() let item = readQueue.removeFirst()
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session) SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
@@ -121,7 +223,7 @@ final class NostrTransport: Transport {
guard hrp == "npub" else { scheduleNextReadAck(); return } guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return } } catch { scheduleNextReadAck(); return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
@@ -143,111 +245,18 @@ final class NostrTransport: Transport {
} }
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: - Helpers
@MainActor @MainActor
private func resolveRecipientNpub(for peerID: String) -> String? { private func resolveRecipientNpub(for peerID: PeerID) -> String? {
if let noiseKey = Data(hexString: peerID), if let noiseKey = Data(hexString: peerID.id),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
if peerID.count == 16, if peerID.id.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
return nil return nil
} }
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: - Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: - Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
} }
@@ -70,26 +70,6 @@ final class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
} }
func sendFavoriteOnlineNotification(nickname: String) {
// Send directly without checking app state for favorites
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = "\(nickname) is online!"
content.body = "wanna get in there?"
content.sound = .default
let request = UNNotificationRequest(
identifier: "favorite-online-\(UUID().uuidString)",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
}
// Geohash public chat notification with deep link to a specific geohash // Geohash public chat notification with deep link to a specific geohash
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) { func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
let title = "\(titlePrefix)\(geohash)" let title = "\(titlePrefix)\(geohash)"
@@ -0,0 +1,81 @@
//
// NotificationStreamAssembler.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct NotificationStreamAssembler {
private var buffer = Data()
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
guard !chunk.isEmpty else { return ([], [], false) }
buffer.append(chunk)
var frames: [Data] = []
var dropped: [UInt8] = []
var reset = false
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
while buffer.count >= minFramePrefix {
guard let first = buffer.first else { break }
if first != 1 {
dropped.append(buffer.removeFirst())
continue
}
guard buffer.count >= minHeaderBytes else { break }
let headerBytes = Array(buffer.prefix(minFramePrefix))
guard headerBytes.count == minFramePrefix else { break }
let flags = headerBytes[11]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
var frameLength = minFramePrefix + payloadLen
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
guard frameLength > 0, frameLength <= maxFrameLength else {
buffer.removeAll()
reset = true
break
}
if buffer.count < frameLength {
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
if dropCount > 0 {
buffer.removeFirst(dropCount)
dropped.append(1) // treat as dropped partial start
}
}
break
}
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
buffer.removeAll(keepingCapacity: false)
}
return (frames, dropped, reset)
}
mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
}
+20 -138
View File
@@ -12,9 +12,9 @@ import SwiftUI
/// Manages all private chat functionality /// Manages all private chat functionality
final class PrivateChatManager: ObservableObject { final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] @Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil @Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<PeerID> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -31,7 +31,7 @@ final class PrivateChatManager: ObservableObject {
private let privateChatCap = TransportConfig.privateChatCap private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: PeerID) {
selectedPeer = peerID selectedPeer = peerID
// Store fingerprint for persistence across reconnections // Store fingerprint for persistence across reconnections
@@ -54,108 +54,32 @@ final class PrivateChatManager: ObservableObject {
selectedPeerFingerprint = nil selectedPeerFingerprint = nil
} }
/// Send a private message /// Remove duplicate messages by ID and keep chronological order
func sendMessage(_ content: String, to peerID: String) { func sanitizeChat(for peerID: PeerID) {
guard let meshService = meshService, guard let arr = privateChats[peerID] else { return }
let peerNickname = meshService.peerNickname(peerID: peerID) else { if arr.count <= 1 {
return return
} }
let messageID = UUID().uuidString var indexByID: [String: Int] = [:]
indexByID.reserveCapacity(arr.count)
// Create local message
let message = BitchatMessage(
id: messageID,
sender: meshService.myNickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: peerNickname,
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// Add to chat
if privateChats[peerID] == nil { privateChats[peerID] = [] }
privateChats[peerID]?.append(message)
// Enforce per-chat cap on local append
if var arr = privateChats[peerID], arr.count > privateChatCap {
let remove = arr.count - privateChatCap
arr.removeFirst(remove)
privateChats[peerID] = arr
}
// Send via mesh service
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
}
/// Handle incoming private message
func handleIncomingMessage(_ message: BitchatMessage) {
guard let senderPeerID = message.senderPeerID else { return }
// Initialize chat if needed
if privateChats[senderPeerID] == nil {
privateChats[senderPeerID] = []
}
// Deduplicate by ID: replace existing message if present, else append
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
privateChats[senderPeerID]?[idx] = message
} else {
privateChats[senderPeerID]?.append(message)
}
// Sanitize chat to avoid duplicate IDs and sort by timestamp
sanitizeChat(for: senderPeerID)
// Enforce cap after sanitize
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
let remove = arr.count - privateChatCap
arr.removeFirst(remove)
privateChats[senderPeerID] = arr
}
// Mark as unread if not in this chat
if selectedPeer != senderPeerID {
unreadMessages.insert(senderPeerID)
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
if !sentReadReceipts.contains(message.id) {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: senderPeerID
)
}
} else {
// Send read receipt if viewing this chat
sendReadReceipt(for: message)
}
}
/// Remove duplicate messages by ID and keep chronological order
func sanitizeChat(for peerID: String) {
guard let arr = privateChats[peerID] else { return }
var seen = Set<String>()
var deduped: [BitchatMessage] = [] var deduped: [BitchatMessage] = []
deduped.reserveCapacity(arr.count)
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(msg.id) { if let existing = indexByID[msg.id] {
seen.insert(msg.id) deduped[existing] = msg
deduped.append(msg)
} else { } else {
// Replace previous with the latest occurrence (which is later in sort) indexByID[msg.id] = deduped.count
if let index = deduped.firstIndex(where: { $0.id == msg.id }) { deduped.append(msg)
deduped[index] = msg
}
} }
} }
privateChats[peerID] = deduped privateChats[peerID] = deduped
} }
/// Mark messages from a peer as read /// Mark messages from a peer as read
func markAsRead(from peerID: String) { func markAsRead(from peerID: PeerID) {
unreadMessages.remove(peerID) unreadMessages.remove(peerID)
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
@@ -168,48 +92,6 @@ final class PrivateChatManager: ObservableObject {
} }
} }
/// Update the selected peer if fingerprint matches (for reconnections)
func updateSelectedPeer(peers: [String: String]) {
guard let fingerprint = selectedPeerFingerprint else { return }
// Find peer with matching fingerprint
for (peerID, _) in peers {
if meshService?.getFingerprint(for: peerID) == fingerprint {
selectedPeer = peerID
break
}
}
}
/// Get chat messages for current context
func getCurrentMessages() -> [BitchatMessage] {
guard let peer = selectedPeer else { return [] }
return privateChats[peer] ?? []
}
/// Clear a private chat
func clearChat(with peerID: String) {
privateChats[peerID]?.removeAll()
}
/// Handle delivery acknowledgment
func handleDeliveryAck(messageID: String, from peerID: String) {
guard privateChats[peerID] != nil else { return }
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
}
}
/// Handle read receipt
func handleReadReceipt(messageID: String, from peerID: String) {
guard privateChats[peerID] != nil else { return }
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
}
}
// MARK: - Private Methods // MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) { private func sendReadReceipt(for message: BitchatMessage) {
@@ -223,13 +105,13 @@ final class PrivateChatManager: ObservableObject {
// Create read receipt using the simplified method // Create read receipt using the simplified method
let receipt = ReadReceipt( let receipt = ReadReceipt(
originalMessageID: message.id, originalMessageID: message.id,
readerID: meshService?.myPeerID ?? "", readerID: meshService?.myPeerID.id ?? "",
readerNickname: meshService?.myNickname ?? "" readerNickname: meshService?.myNickname ?? ""
) )
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established // Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter { if let router = messageRouter {
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session) SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
Task { @MainActor in Task { @MainActor in
router.sendReadReceipt(receipt, to: senderPeerID) router.sendReadReceipt(receipt, to: senderPeerID)
} }
+16 -23
View File
@@ -18,13 +18,17 @@ struct RelayController {
isAnnounce: Bool, isAnnounce: Bool,
degree: Int, degree: Int,
highDegreeThreshold: Int) -> RelayDecision { highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
// Suppress obvious non-relays // Suppress obvious non-relays
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) } if ttlCap <= 1 || senderIsSelf {
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
}
// For session-critical or directed traffic, be deterministic and reliable // For session-critical or directed traffic, be deterministic and reliable
if isHandshake || isDirectedFragment || isDirectedEncrypted { if isHandshake || isDirectedFragment || isDirectedEncrypted {
// Always relay with no TTL cap for these types // Always relay with no TTL cap for these types
let newTTL = (ttl &- 1) let newTTL = ttlCap &- 1
// Slight jitter to desynchronize without adding too much latency // Slight jitter to desynchronize without adding too much latency
// Tighter for faster multi-hop handshakes and directed DMs // Tighter for faster multi-hop handshakes and directed DMs
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60 let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
@@ -32,28 +36,17 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
let baseProb: Double
switch degree {
case 0...2: baseProb = 1.0
case 3...4: baseProb = 0.9
case 5...6: baseProb = 0.7
case 7...9: baseProb = 0.55
default: baseProb = 0.45
}
let prob = baseProb
let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping for broadcast // TTL clamping for broadcast
// - Dense graphs: keep very low to avoid floods // - Dense graphs: keep lower but still allow multi-hop bridging
// - Sparse graphs: allow slightly longer reach for multi-hop discovery // - Announces get a bit more headroom
// - Announces in sparse graphs get a bit more headroom let ttlLimit: UInt8 = {
let ttlCap: UInt8 = { if degree >= highDegreeThreshold {
if degree >= highDegreeThreshold { return 3 } return max(UInt8(2), min(ttlCap, UInt8(5)))
return isAnnounce ? 7 : 6 }
let preferred = UInt8(isAnnounce ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred))
}() }()
let clamped = max(1, min(ttl, ttlCap)) let newTTL = ttlLimit &- 1
let newTTL = clamped &- 1
// Wider jitter window to allow duplicate suppression to win more often // Wider jitter window to allow duplicate suppression to win more often
// For sparse graphs (<=2), relay quickly to avoid cancellation races // For sparse graphs (<=2), relay quickly to avoid cancellation races
@@ -64,6 +57,6 @@ struct RelayController {
case 6...9: delayMs = Int.random(in: 80...180) case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220) default: delayMs = Int.random(in: 100...220)
} }
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
} }
+23 -26
View File
@@ -4,7 +4,7 @@ import Combine
/// Abstract transport interface used by ChatViewModel and services. /// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too. /// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot: Equatable, Hashable { struct TransportPeerSnapshot: Equatable, Hashable {
let id: String let peerID: PeerID
let nickname: String let nickname: String
let isConnected: Bool let isConnected: Bool
let noisePublicKey: Data? let noisePublicKey: Data?
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
} }
protocol Transport: AnyObject { protocol Transport: AnyObject {
// Peer events (preferred over publishers for UI)
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
// Event sink // Event sink
var delegate: BitchatDelegate? { get set } var delegate: BitchatDelegate? { get set }
// Peer events (preferred over publishers for UI)
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
// Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot]
// Identity // Identity
var myPeerID: String { get } var myPeerID: PeerID { get }
var myNickname: String { get } var myNickname: String { get }
func setNickname(_ nickname: String) func setNickname(_ nickname: String)
@@ -28,40 +32,33 @@ protocol Transport: AnyObject {
func emergencyDisconnectAll() func emergencyDisconnectAll()
// Connectivity and peers // Connectivity and peers
func isPeerConnected(_ peerID: String) -> Bool func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: String) -> Bool func isPeerReachable(_ peerID: PeerID) -> Bool
func peerNickname(peerID: String) -> String? func peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [String: String] func getPeerNicknames() -> [PeerID: String]
// Protocol utilities // Protocol utilities
func getFingerprint(for peerID: String) -> String? func getFingerprint(for peerID: PeerID) -> String?
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: String) func triggerHandshake(with peerID: PeerID)
func getNoiseService() -> NoiseEncryptionService func getNoiseService() -> NoiseEncryptionService
// Messaging // Messaging
func sendMessage(_ content: String, mentions: [String]) func sendMessage(_ content: String, mentions: [String])
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce() func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: String) func sendDeliveryAck(for messageID: String, to peerID: PeerID)
// Media transfer (file transfer TLV)
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String)
// QR verification (optional for transports) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot]
} }
extension Transport { extension Transport {
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {} func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
} }
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
+1 -1
View File
@@ -188,7 +188,7 @@ enum TransportConfig {
static let uiWindowStepCount: Int = 200 static let uiWindowStepCount: Int = 200
// Share extension // Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3 static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
} }
+30 -72
View File
@@ -10,7 +10,6 @@ import BitLogger
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
import CryptoKit
/// Single source of truth for peer state, combining mesh connectivity and favorites /// Single source of truth for peer state, combining mesh connectivity and favorites
@MainActor @MainActor
@@ -19,15 +18,16 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Published Properties // MARK: - Published Properties
@Published private(set) var peers: [BitchatPeer] = [] @Published private(set) var peers: [BitchatPeer] = []
@Published private(set) var connectedPeerIDs: Set<String> = [] @Published private(set) var connectedPeerIDs: Set<PeerID> = []
@Published private(set) var favorites: [BitchatPeer] = [] @Published private(set) var favorites: [BitchatPeer] = []
@Published private(set) var mutualFavorites: [BitchatPeer] = [] @Published private(set) var mutualFavorites: [BitchatPeer] = []
// MARK: - Private Properties // MARK: - Private Properties
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [PeerID: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [PeerID: String] = [:]
private let meshService: Transport private let meshService: Transport
private let idBridge: NostrIdentityBridge
private let identityManager: SecureIdentityStateManagerProtocol private let identityManager: SecureIdentityStateManagerProtocol
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
@@ -35,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Initialization // MARK: - Initialization
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) { init(
meshService: Transport,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol
) {
self.meshService = meshService self.meshService = meshService
self.idBridge = idBridge
self.identityManager = identityManager self.identityManager = identityManager
// Subscribe to changes from both services // Subscribe to changes from both services
@@ -78,12 +83,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favorites = favoritesService.favorites let favorites = favoritesService.favorites
var enrichedPeers: [BitchatPeer] = [] var enrichedPeers: [BitchatPeer] = []
var connected: Set<String> = [] var connected: Set<PeerID> = []
var addedPeerIDs: Set<String> = [] var addedPeerIDs: Set<PeerID> = []
// Phase 1: Add all mesh peers (connected and reachable) // Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers { for peerInfo in meshPeers {
let peerID = peerInfo.id let peerID = peerInfo.peerID
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh( let peer = buildPeerFromMesh(
@@ -104,7 +109,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 2: Add offline favorites that we actively favorite // Phase 2: Add offline favorites that we actively favorite
for (favoriteKey, favorite) in favorites where favorite.isFavorite { for (favoriteKey, favorite) in favorites where favorite.isFavorite {
let peerID = favoriteKey.hexEncodedString() let peerID = PeerID(hexData: favoriteKey)
// Skip if already added (connected peer) // Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue } if addedPeerIDs.contains(peerID) { continue }
@@ -138,10 +143,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 4: Build subsets and indices // Phase 4: Build subsets and indices
var favoritesList: [BitchatPeer] = [] var favoritesList: [BitchatPeer] = []
var mutualsList: [BitchatPeer] = [] var mutualsList: [BitchatPeer] = []
var newIndex: [String: BitchatPeer] = [:] var newIndex: [PeerID: BitchatPeer] = [:]
for peer in enrichedPeers { for peer in enrichedPeers {
newIndex[peer.id] = peer newIndex[peer.peerID] = peer
if peer.isFavorite { if peer.isFavorite {
favoritesList.append(peer) favoritesList.append(peer)
@@ -185,7 +190,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached) let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerInfo.id, peerID: peerInfo.peerID,
noisePublicKey: peerInfo.noisePublicKey ?? Data(), noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname, nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen, lastSeen: peerInfo.lastSeen,
@@ -205,10 +210,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func buildPeerFromFavorite( private func buildPeerFromFavorite(
favorite: FavoritesPersistenceService.FavoriteRelationship, favorite: FavoritesPersistenceService.FavoriteRelationship,
peerID: String peerID: PeerID
) -> BitchatPeer { ) -> BitchatPeer {
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerID, peerID: peerID,
noisePublicKey: favorite.peerNoisePublicKey, noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname, nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated, lastSeen: favorite.lastUpdated,
@@ -225,27 +230,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Public Methods // MARK: - Public Methods
/// Get peer by ID /// Get peer by ID
func getPeer(by id: String) -> BitchatPeer? { func getPeer(by peerID: PeerID) -> BitchatPeer? {
return peerIndex[id] return peerIndex[peerID]
} }
/// Get peer ID for nickname /// Get peer ID for nickname
func getPeerID(for nickname: String) -> String? { func getPeerID(for nickname: String) -> PeerID? {
for peer in peers { for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname { if peer.displayName == nickname || peer.nickname == nickname {
return peer.id return peer.peerID
} }
} }
return nil return nil
} }
/// Check if peer is online
func isOnline(_ peerID: String) -> Bool {
return connectedPeerIDs.contains(peerID)
}
/// Check if peer is blocked /// Check if peer is blocked
func isBlocked(_ peerID: String) -> Bool { func isBlocked(_ peerID: PeerID) -> Bool {
// Get fingerprint // Get fingerprint
guard let fingerprint = getFingerprint(for: peerID) else { return false } guard let fingerprint = getFingerprint(for: peerID) else { return false }
@@ -258,7 +258,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
/// Toggle favorite status /// Toggle favorite status
func toggleFavorite(_ peerID: String) { func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session) SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
return return
@@ -291,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var peerNostrKey = peer.nostrPublicKey var peerNostrKey = peer.nostrPublicKey
if peerNostrKey == nil { if peerNostrKey == nil {
// Try to get from NostrIdentityBridge association // Try to get from NostrIdentityBridge association
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey) peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
} }
// Add favorite // Add favorite
@@ -322,39 +322,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
} }
/// Toggle blocked status func getFingerprint(for peerID: PeerID) -> String? {
func toggleBlocked(_ peerID: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return }
// Get or create social identity
var identity = identityManager.getSocialIdentity(for: fingerprint)
?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Toggle blocked status
identity.isBlocked = !identity.isBlocked
// Can't be both favorite and blocked
if identity.isBlocked {
identity.isFavorite = false
// Also remove from favorites service
if let peer = getPeer(by: peerID) {
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
}
}
identityManager.updateSocialIdentity(identity)
}
/// Get fingerprint for peer ID
func getFingerprint(for peerID: String) -> String? {
// Check cache first // Check cache first
if let cached = fingerprintCache[peerID] { if let cached = fingerprintCache[peerID] {
return cached return cached
@@ -379,23 +347,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Compatibility Methods (for easy migration) // MARK: - Compatibility Methods (for easy migration)
var allPeers: [BitchatPeer] { peers } var allPeers: [BitchatPeer] { peers }
var connectedPeers: [String] { Array(connectedPeerIDs) } var connectedPeers: Set<PeerID> { connectedPeerIDs }
var favoritePeers: Set<String> { var favoritePeers: Set<String> {
Set(favorites.compactMap { getFingerprint(for: $0.id) }) Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
} }
var blockedUsers: Set<String> { var blockedUsers: Set<String> {
Set(peers.compactMap { peer in Set(peers.compactMap { peer in
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
}) })
} }
} }
// MARK: - Helper Extensions
extension Data {
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
import Foundation import Foundation
import CryptoKit
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers. /// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
final class VerificationService { final class VerificationService {
@@ -95,7 +94,7 @@ final class VerificationService {
nickname: payload.nickname, nickname: payload.nickname,
ts: payload.ts, ts: payload.ts,
nonceB64: payload.nonceB64, nonceB64: payload.nonceB64,
sigHex: sig.map { String(format: "%02x", $0) }.joined()) sigHex: sig.hexEncodedString())
let out = signed.toURLString() let out = signed.toURLString()
Cache.last = (nickname, npub, Date(), out) Cache.last = (nickname, npub, Date(), out)
return out return out
+70 -16
View File
@@ -4,7 +4,7 @@ import CryptoKit
// Golomb-Coded Set (GCS) filter utilities for sync. // Golomb-Coded Set (GCS) filter utilities for sync.
// Hashing: // Hashing:
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID. // - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
// - Map to [0, M) via (h64 % M). // - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
// Encoding (v1): // Encoding (v1):
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1. // - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1). // - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
@@ -29,25 +29,40 @@ enum GCSFilter {
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params { static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
let p = deriveP(targetFpr: targetFpr) let p = deriveP(targetFpr: targetFpr)
guard !ids.isEmpty else {
return Params(p: p, m: 1, data: Data())
}
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
let n = min(ids.count, cap) let selected = Array(ids.prefix(cap))
let selected = Array(ids.prefix(n)) let range = max(1, hashRange(count: selected.count, p: p))
// Map to [0, M) let modulo = UInt64(range)
let mInit = UInt32(n << p)
var mapped = selected.map { id16 -> UInt64 in var mapped = selected
let h = h64(id16) .map { h64($0) }
return UInt64(h % UInt64(max(1, mInit))) .map { mapHash($0, modulo: modulo) }
}.sorted() .sorted()
mapped = normalizeMappedValues(mapped, modulo: modulo)
if mapped.isEmpty {
return Params(p: p, m: range, data: Data())
}
var encoded = encode(sorted: mapped, p: p) var encoded = encode(sorted: mapped, p: p)
var trimmedN = n var trimmedCount = mapped.count
// Trim if over budget
while encoded.count > maxBytes && trimmedN > 0 { while encoded.count > maxBytes && trimmedCount > 0 {
trimmedN = (trimmedN * 9) / 10 // drop ~10% if trimmedCount == 1 {
mapped = Array(mapped.prefix(trimmedN)) mapped.removeAll()
encoded = Data()
break
}
trimmedCount = max(1, (trimmedCount * 9) / 10)
mapped = Array(mapped.prefix(trimmedCount))
encoded = encode(sorted: mapped, p: p) encoded = encode(sorted: mapped, p: p)
} }
let finalM = UInt32(max(1, trimmedN << p))
return Params(p: p, m: finalM, data: encoded) return Params(p: p, m: range, data: encoded)
} }
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
@@ -77,6 +92,12 @@ enum GCSFilter {
return false return false
} }
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
let modulo = UInt64(max(1, m))
guard modulo > 1 else { return 0 }
return mapHash(h64(id), modulo: modulo)
}
private static func h64(_ id16: Data) -> UInt64 { private static func h64(_ id16: Data) -> UInt64 {
var hasher = SHA256() var hasher = SHA256()
hasher.update(data: id16) hasher.update(data: id16)
@@ -88,6 +109,39 @@ enum GCSFilter {
return x & 0x7fff_ffff_ffff_ffff return x & 0x7fff_ffff_ffff_ffff
} }
private static func hashRange(count: Int, p: Int) -> UInt32 {
guard count > 0 else { return 1 }
if p >= 64 { return UInt32.max }
let multiplier = UInt64(1) << UInt64(p)
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
if overflow { return UInt32.max }
if product == 0 { return 1 }
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
}
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
guard modulo > 1 else { return 0 }
let value = hash % modulo
if value == 0 { return 1 }
return value
}
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
guard modulo > 1 else { return [] }
guard !values.isEmpty else { return [] }
var result: [UInt64] = []
result.reserveCapacity(values.count)
var last: UInt64 = 0
for value in values {
let normalized = min(value, modulo - 1)
if normalized > last {
result.append(normalized)
last = normalized
}
}
return result
}
private static func encode(sorted: [UInt64], p: Int) -> Data { private static func encode(sorted: [UInt64], p: Int) -> Data {
let writer = BitWriter() let writer = BitWriter()
var prev: UInt64 = 0 var prev: UInt64 = 0
+163 -33
View File
@@ -1,11 +1,10 @@
import Foundation import Foundation
import CryptoKit
// Gossip-based sync manager using on-demand GCS filters // Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager { final class GossipSyncManager {
protocol Delegate: AnyObject { protocol Delegate: AnyObject {
func sendPacket(_ packet: BitchatPacket) func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: String, packet: BitchatPacket) func sendPacket(to peerID: PeerID, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
} }
@@ -13,9 +12,13 @@ final class GossipSyncManager {
var seenCapacity: Int = 1000 // max packets per sync (cap across types) var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1% var gcsTargetFpr: Double = 0.01 // 1%
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
} }
private let myPeerID: String private let myPeerID: PeerID
private let config: Config private let config: Config
weak var delegate: Delegate? weak var delegate: Delegate?
@@ -27,8 +30,9 @@ final class GossipSyncManager {
// Timer // Timer
private var periodicTimer: DispatchSourceTimer? private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast
init(myPeerID: String, config: Config = Config()) { init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID self.myPeerID = myPeerID
self.config = config self.config = config
} }
@@ -36,8 +40,11 @@ final class GossipSyncManager {
func start() { func start() {
stop() stop()
let timer = DispatchSource.makeTimerSource(queue: queue) let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1)) let interval = max(0.1, config.maintenanceIntervalSeconds)
timer.setEventHandler { [weak self] in self?.sendRequestSync() } timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
timer.setEventHandler { [weak self] in
self?.performPeriodicMaintenance()
}
timer.resume() timer.resume()
periodicTimer = timer periodicTimer = timer
} }
@@ -46,13 +53,40 @@ final class GossipSyncManager {
periodicTimer?.cancel(); periodicTimer = nil periodicTimer?.cancel(); periodicTimer = nil
} }
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) { func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID) self?.sendRequestSync(to: peerID)
} }
} }
func onPublicPacketSeen(_ packet: BitchatPacket) { func onPublicPacketSeen(_ packet: BitchatPacket) {
queue.async { [weak self] in
self?._onPublicPacketSeen(packet)
}
}
// Helper to check if a packet is within the age threshold
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
// If current time is less than threshold, accept all (handle clock issues gracefully)
guard nowMs >= ageThresholdMs else { return true }
let cutoffMs = nowMs - ageThresholdMs
return packet.timestamp >= cutoffMs
}
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
guard config.stalePeerTimeoutSeconds > 0 else { return true }
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
guard nowMs >= timeoutMs else { return true }
let cutoffMs = nowMs - timeoutMs
return packet.timestamp >= cutoffMs
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type) let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = { let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true } guard let r = packet.recipientID else { return true }
@@ -62,6 +96,17 @@ final class GossipSyncManager {
let isAnnounce = (mt == .announce) let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return } guard isBroadcastMessage || isAnnounce else { return }
// Reject expired packets to prevent ghost peers and old messages
guard isPacketFresh(packet) else { return }
if isAnnounce {
guard isAnnouncementFresh(packet) else {
let sender = packet.senderID.hexEncodedString().lowercased()
removeState(forNormalizedPeerID: sender)
return
}
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage { if isBroadcastMessage {
@@ -76,7 +121,7 @@ final class GossipSyncManager {
} }
} }
} else if isAnnounce { } else if isAnnounce {
let sender = packet.senderID.hexEncodedString() let sender = packet.senderID.hexEncodedString().lowercased()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
} }
} }
@@ -85,7 +130,7 @@ final class GossipSyncManager {
let payload = buildGcsPayload() let payload = buildGcsPayload()
let pkt = BitchatPacket( let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue, type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: Data(hexString: myPeerID.id) ?? Data(),
recipientID: nil, // broadcast recipientID: nil, // broadcast
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -96,10 +141,10 @@ final class GossipSyncManager {
delegate?.sendPacket(signed) delegate?.sendPacket(signed)
} }
private func sendRequestSync(to peerID: String) { private func sendRequestSync(to peerID: PeerID) {
let payload = buildGcsPayload() let payload = buildGcsPayload()
var recipient = Data() var recipient = Data()
var temp = peerID var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 { while temp.count >= 2 && recipient.count < 8 {
let hexByte = String(temp.prefix(2)) let hexByte = String(temp.prefix(2))
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) } if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
@@ -107,7 +152,7 @@ final class GossipSyncManager {
} }
let pkt = BitchatPacket( let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue, type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: Data(hexString: myPeerID.id) ?? Data(),
recipientID: recipient, recipientID: recipient,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -118,51 +163,60 @@ final class GossipSyncManager {
delegate?.sendPacket(to: peerID, packet: signed) delegate?.sendPacket(to: peerID, packet: signed)
} }
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
queue.async { [weak self] in
self?._handleRequestSync(from: peerID, request: request)
}
}
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
// Decode GCS into sorted set and prepare membership checker // Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool { func mightContain(_ id: Data) -> Bool {
var hasher = SHA256() let bucket = GCSFilter.bucket(for: id, modulus: request.m)
hasher.update(data: id) // 16-byte PacketId return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
let digest = hasher.finalize()
let db = Data(digest)
var x: UInt64 = 0
let take = min(8, db.count)
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
return GCSFilter.contains(sortedValues: sorted, candidate: v)
} }
// 1) Announcements: send latest per peer if requester lacks them // 1) Announcements: send latest per peer if requester lacks them (and not expired)
for (_, pair) in latestAnnouncementByPeer { for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data() let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
} }
} }
// 2) Broadcast messages: send all missing // 2) Broadcast messages: send all missing (and not expired)
let toSendMsgs = messageOrder.compactMap { messages[$0] } let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs { for pkt in toSendMsgs {
guard isPacketFresh(pkt) else { continue }
let idBytes = PacketIdUtil.computeId(pkt) let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
} }
} }
} }
// Build REQUEST_SYNC payload using current candidates and GCS params // Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload() -> Data { private func buildGcsPayload() -> Data {
// Collect candidates: latest announce per peer + broadcast messages // Collect candidates: latest announce per peer + broadcast messages (only fresh)
var candidates: [BitchatPacket] = [] var candidates: [BitchatPacket] = []
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count) candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) } for (_, pair) in latestAnnouncementByPeer {
for id in messageOrder { if let p = messages[id] { candidates.append(p) } } if isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
for id in messageOrder {
if let p = messages[id], isPacketFresh(p) {
candidates.append(p)
}
}
// Sort by timestamp desc // Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp } candidates.sort { $0.timestamp > $1.timestamp }
@@ -180,11 +234,65 @@ final class GossipSyncManager {
return req.encode() return req.encode()
} }
// Explicit removal hook for LEAVE/stale peer // Periodic cleanup of expired messages and announcements
func removeAnnouncementForPeer(_ peerID: String) { private func cleanupExpiredMessages() {
let normalizedPeerID = peerID.lowercased() // Remove expired announcements
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID) latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
isPacketFresh(pair.packet)
}
// Remove expired messages
let expiredMessageIds = messages.compactMap { id, pkt in
isPacketFresh(pkt) ? nil : id
}
for id in expiredMessageIds {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
sendRequestSync()
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
return
}
lastStalePeerCleanup = now
cleanupStaleAnnouncements(now: now)
}
private func cleanupStaleAnnouncements(now: Date) {
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
removeState(forNormalizedPeerID: peerKey)
}
}
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: PeerID) {
queue.async { [weak self] in
self?._removeAnnouncementForPeer(peerID)
}
}
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased()
removeState(forNormalizedPeerID: normalizedPeerID)
}
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer // Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification // Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
@@ -198,3 +306,25 @@ final class GossipSyncManager {
} }
} }
} }
#if DEBUG
extension GossipSyncManager {
func _performMaintenanceSynchronously(now: Date = Date()) {
queue.sync {
performPeriodicMaintenance(now: now)
}
}
func _hasAnnouncement(for peerID: PeerID) -> Bool {
queue.sync {
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
}
}
}
#endif
-4
View File
@@ -14,8 +14,4 @@ enum PacketIdUtil {
let digest = hasher.finalize() let digest = hasher.finalize()
return Data(digest.prefix(16)) return Data(digest.prefix(16))
} }
static func computeIdHex(_ packet: BitchatPacket) -> String {
return computeId(packet).hexEncodedString()
}
} }
+22
View File
@@ -0,0 +1,22 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
+41
View File
@@ -0,0 +1,41 @@
import SwiftUI
/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto
/// preferred text styles so the UI scales with user accessibility settings.
extension Font {
static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {
let style = Font.TextStyle.bitchatPreferredStyle(for: size)
var font = Font.system(style, design: design)
if weight != .regular {
font = font.weight(weight)
}
return font
}
}
private extension Font.TextStyle {
static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {
switch size {
case ..<11.5:
return .caption2
case ..<13.0:
return .caption
case ..<13.75:
return .footnote
case ..<15.5:
return .subheadline
case ..<17.5:
return .callout
case ..<19.5:
return .body
case ..<22.5:
return .title3
case ..<27.5:
return .title2
case ..<34.0:
return .title
default:
return .largeTitle
}
}
}
+8 -81
View File
@@ -8,35 +8,15 @@ struct InputValidator {
struct Limits { struct Limits {
static let maxNicknameLength = 50 static let maxNicknameLength = 50
static let maxMessageLength = 10_000 // BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
static let maxReasonLength = 200 // for headers/padding by limiting user content to 60_000 bytes.
static let maxPeerIDLength = 64 static let maxMessageLength = 60_000
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
}
// MARK: - Peer ID Validation
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
static func validatePeerID(_ peerID: String) -> Bool {
// Accept short routing IDs (exact 16-hex)
if PeerIDResolver.isShortID(peerID) { return true }
// If length equals short-hex length but isn't valid hex, reject
if peerID.count == Limits.hexPeerIDLength { return false }
// Accept full Noise key hex (exact 64-hex)
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
// If length equals full key length but isn't valid hex, reject
if peerID.count == Limits.maxPeerIDLength { return false }
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !peerID.isEmpty &&
peerID.count < Limits.maxPeerIDLength &&
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
} }
// MARK: - String Content Validation // MARK: - String Content Validation
/// Validates and sanitizes user-provided strings (nicknames, messages) /// Validates and sanitizes user-provided strings used in UI
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? { static func validateUserString(_ string: String, maxLength: Int) -> String? {
// Check empty // Check empty
guard !string.isEmpty else { return nil } guard !string.isEmpty else { return nil }
@@ -47,13 +27,8 @@ struct InputValidator {
// Check length // Check length
guard trimmed.count <= maxLength else { return nil } guard trimmed.count <= maxLength else { return nil }
// Remove control characters except allowed ones // Remove control characters
var allowedControlChars = CharacterSet() let controlChars = CharacterSet.controlCharacters
if allowNewlines {
allowedControlChars.insert(charactersIn: "\n\r")
}
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
let cleaned = trimmed.components(separatedBy: controlChars).joined() let cleaned = trimmed.components(separatedBy: controlChars).joined()
// Ensure valid UTF-8 (should already be, but double-check) // Ensure valid UTF-8 (should already be, but double-check)
@@ -68,17 +43,7 @@ struct InputValidator {
/// Validates nickname /// Validates nickname
static func validateNickname(_ nickname: String) -> String? { static func validateNickname(_ nickname: String) -> String? {
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false) return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
}
/// Validates message content
static func validateMessageContent(_ content: String) -> String? {
return validateUserString(content, maxLength: Limits.maxMessageLength, allowNewlines: true)
}
/// Validates error/reason strings
static func validateReasonString(_ reason: String) -> String? {
return validateUserString(reason, maxLength: Limits.maxReasonLength, allowNewlines: false)
} }
// MARK: - Protocol Field Validation // MARK: - Protocol Field Validation
@@ -86,11 +51,6 @@ struct InputValidator {
// Note: Message type validation is performed closer to decoding using // Note: Message type validation is performed closer to decoding using
// MessageType/NoisePayloadType enums; keeping validator free of stale lists. // MessageType/NoisePayloadType enums; keeping validator free of stale lists.
/// Validates hop count is reasonable
static func validateHopCount(_ hopCount: UInt8) -> Bool {
return hopCount <= 10 // Prevent excessive forwarding
}
/// Validates timestamp is reasonable (not too far in past or future) /// Validates timestamp is reasonable (not too far in past or future)
static func validateTimestamp(_ timestamp: Date) -> Bool { static func validateTimestamp(_ timestamp: Date) -> Bool {
let now = Date() let now = Date()
@@ -99,37 +59,4 @@ struct InputValidator {
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
} }
/// Validates data size for different contexts
static func validateDataSize(_ data: Data, maxSize: Int) -> Bool {
return data.count > 0 && data.count <= maxSize
}
// MARK: - Binary Data Validation
/// Validates UUID format
static func validateUUID(_ uuid: String) -> Bool {
// Remove dashes and validate hex
let cleaned = uuid.replacingOccurrences(of: "-", with: "")
return cleaned.count == 32 && cleaned.allSatisfy { $0.isHexDigit }
}
/// Validates public key data
static func validatePublicKey(_ keyData: Data) -> Bool {
// Curve25519 public keys are 32 bytes
return keyData.count == 32
}
/// Validates signature data
static func validateSignature(_ signature: Data) -> Bool {
// Ed25519 signatures are 64 bytes
return signature.count == 64
}
}
// MARK: - Character Extensions
private extension Character {
var isHexDigit: Bool {
return "0123456789abcdefABCDEF".contains(self)
}
} }
+5 -5
View File
@@ -4,10 +4,10 @@ import Foundation
struct PeerDisplayNameResolver { struct PeerDisplayNameResolver {
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur. /// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
/// - Parameters: /// - Parameters:
/// - peers: Array of tuples (id, nickname, isConnected). /// - peers: Array of tuples (peerID, nickname, isConnected).
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it. /// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
/// - Returns: Map of peerID -> displayName. /// - Returns: Map of peerID -> displayName.
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] { static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {
// Count collisions among connected peers and include our own nickname // Count collisions among connected peers and include our own nickname
var counts: [String: Int] = [:] var counts: [String: Int] = [:]
for p in peers where p.isConnected { for p in peers where p.isConnected {
@@ -15,13 +15,13 @@ struct PeerDisplayNameResolver {
} }
counts[selfNickname, default: 0] += 1 counts[selfNickname, default: 0] += 1
var result: [String: String] = [:] var result: [PeerID: String] = [:]
for p in peers { for p in peers {
var name = p.nickname var name = p.nickname
if p.isConnected, (counts[p.nickname] ?? 0) > 1 { if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
name += "#" + String(p.id.prefix(4)) name += "#" + String(p.peerID.id.prefix(4))
} }
result[p.id] = name result[p.peerID] = name
} }
return result return result
} }
-20
View File
@@ -1,20 +0,0 @@
import Foundation
struct PeerIDResolver {
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
static func toShortID(_ id: String) -> String {
if id.count == 64, let data = Data(hexString: id) {
return PeerIDUtils.derivePeerID(fromPublicKey: data)
}
return id
}
static func isShortID(_ id: String) -> Bool {
return id.count == 16 && Data(hexString: id) != nil
}
static func isNoiseKeyHex(_ id: String) -> Bool {
return id.count == 64 && Data(hexString: id) != nil
}
}
@@ -1,7 +0,0 @@
// ChatViewModel+Extensions.swift
// bitchat
//
// Deprecated helper. No longer needed after MessageHeaderLine simplification.
// Intentionally left empty to avoid accessing private properties across files.
// This file remains as a placeholder to preserve project references if any.
@@ -1,113 +0,0 @@
// ChatViewModel+Images.swift
// bitchat
//
// Extracted image sending helpers to keep ChatViewModel compact.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#endif
extension ChatViewModel {
// MARK: - Images (Send)
#if os(iOS)
@MainActor
func sendImage(_ image: UIImage) {
// Downscale on long edge to 512px with 85% JPEG quality (Android parity)
guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return }
// Persist to app files (outgoing)
guard let fileURL = saveOutgoingImage(data: data) else { return }
let name = fileURL.lastPathComponent
let mime = "image/jpeg"
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data)
guard let payload = tlv.encode() else { return }
let transferId = payload.sha256Hex()
let contentMarker = "[image] \(fileURL.path)"
let now = Date()
if let peer = selectedPrivateChatPeer {
let messageID = UUID().uuidString
let msg = BitchatMessage(
id: messageID,
sender: nickname,
content: contentMarker,
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: peer),
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// appendToPrivateChat is private in ChatViewModel; inline minimal append
var arr = privateChats[peer] ?? []
arr.append(msg)
privateChats[peer] = arr
objectWillChange.send()
meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID)
} else {
let messageID = UUID().uuidString
let msg = BitchatMessage(
id: messageID,
sender: nickname,
content: contentMarker,
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// public buffer helpers are private; append directly to visible messages
messages.append(msg)
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
}
}
// Downscale without cropping; preserves aspect ratio. Long edge == maxDimension.
internal func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? {
let size = image.size
guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) }
let maxSide = max(size.width, size.height)
let scale = min(1.0, maxDimension / maxSide)
let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale))
let rendererFormat = UIGraphicsImageRendererFormat.default()
rendererFormat.scale = 1
let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat)
let scaled = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
return scaled.jpegData(compressionQuality: quality)
}
internal func saveOutgoingImage(data: Data) -> URL? {
do {
let fm = FileManager.default
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true)
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
let ts = Int(Date().timeIntervalSince1970)
let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg")
try data.write(to: fileURL, options: .atomic)
return fileURL
} catch {
// Avoid BitLogger dependency in this small extension
print("❌ Failed to save outgoing image: \(error)")
return nil
}
}
#endif
}
File diff suppressed because it is too large Load Diff
+122 -90
View File
@@ -18,41 +18,77 @@ struct AppInfoView: View {
// MARK: - Constants // MARK: - Constants
private enum Strings { private enum Strings {
static let appName = "bitchat" static let appName: LocalizedStringKey = "app_info.app_name"
static let tagline = "sidegroupchat" static let tagline: LocalizedStringKey = "app_info.tagline"
enum Features { enum Features {
static let title = "FEATURES" static let title: LocalizedStringKey = "app_info.features.title"
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy") static let offlineComm = AppInfoFeatureInfo(
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol") icon: "wifi.slash",
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance") title: "app_info.features.offline.title",
static let mentions = ("at", "mentions", "use @nickname to notify specific people") description: "app_info.features.offline.description"
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join") )
static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays") static let encryption = AppInfoFeatureInfo(
icon: "lock.shield",
title: "app_info.features.encryption.title",
description: "app_info.features.encryption.description"
)
static let extendedRange = AppInfoFeatureInfo(
icon: "antenna.radiowaves.left.and.right",
title: "app_info.features.extended_range.title",
description: "app_info.features.extended_range.description"
)
static let mentions = AppInfoFeatureInfo(
icon: "at",
title: "app_info.features.mentions.title",
description: "app_info.features.mentions.description"
)
static let favorites = AppInfoFeatureInfo(
icon: "star.fill",
title: "app_info.features.favorites.title",
description: "app_info.features.favorites.description"
)
static let geohash = AppInfoFeatureInfo(
icon: "number",
title: "app_info.features.geohash.title",
description: "app_info.features.geohash.description"
)
} }
enum Privacy { enum Privacy {
static let title = "PRIVACY" static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection") static let noTracking = AppInfoFeatureInfo(
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly") icon: "eye.slash",
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data") title: "app_info.privacy.no_tracking.title",
description: "app_info.privacy.no_tracking.description"
)
static let ephemeral = AppInfoFeatureInfo(
icon: "shuffle",
title: "app_info.privacy.ephemeral.title",
description: "app_info.privacy.ephemeral.description"
)
static let panic = AppInfoFeatureInfo(
icon: "hand.raised.fill",
title: "app_info.privacy.panic.title",
description: "app_info.privacy.panic.description"
)
} }
enum HowToUse { enum HowToUse {
static let title = "HOW TO USE" static let title: LocalizedStringKey = "app_info.how_to_use.title"
static let instructions = [ static let instructions: [LocalizedStringKey] = [
"• set your nickname by tapping it", "app_info.how_to_use.set_nickname",
"• tap #mesh to change channels", "app_info.how_to_use.change_channels",
"• tap people icon for sidebar", "app_info.how_to_use.open_sidebar",
"• tap a peer's name to start a DM", "app_info.how_to_use.start_dm",
"• triple-tap chat to clear", "app_info.how_to_use.clear_chat",
"• type / for commands" "app_info.how_to_use.commands"
] ]
} }
enum Warning { enum Warning {
static let title = "WARNING" static let title: LocalizedStringKey = "app_info.warning.title"
static let message = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears." static let message: LocalizedStringKey = "app_info.warning.message"
} }
} }
@@ -62,7 +98,7 @@ struct AppInfoView: View {
// Custom header for macOS // Custom header for macOS
HStack { HStack {
Spacer() Spacer()
Button("DONE") { Button("app_info.done") {
dismiss() dismiss()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -88,12 +124,12 @@ struct AppInfoView: View {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced)) .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Close") .accessibilityLabel("app_info.close")
} }
} }
} }
@@ -106,82 +142,64 @@ struct AppInfoView: View {
// Header // Header
VStack(alignment: .center, spacing: 8) { VStack(alignment: .center, spacing: 8) {
Text(Strings.appName) Text(Strings.appName)
.font(.system(size: 32, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(Strings.tagline) Text(Strings.tagline)
.font(.system(size: 16, design: .monospaced)) .font(.bitchatSystem(size: 16, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical) .padding(.vertical)
// Features
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title)
FeatureRow(icon: Strings.Features.offlineComm.0,
title: Strings.Features.offlineComm.1,
description: Strings.Features.offlineComm.2)
FeatureRow(icon: Strings.Features.encryption.0,
title: Strings.Features.encryption.1,
description: Strings.Features.encryption.2)
FeatureRow(icon: Strings.Features.extendedRange.0,
title: Strings.Features.extendedRange.1,
description: Strings.Features.extendedRange.2)
FeatureRow(icon: Strings.Features.favorites.0,
title: Strings.Features.favorites.1,
description: Strings.Features.favorites.2)
FeatureRow(icon: Strings.Features.geohash.0,
title: Strings.Features.geohash.1,
description: Strings.Features.geohash.2)
FeatureRow(icon: Strings.Features.mentions.0,
title: Strings.Features.mentions.1,
description: Strings.Features.mentions.2)
}
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
FeatureRow(icon: Strings.Privacy.noTracking.0,
title: Strings.Privacy.noTracking.1,
description: Strings.Privacy.noTracking.2)
FeatureRow(icon: Strings.Privacy.ephemeral.0,
title: Strings.Privacy.ephemeral.1,
description: Strings.Privacy.ephemeral.2)
FeatureRow(icon: Strings.Privacy.panic.0,
title: Strings.Privacy.panic.1,
description: Strings.Privacy.panic.2)
}
// How to Use // How to Use
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title) SectionHeader(Strings.HowToUse.title)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in
Text(instruction) Text(instruction)
} }
} }
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
} }
// Features
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title)
FeatureRow(info: Strings.Features.offlineComm)
FeatureRow(info: Strings.Features.encryption)
FeatureRow(info: Strings.Features.extendedRange)
FeatureRow(info: Strings.Features.favorites)
FeatureRow(info: Strings.Features.geohash)
FeatureRow(info: Strings.Features.mentions)
}
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
FeatureRow(info: Strings.Privacy.noTracking)
FeatureRow(info: Strings.Privacy.ephemeral)
FeatureRow(info: Strings.Privacy.panic)
}
// Warning // Warning
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
SectionHeader(Strings.Warning.title) SectionHeader(Strings.Warning.title)
.foregroundColor(Color.red) .foregroundColor(Color.red)
Text(Strings.Warning.message) Text(Strings.Warning.message)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(Color.red) .foregroundColor(Color.red)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@@ -197,30 +215,34 @@ struct AppInfoView: View {
} }
} }
struct AppInfoFeatureInfo {
let icon: String
let title: LocalizedStringKey
let description: LocalizedStringKey
}
struct SectionHeader: View { struct SectionHeader: View {
let title: String let title: LocalizedStringKey
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
private var textColor: Color { private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
} }
init(_ title: String) { init(_ title: LocalizedStringKey) {
self.title = title self.title = title
} }
var body: some View { var body: some View {
Text(title) Text(title)
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.padding(.top, 8) .padding(.top, 8)
} }
} }
struct FeatureRow: View { struct FeatureRow: View {
let icon: String let info: AppInfoFeatureInfo
let title: String
let description: String
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
private var textColor: Color { private var textColor: Color {
@@ -233,18 +255,18 @@ struct FeatureRow: View {
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: icon) Image(systemName: info.icon)
.font(.system(size: 20)) .font(.bitchatSystem(size: 20))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 30) .frame(width: 30)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title) Text(info.title)
.font(.system(size: 14, weight: .semibold, design: .monospaced)) .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(description) Text(info.description)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@@ -254,6 +276,16 @@ struct FeatureRow: View {
} }
} }
#Preview { #Preview("Default") {
AppInfoView() AppInfoView()
} }
#Preview("Dynamic Type XXL") {
AppInfoView()
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
}
#Preview("Dynamic Type XS") {
AppInfoView()
.environment(\.sizeCategory, .extraSmall)
}
@@ -0,0 +1,144 @@
//
// DeliveryStatusView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
struct DeliveryStatusView: View {
@Environment(\.colorScheme) private var colorScheme
let status: DeliveryStatus
// MARK: - Computed Properties
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
private enum Strings {
static func delivered(to nickname: String) -> String {
String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
}
static func read(by nickname: String) -> String {
String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
}
static func failed(_ reason: String) -> String {
String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
}
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
// MARK: - Body
var body: some View {
switch status {
case .sending:
Image(systemName: "circle")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .sent:
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .delivered(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
}
.foregroundColor(textColor.opacity(0.8))
.help(Strings.delivered(to: nickname))
case .read(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
}
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
.help(Strings.read(by: nickname))
case .failed(let reason):
Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8))
.help(Strings.failed(reason))
case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
Text(verbatim: "\(reached)/\(total)")
.font(.bitchatSystem(size: 10, design: .monospaced))
}
.foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
}
}
}
#Preview {
let statuses: [DeliveryStatus] = [
.sending,
.sent,
.delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"),
.partiallyDelivered(reached: 2, total: 5)
]
List {
ForEach(statuses, id: \.self) { status in
HStack {
Text(status.displayText)
Spacer()
DeliveryStatusView(status: status)
}
}
}
.environment(\.colorScheme, .light)
List {
ForEach(statuses, id: \.self) { status in
HStack {
Text(status.displayText)
Spacer()
DeliveryStatusView(status: status)
}
}
}
.environment(\.colorScheme, .dark)
}
@@ -0,0 +1,107 @@
//
// PaymentChipView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
enum PaymentType {
case cashu(String)
case lightning(String)
var url: URL? {
switch self {
case .cashu(let link), .lightning(let link):
return URL(string: link)
}
}
var emoji: String {
switch self {
case .cashu: "🥜"
case .lightning: ""
}
}
var label: String {
switch self {
case .cashu:
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
case .lightning:
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
}
}
}
let paymentType: PaymentType
private var fgColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
}
private var border: Color { fgColor.opacity(0.25) }
var body: some View {
Button {
#if os(iOS)
if let url = paymentType.url { openURL(url) }
#else
if let url = paymentType.url { NSWorkspace.shared.open(url) }
#endif
} label: {
HStack(spacing: 6) {
Text(paymentType.emoji)
Text(paymentType.label)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(bgColor)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(border, lineWidth: 1)
)
.foregroundColor(fgColor)
}
.buttonStyle(.plain)
}
}
#Preview {
let cashuLink = "https://example.com/cashu"
let lightningLink = "https://example.com/lightning"
List {
HStack {
PaymentChipView(paymentType: .cashu(cashuLink))
PaymentChipView(paymentType: .lightning(lightningLink))
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.listRowBackground(EmptyView())
}
.environment(\.colorScheme, .light)
List {
HStack {
PaymentChipView(paymentType: .cashu(cashuLink))
PaymentChipView(paymentType: .lightning(lightningLink))
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.listRowBackground(EmptyView())
}
.environment(\.colorScheme, .dark)
}
@@ -0,0 +1,98 @@
//
// TextMessageView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@EnvironmentObject private var viewModel: ChatViewModel
let message: BitchatMessage
@Binding var expandedMessageIDs: Set<String>
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Precompute heavy token scans once per row
let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
}
// Expand/Collapse for very long messages
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id)
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue)
.padding(.top, 4)
}
// Render payment chips (Lightning / Cashu) with rounded background
if !lightningLinks.isEmpty || !cashuLinks.isEmpty {
HStack(spacing: 8) {
ForEach(lightningLinks, id: \.self) { link in
PaymentChipView(paymentType: .lightning(link))
}
ForEach(cashuLinks, id: \.self) { link in
PaymentChipView(paymentType: .cashu(link))
}
}
.padding(.top, 6)
.padding(.leading, 2)
}
}
}
}
@available(macOS 14, iOS 17, *)
#Preview {
@Previewable @State var ids: Set<String> = []
let keychain = PreviewKeychainManager()
Group {
List {
TextMessageView(message: .preview, expandedMessageIDs: $ids)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.listRowBackground(EmptyView())
}
.environment(\.colorScheme, .light)
List {
TextMessageView(message: .preview, expandedMessageIDs: $ids)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.listRowBackground(EmptyView())
}
.environment(\.colorScheme, .dark)
}
.environmentObject(
ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain)
)
)
}
File diff suppressed because it is too large Load Diff
+60 -32
View File
@@ -22,19 +22,42 @@ struct FingerprintView: View {
colorScheme == .dark ? Color.black : Color.white colorScheme == .dark ? Color.black : Color.white
} }
private enum Strings {
static let title: LocalizedStringKey = "fingerprint.title"
static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label"
static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending"
static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label"
static let copy: LocalizedStringKey = "common.copy"
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
static func verifyHint(_ nickname: String) -> String {
String(
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
locale: .current,
nickname
)
}
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
static func unknownPeer() -> String {
String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
// Header // Header
HStack { HStack {
Text("SECURITY VERIFICATION") Text(Strings.title)
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.bitchatSystem(size: 14, weight: .semibold))
} }
.foregroundColor(textColor) .foregroundColor(textColor)
} }
@@ -43,13 +66,13 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status // Prefer short mesh ID for session/encryption status
let statusPeerID: String = { let statusPeerID: String = {
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short } if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
return peerID return peerID
}() }()
// Resolve a friendly name // Resolve a friendly name
let peerNickname: String = { let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name } if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
if peerID.count == 64, let data = Data(hexString: peerID) { if peerID.count == 64, let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint() let fp = data.sha256Fingerprint()
@@ -58,25 +81,25 @@ struct FingerprintView: View {
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
} }
return "Unknown" return Strings.unknownPeer()
}() }()
// Accurate encryption state based on short ID session // Accurate encryption state based on short ID session
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
HStack { HStack {
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 20)) .font(.bitchatSystem(size: 20))
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor) .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(peerNickname) Text(peerNickname)
.font(.system(size: 18, weight: .semibold, design: .monospaced)) .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(encryptionStatus.description) Text(encryptionStatus.description)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
} }
@@ -88,13 +111,13 @@ struct FingerprintView: View {
// Their fingerprint // Their fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("THEIR FINGERPRINT:") Text(Strings.theirFingerprint)
.font(.system(size: 12, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
Text(formatFingerprint(fingerprint)) Text(formatFingerprint(fingerprint))
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(nil) .lineLimit(nil)
@@ -104,7 +127,7 @@ struct FingerprintView: View {
.background(Color.gray.opacity(0.1)) .background(Color.gray.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button("Copy") { Button(Strings.copy) {
#if os(iOS) #if os(iOS)
UIPasteboard.general.string = fingerprint UIPasteboard.general.string = fingerprint
#else #else
@@ -114,8 +137,8 @@ struct FingerprintView: View {
} }
} }
} else { } else {
Text("not available - handshake in progress") Text(Strings.handshakePending)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
.padding() .padding()
} }
@@ -123,13 +146,13 @@ struct FingerprintView: View {
// My fingerprint // My fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("YOUR FINGERPRINT:") Text(Strings.yourFingerprint)
.font(.system(size: 12, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
let myFingerprint = viewModel.getMyFingerprint() let myFingerprint = viewModel.getMyFingerprint()
Text(formatFingerprint(myFingerprint)) Text(formatFingerprint(myFingerprint))
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(nil) .lineLimit(nil)
@@ -139,7 +162,7 @@ struct FingerprintView: View {
.background(Color.gray.opacity(0.1)) .background(Color.gray.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button("Copy") { Button(Strings.copy) {
#if os(iOS) #if os(iOS)
UIPasteboard.general.string = myFingerprint UIPasteboard.general.string = myFingerprint
#else #else
@@ -153,17 +176,22 @@ struct FingerprintView: View {
// Verification status // Verification status
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified { if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
let isVerified = encryptionStatus == .noiseVerified let isVerified = encryptionStatus == .noiseVerified
let peerID = PeerID(str: peerID)
VStack(spacing: 12) { VStack(spacing: 12) {
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED") Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
.font(.system(size: 14, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(isVerified ? Color.green : Color.orange) .foregroundColor(isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Text(isVerified ? Group {
"you have verified this person's identity." : if isVerified {
"compare these fingerprints with \(peerNickname) using a secure channel.") Text(Strings.verifiedMessage)
.font(.system(size: 12, design: .monospaced)) } else {
Text(Strings.verifyHint(peerNickname))
}
}
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(nil) .lineLimit(nil)
@@ -175,8 +203,8 @@ struct FingerprintView: View {
viewModel.verifyFingerprint(for: peerID) viewModel.verifyFingerprint(for: peerID)
dismiss() dismiss()
}) { }) {
Text("MARK AS VERIFIED") Text(Strings.markVerified)
.font(.system(size: 14, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -189,8 +217,8 @@ struct FingerprintView: View {
viewModel.unverifyFingerprint(for: peerID) viewModel.unverifyFingerprint(for: peerID)
dismiss() dismiss()
}) { }) {
Text("REMOVE VERIFICATION") Text(Strings.removeVerification)
.font(.system(size: 14, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
+21 -26
View File
@@ -8,11 +8,19 @@ struct GeohashPeopleList: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = [] @State private var orderedIDs: [String] = []
private enum Strings {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block"
}
var body: some View { var body: some View {
if viewModel.visibleGeohashPeople().isEmpty { if viewModel.visibleGeohashPeople().isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text(Strings.noneNearby)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
@@ -20,7 +28,7 @@ struct GeohashPeopleList: View {
} else { } else {
let myHex: String? = { let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased() return id.publicKeyHex.lowercased()
} }
return nil return nil
@@ -51,32 +59,32 @@ struct GeohashPeopleList: View {
let icon = teleported ? "face.dashed" : "mappin.and.ellipse" let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor let rowColor: Color = isMe ? .orange : assignedColor
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor) Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
let (base, suffix) = splitSuffix(from: person.displayName) let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) { HStack(spacing: 0) {
Text(base) Text(base)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.fontWeight(isMe ? .bold : .regular) .fontWeight(isMe ? .bold : .regular)
.foregroundColor(rowColor) .foregroundColor(rowColor)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix) Text(suffix)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(suffixColor) .foregroundColor(suffixColor)
} }
if isMe { if isMe {
Text(" (you)") Text(Strings.youSuffix)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(rowColor) .foregroundColor(rowColor)
} }
} }
if let me = myHex, person.id != me { if let me = myHex, person.id != me {
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) { if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
Image(systemName: "nosign") Image(systemName: "nosign")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.red) .foregroundColor(.red)
.help("Blocked in geochash") .help(Strings.blockedTooltip)
} }
} }
Spacer() Spacer()
@@ -97,9 +105,9 @@ struct GeohashPeopleList: View {
} else { } else {
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
if blocked { if blocked {
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
} else { } else {
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
} }
} }
} }
@@ -118,16 +126,3 @@ struct GeohashPeopleList: View {
} }
} }
} }
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
-54
View File
@@ -1,54 +0,0 @@
// ImageMessageRow.swift
// bitchat
//
// Renders an image message bubble with rounded corners and optional delivery status.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(iOS)
import UIKit
#endif
struct ImageMessageRow: View {
let path: String
let message: BitchatMessage
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var viewModel: ChatViewModel
var body: some View {
#if os(iOS)
if let uiImage = UIImage(contentsOfFile: path) {
VStack(alignment: .leading, spacing: 4) {
// Ensure image aligns with the same left edge as text messages
HStack(spacing: 0) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 300, maxHeight: 400, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
Spacer(minLength: 0)
}
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status, colorScheme: colorScheme)
.padding(.leading, 4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text("⚠️ Image unavailable")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
#else
Text("📷 Image: \(path)")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0))
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
}
-68
View File
@@ -1,68 +0,0 @@
// ImagePickerView.swift
// Simple UIKit-based image picker bridge
import SwiftUI
#if os(iOS)
import UIKit
import PhotosUI
struct ImagePickerView: UIViewControllerRepresentable {
typealias UIViewControllerType = UIViewController
var onPicked: (UIImage?) -> Void
func makeUIViewController(context: Context) -> UIViewController {
if #available(iOS 14.0, *) {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
} else {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
picker.allowsEditing = false
return picker
}
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onPicked: onPicked)
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPicked: (UIImage?) -> Void
init(onPicked: @escaping (UIImage?) -> Void) { self.onPicked = onPicked }
// PHPicker
@available(iOS 14.0, *)
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let item = results.first else { onPicked(nil); return }
if item.itemProvider.canLoadObject(ofClass: UIImage.self) {
item.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in
DispatchQueue.main.async {
self.onPicked(object as? UIImage)
}
}
} else {
onPicked(nil)
}
}
// UIImagePicker
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
onPicked(nil)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
let image = info[.originalImage] as? UIImage
picker.dismiss(animated: true)
onPicked(image)
}
}
}
#endif
+243 -129
View File
@@ -9,6 +9,7 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var network = NetworkActivationService.shared
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = "" @State private var customGeohash: String = ""
@@ -16,21 +17,81 @@ struct LocationChannelsSheet: View {
private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
static let description: LocalizedStringKey = "location_channels.description"
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
return rowTitle(label: label, count: count)
}
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
return rowTitle(label: level.displayName, count: count)
}
static func bookmarkTitle(geohash: String, count: Int) -> String {
return rowTitle(label: "#\(geohash)", count: count)
}
static func subtitlePrefix(geohash: String, coverage: String) -> String {
String(
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
locale: .current,
geohash, coverage
)
}
static func subtitle(prefix: String, name: String?) -> String {
guard let name, !name.isEmpty else { return prefix }
return String(
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
locale: .current,
prefix, name
)
}
private static func rowTitle(label: String, count: Int) -> String {
String(
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
locale: .current,
label, count
)
}
}
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("#location channels") HStack(spacing: 12) {
.font(.system(size: 18, design: .monospaced)) Text(Strings.title)
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.") .font(.bitchatSystem(size: 18, design: .monospaced))
.font(.system(size: 12, design: .monospaced)) Spacer()
closeButton
}
Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Group { Group {
switch manager.permissionState { switch manager.permissionState {
case LocationChannelManager.PermissionState.notDetermined: case LocationChannelManager.PermissionState.notDetermined:
Button(action: { manager.enableLocationChannels() }) { Button(action: { manager.enableLocationChannels() }) {
Text("get location and my geohashes") Text(Strings.requestPermissions)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -40,10 +101,10 @@ struct LocationChannelsSheet: View {
.buttonStyle(.plain) .buttonStyle(.plain)
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted: case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("location permission denied. enable in settings to use location channels.") Text(Strings.permissionDenied)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button("open settings") { openSystemLocationSettings() } Button(Strings.openSettings) { openSystemLocationSettings() }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
case LocationChannelManager.PermissionState.authorized: case LocationChannelManager.PermissionState.authorized:
@@ -59,29 +120,9 @@ struct LocationChannelsSheet: View {
.background(backgroundColor) .background(backgroundColor)
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .navigationBarHidden(true)
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
}
#else #else
.toolbar { .navigationTitle("")
ToolbarItem(placement: .automatic) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
}
#endif #endif
} }
#if os(iOS) #if os(iOS)
@@ -111,24 +152,37 @@ struct LocationChannelsSheet: View {
.onChange(of: manager.availableChannels) { _ in } .onChange(of: manager.availableChannels) { _ in }
} }
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
private var channelList: some View { private var channelList: some View {
List { ScrollView {
// Mesh option first (no bookmark) LazyVStack(spacing: 0) {
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh) manager.select(ChannelID.mesh)
isPresented = false isPresented = false
} }
.padding(.vertical, 6)
// Nearby options let nearby = manager.availableChannels.filter { $0.level != .building }
if !manager.availableChannels.isEmpty { if !nearby.isEmpty {
ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in ForEach(nearby) { channel in
sectionDivider
let coverage = coverageString(forPrecision: channel.geohash.count) let coverage = coverageString(forPrecision: channel.geohash.count)
let nameBase = locationName(for: channel.level) let nameBase = locationName(for: channel.level)
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitlePrefix = "#\(channel.geohash)\(coverage)" let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
let highlight = participantCount > 0
channelRow( channelRow(
title: geohashTitleWithCount(for: channel), title: Strings.levelTitle(for: channel.level, count: participantCount),
subtitlePrefix: subtitlePrefix, subtitlePrefix: subtitlePrefix,
subtitleName: namePart, subtitleName: namePart,
isSelected: isSelected(channel), isSelected: isSelected(channel),
@@ -136,31 +190,81 @@ struct LocationChannelsSheet: View {
trailingAccessory: { trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) { Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .padding(.leading, 8)
} }
) { ) {
// Selecting a suggested nearby channel is not a teleport. Persist this.
manager.markTeleported(for: channel.geohash, false) manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel)) manager.select(ChannelID.location(channel))
isPresented = false isPresented = false
} }
.padding(.vertical, 6)
} }
} else { } else {
HStack { sectionDivider
HStack(spacing: 8) {
ProgressView() ProgressView()
Text("finding nearby channels…") Text(Strings.loadingNearby)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
} }
// Custom geohash teleport sectionDivider
customTeleportSection
.padding(.vertical, 8)
let bookmarkedList = bookmarks.bookmarks
if !bookmarkedList.isEmpty {
sectionDivider
bookmarkedSection(bookmarkedList)
.padding(.vertical, 8)
}
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
sectionDivider
torToggleSection
.padding(.top, 12)
Button(action: {
openSystemLocationSettings()
}) {
Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
.padding(.vertical, 8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.background(backgroundColor)
}
.background(backgroundColor)
}
private var sectionDivider: some View {
Rectangle()
.fill(dividerColor)
.frame(height: 1)
}
private var dividerColor: Color {
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
}
private var customTeleportSection: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) { HStack(spacing: 2) {
Text("#") Text(verbatim: "#")
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS) #if os(iOS)
@@ -168,9 +272,8 @@ struct LocationChannelsSheet: View {
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
.keyboardType(.asciiCapable) .keyboardType(.asciiCapable)
#endif #endif
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.onChange(of: customGeohash) { newValue in .onChange(of: customGeohash) { newValue in
// Allow only geohash base32 characters, strip '#', limit length
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let filtered = newValue let filtered = newValue
.lowercased() .lowercased()
@@ -182,29 +285,31 @@ struct LocationChannelsSheet: View {
customGeohash = filtered customGeohash = filtered
} }
} }
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") let normalized = customGeohash
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized) let isValid = validateGeohash(normalized)
Button(action: { Button(action: {
let gh = normalized let gh = normalized
guard isValid else { customError = "invalid geohash"; return } guard isValid else { customError = Strings.invalidGeohash; return }
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
// Mark this selection as a manual teleport
manager.markTeleported(for: ch.geohash, true) manager.markTeleported(for: ch.geohash, true)
manager.select(ChannelID.location(ch)) manager.select(ChannelID.location(ch))
isPresented = false isPresented = false
}) { }) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("teleport") Text(Strings.teleport)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
Image(systemName: "face.dashed") Image(systemName: "face.dashed")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.padding(.horizontal, 10)
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12)) .background(Color.secondary.opacity(0.12))
.cornerRadius(6) .cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4) .opacity(isValid ? 1.0 : 0.4)
@@ -212,39 +317,39 @@ struct LocationChannelsSheet: View {
} }
if let err = customError { if let err = customError {
Text(err) Text(err)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.red) .foregroundColor(.red)
} }
} }
}
// Bookmarked geohashes private func bookmarkedSection(_ entries: [String]) -> some View {
if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("bookmarked") Text(Strings.bookmarked)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
VStack(spacing: 6) { LazyVStack(spacing: 0) {
ForEach(bookmarks.bookmarks, id: \.self) { gh in ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh) let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count) let coverage = coverageString(forPrecision: gh.count)
let subtitle = "#\(gh)\(coverage)" let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
let name = bookmarks.bookmarkNames[gh] let name = bookmarks.bookmarkNames[gh]
let participantCount = viewModel.geohashParticipantCount(for: gh)
channelRow( channelRow(
title: geohashHashTitleWithCount(gh), title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
subtitlePrefix: subtitle, subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 }, subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel), isSelected: isSelected(channel),
trailingAccessory: { trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) { Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .padding(.leading, 8)
} }
) { ) {
// For bookmarked selection, mark teleported based on regional membership
let inRegional = manager.availableChannels.contains { $0.geohash == gh } let inRegional = manager.availableChannels.contains { $0.geohash == gh }
if !inRegional && !manager.availableChannels.isEmpty { if !inRegional && !manager.availableChannels.isEmpty {
manager.markTeleported(for: gh, true) manager.markTeleported(for: gh, true)
@@ -254,38 +359,17 @@ struct LocationChannelsSheet: View {
manager.select(ChannelID.location(channel)) manager.select(ChannelID.location(channel))
isPresented = false isPresented = false
} }
.padding(.vertical, 6)
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) } .onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
if index < entries.count - 1 {
sectionDivider
}
} }
} }
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
} }
.listRowSeparator(.hidden)
} }
// Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: {
openSystemLocationSettings()
}) {
Text("remove location access")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(backgroundColor)
}
private func isSelected(_ channel: GeohashChannel) -> Bool { private func isSelected(_ channel: GeohashChannel) -> Bool {
if case .location(let ch) = manager.selectedChannel { if case .location(let ch) = manager.selectedChannel {
@@ -317,31 +401,26 @@ struct LocationChannelsSheet: View {
let parts = splitTitleAndCount(title) let parts = splitTitleAndCount(title)
HStack(spacing: 4) { HStack(spacing: 4) {
Text(parts.base) Text(parts.base)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary) .foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty { if let count = parts.countSuffix, !count.isEmpty {
Text(count) Text(count)
.font(.system(size: 11, design: .monospaced)) .font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
let subtitleFull: String = { let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
if let name = subtitleName, !name.isEmpty {
return subtitlePrefix + "" + name
}
return subtitlePrefix
}()
Text(subtitleFull) Text(subtitleFull)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
Spacer() Spacer()
if isSelected { if isSelected {
Text("✔︎") Text(verbatim: "✔︎")
.font(.system(size: 16, design: .monospaced)) .font(.bitchatSystem(size: 16, design: .monospaced))
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
} }
trailingAccessory() trailingAccessory()
@@ -360,36 +439,15 @@ struct LocationChannelsSheet: View {
} }
// MARK: - Helpers for counts // MARK: - Helpers for counts
private func meshTitleWithCount() -> String {
// Count currently connected mesh peers (excluding self)
let meshCount = meshCount()
let noun = meshCount == 1 ? "person" : "people"
return "mesh [\(meshCount) \(noun)]"
}
private func meshCount() -> Int { private func meshCount() -> Int {
// Count mesh-connected OR mesh-reachable peers (exclude self) // Count mesh-connected OR mesh-reachable peers (exclude self)
let myID = viewModel.meshService.myPeerID let myID = viewModel.meshService.myPeerID
return viewModel.allPeers.reduce(0) { acc, peer in return viewModel.allPeers.reduce(0) { acc, peer in
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 } if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
return acc return acc
} }
} }
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
// Main list: keep level labels (block/neighborhood/city/province/region)
let count = viewModel.geohashParticipantCount(for: channel.geohash)
let noun = count == 1 ? "person" : "people"
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
}
private func geohashHashTitleWithCount(_ geohash: String) -> String {
// Bookmarked list: show the #geohash as the main label
let count = viewModel.geohashParticipantCount(for: geohash)
let noun = count == 1 ? "person" : "people"
return "#\(geohash) [\(count) \(noun)]"
}
private func validateGeohash(_ s: String) -> Bool { private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false } guard !s.isEmpty, s.count <= 12 else { return false }
@@ -409,8 +467,34 @@ struct LocationChannelsSheet: View {
} }
} }
// MARK: - Standardized Colors // MARK: - TOR Toggle & Standardized Colors
extension LocationChannelsSheet { extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { network.userTorEnabled },
set: { network.setUserTorEnabled($0) }
)
}
private var torToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(.primary)
Text(Strings.torSubtitle)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
}
private var standardGreen: Color { private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
} }
@@ -419,6 +503,36 @@ extension LocationChannelsSheet {
} }
} }
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
let onLabel: LocalizedStringKey
let offLabel: LocalizedStringKey
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Coverage helpers // MARK: - Coverage helpers
extension LocationChannelsSheet { extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String { private func coverageString(forPrecision len: Int) -> String {
+209 -104
View File
@@ -7,6 +7,7 @@ struct LocationNotesView: View {
let onNotesCountChanged: ((Int) -> Void)? let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var draft: String = "" @State private var draft: String = ""
@@ -18,30 +19,39 @@ struct LocationNotesView: View {
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh)) _manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
} }
private var backgroundColor: Color { private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
colorScheme == .dark ? Color.black : Color.white private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
} private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) private enum Strings {
} static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
private var secondaryTextColor: Color { static let description: LocalizedStringKey = "location_notes.description"
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
} static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
// Slightly darker green for hash suffix emphasis static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
private var darkerTextColor: Color { static let retry: LocalizedStringKey = "location_notes.action.retry"
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0) static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
} }
var body: some View { var body: some View {
#if os(macOS)
VStack(spacing: 0) { VStack(spacing: 0) {
header ScrollView {
Divider() VStack(spacing: 0) {
list headerSection
Divider() notesContent
input }
} }
.background(backgroundColor) .background(backgroundColor)
.foregroundColor(textColor) inputSection
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.background(backgroundColor)
.onDisappear { manager.cancel() } .onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in .onChange(of: geohash) { newValue in
manager.setGeohash(newValue) manager.setGeohash(newValue)
@@ -50,100 +60,206 @@ struct LocationNotesView: View {
.onChange(of: manager.notes.count) { newValue in .onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue) onNotesCountChanged?(newValue)
} }
#else
NavigationView {
VStack(spacing: 0) {
headerSection
ScrollView {
notesContent
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection
}
.background(backgroundColor)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
#else
.navigationTitle("")
#endif
}
#if os(iOS)
.presentationDetents([.large])
#endif
.background(backgroundColor)
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#endif
} }
private var header: some View { private var closeButton: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
let c = manager.notes.count
Text("\(c) \(c == 1 ? "note" : "notes") ")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("@ ")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("#\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
}
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
Text(buildingName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
} else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty {
Text(blockName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
}
Spacer()
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced)) .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Close") .accessibilityLabel(Strings.closeAccessibility)
}
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
} }
private var list: some View { private var headerSection: some View {
ScrollView { let count = manager.notes.count
LazyVStack(alignment: .leading, spacing: 8) { return VStack(alignment: .leading, spacing: 8) {
ForEach(manager.notes) { note in HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) { Text(headerTitle(for: count))
HStack(spacing: 6) { .font(.bitchatSystem(size: 18, design: .monospaced))
// Show @name without the #abcd suffix; timestamp in brackets Spacer()
HStack(spacing: 0) { closeButton
Text("@")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
let parts = splitSuffix(from: note.displayName)
Text(parts.0)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
} }
let ts = timestampText(for: note.createdAt) if let building = locationManager.locationNames[.building], !building.isEmpty {
Text(ts.isEmpty ? "" : "[\(ts)]") Text(building)
.font(.system(size: 11, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8)) .foregroundColor(accentGreen)
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
} }
Text(note.content) Text(Strings.description)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} if manager.state == .noRelays {
.padding(.horizontal, 12) Text(Strings.relaysPaused)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
} }
} }
.padding(.vertical, 8) .padding(.horizontal, 16)
} .padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor) .background(backgroundColor)
} }
private var input: some View { private func headerTitle(for count: Int) -> String {
HStack(alignment: .center, spacing: 8) { String(
TextField("add a note for this place", text: $draft, axis: .vertical) format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
.textFieldStyle(.plain) locale: .current,
.font(.system(size: 14, design: .monospaced)) "\(geohash) ± 1", count
.lineLimit(3, reservesSpace: true) )
.padding(.horizontal, 12) }
private var notesContent: some View {
LazyVStack(alignment: .leading, spacing: 12) {
if manager.state == .noRelays {
noRelaysRow
} else if manager.state == .loading && !manager.initialLoadComplete {
loadingRow
} else if manager.notes.isEmpty {
emptyRow
} else {
ForEach(manager.notes) { note in
noteRow(note)
}
}
if let error = manager.errorMessage, manager.state != .noRelays {
errorRow(message: error)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
let ts = timestampText(for: note.createdAt)
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(verbatim: "@\(baseName)")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
if !ts.isEmpty {
Text(ts)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
Spacer()
}
Text(note.content)
.font(.bitchatSystem(size: 14, design: .monospaced))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
}
private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text(Strings.relaysRetryHint)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button(Strings.retry) { manager.refresh() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var loadingRow: some View {
HStack(spacing: 10) {
ProgressView()
Text(Strings.loadingNotes)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Spacer()
}
.padding(.vertical, 8)
}
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text(Strings.emptySubtitle)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
}
private func errorRow(message: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 12, design: .monospaced))
Text(message)
.font(.bitchatSystem(size: 12, design: .monospaced))
Spacer()
}
Button(Strings.dismissError) { manager.clearError() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var inputSection: some View {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) { Button(action: send) {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20)) .font(.bitchatSystem(size: 20))
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor) .foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
} }
.padding(.top, 2)
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(!sendButtonEnabled)
.padding(.trailing, 12)
} }
.frame(minHeight: 44) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 14)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor)
.overlay(Divider(), alignment: .top)
} }
private func send() { private func send() {
@@ -153,15 +269,17 @@ struct LocationNotesView: View {
draft = "" draft = ""
} }
private var sendButtonEnabled: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting // MARK: - Timestamp Formatting
private func timestampText(for date: Date) -> String { private func timestampText(for date: Date) -> String {
let now = Date() let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
// Relative (minute/hour/day), no seconds
let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago" return rel.isEmpty ? "" : "\(rel) ago"
} else { } else {
// Absolute date (MMM d or MMM d, yyyy if different year)
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
return fmt.string(from: date) return fmt.string(from: date)
@@ -189,16 +307,3 @@ struct LocationNotesView: View {
return f return f
}() }()
} }
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
+46 -53
View File
@@ -11,103 +11,109 @@ struct MeshPeerList: View {
@State private var orderedIDs: [String] = [] @State private var orderedIDs: [String] = []
private enum Strings {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
}
var body: some View { var body: some View {
let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID)
let enc = viewModel.getEncryptionStatus(for: peer.peerID)
return (peer, isMe, hasUnread, enc)
}
// Stable visual order without mutating state here
let currentIDs = mapped.map { $0.peer.peerID.id }
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
mapped.first(where: { $0.peer.peerID.id == id })
}
if viewModel.allPeers.isEmpty { if viewModel.allPeers.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text(Strings.noneNearby)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
} }
} else { } else {
let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.id == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
let enc = viewModel.getEncryptionStatus(for: peer.id)
return (peer, isMe, hasUnread, enc)
}
// Stable visual order without mutating state here
let currentIDs = mapped.map { $0.peer.id }
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
mapped.first(where: { $0.peer.id == id })
}
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(0..<peers.count, id: \.self) { idx in ForEach(0..<peers.count, id: \.self) { idx in
let item = peers[idx] let item = peers[idx]
let peer = item.peer let peer = item.peer
let isMe = item.isMe let isMe = item.isMe
HStack(spacing: 4) { HStack(spacing: 4) {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark) let assigned = viewModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned let baseColor = isMe ? Color.orange : assigned
if isMe { if isMe {
Image(systemName: "person.fill") Image(systemName: "person.fill")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isConnected { } else if peer.isConnected {
// Mesh-connected peer: radio icon // Mesh-connected peer: radio icon
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isReachable { } else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon // Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isMutualFavorite { } else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple) // Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe") Image(systemName: "globe")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.purple) .foregroundColor(.purple)
} else { } else {
// Fallback icon for others (dimmed) // Fallback icon for others (dimmed)
Image(systemName: "person") Image(systemName: "person")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
} }
let displayName = isMe ? viewModel.nickname : peer.nickname let displayName = isMe ? viewModel.nickname : peer.nickname
let (base, suffix) = splitSuffix(from: displayName) let (base, suffix) = displayName.splitSuffix()
HStack(spacing: 0) { HStack(spacing: 0) {
Text(base) Text(base)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(baseColor) .foregroundColor(baseColor)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix) Text(suffix)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(suffixColor) .foregroundColor(suffixColor)
} }
} }
if !isMe, viewModel.isPeerBlocked(peer.id) { if !isMe, viewModel.isPeerBlocked(peer.peerID) {
Image(systemName: "nosign") Image(systemName: "nosign")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.red) .foregroundColor(.red)
.help("Blocked") .help(Strings.blockedTooltip)
} }
if !isMe { if !isMe {
if peer.isConnected { if peer.isConnected {
if let icon = item.enc.icon { if let icon = item.enc.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} else { } else {
// Offline: prefer showing verified badge from persisted fingerprints // Offline: prefer showing verified badge from persisted fingerprints
if let fp = viewModel.getFingerprint(for: peer.id), if let fp = viewModel.getFingerprint(for: peer.peerID),
viewModel.verifiedFingerprints.contains(fp) { viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if let icon = item.enc.icon { } else if let icon = item.enc.icon {
// Fallback to whatever status says (likely lock if we had a past session) // Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} }
@@ -118,15 +124,15 @@ struct MeshPeerList: View {
// Unread message indicator for this peer // Unread message indicator for this peer
if !isMe, item.hasUnread { if !isMe, item.hasUnread {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.orange) .foregroundColor(.orange)
.help("New messages") .help(Strings.newMessagesTooltip)
} }
if !isMe { if !isMe {
Button(action: { onToggleFavorite(peer.id) }) { Button(action: { onToggleFavorite(peer.peerID.id) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star") Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
.font(.system(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor) .foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -136,16 +142,16 @@ struct MeshPeerList: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0) .padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.id) } } .onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } } .onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
} }
} }
// Seed and update order outside result builder // Seed and update order outside result builder
.onAppear { .onAppear {
let currentIDs = mapped.map { $0.peer.id } let currentIDs = mapped.map { $0.peer.peerID.id }
orderedIDs = currentIDs orderedIDs = currentIDs
} }
.onChange(of: mapped.map { $0.peer.id }) { ids in .onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
var newOrder = orderedIDs var newOrder = orderedIDs
newOrder.removeAll { !ids.contains($0) } newOrder.removeAll { !ids.contains($0) }
for id in ids where !newOrder.contains(id) { newOrder.append(id) } for id in ids where !newOrder.contains(id) { newOrder.append(id) }
@@ -154,16 +160,3 @@ struct MeshPeerList: View {
} }
} }
} }
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
-71
View File
@@ -1,71 +0,0 @@
// MessageHeaderLine.swift
// bitchat
//
// Renders a header like normal messages: "<@nickname> [HH:mm:ss]"
// Used above media rows (images, audio) to match text message style.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
struct MessageHeaderLine: View {
@EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme
let message: BitchatMessage
var body: some View {
Text(formattedHeader())
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 2)
}
private func formattedHeader() -> AttributedString {
var result = AttributedString()
let isDark = colorScheme == .dark
let baseColor = viewModel.peerColor(for: message, isDark: isDark)
// Determine self to bold our own name
let isSelf: Bool = {
if let spid = message.senderPeerID {
return spid == viewModel.meshService.myPeerID
}
return message.sender == viewModel.nickname || message.sender.hasPrefix(viewModel.nickname + "#")
}()
// Split suffix like name#abcd
let (base, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .system(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
senderStyle.link = url
}
// "<@"
result.append(AttributedString("<@").mergingAttributes(senderStyle))
// base name
result.append(AttributedString(base).mergingAttributes(senderStyle))
// optional suffix with lighter color
if !suffix.isEmpty {
var suf = senderStyle
suf.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suf))
}
// Close angle without adding content or ">"
result.append(AttributedString("> ").mergingAttributes(senderStyle))
// Timestamp like normal messages
let ts = AttributedString("[\(message.formattedTimestamp)]")
var tsStyle = AttributeContainer()
tsStyle.foregroundColor = Color.gray.opacity(0.7)
tsStyle.font = .system(size: 10, design: .monospaced)
result.append(ts.mergingAttributes(tsStyle))
return result
}
}
+7 -6
View File
@@ -37,15 +37,16 @@ extension String {
} }
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuTokens(max: Int = 3) -> [String] { func extractCashuLinks(max: Int = 3) -> [String] {
let regex = RegexCache.cashu let regex = RegexCache.cashu
let ns = self as NSString let ns = self as NSString
let range = NSRange(location: 0, length: ns.length) let range = NSRange(location: 0, length: ns.length)
var found: [String] = [] var found: [String] = []
for m in regex.matches(in: self, options: [], range: range) { for m in regex.matches(in: self, range: range) {
if m.numberOfRanges > 0 { if m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0)) let token = ns.substring(with: m.range(at: 0))
found.append(token) let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
found.append("cashu:\(enc)")
if found.count >= max { break } if found.count >= max { break }
} }
} }
@@ -58,19 +59,19 @@ extension String {
let ns = self as NSString let ns = self as NSString
let full = NSRange(location: 0, length: ns.length) let full = NSRange(location: 0, length: ns.length)
// lightning: scheme // lightning: scheme
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) { for m in RegexCache.lightningScheme.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append(s) results.append(s)
if results.count >= max { return results } if results.count >= max { return results }
} }
// BOLT11 // BOLT11
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) { for m in RegexCache.bolt11.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)") results.append("lightning:\(s)")
if results.count >= max { return results } if results.count >= max { return results }
} }
// LNURL bech32 // LNURL bech32
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) { for m in RegexCache.lnurl.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)") results.append("lightning:\(s)")
if results.count >= max { return results } if results.count >= max { return results }
@@ -1,60 +0,0 @@
import SwiftUI
import Combine
/// Real-time scrolling waveform for live recording.
/// Provide a normalized amplitude [0,1]. The view maintains a sliding window of bars.
struct RealtimeScrollingWaveform: View {
var amplitudeNorm: CGFloat
var bars: Int = 240
var barColor: Color = Color(red: 0, green: 1, blue: 0.5) // neon-ish green
@State private var samples: [CGFloat] = []
@State private var ticker = Timer.publish(every: 0.02, on: .main, in: .common).autoconnect()
var body: some View {
GeometryReader { geo in
Canvas { ctx, size in
let w = size.width
let h = size.height
guard w > 0 && h > 0 else { return }
let n = samples.count
guard n > 0 else { return }
let stepX = w / CGFloat(n)
let midY = h / 2
let stroke: CGFloat = 1.2
for i in 0..<n {
let amp = max(0, min(1, samples[i]))
// Amplify only higher amplitudes so quiet parts stay subtle
let t: CGFloat = 0.6
let k: CGFloat = 0.7
let boosted = amp <= t ? amp : min(1, amp + k * (amp - t))
let lineH = max(1, boosted * (h * 0.95))
let x = CGFloat(i) * stepX + stepX / 2
let yTop = midY - lineH / 2
let yBot = midY + lineH / 2
var path = Path()
path.move(to: CGPoint(x: x, y: yTop))
path.addLine(to: CGPoint(x: x, y: yBot))
ctx.stroke(path, with: .color(barColor), lineWidth: stroke)
}
}
.onAppear {
if samples.isEmpty { samples = Array(repeating: 0, count: bars) }
}
.onChange(of: bars) { newVal in
let clamped = max(8, newVal)
samples = Array(samples.suffix(clamped))
if samples.count < clamped {
samples.insert(contentsOf: Array(repeating: 0, count: clamped - samples.count), at: 0)
}
}
.onReceive(ticker) { _ in
let v = max(0, min(1, amplitudeNorm))
samples.append(v)
let overflow = samples.count - bars
if overflow > 0 { samples.removeFirst(overflow) }
}
}
}
}
+73 -23
View File
@@ -13,18 +13,23 @@ struct MyQRView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
private var boxColor: Color { Color.gray.opacity(0.1) } private var boxColor: Color { Color.gray.opacity(0.1) }
private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title"
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
}
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("scan to verify me") Text(Strings.title)
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
VStack(spacing: 10) { VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240) QRCodeImage(data: qrString, size: 240)
.accessibilityLabel("verification qr code") .accessibilityLabel(Strings.accessibilityLabel)
// Non-scrolling, fully visible URL (wraps across lines) // Non-scrolling, fully visible URL (wraps across lines)
Text(qrString) Text(qrString)
.font(.system(size: 11, design: .monospaced)) .font(.bitchatSystem(size: 11, design: .monospaced))
.textSelection(.enabled) .textSelection(.enabled)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -49,6 +54,10 @@ struct QRCodeImage: View {
private let context = CIContext() private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator() private let filter = CIFilter.qrCodeGenerator()
private enum Strings {
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
}
var body: some View { var body: some View {
Group { Group {
if let image = generateImage() { if let image = generateImage() {
@@ -59,8 +68,8 @@ struct QRCodeImage: View {
.stroke(Color.gray.opacity(0.5), lineWidth: 1) .stroke(Color.gray.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Text("qr unavailable") Text(Strings.unavailable)
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.gray) .foregroundColor(.gray)
) )
} }
@@ -100,17 +109,41 @@ struct ImageWrapper: View {
struct QRScanView: View { struct QRScanView: View {
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
var isActive: Bool = true var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = "" @State private var input = ""
@State private var result: String = "" // not shown for iOS scanner @State private var result: String = "" // not shown for iOS scanner
@State private var lastValid: String = "" @State private var lastValid: String = ""
private enum Strings {
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
static let validate: LocalizedStringKey = "verification.scan.validate"
static func requested(_ nickname: String) -> String {
String(
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
locale: .current,
nickname
)
}
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
#if os(iOS) #if os(iOS)
CameraScannerView(isActive: isActive) { code in CameraScannerView(isActive: isActive) { code in
// Deduplicate: ignore if we just processed this exact QR code
guard code != lastValid else { return }
if let qr = VerificationService.shared.verifyScannedQR(code) { if let qr = VerificationService.shared.verifyScannedQR(code) {
let ok = viewModel.beginQRVerification(with: qr) let ok = viewModel.beginQRVerification(with: qr)
if !ok { /* already pending; continue scanning */ } if ok {
// Successfully initiated verification; remember this QR to prevent re-scanning
lastValid = code lastValid = code
// Close scanner and return to "My QR" view
onSuccess?()
}
// If !ok, peer not found or already pending - don't set lastValid so user can retry
} else { } else {
// ignore invalid reads; continue scanning // ignore invalid reads; continue scanning
} }
@@ -118,17 +151,30 @@ struct QRScanView: View {
.frame(height: 260) .frame(height: 260)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
#else #else
Text("paste qr content to validate:") Text(Strings.pastePrompt)
.font(.system(size: 14, weight: .medium, design: .monospaced)) .font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
TextEditor(text: $input) TextEditor(text: $input)
.frame(height: 100) .frame(height: 100)
.border(Color.gray.opacity(0.4)) .border(Color.gray.opacity(0.4))
Button("validate") { Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else {
result = Strings.requested("") // Already processed
return
}
if let qr = VerificationService.shared.verifyScannedQR(input) { if let qr = VerificationService.shared.verifyScannedQR(input) {
let ok = viewModel.beginQRVerification(with: qr) let ok = viewModel.beginQRVerification(with: qr)
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer" if ok {
result = Strings.requested(qr.nickname)
lastValid = input
// Close scanner and return to "My QR" view
onSuccess?()
} else { } else {
result = "invalid or expired qr payload" result = Strings.notFound
}
} else {
result = Strings.invalid
} }
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
@@ -246,7 +292,7 @@ struct VerificationSheetView: View {
private var boxColor: Color { Color.gray.opacity(0.1) } private var boxColor: Color { Color.gray.opacity(0.1) }
private func myQRString() -> String { private func myQRString() -> String {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
} }
@@ -254,8 +300,8 @@ struct VerificationSheetView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Top header (always at top) // Top header (always at top)
HStack { HStack {
Text("VERIFY") Text("verification.sheet.title")
.font(.system(size: 14, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(accentColor) .foregroundColor(accentColor)
Spacer() Spacer()
Button(action: { Button(action: {
@@ -263,7 +309,7 @@ struct VerificationSheetView: View {
isPresented = false isPresented = false
}) { }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.bitchatSystem(size: 14, weight: .semibold))
.foregroundColor(accentColor) .foregroundColor(accentColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -278,18 +324,22 @@ struct VerificationSheetView: View {
Group { Group {
if showingScanner { if showingScanner {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("scan a friend's qr") Text("verification.scan.prompt_friend")
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(accentColor) .foregroundColor(accentColor)
#if os(iOS) #if os(iOS)
QRScanView(isActive: showingScanner) QRScanView(isActive: showingScanner, onSuccess: {
showingScanner = false
})
.environmentObject(viewModel) .environmentObject(viewModel)
.frame(height: 280) .frame(height: 280)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
#else #else
QRScanView() QRScanView(onSuccess: {
showingScanner = false
})
.environmentObject(viewModel) .environmentObject(viewModel)
#endif #endif
} }
@@ -310,13 +360,13 @@ struct VerificationSheetView: View {
if showingScanner { if showingScanner {
Button(action: { showingScanner = false }) { Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode") Label("show my qr", systemImage: "qrcode")
.font(.system(size: 13, design: .monospaced)) .font(.bitchatSystem(size: 13, design: .monospaced))
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} else { } else {
Button(action: { showingScanner = true }) { Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder") Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.system(size: 13, weight: .medium, design: .monospaced)) .font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.gray) .tint(.gray)
@@ -328,7 +378,7 @@ struct VerificationSheetView: View {
viewModel.verifiedFingerprints.contains(fp) { viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) { Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
Label("remove verification", systemImage: "minus.circle") Label("remove verification", systemImage: "minus.circle")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.gray) .tint(.gray)
-100
View File
@@ -1,100 +0,0 @@
import SwiftUI
import AVFoundation
struct VoiceMessageRow: View {
let fileURL: URL
var sendProgress: Double? = nil // 0..1 filling during send
@State private var bins: [Float] = []
@State private var isPlaying = false
@State private var progress: Double = 0
@State private var duration: TimeInterval = 0
@State private var player: AVAudioPlayer?
var body: some View {
HStack(alignment: .center, spacing: 8) {
// Play / Pause control (fixed size similar to Android's 28dp)
Button(action: togglePlay) {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 24))
}
.buttonStyle(.plain)
// Waveform takes remaining width, fixed height, vertically centered
GeometryReader { geo in
let barWidth: CGFloat = 2
let barSpacing: CGFloat = 2
let step = barWidth + barSpacing
let maxBars = max(0, Int(floor(geo.size.width / step)))
let displayCount = min(maxBars, bins.count)
ZStack(alignment: .leading) {
// Base waveform (gray)
HStack(spacing: barSpacing) {
ForEach(0..<displayCount, id: \.self) { i in
let v = bins[i]
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
let h = max(2, CGFloat(normalizedV) * geo.size.height)
VStack { Spacer(minLength: 0); Capsule().fill(Color.gray.opacity(0.4)).frame(width: barWidth, height: h); Spacer(minLength: 0) }
}
}
// Filled progress (blue) or send progress if provided
let activeProgress = sendProgress ?? progress
let filledBars = Int(Double(displayCount) * activeProgress)
HStack(spacing: barSpacing) {
ForEach(0..<max(0, min(displayCount, filledBars)), id: \.self) { i in
let v = bins[i]
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
let h = max(2, CGFloat(normalizedV) * geo.size.height)
VStack { Spacer(minLength: 0); Capsule().fill(Color.blue).frame(width: barWidth, height: h); Spacer(minLength: 0) }
}
}
}
.clipped() // prevent overflow beyond available width/height
}
.frame(height: 36)
// Reserve width for duration to avoid being pushed out by waveform
Text(formattedDuration)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 44, alignment: .trailing)
}
.onAppear(perform: load)
.onDisappear { player?.stop(); isPlaying = false }
}
private var formattedDuration: String {
let total = duration
let mins = Int(total) / 60
let secs = Int(total) % 60
return String(format: "%d:%02d", mins, secs)
}
private func load() {
bins = WaveformExtractor.extractBins(url: fileURL, binCount: 120)
do {
player = try AVAudioPlayer(contentsOf: fileURL)
duration = player?.duration ?? 0
player?.prepareToPlay()
startProgressTimer()
} catch {
// Ignore
}
}
private func togglePlay() {
guard let p = player else { return }
if isPlaying { p.pause(); isPlaying = false }
else { p.play(); isPlaying = true; startProgressTimer() }
}
private func startProgressTimer() {
guard let p = player else { return }
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in
if !isPlaying || p.duration == 0 { t.invalidate(); return }
progress = min(1.0, p.currentTime / p.duration)
if progress >= 1.0 { isPlaying = false; t.invalidate() }
}
}
}
@@ -0,0 +1,27 @@
//
// BitchatMessage+Preview.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension BitchatMessage {
static var preview: BitchatMessage {
BitchatMessage(
id: UUID().uuidString,
sender: "John Doe",
content: "Hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: "Jane Doe",
senderPeerID: nil,
mentions: nil,
deliveryStatus: .sent
)
}
}

Some files were not shown because too many files have changed in this diff Show More