Compare commits

..
Author SHA1 Message Date
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
9cbdb0a764 Gate Tor/Nostr start behind permissions or mutual favorites; add clean shutdown + robust gating (#619)
- Add NetworkActivationService to permit Tor/Nostr only when location is authorized OR at least one mutual favorite exists.
- Gate TorManager.startIfNeeded/ensureRunningOnForeground behind a global allowAutoStart flag.
- Always stop Tor on background for deterministic restarts; rebuild sessions on foreground when allowed.
- NostrRelayManager respects the gate in connect/ensureConnections/subscribe/send/connectToRelay and skips reconnection when disallowed.
- Symmetric shutdown when conditions become disallowed: disconnect relays and stop Tor.
- Fix double-start by avoiding restart if Tor is already ready; prevent background thrash.
- Improve UX: post "starting tor…" via TorWillStart, and "tor started…" on initial ready; keep existing restart messages.

Rationale: Avoid starting Tor/relays when the user has no location permission and no mutual favorites, and ensure a clean, predictable lifecycle (no stale sockets, no double starts).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 16:46:49 +02:00
IslamandGitHub d1682db79b Refactor 1/n: ChatViewModel's Message Sending section (#613)
* Extract BitchatMessage into a separate file

* Convert `fromBinaryPayload` to `convenience init?`

* Extract message dedup into an extension

* Remove dead `formatMessageContent`

* Minor refactor of timestamp and username formatting

* Remove dead `getSenderColor`

* Extract MessagePadding into a separate file

* Extract BitchatPacket into a separate file

* Extract ReadReceipt into a separate file

* Extract NoisePayload into a separate file

* Remove unnecessary import

* Extract peer-seed color calculation out

* Extract `handleDeliveredReadReceipt` to a new function

* Extract `handlePrivateMessage` to a new function

* Separate `delivered` and `readReceipt` functions

* Extract `handleGiftWrap` into a function

* Extract `subscribeToGeoChat` into a function

* Minor cleanup

* Extract `handleNostrEvent` into a function

* Minor cleanup

* Create `sendGeohash` function + minor cleanup

* Extract sending geohash dm into a function

* Check for blocks before trying to send a DM
2025-09-15 15:29:47 +02:00
IslamandGitHub 6a6504c6f2 Refactor: Extract types from BitchatProtocol (#611)
* Extract BitchatMessage into a separate file

* Convert `fromBinaryPayload` to `convenience init?`

* Extract message dedup into an extension

* Remove dead `formatMessageContent`

* Minor refactor of timestamp and username formatting

* Remove dead `getSenderColor`

* Extract MessagePadding into a separate file

* Extract BitchatPacket into a separate file

* Extract ReadReceipt into a separate file

* Extract NoisePayload into a separate file

* Remove unnecessary import
2025-09-15 15:21:03 +02:00
IslamandGitHub ea8d51a36b Refactor: BitchatMessage (#610)
* Extract BitchatMessage into a separate file

* Convert `fromBinaryPayload` to `convenience init?`

* Extract message dedup into an extension

* Remove dead `formatMessageContent`

* Minor refactor of timestamp and username formatting

* Remove dead `getSenderColor`
2025-09-15 15:00:12 +02:00
IslamandGitHub 347ce5ece4 Modularization: Extract SecureLogger into a separate module (#600)
* Extract SecureLogger into a separate module

* Add BitLogger package as a dependency for iOS & macOS targets
2025-09-15 14:45:58 +02:00
IslamandGitHub 7c4bde59b9 Xcode Configuration files: .xcconfigs + Remove xcodegen (#608)
* Create configs files with basic settings populated

* Add Configs and set the global Debug/Release settings

* Update build settings to be read from the configs

* Remove `xcodegen`’s `project.yml`

* Configurable and dynamic bundle and group ids

* Simplified local development with custom Team IDs
2025-09-15 13:58:49 +02:00
2ac01db9c4 SYNC_REQUEST 2 (#616)
* wip

* woohooo

* Plumtree gossip: don't subset REQUEST_SYNC fanout; make RequestSyncPacket.encode use const

* bloom -> gcs [wip]

* fix build

* fix broadcast

* prune old messages too

* faster sync

* prune better

* adjust parameters

* fix(sync): make cap a constant in GCSFilter.buildFilter to silence 'never mutated' warning

* fix(mesh): surface self-origin public messages recovered via sync; only ignore self when TTL != 0 in handleMessage

* sync: allow self messages via GCS restore and relax TTL==0 acceptance\n- Bypass dedup for self TTL==0 packets in handleReceivedPacket\n- Accept self TTL==0 in handleMessage and set nickname\n- Accept unknown senders for TTL==0 with anon# prefix to restore history

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 13:57:09 +02:00
jack 42cdc4c123 Xcode: dedupe libz.tbd reference in project.pbxproj 2025-09-14 15:46:06 +02:00
IslamandGitHub fb94e799a5 Refactor .xcodeproj with buildable folders (#599)
* Remove unused LocationNotesSheet.swift

* Add README.md to bitchatTest group to mirror the folder

* Convert bitchat, Tests, ShareExtension to folders

* Update Project Format to Xcode 16.3 (latest)
2025-09-14 15:37:45 +02:00
IslamandGitHub 04671caeb8 Remove unused LocationNotesSheet.swift (#606) 2025-09-14 14:38:34 +02:00
GitHub Action a485335649 Automated update of relay data - Sun Sep 14 06:04:21 UTC 2025 2025-09-14 06:04:21 +00:00
de2b5ed142 Fix/various UI fixes (#605)
* UI: replace textual 'close' with X icon\n\n- AppInfoView (iOS): use xmark icon in nav bar to match Location Notes style.\n- LocationChannelsSheet: use xmark icon for close on iOS/macOS toolbars; add accessibility label.

* Location Notes: prefix usernames with @ and lighten #geohash\n\n- Show @ before usernames in notes list.\n- Split header into '@' and '#geohash' and color the geohash with secondary green for consistency.

* Header spacing: add breathing room between channel badge, notes button, and people count\n\n- Add trailing padding after #mesh/#geohash badge.\n- Add leading padding before notes button and people counter to improve readability.

* Header: nudge #mesh/#geohash badge right with leading padding

* Location Notes + Header polish\n\n- Header: add space in '@ #geohash' and use darker green for geohash.\n- Notes list: render '@name#abcd' with darker green for #abcd to match chat.\n- Header: move geochat bookmark icon after #geohash badge with consistent spacing.

* Location Notes: @name regular green, #abcd darker; nudge #hash\n\n- Render '@' and base name in regular green, suffix '#abcd' in darker green.\n- Add extra left padding before '#geohash' in notes header.\n- Increase leading padding for channel badge to push #mesh/#geohash further right.

* Fix notes icon color: subscribe/count at block-level geohash\n\n- Use block (precision 7) geohash for notes: when opening sheet, on channel changes, and when subscribing the counter.\n- Aligns with LocationNotesManager which publishes at street-level geohash, allowing the counter to detect notes and turn icon blue.

* Header spacing: move #mesh/#geohash closer to notes/bookmark\n\n- Reduce trailing padding on channel badge and leading padding on notes/bookmark to cluster them together.\n- Keeps larger gap before people count for readability.

* Notes: standardize on building-level (8 chars) for publish/read\n\n- Use .building geohash when opening notes, reacting to channel updates, and subscribing the counter.\n- Update comments to reflect building-level scope.

* Location Channels sheet: use black sheet background like other sheets\n\n- Add backgroundColor and apply to container and list.\n- Hide list default background with scrollContentBackground(.hidden).

* Notes icon: use green when notes exist (matches app green)

* Location Channels: boxed 'bookmarked' section; keep 'remove location access' outside box\n\n- Wrap bookmarked list in a rounded, subtle grey box within the list.\n- Ensure the 'remove location access' button is not inside the box and clears list row background.

* Notes counter UX: avoid grey flicker when closing sheet\n\n- Preserve last count during resubscribe to prevent brief 0 state.\n- Keep existing subscription when building geohash temporarily unavailable; only cancel if none or permission revoked.

* Notes counter: unsubscribe without clearing count on resubscribe\n\n- Avoid calling cancel() in subscribe; just unsubscribe old sub to prevent wiping count to 0.\n- Prevents green→grey flicker after closing sheet or location updates.

* Notes icon: use sheet count until counter finishes initial load; don’t zero on sheet close\n\n- Compute hasNotes using max(count, sheetCount) while initialLoadComplete is false.\n- Remove sheetNotesCount reset on sheet disappear to avoid transient grey.

* Location Notes header: remove extra gap before #geohash (drop leading padding)

* Notes sheet: color #abcd suffix as darker green via opacity (match chat)

* Notes sheet: show timestamp in brackets; drop #abcd from @name

* chore: commit remaining local changes

* Header: move notes + bookmark to left of #mesh/#geohash

* Header spacing: tighten gap between #mesh/#geohash and peer count

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-13 23:05:19 +02:00
jack 57cc9028cb Fix EXC_GUARD crash: avoid closing Tor-owned fd on tor exit\n\nCTorHost: stop calling close(owning_fd_tor) in tor_thread_main. Tor already\ncloses its end; closing a reused guarded fd can trigger EXC_GUARD on iOS 18.\nWe now treat it as Tor-owned and just invalidate our reference. 2025-09-13 21:09:23 +02:00
b0a50c663f Tor (Simulator): add iOS simulator slice to tor-nolzma.xcframework; wire Xcode project; basic docs (#604)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-13 20:48:57 +02:00
jack 0a98844c1a Xcode: update Tor C group and references after moving CTorHost.c into Services/Tor/C and adding include/.gitkeep 2025-09-13 15:06:52 +02:00
IslamandGitHub efb9a5070d SPM Test target + Github Action to build and test (#596)
* SPM Test target + Github Action to build and test

Because the tests are XCTests `swift test` runs them first and then runs another batch of empty tests which results in "0 tests" at the end of the report - https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345

* Fix dependency and library issues + handle mixed languages

`include` folder has to be next to the `*.c` file for it to work
2025-09-13 15:01:30 +02:00
2870acdcc7 Location Notes (kind 1) at Building Precision + Live Updates (#598)
* Location notes: Matrix loader with 1–3s minimum display; allow multiple notes; wire notes UI from mesh toolbar

* Location notes: prevent duplicate loading/subscriptions (StateObject manager, geohash captured at open, guard subscribe)

* Location notes: fix duplicate  state redeclaration in ContentView

* Location notes: render note bodies with monospaced font in notes list

* Location notes: add close (x) button in sheet header (upper-right) using @Environment(\.dismiss)

* Location notes: acquire geohash on open (force refresh) and show Matrix loader until block-level geohash resolves; add LocationNotesSheet wrapper

* Location notes: inline sheet wrapper to avoid missing file in target; force location refresh and show Matrix loader until block geohash resolves

* Location notes: remove Matrix animation; use simple spinner when acquiring location; simplify notes view layout

* Location notes: remove per-note relative timestamp from sheet (no seconds display)

* Location notes: fix intermittent first load by ensuring resubscribe after geohash change, removing over-eager subscribe guard, and widening note fetch window (no since filter)

* Location notes: add background counter service and show count next to notes icon on mesh; auto-subscribe to current block geohash

* Location notes: move notes icon to the right of #mesh badge in toolbar

* Location notes: restore timestamps in notes list; remove loading state from manager and sheet (no spinner/animation)

* Location notes: drop 'teleport' tag from kind-1 events; simplify note builder API and usage

* Location notes: show timestamp as relative within 7 days, else absolute date (MMM d or MMM d, y); keep monospaced styling

* Location notes: append 'ago' to relative timestamps (within 7 days)

* Switch location notes and UI helpers to building-level geohash (precision 8): add GeohashChannelLevel.building, map length 8, use building for notes selection and counter, add building name mapping

* Location notes: change notes toolbar icon to SF Symbol 'long.text.page.and.pencil'

* Revert notes geohash selection back to block-level (precision 7); keep building level available but unused; update counter and sheet accordingly

* Location notes: show block name (from reverse geocode) in header instead of 'street-level notes'

* Nostr: add EOSE handling with callback support for subscriptions; wire to LocationNotesManager/Counter (initialLoadComplete). Remove 'building' level from channel enum and geocoder mapping; geohash list shows block/neighborhood/city/province/region only

* Fix Swift 6 isolation: hop to @MainActor inside Timer callback for EOSE tracker mutations

* Notes counter: show 0 by default; subscribe at building-level (precision 8) for notes and counter; keep building hidden in channel list

* Notes header: show building name when available (fallback to block name)

* Notes: subscribe counter to building + parent block (merge results); hide notes icon unless location is authorized; replace 10s timer with live location updates while sheet open

* Notes counter: subscribe only to building geohash (precision 8) so count reflects current 8-cell; auto-begin live location refresh on mesh (and permission) to update as you walk

* Notes header: show count in parentheses; icon shows blue if any notes, grey if none; only start live location updates while sheet is open; reduce nickname width; set live distance filter to 10m

* Notes header: show count as '(N note/notes)' with non-bold, secondary styling next to title

* Notes header: move count before title as '<N> note(s) @ #gh'; remove parentheses; keep count non-bold

* Notes header: bold count text; ensure sheet updates when building geohash changes by calling manager.setGeohash on geohash change

* Notes sheet: add light haptic feedback when building geohash changes (iOS only)

* Notes icon: force a one-shot location refresh before (re)subscribing the counter so icon turns blue immediately on current 8-cell

* Notes subscribe: fallback to default relays when GeoRelayDirectory has no entries (pass nil relayUrls) so counter/icon turn blue and sheet loads even before georelay fetch

* Notes icon: turn blue immediately when sheet shows notes by passing count up from sheet (closure) and OR-ing with counter; reset on sheet close

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-13 14:55:04 +02:00
90 changed files with 5407 additions and 3801 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
+27
View File
@@ -0,0 +1,27 @@
name: Build & Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
name: Run Swift Tests
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Swift
uses: swift-actions/setup-swift@v2
- name: Build the package
run: swift build
- 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
+3
View File
@@ -75,3 +75,6 @@ TestResult.xcresult/
*.xcresult/ *.xcresult/
build.log build.log
*.log *.log
# Local configs
Local.xcconfig
+4
View File
@@ -0,0 +1,4 @@
#include "Release.xcconfig"
// Optional include of local configs
#include? "Local.xcconfig"
+5
View File
@@ -0,0 +1,5 @@
// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947
DEVELOPMENT_TEAM = ABC123
// Unique bundle id to be able to register and run locally
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
+11
View File
@@ -0,0 +1,11 @@
MARKETING_VERSION = 1.4.2
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
MACOSX_DEPLOYMENT_TARGET = 13.0
SWIFT_VERSION = 5.0
DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
+4 -16
View File
@@ -14,7 +14,6 @@ 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 "❌ Xcode 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)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met" @echo "✅ All prerequisites met"
@@ -22,8 +21,6 @@ check:
# 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 +41,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 +67,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 +96,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"
+28 -3
View File
@@ -15,13 +15,17 @@ let package = Package(
), ),
], ],
dependencies:[ dependencies:[
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"), .package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "bitchat", name: "bitchat",
dependencies: [ dependencies: [
.product(name: "P256K", package: "swift-secp256k1") .product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitLogger", package: "BitLogger"),
.target(name: "TorC"),
.target(name: "tor-nolzma")
], ],
path: "bitchat", path: "bitchat",
exclude: [ exclude: [
@@ -29,8 +33,29 @@ 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: [
.linkedLibrary("z")
] ]
), ),
.target(
name: "TorC",
path: "bitchat/Services/Tor/C"
),
.binaryTarget(
name: "tor-nolzma",
path: "Frameworks/tor-nolzma.xcframework"
),
.testTarget(
name: "bitchatTests",
dependencies: ["bitchat"],
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md"
]
)
] ]
) )
+11 -31
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
@@ -94,45 +94,25 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
## Setup ## Setup
### Option 1: Using XcodeGen (Recommended) ### Option 1: Using Xcode
1. Install XcodeGen if you haven't already:
```bash
brew install xcodegen
```
2. Generate the Xcode project:
```bash ```bash
cd bitchat cd bitchat
xcodegen generate
```
3. Open the generated project:
```bash
open bitchat.xcodeproj open bitchat.xcodeproj
``` ```
### Option 2: Using Swift Package Manager To run on a device there're a few steps to prepare the code:
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
- Entitlements need to be updated manually (TODO: Automate):
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
1. Open the project in Xcode: ### Option 2: Using `just`
```bash ```bash
cd bitchat brew install just
open Package.swift
``` ```
2. Select your target device and run
### Option 3: Manual Xcode Project
1. Open Xcode and create a new iOS/macOS App
2. Copy all Swift files from the `bitchat` directory into your project
3. Update Info.plist with Bluetooth permissions
4. Set deployment target to iOS 16.0 / macOS 13.0
### Option 4: just
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.
+220 -808
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "image-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "image-1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "image-1024 2.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+19 -12
View File
@@ -11,6 +11,9 @@ import UserNotifications
@main @main
struct BitchatApp: App { struct BitchatApp: App {
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)"
@StateObject private var chatViewModel: ChatViewModel @StateObject private var chatViewModel: ChatViewModel
#if os(iOS) #if os(iOS)
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@@ -54,8 +57,8 @@ struct BitchatApp: App {
#elseif os(macOS) #elseif os(macOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#endif #endif
// Spin up Tor early; all internet will gate on Tor 100% // Initialize network activation policy; will start Tor/Nostr only when allowed
TorManager.shared.startIfNeeded() NetworkActivationService.shared.start()
// Check for shared content // Check for shared content
checkForSharedContent() checkForSharedContent()
} }
@@ -67,7 +70,7 @@ struct BitchatApp: App {
switch newPhase { switch newPhase {
case .background: case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically // Keep BLE mesh running in background; BLEService adapts scanning automatically
// Optionally nudge Tor to dormant to save power // Always send Tor to dormant on background for a clean restart later.
TorManager.shared.setAppForeground(false) TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground() TorManager.shared.goDormantOnBackground()
// Stop geohash sampling while backgrounded // Stop geohash sampling while backgrounded
@@ -84,18 +87,22 @@ struct BitchatApp: App {
// On initial cold launch, Tor was just started in onAppear. // On initial cold launch, Tor was just started in onAppear.
// Skip the deterministic restart the first time we become active. // Skip the deterministic restart the first time we become active.
if didHandleInitialActive && didEnterBackground { if didHandleInitialActive && didEnterBackground {
TorManager.shared.ensureRunningOnForeground() if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else { } else {
didHandleInitialActive = true didHandleInitialActive = true
} }
didEnterBackground = false didEnterBackground = false
Task.detached { if TorManager.shared.isAutoStartAllowed() {
let _ = await TorManager.shared.awaitReady(timeout: 60) Task.detached {
await MainActor.run { let _ = await TorManager.shared.awaitReady(timeout: 60)
// Rebuild proxied sessions to bind to the live Tor after readiness await MainActor.run {
TorURLSession.shared.rebuild() // Rebuild proxied sessions to bind to the live Tor after readiness
// Reconnect Nostr via fresh sessions; will gate until Tor 100% TorURLSession.shared.rebuild()
NostrRelayManager.shared.resetAllConnections() // Reconnect Nostr via fresh sessions; will gate until Tor 100%
NostrRelayManager.shared.resetAllConnections()
}
} }
} }
checkForSharedContent() checkForSharedContent()
@@ -130,7 +137,7 @@ struct BitchatApp: App {
private func checkForSharedContent() { private func checkForSharedContent() {
// Check app group for shared content from extension // Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
return return
} }
-17
View File
@@ -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
@@ -90,6 +90,7 @@
/// - Advanced conflict resolution /// - Advanced conflict resolution
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
+343
View File
@@ -0,0 +1,343 @@
//
// BitchatMessage.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
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) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Binary encoding
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
convenience init?(_ data: Data) {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: String?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
self.init(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
}
}
// MARK: - Helpers
extension BitchatMessage {
private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp)
}
}
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
guard arr.count > 1 else {
return arr
}
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
return dedup
}
}
+91
View File
@@ -0,0 +1,91 @@
//
// BitchatPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable {
let version: UInt8
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
senderData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
self.senderID = senderData
self.recipientID = nil
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
self.payload = payload
self.signature = nil
self.ttl = ttl
}
var data: Data? {
BinaryProtocol.encode(self)
}
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
+61
View File
@@ -0,0 +1,61 @@
//
// MessagePadding.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
+41
View File
@@ -0,0 +1,41 @@
//
// NoisePayload.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Helper to create typed Noise payloads
struct NoisePayload {
let type: NoisePayloadType
let data: Data
/// Encode payload with type prefix
func encode() -> Data {
var encoded = Data()
encoded.append(type.rawValue)
encoded.append(data)
return encoded
}
/// Decode payload from data
static func decode(_ data: Data) -> NoisePayload? {
// Ensure we have at least 1 byte for the type
guard !data.isEmpty else {
return nil
}
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil
}
// Create a proper Data copy (not a subsequence) for thread safety
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
return NoisePayload(type: type, data: payloadData)
}
}
+95
View File
@@ -0,0 +1,95 @@
//
// ReadReceipt.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = timestamp
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
// MARK: - Binary Encoding
func toBinaryData() -> Data {
var data = Data()
data.appendUUID(originalMessageID)
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
readerData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
while readerData.count < 8 {
readerData.append(0)
}
data.append(readerData)
data.appendDate(timestamp)
data.appendString(readerNickname)
return data
}
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
// Create defensive copy
let dataCopy = Data(data)
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
guard dataCopy.count >= 49 else { return nil }
var offset = 0
guard let originalMessageID = dataCopy.readUUID(at: &offset),
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard InputValidator.validatePeerID(readerID) else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID,
readerID: readerID,
readerNickname: readerNickname,
timestamp: timestamp)
}
}
+63
View File
@@ -0,0 +1,63 @@
import Foundation
// REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: P (uint8) Golomb-Rice parameter
// - 0x02: M (uint32, big-endian) hash range (N * 2^P)
// - 0x03: data (opaque) GR bitstream bytes (MSB-first)
struct RequestSyncPacket {
let p: Int
let m: UInt32
let data: Data
func encode() -> Data {
var out = Data()
func putTLV(_ t: UInt8, _ v: Data) {
out.append(t)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
// P
putTLV(0x01, Data([UInt8(p & 0xFF)]))
// M (uint32)
var mBE = m.bigEndian
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data
putTLV(0x03, data)
return out
}
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
guard off + 2 <= data.count else { return nil }
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
guard off + len <= data.count else { return nil }
let v = data.subdata(in: off..<(off+len)); off += len
switch t {
case 0x01:
if v.count == 1 { p = Int(v[0]) }
case 0x02:
if v.count == 4 {
var mm: UInt32 = 0
for b in v { mm = (mm << 8) | UInt32(b) }
m = mm
}
case 0x03:
if v.count > maxAcceptBytes { return nil }
payload = v
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd)
}
}
@@ -1,357 +0,0 @@
//
// NoiseHandshakeCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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()
}
}
}
+1
View File
@@ -77,6 +77,7 @@
/// - Noise Specification: http://www.noiseprotocol.org/noise.html /// - Noise Specification: http://www.noiseprotocol.org/noise.html
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -209,6 +210,15 @@ final class NoiseRateLimiter {
self.messageTimestamps.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()
}
}
} }
// MARK: - Security Errors // MARK: - Security Errors
+10
View File
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -308,6 +309,15 @@ final class NoiseSessionManager {
} }
} }
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
sessions.removeAll()
}
}
func getEstablishedSessions() -> [String: NoiseSession] { func getEstablishedSessions() -> [String: NoiseSession] {
return managerQueue.sync { return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() } return sessions.filter { $0.value.isEstablished() }
+1
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
/// 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.
+24 -6
View File
@@ -150,13 +150,31 @@ struct NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity /// Clear all Nostr identity associations and current identity
static func clearAllAssociations() { static func clearAllAssociations() {
// Delete current Nostr identity let query: [String: Any] = [
KeychainHelper.delete(key: currentIdentityKey, service: keychainService) kSecClass as String: kSecClassGenericPassword,
KeychainHelper.delete(key: deviceSeedKey, service: keychainService) kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
// Note: We can't efficiently delete all noise-nostr associations var result: AnyObject?
// without tracking them, but they'll be orphaned and eventually cleaned up let status = SecItemCopyMatching(query as CFDictionary, &result)
// The important part is deleting the current identity so a new one is generated 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) // MARK: - Per-Geohash Identities (Location Channels)
+23
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
@@ -124,6 +125,28 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods // MARK: - Private Methods
private static func createSeal( private static func createSeal(
+183 -33
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import Network import Network
import Combine import Combine
@@ -34,10 +35,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)
@@ -46,6 +51,14 @@ final class NostrRelayManager: ObservableObject {
private var subscribeCoalesce: [String: Date] = [:] private var subscribeCoalesce: [String: Date] = [:]
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
// Message queue for reliability // Message queue for reliability
// Pending sends held only for relays that are not yet connected. // Pending sends held only for relays that are not yet connected.
private struct PendingSend { private struct PendingSend {
@@ -56,6 +69,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
@@ -69,27 +84,55 @@ 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() {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect. // Global network policy gate
Task.detached { guard networkService.activationAllowed else { return }
let ready = await TorManager.shared.awaitReady() if shouldUseTor {
await MainActor.run { // Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
if !ready { Task.detached {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) let ready = await TorManager.shared.awaitReady()
return await MainActor.run {
} if !ready {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
for relay in self.relays { return
self.connectToRelay(relay.url) }
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
}
} }
} }
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
} }
} }
@@ -108,7 +151,11 @@ 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]) {
if TorManager.shared.torEnforced && !TorManager.shared.isReady { // Global network policy gate
guard networkService.activationAllowed else { return }
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 }
@@ -117,20 +164,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) {
if TorManager.shared.torEnforced && !TorManager.shared.isReady { // Global network policy gate
guard networkService.activationAllowed else { return }
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 }
@@ -139,7 +187,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
@@ -201,8 +251,11 @@ final class NostrRelayManager: ObservableObject {
filter: NostrFilter, filter: NostrFilter,
id: String = UUID().uuidString, id: String = UUID().uuidString,
relayUrls: [String]? = nil, relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate
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 {
@@ -211,7 +264,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 }
@@ -239,17 +292,38 @@ 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
if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
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)
@@ -264,6 +338,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)
@@ -291,13 +414,15 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate
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
} }
@@ -312,7 +437,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()
@@ -418,9 +543,17 @@ final class NostrRelayManager: ObservableObject {
} else { } else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
} }
case .eose: case .eose(let subId):
// No-op for now if var tracker = eoseTrackers[subId] {
break tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
tracker.timer?.invalidate()
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
eoseTrackers[subId] = tracker
}
}
case .ok(let eventId, let success, let reason): case .ok(let eventId, let success, let reason):
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
@@ -489,6 +622,13 @@ 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 !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
return
}
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)
@@ -755,6 +895,16 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter return filter
} }
// For location notes: persistent text notes (kind 1) tagged with geohash
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]]
filter.limit = limit
return filter
}
} }
// Dynamic coding key for tag filters // Dynamic coding key for tag filters
-233
View File
@@ -328,236 +328,3 @@ struct BinaryProtocol {
} }
} }
} }
// Binary encoding for BitchatMessage
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: String?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
let message = BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
return message
}
}
+2 -338
View File
@@ -59,61 +59,6 @@
/// ///
import Foundation import Foundation
import CryptoKit
// MARK: - Message Padding
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
// MARK: - Message Types // MARK: - Message Types
@@ -125,6 +70,7 @@ enum MessageType: UInt8 {
case announce = 0x01 // "I'm here" with nickname case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving" case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
@@ -138,6 +84,7 @@ enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
@@ -181,187 +128,6 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed case failed(Error) // Handshake failed
} }
//
// MARK: - Core Protocol Structures
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable {
let version: UInt8
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
senderData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
self.senderID = senderData
self.recipientID = nil
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
self.payload = payload
self.signature = nil
self.ttl = ttl
}
var data: Data? {
BinaryProtocol.encode(self)
}
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
//
// MARK: - Read Receipts
// Read receipt structure
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = timestamp
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
// MARK: - Binary Encoding
func toBinaryData() -> Data {
var data = Data()
data.appendUUID(originalMessageID)
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
readerData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
while readerData.count < 8 {
readerData.append(0)
}
data.append(readerData)
data.appendDate(timestamp)
data.appendString(readerNickname)
return data
}
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
// Create defensive copy
let dataCopy = Data(data)
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
guard dataCopy.count >= 49 else { return nil }
var offset = 0
guard let originalMessageID = dataCopy.readUUID(at: &offset),
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard InputValidator.validatePeerID(readerID) else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID,
readerID: readerID,
readerNickname: readerNickname,
timestamp: timestamp)
}
}
//
// MARK: - Delivery Status // MARK: - Delivery Status
// Delivery status for messages // Delivery status for messages
@@ -391,74 +157,6 @@ enum DeliveryStatus: Codable, Equatable {
} }
} }
// MARK: - Message Model
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
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) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// Equatable conformance for BitchatMessage
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Delegate Protocol // MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
@@ -495,37 +193,3 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
} }
// MARK: - Noise Payload Helpers
/// Helper to create typed Noise payloads
struct NoisePayload {
let type: NoisePayloadType
let data: Data
/// Encode payload with type prefix
func encode() -> Data {
var encoded = Data()
encoded.append(type.rawValue)
encoded.append(data)
return encoded
}
/// Decode payload from data
static func decode(_ data: Data) -> NoisePayload? {
// Ensure we have at least 1 byte for the type
guard !data.isEmpty else {
return nil
}
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil
}
// Create a proper Data copy (not a subsequence) for thread safety
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
return NoisePayload(type: type, data: payloadData)
}
}
+6
View File
@@ -2,6 +2,7 @@ import Foundation
/// Levels of location channels mapped to geohash precisions. /// Levels of location channels mapped to geohash precisions.
enum GeohashChannelLevel: CaseIterable, Codable, Equatable { enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case building
case block case block
case neighborhood case neighborhood
case city case city
@@ -11,6 +12,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
/// Geohash length used for this level. /// Geohash length used for this level.
var precision: Int { var precision: Int {
switch self { switch self {
case .building: return 8
case .block: return 7 case .block: return 7
case .neighborhood: return 6 case .neighborhood: return 6
case .city: return 5 case .city: return 5
@@ -21,6 +23,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String { var displayName: String {
switch self { switch self {
case .building: return "Building"
case .block: return "Block" case .block: return "Block"
case .neighborhood: return "Neighborhood" case .neighborhood: return "Neighborhood"
case .city: return "City" case .city: return "City"
@@ -35,6 +38,7 @@ extension GeohashChannelLevel {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
if let raw = try? container.decode(String.self) { if let raw = try? container.decode(String.self) {
switch raw { switch raw {
case "building": self = .building
case "block": self = .block case "block": self = .block
case "neighborhood": self = .neighborhood case "neighborhood": self = .neighborhood
case "city": self = .city case "city": self = .city
@@ -46,6 +50,7 @@ extension GeohashChannelLevel {
} }
} else if let precision = try? container.decode(Int.self) { } else if let precision = try? container.decode(Int.self) {
switch precision { switch precision {
case 8: self = .building
case 7: self = .block case 7: self = .block
case 6: self = .neighborhood case 6: self = .neighborhood
case 5: self = .city case 5: self = .city
@@ -61,6 +66,7 @@ extension GeohashChannelLevel {
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
switch self { switch self {
case .building: try container.encode("building")
case .block: try container.encode("block") case .block: try container.encode("block")
case .neighborhood: try container.encode("neighborhood") case .neighborhood: try container.encode("neighborhood")
case .city: try container.encode("city") case .city: try container.encode("city")
+302 -55
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
@@ -6,6 +7,78 @@ import CryptoKit
import UIKit import UIKit
#endif #endif
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)
}
}
/// BLEService Bluetooth Mesh Transport /// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI. /// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -39,6 +112,7 @@ final class BLEService: NSObject {
var isConnecting: Bool = false var isConnecting: Bool = false
var isConnected: Bool = false var isConnected: Bool = false
var lastConnectionAttempt: Date? = nil var lastConnectionAttempt: Date? = nil
var assembler = NotificationStreamAssembler()
} }
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
@@ -88,8 +162,9 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private let noiseService: NoiseEncryptionService private var noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
private var myPeerIDData: Data = Data() private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
@@ -139,6 +214,9 @@ final class BLEService: NSObject {
// Debounce for 'reconnected' logs // Debounce for 'reconnected' logs
private var lastReconnectLogAt: [String: Date] = [:] private var lastReconnectLogAt: [String: Date] = [:]
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
// MARK: - Maintenance Timer // MARK: - Maintenance Timer
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
@@ -186,7 +264,9 @@ final class BLEService: NSObject {
// MARK: - Helpers: IDs, selection, and write backpressure // MARK: - Helpers: IDs, selection, and write backpressure
private func makeMessageID(for packet: BitchatPacket) -> String { private func makeMessageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
return "\(senderID)-\(packet.timestamp)-\(packet.type)" let digest = SHA256.hash(data: packet.payload)
let digestPrefix = digest.prefix(4).map { String(format: "%02x", $0) }.joined()
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
} }
private func subsetSizeForFanout(_ n: Int) -> Int { private func subsetSizeForFanout(_ n: Int) -> Int {
@@ -326,33 +406,44 @@ final class BLEService: NSObject {
} }
} }
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) { private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
noiseService = NoiseEncryptionService(keychain: keychain) service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
self.identityManager = identityManager
super.init()
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
self.myPeerID = String(fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
// Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ())
// Set up Noise session establishment callback
// This ensures we send pending messages only when session is truly established
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...") SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
// Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
} }
// Proactive presence nudge: announce immediately after handshake
self?.messageQueue.async { [weak self] in self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} }
}
private func refreshPeerIdentity() {
let fingerprint = noiseService.getIdentityFingerprint()
myPeerID = String(fingerprint.prefix(16))
myPeerIDData = Data(hexString: myPeerID) ?? Data()
}
private func restartGossipManager() {
gossipSyncManager?.stop()
let sync = GossipSyncManager(myPeerID: myPeerID)
sync.delegate = self
sync.start()
gossipSyncManager = sync
}
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
self.keychain = keychain
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init()
configureNoiseServiceCallbacks(for: noiseService)
refreshPeerIdentity()
// Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ())
// Set up application state tracking (iOS only) // Set up application state tracking (iOS only)
#if os(iOS) #if os(iOS)
@@ -401,6 +492,9 @@ final class BLEService: NSObject {
// Publish initial empty state // Publish initial empty state
requestPeerDataPublish() requestPeerDataPublish()
// Initialize gossip sync manager
restartGossipManager()
} }
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
@@ -729,6 +823,46 @@ final class BLEService: NSObject {
centralToPeerID.removeAll() centralToPeerID.removeAll()
} }
func resetIdentityForPanic(currentNickname: String) {
messageQueue.sync(flags: .barrier) {
pendingMessagesAfterHandshake.removeAll()
pendingNoisePayloadsAfterHandshake.removeAll()
}
collectionsQueue.sync(flags: .barrier) {
recentAnnounceBySender.removeAll()
recentAnnounceOrder.removeAll()
pendingPeripheralWrites.removeAll()
pendingNotifications.removeAll()
pendingDirectedRelays.removeAll()
ingressByMessageID.removeAll()
recentPacketTimestamps.removeAll()
scheduledRelays.values.forEach { $0.cancel() }
scheduledRelays.removeAll()
}
bleQueue.sync {
pendingWriteBuffers.removeAll()
recentConnectTimeouts.removeAll()
}
recentDisconnectNotifies.removeAll()
noiseService.clearEphemeralStateForPanic()
noiseService.clearPersistentIdentity()
let newNoise = NoiseEncryptionService(keychain: keychain)
noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
restartGossipManager()
setNickname(currentNickname)
messageDeduplicator.reset()
requestPeerDataPublish()
startServices()
}
func getNoiseService() -> NoiseEncryptionService { func getNoiseService() -> NoiseEncryptionService {
return noiseService return noiseService
} }
@@ -775,6 +909,8 @@ final class BLEService: NSObject {
self.messageDeduplicator.markProcessed(dedupID) self.messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue // Call synchronously since we're already on background queue
self.broadcastPacket(signedPacket) self.broadcastPacket(signedPacket)
// Track our own broadcast for sync
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
} }
} }
@@ -1049,6 +1185,13 @@ final class BLEService: NSObject {
// Determine last-hop link for this message to avoid echoing back // Determine last-hop link for this message to avoid echoing back
let messageID = makeMessageID(for: packet) let messageID = makeMessageID(for: packet)
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link } let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
let directedPeerHint: String? = {
if let explicit = directedOnlyPeer { return explicit }
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty {
return recipient
}
return nil
}()
let states = snapshotPeripheralStates() let states = snapshotPeripheralStates()
var minCentralWriteLen: Int? var minCentralWriteLen: Int?
@@ -1099,10 +1242,13 @@ final class BLEService: NSObject {
} }
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically // For broadcast (no directed peer) and non-fragment, choose a subset deterministically
// Special-case announces: do NOT subset to maximize reach for presence // Special-case control/presence messages: do NOT subset to maximize immediate coverage
var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs) var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue { if directedPeerHint == nil
&& packet.type != MessageType.fragment.rawValue
&& packet.type != MessageType.announce.rawValue
&& packet.type != MessageType.requestSync.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count) let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count) let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
@@ -1110,7 +1256,7 @@ final class BLEService: NSObject {
} }
// If directed and we currently have no links to forward on, spool for a short window // If directed and we currently have no links to forward on, spool for a short window
if let only = directedOnlyPeer, if let only = directedPeerHint,
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty, selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
spoolDirectedPacket(packet, recipientPeerID: only) spoolDirectedPacket(packet, recipientPeerID: only)
@@ -1133,6 +1279,12 @@ final class BLEService: NSObject {
} }
} }
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) {
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
}
// MARK: - Directed store-and-forward // MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) { private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
@@ -1339,7 +1491,10 @@ final class BLEService: NSObject {
// Efficient deduplication // Efficient deduplication
// Important: do not dedup fragment packets globally (each piece must pass) // Important: do not dedup fragment packets globally (each piece must pass)
if packet.type != MessageType.fragment.rawValue && messageDeduplicator.isDuplicate(messageID) { // Special case: allow our own packets recovered via sync (TTL==0) to pass
// through even if we've marked them as seen at send time.
let allowSelfSyncReplay = (packet.ttl == 0) && (senderID == myPeerID)
if packet.type != MessageType.fragment.rawValue && !allowSelfSyncReplay && messageDeduplicator.isDuplicate(messageID) {
// Announce packets (type 1) are sent every 10 seconds for peer discovery // Announce packets (type 1) are sent every 10 seconds for peer discovery
// It's normal to see these as duplicates - don't log them to reduce noise // It's normal to see these as duplicates - don't log them to reduce noise
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
@@ -1383,6 +1538,9 @@ final class BLEService: NSObject {
case .message: case .message:
handleMessage(packet, from: senderID) handleMessage(packet, from: senderID)
case .requestSync:
handleRequestSync(packet, from: senderID)
case .noiseHandshake: case .noiseHandshake:
handleNoiseHandshake(packet, from: senderID) handleNoiseHandshake(packet, from: senderID)
@@ -1583,12 +1741,17 @@ final class BLEService: NSObject {
// Only notify of connection for new or reconnected peers when it is a direct announce // Only notify of connection for new or reconnected peers when it is a direct announce
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
// Schedule initial unicast sync to this peer
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
} }
self.requestPeerDataPublish() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
// Track for sync (include our own and others' announces)
gossipSyncManager?.onPublicPacketSeen(packet)
// Send announce back for bidirectional discovery (only once per peer) // Send announce back for bidirectional discovery (only once per peer)
let announceBackID = "announce-back-\(peerID)" let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !messageDeduplicator.contains(announceBackID) let shouldSendBack = !messageDeduplicator.contains(announceBackID)
@@ -1611,16 +1774,32 @@ final class BLEService: NSObject {
} }
} }
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
private func handleRequestSync(_ packet: BitchatPacket, from peerID: String) {
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
return
}
gossipSyncManager?.handleRequestSync(fromPeerID: peerID, request: req)
}
// Mention parsing moved to ChatViewModel // Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: String) { private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
// Ignore self-origin public messages that may be seen again via relay // Ignore self-origin public messages except when returned via sync (TTL==0).
if peerID == myPeerID { return } // This allows our own messages to be surfaced when they come back via
// the sync path without re-processing regular relayed copies.
if peerID == myPeerID && packet.ttl != 0 { return }
var accepted = false var accepted = false
var senderNickname: String = "" var senderNickname: String = ""
if let info = peers[peerID], info.isVerifiedNickname { // If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
if peerID == myPeerID {
accepted = true
senderNickname = myNickname
}
else if let info = peers[peerID], info.isVerifiedNickname {
// Known verified peer path // Known verified peer path
accepted = true accepted = true
senderNickname = info.nickname senderNickname = info.nickname
@@ -1648,6 +1827,22 @@ final class BLEService: NSObject {
} }
} }
} }
// If still not accepted and this is a sync-returned packet (TTL==0),
// accept with a generic nickname so history can be restored even for
// peers we haven't verified yet.
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.prefix(4))
}
}
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
if isBroadcastRecipient && packet.type == MessageType.message.rawValue {
gossipSyncManager?.onPublicPacketSeen(packet)
} }
guard accepted else { guard accepted else {
@@ -1780,6 +1975,8 @@ final class BLEService: NSObject {
// Remove the peer when they leave // Remove the peer when they leave
peers.removeValue(forKey: peerID) peers.removeValue(forKey: peerID)
} }
// Remove any stored announcement for sync purposes
gossipSyncManager?.removeAnnouncementForPeer(peerID)
// Send on main thread // Send on main thread
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -1861,6 +2058,8 @@ final class BLEService: NSObject {
self?.broadcastPacket(signedPacket) self?.broadcastPacket(signedPacket)
} }
} }
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
@@ -2088,6 +2287,8 @@ final class BLEService: NSObject {
if !peer.isConnected { if !peer.isConnected {
if age > retention { if age > retention {
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session) SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
// Also remove any stored announcement from sync candidates
gossipSyncManager?.removeAnnouncementForPeer(peerID)
peers.removeValue(forKey: peerID) peers.removeValue(forKey: peerID)
removedOfflineCount += 1 removedOfflineCount += 1
} }
@@ -2249,6 +2450,29 @@ final class BLEService: NSObject {
} }
} }
// MARK: - GossipSyncManager Delegate
extension BLEService: GossipSyncManager.Delegate {
func sendPacket(_ packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
}
func sendPacket(to peerID: String, packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
sendPacketDirected(packet, to: peerID)
} else {
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
}
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
return noiseService.signPacket(packet) ?? packet
}
}
// MARK: - CBCentralManagerDelegate // MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate { extension BLEService: CBCentralManagerDelegate {
@@ -2373,7 +2597,8 @@ extension BLEService: CBCentralManagerDelegate {
peerID: nil, peerID: nil,
isConnecting: true, isConnecting: true,
isConnected: false, isConnected: false,
lastConnectionAttempt: Date() lastConnectionAttempt: Date(),
assembler: NotificationStreamAssembler()
) )
peripheral.delegate = self peripheral.delegate = self
@@ -2422,7 +2647,9 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
characteristic: nil, characteristic: nil,
peerID: nil, peerID: nil,
isConnecting: false, isConnecting: false,
isConnected: true isConnected: true,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
) )
} }
@@ -2560,7 +2787,8 @@ extension BLEService {
peerID: nil, peerID: nil,
isConnecting: true, isConnecting: true,
isConnected: false, isConnected: false,
lastConnectionAttempt: Date() lastConnectionAttempt: Date(),
assembler: NotificationStreamAssembler()
) )
peripheral.delegate = self peripheral.delegate = self
let options: [String: Any] = [ let options: [String: Any] = [
@@ -2697,53 +2925,72 @@ extension BLEService: CBPeripheralDelegate {
return return
} }
guard let data = characteristic.value else { guard let data = characteristic.value, !data.isEmpty else {
SecureLogger.warning("⚠️ No data in notification", category: .session) SecureLogger.warning("⚠️ No data in notification", category: .session)
return return
} }
// Received BLE notification bufferNotificationChunk(data, from: peripheral)
// Process directly on main thread to avoid deadlocks (matches original implementation)
guard let packet = BinaryProtocol.decode(data) else {
// Avoid dumping entire payload; log size and short prefix for diagnostics
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
return
}
// Use the packet's senderID as the peer identifier
let senderID = packet.senderID.hexEncodedString()
// Only log non-announce packets
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
} }
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
let peripheralUUID = peripheral.identifier.uuidString let peripheralUUID = peripheral.identifier.uuidString
// Update mapping ONLY for announce packets that come directly from the peer (not relayed) var state = peripherals[peripheralUUID] ?? PeripheralState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: peripheral.state == .connected,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
)
var assembler = state.assembler
let result = assembler.append(chunk)
state.assembler = assembler
peripherals[peripheralUUID] = state
for byte in result.droppedPrefixes {
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
}
if result.reset {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
}
for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue
}
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
}
}
private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) {
let senderID = packet.senderID.hexEncodedString()
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
}
if packet.type == MessageType.announce.rawValue { if packet.type == MessageType.announce.rawValue {
// Only update mapping if this is a direct announce (TTL == messageTTL means not relayed)
if packet.ttl == messageTTL { if packet.ttl == messageTTL {
if var state = peripherals[peripheralUUID] { if var state = peripherals[peripheralUUID] {
state.peerID = senderID state.peerID = senderID
peripherals[peripheralUUID] = state peripherals[peripheralUUID] = state
} }
peerToPeripheralUUID[senderID] = peripheralUUID peerToPeripheralUUID[senderID] = peripheralUUID
// Mapping update - direct announce from peer
} }
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
} }
// Process the announce packet regardless of whether we updated the mapping
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} else { } else {
// For non-announce packets, DO NOT update mappings
// These could be relayed packets from other peers
// Always use the packet's original senderID
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date()) self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
+4 -2
View File
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import Security import Security
@@ -23,8 +24,8 @@ protocol KeychainManagerProtocol {
final class KeychainManager: KeychainManagerProtocol { final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = "chat.bitchat" private let service = BitchatApp.bundleID
private let appGroup = "group.chat.bitchat" private let appGroup = "group.\(BitchatApp.bundleID)"
private func isSandboxed() -> Bool { private func isSandboxed() -> Bool {
#if os(macOS) #if os(macOS)
@@ -281,6 +282,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,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
@@ -285,12 +286,18 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} else if let locality = pm.locality, !locality.isEmpty { } else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality dict[.neighborhood] = locality
} }
// Block: reuse neighborhood/locality granularity without exposing street level // Block: reuse neighborhood/locality granularity
if let subLocality = pm.subLocality, !subLocality.isEmpty { if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty { } else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality dict[.block] = locality
} }
// Building: prefer place name/street/venue when available
if let name = pm.name, !name.isEmpty {
dict[.building] = name
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
dict[.building] = thoroughfare
}
return dict return dict
} }
} }
@@ -0,0 +1,99 @@
import BitLogger
import Foundation
struct LocationNotesCounterDependencies {
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
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
static let live = LocationNotesCounterDependencies(
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)
}
)
}
/// 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
@Published private(set) var relayAvailable: Bool = true
private var subscriptionID: String? = nil
private var noteIDs = Set<String>()
private let dependencies: LocationNotesCounterDependencies
private init(dependencies: LocationNotesCounterDependencies = .live) {
self.dependencies = dependencies
}
init(testDependencies: LocationNotesCounterDependencies) {
self.dependencies = testDependencies
}
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 { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = norm
noteIDs.removeAll()
initialLoadComplete = false
relayAvailable = true
// Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
relayAvailable = false
initialLoadComplete = true
count = 0
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
return
}
subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
dependencies.subscribe(filter, subID, relays, { [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
}
}, { [weak self] in
self?.initialLoadComplete = true
})
}
func cancel() {
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = nil
count = 0
noteIDs.removeAll()
relayAvailable = true
}
}
+200
View File
@@ -0,0 +1,200 @@
import BitLogger
import Foundation
/// 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
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 NostrIdentityBridge.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.
@MainActor
final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case ready
case noRelays
}
struct Note: Identifiable, Equatable {
let id: String
let pubkey: String
let content: String
let createdAt: Date
let nickname: String?
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
}
}
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var geohash: String
@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 let dependencies: LocationNotesDependencies
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
self.geohash = geohash.lowercased()
self.dependencies = dependencies
subscribe()
}
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
geohash = norm
notes.removeAll()
subscribe()
}
func refresh() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
notes.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 = "No geo relays available near this location. Try again soon."
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
return
}
subscriptionID = subID
initialLoadComplete = false
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
if self.notes.contains(where: { $0.id == event.id }) { return }
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.state = .ready
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
self.state = .ready
}
})
}
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = "No geo relays available near this location. Try again soon."
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
return
}
do {
let id = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname
)
dependencies.sendEvent(event, relays)
// Optimistic local-echo
let echo = Note(
id: event.id,
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: dependencies.now(),
nickname: nickname
)
self.notes.insert(echo, at: 0)
self.state = .ready
self.errorMessage = nil
} catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
errorMessage = "Failed to send note. \(error.localizedDescription)"
}
}
/// Explicitly cancel subscription and release resources.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
state = .idle
errorMessage = nil
}
}
+1
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
/// Routes messages between BLE and Nostr transports /// Routes messages between BLE and Nostr transports
@@ -0,0 +1,113 @@
import Foundation
import BitLogger
import Combine
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR
/// there exists at least one mutual favorite. Otherwise, do not start.
@MainActor
final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true
private var cancellables = Set<AnyCancellable>()
private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private init() {}
func start() {
guard !started else { return }
started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
}
// Initial compute
let allowed = basePolicyAllowed()
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
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.store(in: &cancellables)
// React to mutual favorites changes
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.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() {
let allowed = basePolicyAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired
if statusChanged {
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
activationAllowed = allowed
}
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed {
if torChanged {
// Reset relay sockets when switching transport path (Tor direct)
NostrRelayManager.shared.disconnect()
}
NostrRelayManager.shared.connect()
} else if statusChanged {
NostrRelayManager.shared.disconnect()
}
}
private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual
}
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
}
}
}
+10 -1
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
@@ -83,6 +82,7 @@
/// - Background queue for CPU-intensive operations /// - Background queue for CPU-intensive operations
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -526,6 +526,15 @@ final class NoiseEncryptionService {
SecureLogger.info(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) {
peerFingerprints.removeAll()
fingerprintToPeerID.removeAll()
}
rateLimiter.resetAll()
}
// MARK: - Private Helpers // MARK: - Private Helpers
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
+1
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
+1 -1
View File
@@ -62,7 +62,7 @@ final class NotificationService {
} }
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) { func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 private message from \(sender)" let title = "🔒 DM from \(sender)"
let body = message let body = message
let identifier = "private-\(UUID().uuidString)" let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender] let userInfo = ["peerID": peerID, "senderName": sender]
@@ -6,6 +6,7 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitLogger
import Foundation import Foundation
import SwiftUI import SwiftUI
+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)
} }
} }
@@ -40,7 +40,11 @@ static void *tor_thread_main(void *arg) {
int rc = tor_run_main(cfg); // blocks until tor exits int rc = tor_run_main(cfg); // blocks until tor exits
tor_main_configuration_free(cfg); tor_main_configuration_free(cfg);
if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; } if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; }
if (owning_fd_tor != -1) { close(owning_fd_tor); owning_fd_tor = -1; } // Do not close owning_fd_tor here: Tor may have already closed it and the
// fd number could have been re-used by the time we get here. On iOS 18,
// attempting to close a re-used guarded fd can trigger EXC_GUARD. Treat the
// tor-side end as owned by Tor and simply forget our reference.
owning_fd_tor = -1;
return (void*)(intptr_t)rc; return (void*)(intptr_t)rc;
} }
+134 -3
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import Network import Network
import Darwin import Darwin
@@ -67,19 +68,27 @@ final class TorManager: ObservableObject {
private var controlMonitorStarted = false private var controlMonitorStarted = false
private var pathMonitor: NWPathMonitor? private var pathMonitor: NWPathMonitor?
private var isAppForeground: Bool = true private var isAppForeground: Bool = true
private var isDormant: Bool = false
private var lastRestartAt: Date? = nil private var lastRestartAt: Date? = nil
// Global policy gate: only allow Tor to start when true
private(set) var allowAutoStart: Bool = false
private init() {} private init() {}
// MARK: - Public API // MARK: - Public API
func startIfNeeded() { func startIfNeeded() {
// Respect global start policy
guard allowAutoStart else { return }
// Do not start in background; caller should wait for foreground // Do not start in background; caller should wait for foreground
guard isAppForeground else { return } guard isAppForeground else { return }
guard !didStart else { return } guard !didStart else { return }
didStart = true didStart = true
isDormant = false
isStarting = true isStarting = true
lastError = nil lastError = nil
// Announce initial start so UI can show a status message
NotificationCenter.default.post(name: .TorWillStart, object: nil)
ensureFilesystemLayout() ensureFilesystemLayout()
startTor() startTor()
startPathMonitorIfNeeded() startPathMonitorIfNeeded()
@@ -472,6 +481,8 @@ final class TorManager: ObservableObject {
// MARK: - Foreground recovery and control helpers // MARK: - Foreground recovery and control helpers
func ensureRunningOnForeground() { func ensureRunningOnForeground() {
// Respect global start policy
if !allowAutoStart { return }
// iOS can suspend Tor harshly; the most reliable approach for // iOS can suspend Tor harshly; the most reliable approach for
// embedding is to restart Tor every time we become active. // embedding is to restart Tor every time we become active.
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
@@ -483,18 +494,39 @@ final class TorManager: ObservableObject {
return true return true
} }
if !claimed { return } if !claimed { return }
if await self.resumeTorIfPossible() {
await MainActor.run {
self.restarting = false
self.isStarting = false
}
return
}
await self.restartTor() await self.restartTor()
await MainActor.run { self.restarting = false } await MainActor.run { self.restarting = false }
} }
} }
func goDormantOnBackground() { func goDormantOnBackground() {
// Stricter model: fully stop Tor when app backgrounds to save power // Prefer Tor's DORMANT mode so we can resume on foreground without a full restart.
// and avoid half-suspended states. We'll restart cleanly on .active. // If the control port is unreachable, fall back to a hard shutdown.
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let signaled = await self.controlSendSignal("DORMANT")
if signaled {
SecureLogger.info("TorManager: signalled DORMANT", category: .session)
await MainActor.run {
self.isDormant = true
self.isReady = false
self.socksReady = false
self.isStarting = false
}
return
}
SecureLogger.warning("TorManager: DORMANT signal failed; shutting down", category: .session)
_ = tor_host_shutdown() _ = tor_host_shutdown()
await MainActor.run { await MainActor.run {
self.isDormant = false
self.isReady = false self.isReady = false
self.socksReady = false self.socksReady = false
self.bootstrapProgress = 0 self.bootstrapProgress = 0
@@ -507,6 +539,24 @@ final class TorManager: ObservableObject {
} }
} }
func shutdownCompletely() {
Task.detached { [weak self] in
guard let self = self else { return }
_ = tor_host_shutdown()
await MainActor.run {
self.isDormant = false
self.isReady = false
self.socksReady = false
self.bootstrapProgress = 0
self.bootstrapSummary = ""
self.isStarting = false
self.didStart = false
self.restarting = false
self.controlMonitorStarted = false
}
}
}
private func restartTor() async { private func restartTor() async {
await MainActor.run { await MainActor.run {
// Announce restart so UI can notify the user // Announce restart so UI can notify the user
@@ -516,14 +566,28 @@ final class TorManager: ObservableObject {
self.bootstrapProgress = 0 self.bootstrapProgress = 0
self.bootstrapSummary = "" self.bootstrapSummary = ""
self.isStarting = true self.isStarting = true
self.isDormant = false
self.lastRestartAt = Date() self.lastRestartAt = Date()
} }
// Prefer clean shutdown via owning controller FD; join the tor thread // Prefer clean shutdown via owning controller FD; join the tor thread
_ = tor_host_shutdown() _ = tor_host_shutdown()
// As a fallback, try control signal if needed (harmless if tor already down) // As a fallback, try control signal if needed (harmless if tor already down)
_ = await controlSendSignal("SHUTDOWN") _ = await controlSendSignal("SHUTDOWN")
// Allow Tor thread to fully terminate before re-starting.
var waited = 0
while tor_host_is_running() != 0 && waited < 40 {
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
waited += 1
}
if waited >= 40 {
SecureLogger.warning("TorManager: tor_host_is_running still true before restart", category: .session)
}
// Allow control monitor and start logic to reinitialize cleanly
await MainActor.run {
self.controlMonitorStarted = false
self.didStart = false
}
// Now start fresh // Now start fresh
await MainActor.run { self.didStart = false }
await MainActor.run { self.startIfNeeded() } await MainActor.run { self.startIfNeeded() }
} }
@@ -589,6 +653,62 @@ final class TorManager: ObservableObject {
return (text?.contains("250")) == true return (text?.contains("250")) == true
} }
private func resumeTorIfPossible() async -> Bool {
let wasDormant = await MainActor.run { self.isDormant }
let pendingReady = await MainActor.run { self.socksReady && !self.isReady }
let needsWake = wasDormant || pendingReady
if !needsWake {
return false
}
let activated = await controlSendSignal("ACTIVE")
let pinged = await controlPingBootstrap(timeout: 3.0)
if !activated && !pinged {
SecureLogger.warning("TorManager: ACTIVE signal failed", category: .session)
return false
}
if let info = await controlGetBootstrapInfo() {
await MainActor.run {
self.bootstrapProgress = info.progress
self.bootstrapSummary = info.summary
}
}
await MainActor.run {
self.isDormant = false
self.isStarting = true
self.socksReady = false
}
let firstReady = await waitForSocksReady(timeout: 12.0)
if firstReady {
await MainActor.run {
self.socksReady = true
self.isStarting = false
}
SecureLogger.info("TorManager: resumed Tor via ACTIVE signal", category: .session)
return true
}
if pinged {
let secondReady = await waitForSocksReady(timeout: 20.0)
await MainActor.run {
self.socksReady = secondReady
self.isStarting = !secondReady
}
if secondReady {
SecureLogger.info("TorManager: resumed Tor after extended wait", category: .session)
return true
}
} else {
await MainActor.run { self.isStarting = false }
}
SecureLogger.warning("TorManager: ACTIVE resume failed; will restart", category: .session)
return false
}
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? { private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"), guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
let cookie = try? Data(contentsOf: cookiePath) else { return nil } let cookie = try? Data(contentsOf: cookiePath) else { return nil }
@@ -635,3 +755,14 @@ final class TorManager: ObservableObject {
return resultText return resultText
} }
} }
// MARK: - Start policy configuration
extension TorManager {
@MainActor
func setAutoStartAllowed(_ allow: Bool) {
allowAutoStart = allow
}
@MainActor
func isAutoStartAllowed() -> Bool { allowAutoStart }
}
@@ -3,4 +3,6 @@ import Foundation
extension Notification.Name { extension Notification.Name {
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady") static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillRestart = Notification.Name("TorWillRestart")
static let TorWillStart = Notification.Name("TorWillStart")
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
} }
+19 -22
View File
@@ -4,43 +4,34 @@ import CFNetwork
#endif #endif
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy /// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
/// when Tor is enforced/ready. Falls back to a default session only when /// when Tor is enforced/ready. Allows swapping between proxied and direct
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag. /// sessions so UI can toggle Tor usage at runtime.
final class TorURLSession { final class TorURLSession {
static let shared = TorURLSession() static let shared = TorURLSession()
// Default (no proxy) session for local development when dev bypass is enabled. // Default (no proxy) session for direct Nostr access when Tor is disabled.
private var defaultSession: URLSession = { private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
// Proxied (SOCKS5) session that routes through Tor. // Proxied (SOCKS5) session that routes through Tor.
private var torSession: URLSession = TorURLSession.makeTorSession() private var torSession: URLSession = TorURLSession.makeTorSession()
private var useTorProxy: Bool = true
var session: URLSession { var session: URLSession {
#if BITCHAT_DEV_ALLOW_CLEARNET useTorProxy ? torSession : defaultSession
// Dev bypass: use direct session. Call sites may still await Tor if desired.
return defaultSession
#else
// Production: always use the Tor-proxied session. Call sites ensure readiness.
return torSession
#endif
} }
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart. // Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
func rebuild() { func rebuild() {
#if BITCHAT_DEV_ALLOW_CLEARNET defaultSession = TorURLSession.makeDefaultSession()
defaultSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
#endif
torSession = TorURLSession.makeTorSession() torSession = TorURLSession.makeTorSession()
} }
func setProxyMode(useTor: Bool) {
guard useTorProxy != useTor else { return }
useTorProxy = useTor
rebuild()
}
private static func makeTorSession() -> URLSession { private static func makeTorSession() -> URLSession {
let cfg = URLSessionConfiguration.ephemeral let cfg = URLSessionConfiguration.ephemeral
cfg.waitsForConnectivity = true cfg.waitsForConnectivity = true
@@ -63,4 +54,10 @@ final class TorURLSession {
#endif #endif
return URLSession(configuration: cfg) return URLSession(configuration: cfg)
} }
private static func makeDefaultSession() -> URLSession {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}
} }
+2 -2
View File
@@ -103,7 +103,7 @@ enum TransportConfig {
// Location // Location
static let locationDistanceFilterMeters: Double = 1000 static let locationDistanceFilterMeters: Double = 1000
// Live (channel sheet open) distance threshold for meaningful updates // Live (channel sheet open) distance threshold for meaningful updates
static let locationDistanceFilterLiveMeters: Double = 21.0 static let locationDistanceFilterLiveMeters: Double = 10.0
static let locationLiveRefreshInterval: TimeInterval = 5.0 static let locationLiveRefreshInterval: TimeInterval = 5.0
// Notifications (geohash) // Notifications (geohash)
@@ -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
} }
@@ -6,6 +6,7 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitLogger
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
+237
View File
@@ -0,0 +1,237 @@
import Foundation
import CryptoKit
// Golomb-Coded Set (GCS) filter utilities for sync.
// Hashing:
// - 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 [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
// Encoding (v1):
// - 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).
// - Bitstream is MSB-first within each byte.
enum GCSFilter {
struct Params { let p: Int; let m: UInt32; let data: Data }
// Derive P from FPR (~ 1 / 2^P)
static func deriveP(targetFpr: Double) -> Int {
let f = max(0.000001, min(0.25, targetFpr))
// ceil(log2(1/f))
let p = Int(ceil(log2(1.0 / f)))
return max(1, p)
}
// Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx)
static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int {
let bits = max(8, sizeBytes * 8)
let per = max(3, p + 2)
return max(1, bits / per)
}
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
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 selected = Array(ids.prefix(cap))
let range = max(1, hashRange(count: selected.count, p: p))
let modulo = UInt64(range)
var mapped = selected
.map { h64($0) }
.map { mapHash($0, modulo: modulo) }
.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 trimmedCount = mapped.count
while encoded.count > maxBytes && trimmedCount > 0 {
if trimmedCount == 1 {
mapped.removeAll()
encoded = Data()
break
}
trimmedCount = max(1, (trimmedCount * 9) / 10)
mapped = Array(mapped.prefix(trimmedCount))
encoded = encode(sorted: mapped, p: p)
}
return Params(p: p, m: range, data: encoded)
}
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
var values: [UInt64] = []
let reader = BitReader(data)
var acc: UInt64 = 0
while true {
guard let q = reader.readUnary() else { break }
guard let r = reader.readBits(count: p) else { break }
let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1
acc &+= x
if acc >= UInt64(m) { break }
values.append(acc)
}
return values
}
static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool {
var lo = 0
var hi = sortedValues.count - 1
while lo <= hi {
let mid = (lo + hi) >> 1
let v = sortedValues[mid]
if v == candidate { return true }
if v < candidate { lo = mid + 1 } else { hi = mid - 1 }
}
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 {
var hasher = SHA256()
hasher.update(data: id16)
let d = hasher.finalize()
let db = Data(d)
var x: UInt64 = 0
let take = min(8, db.count)
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
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 {
let writer = BitWriter()
var prev: UInt64 = 0
let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1)
for v in sorted {
let delta = v &- prev
prev = v
let x = delta
let q = (x &- 1) >> UInt64(p)
let r = (x &- 1) & mask
// unary q ones then zero
if q > 0 { writer.writeOnes(count: Int(q)) }
writer.writeBit(0)
writer.writeBits(value: r, count: p)
}
return writer.toData()
}
// MARK: - Bit helpers (MSB-first)
private final class BitWriter {
private var buf = Data()
private var cur: UInt8 = 0
private var nbits: Int = 0
func writeBit(_ bit: Int) { // 0 or 1
cur = UInt8((Int(cur) << 1) | (bit & 1))
nbits += 1
if nbits == 8 {
buf.append(cur)
cur = 0; nbits = 0
}
}
func writeOnes(count: Int) {
guard count > 0 else { return }
for _ in 0..<count { writeBit(1) }
}
func writeBits(value: UInt64, count: Int) {
guard count > 0 else { return }
for i in stride(from: count - 1, through: 0, by: -1) {
let bit = Int((value >> UInt64(i)) & 1)
writeBit(bit)
}
}
func toData() -> Data {
if nbits > 0 {
let rem = UInt8(Int(cur) << (8 - nbits))
buf.append(rem)
cur = 0; nbits = 0
}
return buf
}
}
private final class BitReader {
private let data: Data
private var idx: Int = 0
private var cur: UInt8 = 0
private var left: Int = 0
init(_ data: Data) {
self.data = data
if !data.isEmpty {
cur = data[0]
left = 8
}
}
func readBit() -> Int? {
if idx >= data.count { return nil }
let bit = (Int(cur) >> 7) & 1
cur = UInt8((Int(cur) << 1) & 0xFF)
left -= 1
if left == 0 {
idx += 1
if idx < data.count { cur = data[idx]; left = 8 }
}
return bit
}
func readUnary() -> Int? {
var q = 0
while true {
guard let b = readBit() else { return nil }
if b == 1 { q += 1 } else { break }
}
return q
}
func readBits(count: Int) -> UInt64? {
var v: UInt64 = 0
for _ in 0..<count {
guard let b = readBit() else { return nil }
v = (v << 1) | UInt64(b)
}
return v
}
}
}
+210
View File
@@ -0,0 +1,210 @@
import Foundation
// Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager {
protocol Delegate: AnyObject {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: String, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
}
struct Config {
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1%
}
private let myPeerID: String
private let config: Config
weak var delegate: Delegate?
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
init(myPeerID: String, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
}
func start() {
stop()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
timer.resume()
periodicTimer = timer
}
func stop() {
periodicTimer?.cancel(); periodicTimer = nil
}
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID)
}
}
func onPublicPacketSeen(_ packet: BitchatPacket) {
queue.async { [weak self] in
self?._onPublicPacketSeen(packet)
}
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
}
}
private func sendRequestSync() {
let payload = buildGcsPayload()
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: nil, // broadcast
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: String) {
let payload = buildGcsPayload()
var recipient = Data()
var temp = peerID
while temp.count >= 2 && recipient.count < 8 {
let hexByte = String(temp.prefix(2))
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
temp = String(temp.dropFirst(2))
}
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: recipient,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(to: peerID, packet: signed)
}
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
queue.async { [weak self] in
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
}
}
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
}
// 1) Announcements: send latest per peer if requester lacks them
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
// 2) Broadcast messages: send all missing
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload() -> Data {
// Collect candidates: latest announce per peer + broadcast messages
var candidates: [BitchatPacket] = []
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
// Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp }
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap = max(1, config.seenCapacity)
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data())
return req.encode()
}
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
return req.encode()
}
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: String) {
queue.async { [weak self] in
self?._removeAnnouncementForPeer(peerID)
}
}
private func _removeAnnouncementForPeer(_ peerID: String) {
let normalizedPeerID = peerID.lowercased()
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
}
// Remove messages and update messageOrder
for id in messageIdsToRemove {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import Foundation
import CryptoKit
// Deterministic packet ID used for gossip sync membership
// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]
enum PacketIdUtil {
static func computeId(_ packet: BitchatPacket) -> Data {
var hasher = SHA256()
hasher.update(data: Data([packet.type]))
hasher.update(data: packet.senderID)
var tsBE = packet.timestamp.bigEndian
withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }
hasher.update(data: packet.payload)
let digest = hasher.finalize()
return Data(digest.prefix(16))
}
static func computeIdHex(_ packet: BitchatPacket) -> String {
return computeId(packet).hexEncodedString()
}
}
+37
View File
@@ -0,0 +1,37 @@
//
// Color+Peer.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
extension Color {
private static var peerColorCache: [String: Color] = [:]
init(peerSeed: String, isDark: Bool) {
let cacheKey = peerSeed + (isDark ? "|dark" : "|light")
if let cached = Self.peerColorCache[cacheKey] {
self = cached
}
let h = peerSeed.djb2()
var hue = Double(h % 1000) / 1000.0
let orange = 30.0 / 360.0
if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
}
let sRand = Double((h >> 17) & 0x3FF) / 1023.0
let bRand = Double((h >> 27) & 0x3FF) / 1023.0
let sBase: Double = isDark ? 0.80 : 0.70
let sRange: Double = 0.20
let bBase: Double = isDark ? 0.75 : 0.45
let bRange: Double = isDark ? 0.16 : 0.14
let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))
let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))
let c = Color(hue: hue, saturation: saturation, brightness: brightness)
Self.peerColorCache[cacheKey] = c
self = c
}
}
+37
View File
@@ -0,0 +1,37 @@
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 ..<14.0:
return .footnote
case ..<16.5:
return .body
case ..<19.0:
return .title3
case ..<23.0:
return .title2
case ..<30.0:
return .title
default:
return .largeTitle
}
}
}
+3 -1
View File
@@ -8,7 +8,9 @@ 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
// for headers/padding by limiting user content to 60_000 bytes.
static let maxMessageLength = 60_000
static let maxReasonLength = 200 static let maxReasonLength = 200
static let maxPeerIDLength = 64 static let maxPeerIDLength = 64
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
+17
View File
@@ -0,0 +1,17 @@
//
// String+DJB2.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension String {
func djb2() -> UInt64 {
var hash: UInt64 = 5381
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
return hash
}
}
+25
View File
@@ -0,0 +1,25 @@
//
// String+Nickname.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension String {
/// Split a nickname into base and a '#abcd' suffix if present
func splitSuffix() -> (String, String) {
let name = self.replacingOccurrences(of: "@", with: "")
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, "")
}
}
File diff suppressed because it is too large Load Diff
+38 -24
View File
@@ -86,10 +86,14 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("close") { Button(action: { dismiss() }) {
dismiss() Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
} }
.foregroundColor(textColor) .buttonStyle(.plain)
.accessibilityLabel("Close")
} }
} }
} }
@@ -102,16 +106,29 @@ 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)
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
VStack(alignment: .leading, spacing: 8) {
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
Text(instruction)
}
}
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
// Features // Features
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title) SectionHeader(Strings.Features.title)
@@ -158,26 +175,13 @@ struct AppInfoView: View {
description: Strings.Privacy.panic.2) description: Strings.Privacy.panic.2)
} }
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
VStack(alignment: .leading, spacing: 8) {
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
Text(instruction)
}
}
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
// 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)
} }
@@ -207,7 +211,7 @@ struct SectionHeader: View {
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)
} }
@@ -230,17 +234,17 @@ 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: 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(title)
.font(.system(size: 14, weight: .semibold, design: .monospaced)) .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(description) Text(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)
} }
@@ -250,6 +254,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)
}
+208 -49
View File
@@ -25,10 +25,12 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var notesCounter = LocationNotesCounter.shared
@State private var messageText = "" @State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil @State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var showPeerList = false @State private var showPeerList = false
@State private var showSidebar = false @State private var showSidebar = false
@State private var sidebarDragOffset: CGFloat = 0 @State private var sidebarDragOffset: CGFloat = 0
@@ -49,6 +51,11 @@ struct ContentView: View {
@State private var showLocationChannelsSheet = false @State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false @State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = [] @State private var expandedMessageIDs: Set<String> = []
@State private var showLocationNotes = false
@State private var notesGeohash: String? = nil
@State private var sheetNotesCount: Int = 0
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
// Timer-based refresh removed; use LocationChannelManager live updates instead
// Window sizes for rendering (infinite scroll up) // Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300 @State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:] @State private var windowCountPrivate: [String: Int] = [:]
@@ -67,6 +74,10 @@ struct ContentView: View {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
} }
private var headerLineLimit: Int? {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
@@ -323,7 +334,7 @@ struct ContentView: View {
if isExpanded { expandedMessageIDs.remove(message.id) } if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) } else { expandedMessageIDs.insert(message.id) }
} }
.font(.system(size: 11, weight: .medium, design: .monospaced)) .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue) .foregroundColor(Color.blue)
.padding(.top, 4) .padding(.top, 4)
} }
@@ -457,7 +468,12 @@ struct ContentView: View {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
} }
} }
showMessageActions = true if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true
}
} }
.onOpenURL { url in .onOpenURL { url in
guard url.scheme == "bitchat", url.host == "geohash" else { return } guard url.scheme == "bitchat", url.host == "geohash" else { return }
@@ -466,14 +482,14 @@ struct ContentView: View {
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
func levelForLength(_ len: Int) -> GeohashChannelLevel { func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len { switch len {
case 0...2: return .region case 0...2: return .region
case 3...4: return .province case 3...4: return .province
case 5: return .city case 5: return .city
case 6: return .neighborhood case 6: return .neighborhood
case 7: return .block case 7: return .block
default: return .block default: return .block
}
} }
}
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
// Do not mark teleported when opening a geohash that is in our regional set. // Do not mark teleported when opening a geohash that is in our regional set.
@@ -696,7 +712,7 @@ struct ContentView: View {
}) { }) {
HStack { HStack {
Text(suggestion) Text(suggestion)
.font(.system(size: 11, design: .monospaced)) .font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.fontWeight(.medium) .fontWeight(.medium)
Spacer() Spacer()
@@ -754,14 +770,14 @@ struct ContentView: View {
HStack { HStack {
// Show all aliases together // Show all aliases together
Text(info.commands.joined(separator: ", ")) Text(info.commands.joined(separator: ", "))
.font(.system(size: 11, design: .monospaced)) .font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.fontWeight(.medium) .fontWeight(.medium)
// Show syntax if any // Show syntax if any
if let syntax = info.syntax { if let syntax = info.syntax {
Text(syntax) Text(syntax)
.font(.system(size: 10, design: .monospaced)) .font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8)) .foregroundColor(secondaryTextColor.opacity(0.8))
} }
@@ -769,7 +785,7 @@ struct ContentView: View {
// Show description // Show description
Text(info.description) Text(info.description)
.font(.system(size: 10, design: .monospaced)) .font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -792,7 +808,7 @@ struct ContentView: View {
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
TextField("type a message...", text: $messageText) TextField("type a message...", text: $messageText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.padding(.leading, 12) .padding(.leading, 12)
@@ -867,7 +883,7 @@ struct ContentView: View {
Button(action: sendMessage) { Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20)) .font(.bitchatSystem(size: 20))
.foregroundColor(messageText.isEmpty ? Color.gray : .foregroundColor(messageText.isEmpty ? Color.gray :
viewModel.selectedPrivateChatPeer != nil viewModel.selectedPrivateChatPeer != nil
? Color.orange : textColor) ? Color.orange : textColor)
@@ -908,20 +924,20 @@ struct ContentView: View {
// Header - match main toolbar height // Header - match main toolbar height
HStack { HStack {
Text("PEOPLE") Text("PEOPLE")
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
// Show QR in mesh on all platforms // Show QR in mesh on all platforms
if case .mesh = locationManager.selectedChannel { if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) { Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode") Image(systemName: "qrcode")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Verification: show my QR or scan a friend") .help("Verification: show my QR or scan a friend")
} }
} }
.frame(height: 44) // Match header height .frame(height: headerHeight) // Match header height
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
@@ -1072,7 +1088,7 @@ struct ContentView: View {
private var mainHeaderView: some View { private var mainHeaderView: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("bitchat/") Text("bitchat/")
.font(.system(size: 18, weight: .medium, design: .monospaced)) .font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
// PANIC: Triple-tap to clear all data // PANIC: Triple-tap to clear all data
@@ -1085,13 +1101,13 @@ struct ContentView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("@") Text("@")
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
TextField("nickname", text: $viewModel.nickname) TextField("nickname", text: $viewModel.nickname)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.frame(maxWidth: 100) .frame(maxWidth: 80)
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isNicknameFieldFocused) .focused($isNicknameFieldFocused)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
@@ -1129,21 +1145,46 @@ struct ContentView: View {
if viewModel.hasAnyUnreadMessages { if viewModel.hasAnyUnreadMessages {
Button(action: { viewModel.openMostRelevantPrivateChat() }) { Button(action: { viewModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
.font(.system(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open unread private chat") .accessibilityLabel("Open unread private chat")
} }
// Bookmark toggle for current geohash (not shown for mesh) // Notes icon (mesh only and when location is authorized), to the left of #mesh
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
Button(action: {
// Kick a one-shot refresh and show the sheet immediately.
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
// If we already have a block geohash, pass it; otherwise wait in the sheet.
notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
let currentCount = (notesCounter.count ?? 0)
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
Image(systemName: "long.text.page.and.pencil")
.font(.bitchatSystem(size: 12))
.foregroundColor(hasNotes ? textColor : Color.gray)
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
}
.buttonStyle(.plain)
.accessibilityLabel("Location notes for this place")
}
// Bookmark toggle (geochats): to the left of #geohash
if case .location(let ch) = locationManager.selectedChannel { if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) { Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 12)) .font(.bitchatSystem(size: 12))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)") .accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
} }
// Location channels button '#' // Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) { Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = { let badgeText: String = {
@@ -1161,28 +1202,34 @@ struct ContentView: View {
} }
}() }()
Text(badgeText) Text(badgeText)
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(badgeColor) .foregroundColor(badgeColor)
.lineLimit(1) .lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
.layoutPriority(2) .layoutPriority(2)
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 4)
.padding(.trailing, 2)
HStack(spacing: 4) { HStack(spacing: 4) {
// People icon with count // People icon with count
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.font(.system(size: 11)) .font(.bitchatSystem(size: 11))
.accessibilityLabel("\(headerOtherPeersCount) people") .accessibilityLabel("\(headerOtherPeersCount) people")
Text("\(headerOtherPeersCount)") Text("\(headerOtherPeersCount)")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(headerCountColor) .foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
// QR moved to the PEOPLE header in the sidebar when on mesh channel // QR moved to the PEOPLE header in the sidebar when on mesh channel
} }
.layoutPriority(3)
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle() showSidebar.toggle()
@@ -1194,13 +1241,97 @@ struct ContentView: View {
.environmentObject(viewModel) .environmentObject(viewModel)
} }
} }
.frame(height: 44) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true } .onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false } .onDisappear { viewModel.isLocationChannelsSheetPresented = false }
} }
.sheet(isPresented: $showLocationNotes) {
Group {
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
.environmentObject(viewModel)
} else {
VStack(spacing: 12) {
HStack {
Text("notes")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Text("location unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
Button("enable location") {
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
}
.buttonStyle(.bordered)
Spacer()
}
.background(backgroundColor)
.foregroundColor(textColor)
// per-sheet global onChange added below
}
}
.onAppear {
// Ensure we are authorized and start live location updates (distance-filtered)
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.beginLiveRefresh()
}
.onDisappear {
LocationChannelManager.shared.endLiveRefresh()
}
.onChange(of: locationManager.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash,
notesGeohash != current {
notesGeohash = current
#if os(iOS)
// Light taptic when geohash changes while the sheet is open
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
#endif
}
}
}
.onAppear {
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.onChange(of: locationManager.selectedChannel) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
.onChange(of: locationManager.permissionState) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) { .alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {} Button("ok", role: .cancel) {}
} message: { } message: {
@@ -1292,19 +1423,19 @@ struct ContentView: View {
case .bluetoothConnected: case .bluetoothConnected:
// Radio icon for mesh connection // Radio icon for mesh connection
Image(systemName: "dot.radiowaves.left.and.right") Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Connected via mesh") .accessibilityLabel("Connected via mesh")
case .meshReachable: case .meshReachable:
// point.3 filled icon for reachable via mesh (not directly connected) // point.3 filled icon for reachable via mesh (not directly connected)
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh") .accessibilityLabel("Reachable via mesh")
case .nostrAvailable: case .nostrAvailable:
// Purple globe for Nostr // Purple globe for Nostr
Image(systemName: "globe") Image(systemName: "globe")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(.purple) .foregroundColor(.purple)
.accessibilityLabel("Available via Nostr") .accessibilityLabel("Available via Nostr")
case .offline: case .offline:
@@ -1314,25 +1445,25 @@ struct ContentView: View {
} else if viewModel.meshService.isPeerReachable(headerPeerID) { } else if viewModel.meshService.isPeerReachable(headerPeerID) {
// Fallback: reachable via mesh but not in current peer list // Fallback: reachable via mesh but not in current peer list
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh") .accessibilityLabel("Reachable via mesh")
} else if isNostrAvailable { } else if isNostrAvailable {
// Fallback to Nostr if peer not in list but is mutual favorite // Fallback to Nostr if peer not in list but is mutual favorite
Image(systemName: "globe") Image(systemName: "globe")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(.purple) .foregroundColor(.purple)
.accessibilityLabel("Available via Nostr") .accessibilityLabel("Available via Nostr")
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) { } else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
// Fallback: if peer lookup is missing but mesh reports connected, show radio // Fallback: if peer lookup is missing but mesh reports connected, show radio
Image(systemName: "dot.radiowaves.left.and.right") Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Connected via mesh") .accessibilityLabel("Connected via mesh")
} }
Text("\(privatePeerNick)") Text("\(privatePeerNick)")
.font(.system(size: 16, weight: .medium, design: .monospaced)) .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs) .foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
if !privatePeerID.hasPrefix("nostr_") { if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID) // Use short peer ID if available for encryption status (sessions keyed by short ID)
@@ -1345,7 +1476,7 @@ struct ContentView: View {
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(encryptionStatus == .noiseVerified ? textColor : .foregroundColor(encryptionStatus == .noiseVerified ? textColor :
encryptionStatus == .noiseSecured ? textColor : encryptionStatus == .noiseSecured ? textColor :
Color.red) Color.red)
@@ -1367,7 +1498,7 @@ struct ContentView: View {
} }
}) { }) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 44, height: 44, alignment: .leading) .frame(width: 44, height: 44, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -1383,7 +1514,7 @@ struct ContentView: View {
viewModel.toggleFavorite(peerID: headerPeerID) viewModel.toggleFavorite(peerID: headerPeerID)
}) { }) {
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star") Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
.font(.system(size: 16)) .font(.bitchatSystem(size: 16))
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor) .foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -1392,13 +1523,41 @@ struct ContentView: View {
} }
} }
} }
.frame(height: 44) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
} }
// MARK: - Notes Counter Subscription Helper
extension ContentView {
private func updateNotesCounterSubscription() {
switch locationManager.selectedChannel {
case .mesh:
// Ensure we have a fresh one-shot location fix so building geohash is current
if locationManager.permissionState == .authorized {
LocationChannelManager.shared.refreshChannels()
}
if locationManager.permissionState == .authorized {
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesCounter.shared.subscribe(geohash: building)
} else {
// Keep existing subscription if we had one to avoid flicker
// Only cancel if we have no known geohash
if LocationNotesCounter.shared.geohash == nil {
LocationNotesCounter.shared.cancel()
}
}
} else {
LocationNotesCounter.shared.cancel()
}
case .location:
LocationNotesCounter.shared.cancel()
}
}
}
// MARK: - Helper Views // MARK: - Helper Views
// Rounded payment chip button // Rounded payment chip button
@@ -1421,7 +1580,7 @@ private struct PaymentChipView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(emoji) Text(emoji)
Text(label) Text(label)
.font(.system(size: 12, weight: .semibold, design: .monospaced)) .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -1462,20 +1621,20 @@ struct DeliveryStatusView: View {
switch status { switch status {
case .sending: case .sending:
Image(systemName: "circle") Image(systemName: "circle")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .sent: case .sent:
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .delivered(let nickname, _): case .delivered(let nickname, _):
HStack(spacing: -2) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
} }
.foregroundColor(textColor.opacity(0.8)) .foregroundColor(textColor.opacity(0.8))
.help("Delivered to \(nickname)") .help("Delivered to \(nickname)")
@@ -1483,25 +1642,25 @@ struct DeliveryStatusView: View {
case .read(let nickname, _): case .read(let nickname, _):
HStack(spacing: -2) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold)) .font(.bitchatSystem(size: 10, weight: .bold))
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold)) .font(.bitchatSystem(size: 10, weight: .bold))
} }
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue .foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
.help("Read by \(nickname)") .help("Read by \(nickname)")
case .failed(let reason): case .failed(let reason):
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8)) .foregroundColor(Color.red.opacity(0.8))
.help("Failed: \(reason)") .help("Failed: \(reason)")
case .partiallyDelivered(let reached, let total): case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) { HStack(spacing: 1) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
Text("\(reached)/\(total)") Text("\(reached)/\(total)")
.font(.system(size: 10, design: .monospaced)) .font(.bitchatSystem(size: 10, design: .monospaced))
} }
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
.help("Delivered to \(reached) of \(total) members") .help("Delivered to \(reached) of \(total) members")
+14 -14
View File
@@ -27,14 +27,14 @@ struct FingerprintView: View {
// Header // Header
HStack { HStack {
Text("SECURITY VERIFICATION") Text("SECURITY VERIFICATION")
.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)
} }
@@ -66,17 +66,17 @@ struct FingerprintView: View {
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))
} }
@@ -89,12 +89,12 @@ struct FingerprintView: View {
// Their fingerprint // Their fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("THEIR FINGERPRINT:") Text("THEIR FINGERPRINT:")
.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: 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)
@@ -115,7 +115,7 @@ struct FingerprintView: View {
} }
} else { } else {
Text("not available - handshake in progress") Text("not available - handshake in progress")
.font(.system(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
.padding() .padding()
} }
@@ -124,12 +124,12 @@ struct FingerprintView: View {
// My fingerprint // My fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("YOUR FINGERPRINT:") Text("YOUR FINGERPRINT:")
.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)
@@ -156,14 +156,14 @@ struct FingerprintView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED") Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
.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 ? Text(isVerified ?
"you have verified this person's identity." : "you have verified this person's identity." :
"compare these fingerprints with \(peerNickname) using a secure channel.") "compare these fingerprints with \(peerNickname) using a secure channel.")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(nil) .lineLimit(nil)
@@ -176,7 +176,7 @@ struct FingerprintView: View {
dismiss() dismiss()
}) { }) {
Text("MARK AS VERIFIED") Text("MARK AS VERIFIED")
.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)
@@ -190,7 +190,7 @@ struct FingerprintView: View {
dismiss() dismiss()
}) { }) {
Text("REMOVE VERIFICATION") Text("REMOVE VERIFICATION")
.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)
+6 -6
View File
@@ -12,7 +12,7 @@ struct GeohashPeopleList: View {
if viewModel.visibleGeohashPeople().isEmpty { if viewModel.visibleGeohashPeople().isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text("nobody around...")
.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)
@@ -51,30 +51,30 @@ 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) = splitSuffix(from: person.displayName)
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(" (you)")
.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("Blocked in geochash")
} }
+149 -68
View File
@@ -9,18 +9,25 @@ 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 = ""
@State private var customError: String? = nil @State private var customError: String? = nil
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
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("#location channels")
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.") .font(.bitchatSystem(size: 18, design: .monospaced))
.font(.system(size: 12, design: .monospaced)) Spacer()
closeButton
}
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: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Group { Group {
@@ -28,7 +35,7 @@ struct LocationChannelsSheet: View {
case LocationChannelManager.PermissionState.notDetermined: case LocationChannelManager.PermissionState.notDetermined:
Button(action: { manager.enableLocationChannels() }) { Button(action: { manager.enableLocationChannels() }) {
Text("get location and my geohashes") Text("get location and my geohashes")
.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)
@@ -39,7 +46,7 @@ struct LocationChannelsSheet: View {
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("location permission denied. enable in settings to use location channels.")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button("open settings") { openSystemLocationSettings() } Button("open settings") { openSystemLocationSettings() }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -54,21 +61,12 @@ struct LocationChannelsSheet: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(backgroundColor)
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .navigationBarHidden(true)
ToolbarItem(placement: .navigationBarTrailing) {
Button("close") { isPresented = false }
.font(.system(size: 14, design: .monospaced))
}
}
#else #else
.toolbar { .navigationTitle("")
ToolbarItem(placement: .automatic) {
Button("close") { isPresented = false }
.font(.system(size: 14, design: .monospaced))
}
}
#endif #endif
} }
#if os(iOS) #if os(iOS)
@@ -77,6 +75,7 @@ struct LocationChannelsSheet: View {
#if os(macOS) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
.background(backgroundColor)
.onAppear { .onAppear {
// Refresh channels when opening // Refresh channels when opening
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
@@ -97,6 +96,16 @@ 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 { List {
// Mesh option first (no bookmark) // Mesh option first (no bookmark)
@@ -104,10 +113,11 @@ struct LocationChannelsSheet: View {
manager.select(ChannelID.mesh) manager.select(ChannelID.mesh)
isPresented = false isPresented = false
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
// Nearby options // Nearby options
if !manager.availableChannels.isEmpty { if !manager.availableChannels.isEmpty {
ForEach(manager.availableChannels) { channel in ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in
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 }
@@ -122,7 +132,7 @@ 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)
@@ -133,20 +143,22 @@ struct LocationChannelsSheet: View {
manager.select(ChannelID.location(channel)) manager.select(ChannelID.location(channel))
isPresented = false isPresented = false
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
} }
} else { } else {
HStack { HStack {
ProgressView() ProgressView()
Text("finding nearby channels…") Text("finding nearby channels…")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
} }
// Custom geohash teleport // Custom geohash teleport
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) { HStack(spacing: 2) {
Text("#") Text("#")
.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)
@@ -154,7 +166,7 @@ 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 // Allow only geohash base32 characters, strip '#', limit length
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
@@ -182,14 +194,13 @@ struct LocationChannelsSheet: View {
}) { }) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("teleport") Text("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)
.background(Color.secondary.opacity(0.12)) .background(Color.secondary.opacity(0.12))
.cornerRadius(6) .cornerRadius(6)
@@ -198,60 +209,69 @@ 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)
} }
} }
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
// Bookmarked geohashes // Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty { if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 8) {
Text("bookmarked") Text("bookmarked")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
VStack(spacing: 6) {
ForEach(bookmarks.bookmarks, id: \.self) { gh in
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = "#\(gh)\(coverage)"
let name = bookmarks.bookmarkNames[gh]
channelRow(
title: geohashHashTitleWithCount(gh),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel),
trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// For bookmarked selection, mark teleported based on regional membership
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
if !inRegional && !manager.availableChannels.isEmpty {
manager.markTeleported(for: gh, true)
} else {
manager.markTeleported(for: gh, false)
}
manager.select(ChannelID.location(channel))
isPresented = false
}
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
}
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
} }
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
ForEach(bookmarks.bookmarks, id: \.self) { gh in .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = "#\(gh)\(coverage)"
let name = bookmarks.bookmarkNames[gh]
channelRow(
title: geohashHashTitleWithCount(gh),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel),
trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// For bookmarked selection, mark teleported based on regional membership
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
if !inRegional && !manager.availableChannels.isEmpty {
manager.markTeleported(for: gh, true)
} else {
manager.markTeleported(for: gh, false)
}
manager.select(ChannelID.location(channel))
isPresented = false
}
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
}
} }
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
torToggleSection
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0))
Button(action: { Button(action: {
openSystemLocationSettings() openSystemLocationSettings()
}) { }) {
Text("remove location access") Text("remove location access")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -260,9 +280,13 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
} }
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden)
.background(backgroundColor)
} }
private func isSelected(_ channel: GeohashChannel) -> Bool { private func isSelected(_ channel: GeohashChannel) -> Bool {
@@ -295,12 +319,12 @@ 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)
} }
} }
@@ -311,7 +335,7 @@ struct LocationChannelsSheet: View {
return subtitlePrefix 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)
@@ -319,7 +343,7 @@ struct LocationChannelsSheet: View {
Spacer() Spacer()
if isSelected { if isSelected {
Text("✔︎") Text("✔︎")
.font(.system(size: 16, design: .monospaced)) .font(.bitchatSystem(size: 16, design: .monospaced))
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
} }
trailingAccessory() trailingAccessory()
@@ -381,13 +405,42 @@ struct LocationChannelsSheet: View {
case 5: return .city case 5: return .city
case 6: return .neighborhood case 6: return .neighborhood
case 7: return .block case 7: return .block
case 8: return .building
default: return .block default: return .block
} }
} }
} }
// 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("tor routing")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(.primary)
Text("hides your ip for location channels. recommended: on.")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
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)
} }
@@ -396,6 +449,34 @@ extension LocationChannelsSheet {
} }
} }
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? "on" : "off")
.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 {
+290
View File
@@ -0,0 +1,290 @@
import SwiftUI
struct LocationNotesView: View {
@EnvironmentObject var viewModel: ChatViewModel
@StateObject private var manager: LocationNotesManager
let geohash: String
let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
let gh = geohash.lowercased()
self.geohash = gh
self.onNotesCountChanged = onNotesCountChanged
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
}
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
headerSection
notesContent
}
}
.background(backgroundColor)
inputSection
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.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)
}
#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 closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
private var headerSection: some View {
let count = manager.notes.count
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text("#\(geohash)\(count) \(count == 1 ? "note" : "notes")")
.font(.bitchatSystem(size: 18, design: .monospaced))
Spacer()
closeButton
}
if let building = locationManager.locationNames[.building], !building.isEmpty {
Text(building)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
}
Text("add short permanent notes to this location for other visitors to find.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .loading && !manager.initialLoadComplete {
Text("loading recent notes…")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
} else if manager.state == .noRelays {
Text("geo relays unavailable; notes paused")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
}
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("@\(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("no geo relays nearby")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text("notes rely on geo relays. check connection and try again.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button("retry") { manager.refresh() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var loadingRow: some View {
HStack(spacing: 10) {
ProgressView()
Text("loading notes…")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Spacer()
}
.padding(.vertical, 8)
}
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("no notes yet")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text("be the first to add one for this spot.")
.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("dismiss") { manager.clearError() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var inputSection: some View {
HStack(alignment: .top, spacing: 10) {
TextField("add a note for this place", text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendButtonEnabled)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(backgroundColor)
.overlay(Divider(), alignment: .top)
}
private func send() {
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
manager.send(content: content, nickname: viewModel.nickname)
draft = ""
}
private var sendButtonEnabled: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting
private func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
} else {
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
return fmt.string(from: date)
}
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
private static let absDateYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d, y")
return f
}()
}
+14 -14
View File
@@ -15,7 +15,7 @@ struct MeshPeerList: View {
if viewModel.allPeers.isEmpty { if viewModel.allPeers.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text("nobody around...")
.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)
@@ -45,27 +45,27 @@ struct MeshPeerList: View {
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)
} }
@@ -73,19 +73,19 @@ struct MeshPeerList: View {
let (base, suffix) = splitSuffix(from: displayName) let (base, suffix) = splitSuffix(from: displayName)
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.id) {
Image(systemName: "nosign") Image(systemName: "nosign")
.font(.system(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.red) .foregroundColor(.red)
.help("Blocked") .help("Blocked")
} }
@@ -94,7 +94,7 @@ struct MeshPeerList: View {
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 {
@@ -102,12 +102,12 @@ struct MeshPeerList: View {
if let fp = viewModel.getFingerprint(for: peer.id), if let fp = viewModel.getFingerprint(for: peer.id),
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,7 +118,7 @@ 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("New messages")
} }
@@ -126,7 +126,7 @@ struct MeshPeerList: View {
if !isMe { if !isMe {
Button(action: { onToggleFavorite(peer.id) }) { Button(action: { onToggleFavorite(peer.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)
+10 -10
View File
@@ -16,7 +16,7 @@ struct MyQRView: View {
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("scan to verify me") Text("scan to verify me")
.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)
@@ -24,7 +24,7 @@ struct MyQRView: View {
// 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)
@@ -60,7 +60,7 @@ struct QRCodeImage: View {
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Text("qr unavailable") Text("qr unavailable")
.font(.system(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.gray) .foregroundColor(.gray)
) )
} }
@@ -119,7 +119,7 @@ struct QRScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
#else #else
Text("paste qr content to validate:") Text("paste qr content to validate:")
.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))
@@ -255,7 +255,7 @@ struct VerificationSheetView: View {
// Top header (always at top) // Top header (always at top)
HStack { HStack {
Text("VERIFY") Text("VERIFY")
.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 +263,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)
@@ -279,7 +279,7 @@ struct VerificationSheetView: View {
if showingScanner { if showingScanner {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("scan a friend's qr") Text("scan a friend's qr")
.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)
@@ -310,13 +310,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 +328,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)
@@ -12,6 +12,9 @@ import UniformTypeIdentifiers
/// Modern share extension using UIKit + UTTypes. /// Modern share extension using UIKit + UTTypes.
/// Avoids deprecated Social framework and SLComposeServiceViewController. /// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController { final class ShareViewController: UIViewController {
// Bundle.main.bundleIdentifier would get the extension's bundleID
private static let groupID = "group.chat.bitchat"
private let statusLabel: UILabel = { private let statusLabel: UILabel = {
let l = UILabel() let l = UILabel()
l.translatesAutoresizingMaskIntoConstraints = false l.translatesAutoresizingMaskIntoConstraints = false
@@ -32,8 +35,9 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
]) ])
DispatchQueue.global().async {
processShare() self.processShare()
}
} }
// MARK: - Processing // MARK: - Processing
@@ -149,7 +153,7 @@ final class ShareViewController: UIViewController {
} }
private func saveToSharedDefaults(content: String, type: String) { private func saveToSharedDefaults(content: String, type: String) {
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return } guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
userDefaults.set(content, forKey: "sharedContent") userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate") userDefaults.set(Date(), forKey: "sharedContentDate")
@@ -160,7 +164,7 @@ final class ShareViewController: UIViewController {
statusLabel.text = msg statusLabel.text = msg
// Complete shortly after showing status // Complete shortly after showing status
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} }
} }
} }
+1 -1
View File
@@ -215,7 +215,7 @@ final class BLEServiceTests: XCTestCase {
let expectation = XCTestExpectation(description: "Delivery handler called") let expectation = XCTestExpectation(description: "Delivery handler called")
service.packetDeliveryHandler = { packet in service.packetDeliveryHandler = { packet in
if let msg = BitchatMessage.fromBinaryPayload(packet.payload) { if let msg = BitchatMessage(packet.payload) {
XCTAssertEqual(msg.content, "Test delivery") XCTAssertEqual(msg.content, "Test delivery")
expectation.fulfill() expectation.fulfill()
} }
@@ -139,7 +139,7 @@ final class PrivateChatE2ETests: XCTestCase {
alice.packetDeliveryHandler = { packet in alice.packetDeliveryHandler = { packet in
// Encrypt outgoing private messages // Encrypt outgoing private messages
if packet.type == 0x01, if packet.type == 0x01,
let message = BitchatMessage.fromBinaryPayload(packet.payload), let message = BitchatMessage(packet.payload),
message.isPrivate { message.isPrivate {
do { do {
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2) let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
@@ -164,7 +164,7 @@ final class PrivateChatE2ETests: XCTestCase {
if packet.type == 0x02 { if packet.type == 0x02 {
do { do {
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1) let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
if let message = BitchatMessage.fromBinaryPayload(decrypted) { if let message = BitchatMessage(decrypted) {
XCTAssertEqual(message.content, TestConstants.testMessage1) XCTAssertEqual(message.content, TestConstants.testMessage1)
XCTAssertTrue(message.isPrivate) XCTAssertTrue(message.isPrivate)
expectation.fulfill() expectation.fulfill()
@@ -98,7 +98,7 @@ final class PublicChatE2ETests: XCTestCase {
// Set up relay in Bob // Set up relay in Bob
bob.packetDeliveryHandler = { packet in bob.packetDeliveryHandler = { packet in
// Bob should relay to Charlie // Bob should relay to Charlie
if let message = BitchatMessage.fromBinaryPayload(packet.payload), if let message = BitchatMessage(packet.payload),
message.sender == TestConstants.testNickname1 { message.sender == TestConstants.testNickname1 {
// Create relay message // Create relay message
@@ -437,7 +437,7 @@ final class PublicChatE2ETests: XCTestCase {
// Check if should relay // Check if should relay
guard packet.ttl > 1 else { return } guard packet.ttl > 1 else { return }
if let message = BitchatMessage.fromBinaryPayload(packet.payload) { if let message = BitchatMessage(packet.payload) {
// Don't relay own messages // Don't relay own messages
guard message.senderPeerID != node.peerID else { return } guard message.senderPeerID != node.peerID else { return }
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
import XCTest
@testable import bitchat
final class FontBitchatTests: XCTestCase {
func testMonospacedMapping() {
XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))
XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
}
func testWeightIsPreserved() {
let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
}
}
+22
View File
@@ -0,0 +1,22 @@
import XCTest
@testable import bitchat
final class GCSFilterTests: XCTestCase {
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
let id = Data(repeating: 0xAB, count: 16)
let ids = Array(repeating: id, count: 64)
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
XCTAssertGreaterThanOrEqual(params.m, 1)
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
XCTAssertLessThanOrEqual(decoded.count, 1)
}
func testBucketAvoidsZeroCandidate() {
let id = Data(repeating: 0x01, count: 16)
let bucket = GCSFilter.bucket(for: id, modulus: 2)
XCTAssertNotEqual(bucket, 0)
XCTAssertLessThan(bucket, 2)
}
}
+70
View File
@@ -0,0 +1,70 @@
import Foundation
import XCTest
@testable import bitchat
final class GossipSyncManagerTests: XCTestCase {
func testConcurrentPacketIntakeAndSyncRequest() {
let manager = GossipSyncManager(myPeerID: "0102030405060708")
let delegate = RecordingDelegate()
let sendExpectation = expectation(description: "sync request sent")
delegate.onSend = { sendExpectation.fulfill() }
manager.delegate = delegate
let iterations = 200
let group = DispatchGroup()
for i in 0..<iterations {
group.enter()
DispatchQueue.global(qos: .userInitiated).async {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: "1122334455667788") ?? Data(),
recipientID: nil,
timestamp: 1_000_000 + UInt64(i),
payload: Data([UInt8(truncatingIfNeeded: i)]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(packet)
Thread.sleep(forTimeInterval: 0.001)
group.leave()
}
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
}
group.wait()
wait(for: [sendExpectation], timeout: 2.0)
guard let lastPacket = delegate.lastPacket else {
XCTFail("Expected sync packet to be sent")
return
}
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
}
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
private(set) var lastPacket: BitchatPacket?
private let lock = NSLock()
func sendPacket(_ packet: BitchatPacket) {
lock.lock()
lastPacket = packet
lock.unlock()
onSend?()
}
func sendPacket(to peerID: String, packet: BitchatPacket) {
sendPacket(packet)
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
packet
}
}
@@ -531,7 +531,7 @@ final class IntegrationTests: XCTestCase {
// Setup encryption at Alice // Setup encryption at Alice
nodes["Alice"]!.packetDeliveryHandler = { packet in nodes["Alice"]!.packetDeliveryHandler = { packet in
if packet.type == 0x01, if packet.type == 0x01,
let message = BitchatMessage.fromBinaryPayload(packet.payload), let message = BitchatMessage(packet.payload),
message.isPrivate && packet.recipientID != nil { message.isPrivate && packet.recipientID != nil {
// Encrypt private messages // Encrypt private messages
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) { if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
@@ -553,7 +553,7 @@ final class IntegrationTests: XCTestCase {
nodes["Bob"]!.packetDeliveryHandler = { packet in nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 { if packet.type == 0x02 {
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1), if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
let message = BitchatMessage.fromBinaryPayload(decrypted) { let message = BitchatMessage(decrypted) {
bobDecrypted = message.content == "Secret message" bobDecrypted = message.content == "Secret message"
expectation.fulfill() expectation.fulfill()
} }
@@ -626,7 +626,7 @@ final class IntegrationTests: XCTestCase {
node.packetDeliveryHandler = { packet in node.packetDeliveryHandler = { packet in
guard packet.ttl > 1 else { return } guard packet.ttl > 1 else { return }
if let message = BitchatMessage.fromBinaryPayload(packet.payload) { if let message = BitchatMessage(packet.payload) {
guard message.senderPeerID != node.peerID else { return } guard message.senderPeerID != node.peerID else { return }
let relayMessage = BitchatMessage( let relayMessage = BitchatMessage(
@@ -0,0 +1,146 @@
import XCTest
@testable import bitchat
@MainActor
final class LocationNotesManagerTests: XCTestCase {
func testSubscribeWithoutRelaysSetsNoRelaysState() {
var subscribeCalled = false
let deps = LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in
subscribeCalled = true
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in fatalError("should not derive identity") },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
XCTAssertFalse(subscribeCalled)
XCTAssertEqual(manager.state, .noRelays)
XCTAssertTrue(manager.initialLoadComplete)
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
}
func testSendWhenNoRelaysSurfacesError() {
var sendCalled = false
let deps = LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { _, _ in sendCalled = true },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
manager.send(content: "hello", nickname: "tester")
XCTAssertFalse(sendCalled)
XCTAssertEqual(manager.state, .noRelays)
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
}
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
var relaysCaptured: [String] = []
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(filter.kinds, [1])
XCTAssertFalse(id.isEmpty)
relaysCaptured = relays
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
XCTAssertEqual(manager.state, .loading)
var event = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "abcd1234"]],
content: "hi"
)
event.id = "event1"
storedHandler?(event)
storedEOSE?()
XCTAssertEqual(manager.state, .ready)
XCTAssertEqual(manager.notes.count, 1)
XCTAssertEqual(manager.notes.first?.content, "hi")
}
private enum TestError: Error {
case shouldNotDerive
}
}
@MainActor
final class LocationNotesCounterTests: XCTestCase {
func testSubscribeWithoutRelaysMarksUnavailable() {
var subscribeCalled = false
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in subscribeCalled = true },
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "abcdefgh")
XCTAssertFalse(subscribeCalled)
XCTAssertFalse(counter.relayAvailable)
XCTAssertTrue(counter.initialLoadComplete)
XCTAssertEqual(counter.count, 0)
}
func testSubscribeCountsUniqueNotes() {
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in ["wss://relay.geo"] },
subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(relays, ["wss://relay.geo"])
XCTAssertEqual(filter.kinds, [1])
XCTAssertFalse(id.isEmpty)
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "abcdefgh")
var first = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "abcdefgh"]],
content: "a"
)
first.id = "eventA"
storedHandler?(first)
let duplicate = first
storedHandler?(duplicate)
storedEOSE?()
XCTAssertTrue(counter.relayAvailable)
XCTAssertEqual(counter.count, 1)
XCTAssertTrue(counter.initialLoadComplete)
}
}
+1 -1
View File
@@ -309,7 +309,7 @@ final class MockBLEService: NSObject {
func simulateIncomingPacket(_ packet: BitchatPacket) { func simulateIncomingPacket(_ packet: BitchatPacket) {
// Process through the actual handling logic // Process through the actual handling logic
if let message = BitchatMessage.fromBinaryPayload(packet.payload) { if let message = BitchatMessage(packet.payload) {
var shouldDeliver = false var shouldDeliver = false
seenLock.lock() seenLock.lock()
if !seenMessageIDs.contains(message.id) { if !seenMessageIDs.contains(message.id) {
@@ -0,0 +1,111 @@
import XCTest
@testable import bitchat
final class NotificationStreamAssemblerTests: XCTestCase {
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
return BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: timestamp,
payload: Data([0xDE, 0xAD, 0xBE, 0xEF]),
signature: nil,
ttl: 3
)
}
func testAssemblesSingleFrameAcrossChunks() {
var assembler = NotificationStreamAssembler()
let packet = makePacket()
guard let frame = packet.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packet")
}
XCTAssertNotNil(BinaryProtocol.decode(frame))
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
XCTAssertEqual(payloadLen, packet.payload.count)
let splitIndex = min(20, max(1, frame.count / 2))
let first = frame.prefix(splitIndex)
let second = frame.suffix(from: splitIndex)
XCTAssertEqual(first.count + second.count, frame.count)
var result = assembler.append(first)
XCTAssertTrue(result.frames.isEmpty)
XCTAssertTrue(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset)
result = assembler.append(second)
XCTAssertEqual(result.frames.count, 1)
XCTAssertTrue(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset)
guard let frameData = result.frames.first else {
return XCTFail("Missing frame data")
}
if frameData.count != frame.count {
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))")
return
}
guard let decoded = BinaryProtocol.decode(frameData) else {
return XCTFail("Failed to decode frame")
}
XCTAssertEqual(decoded.type, packet.type)
XCTAssertEqual(decoded.payload, packet.payload)
XCTAssertEqual(decoded.senderID, packet.senderID)
XCTAssertEqual(decoded.timestamp, packet.timestamp)
var directAssembler = NotificationStreamAssembler()
let directResult = directAssembler.append(frame)
XCTAssertEqual(directResult.frames.first?.count, frame.count)
}
func testAssemblesMultipleFramesSequentially() {
var assembler = NotificationStreamAssembler()
let packet1 = makePacket(timestamp: 0xABC)
let packet2 = makePacket(timestamp: 0xDEF)
guard let frame1 = packet1.toBinaryData(padding: false),
let frame2 = packet2.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packets")
}
var combined = Data()
combined.append(frame1)
combined.append(frame2)
let firstChunk = combined.prefix(20)
let secondChunk = combined.suffix(from: 20)
var result = assembler.append(firstChunk)
XCTAssertTrue(result.frames.isEmpty)
result = assembler.append(secondChunk)
XCTAssertEqual(result.frames.count, 2)
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
let decoded2 = BinaryProtocol.decode(result.frames[1]) else {
return XCTFail("Failed to decode frames")
}
XCTAssertEqual(decoded1.timestamp, packet1.timestamp)
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
}
func testDropsInvalidPrefixByte() {
var assembler = NotificationStreamAssembler()
let packet = makePacket(timestamp: 0xF00)
guard let frame = packet.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packet")
}
var noisyFrame = Data([0x00])
noisyFrame.append(frame)
let result = assembler.append(noisyFrame)
XCTAssertEqual(result.droppedPrefixes, [0x00])
XCTAssertEqual(result.frames.count, 1)
XCTAssertFalse(result.reset)
guard let decoded = BinaryProtocol.decode(result.frames[0]) else {
return XCTFail("Failed to decode frame after drop")
}
XCTAssertEqual(decoded.timestamp, packet.timestamp)
}
}
@@ -197,7 +197,7 @@ final class BinaryProtocolTests: XCTestCase {
return return
} }
guard let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else { guard let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to decode message from binary") XCTFail("Failed to decode message from binary")
return return
} }
@@ -219,7 +219,7 @@ final class BinaryProtocolTests: XCTestCase {
) )
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else { let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to encode/decode private message") XCTFail("Failed to encode/decode private message")
return return
} }
@@ -233,7 +233,7 @@ final class BinaryProtocolTests: XCTestCase {
let message = TestHelpers.createTestMessage(mentions: mentions) let message = TestHelpers.createTestMessage(mentions: mentions)
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else { let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to encode/decode message with mentions") XCTFail("Failed to encode/decode message with mentions")
return return
} }
@@ -256,7 +256,7 @@ final class BinaryProtocolTests: XCTestCase {
) )
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else { let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to encode/decode relay message") XCTFail("Failed to encode/decode relay message")
return return
} }
@@ -294,7 +294,7 @@ final class BinaryProtocolTests: XCTestCase {
let message = TestHelpers.createTestMessage(content: largeContent) let message = TestHelpers.createTestMessage(content: largeContent)
guard let payload = message.toBinaryPayload(), guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else { let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to handle large message") XCTFail("Failed to handle large message")
return return
} }
@@ -307,7 +307,7 @@ final class BinaryProtocolTests: XCTestCase {
let emptyMessage = TestHelpers.createTestMessage(content: "") let emptyMessage = TestHelpers.createTestMessage(content: "")
guard let payload = emptyMessage.toBinaryPayload(), guard let payload = emptyMessage.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else { let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to handle empty message") XCTFail("Failed to handle empty message")
return return
} }
+23
View File
@@ -0,0 +1,23 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "BitLogger",
platforms: [
.iOS(.v16),
.macOS(.v13)
],
products: [
.library(
name: "BitLogger",
targets: ["BitLogger"]
)
],
targets: [
.target(
name: "BitLogger",
path: "Sources"
)
]
)
@@ -1,6 +1,6 @@
// //
// OSLog+Categories.swift // OSLog+Categories.swift
// bitchat // BitLogger
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
@@ -8,7 +8,7 @@
import os.log import os.log
extension OSLog { public extension OSLog {
private static let subsystem = "chat.bitchat" private static let subsystem = "chat.bitchat"
static let noise = OSLog(subsystem: subsystem, category: "noise") static let noise = OSLog(subsystem: subsystem, category: "noise")
@@ -1,6 +1,6 @@
// //
// SecureLogger.swift // SecureLogger.swift
// bitchat // BitLogger
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
@@ -11,7 +11,7 @@ import os.log
/// Centralized security-aware logging framework /// Centralized security-aware logging framework
/// Provides safe logging that filters sensitive data and security events /// Provides safe logging that filters sensitive data and security events
final class SecureLogger { public final class SecureLogger {
// MARK: - Timestamp Formatter // MARK: - Timestamp Formatter
@@ -85,8 +85,50 @@ final class SecureLogger {
private static func shouldLog(_ level: LogLevel) -> Bool { private static func shouldLog(_ level: LogLevel) -> Bool {
return level.order >= minimumLevel.order return level.order >= minimumLevel.order
} }
}
// MARK: - Security Event Types // MARK: - Public Logging Methods
public extension SecureLogger {
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .debug, file: file, line: line, function: function)
}
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .info, file: file, line: line, function: function)
}
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .warning, file: file, line: line, function: function)
}
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .error, file: file, line: line, function: function)
}
/// Log errors with context
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription)
#if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
#else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif
}
}
// MARK: Security Event Logging
public extension SecureLogger {
enum SecurityEvent { enum SecurityEvent {
case handshakeStarted(peerID: String) case handshakeStarted(peerID: String)
@@ -111,30 +153,6 @@ final class SecureLogger {
} }
} }
// MARK: - Public Logging Methods
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .debug, file: file, line: line, function: function)
}
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .info, file: file, line: line, function: function)
}
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .warning, file: file, line: line, function: function)
}
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .error, file: file, line: line, function: function)
}
// MARK: Security Event Logging
static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .debug, file: file, line: line, function: function) logSecurityEvent(event, level: .debug, file: file, line: line, function: function)
} }
@@ -150,25 +168,11 @@ final class SecureLogger {
static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) { static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .error, file: file, line: line, function: function) logSecurityEvent(event, level: .error, file: file, line: line, function: function)
} }
/// Log errors with context
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription)
#if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
#else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif
}
} }
// MARK: - Convenience Extensions // MARK: - Convenience Extensions
extension SecureLogger { public extension SecureLogger {
enum KeyOperation: String, CustomStringConvertible { enum KeyOperation: String, CustomStringConvertible {
case load case load
@@ -177,7 +181,7 @@ extension SecureLogger {
case delete case delete
case save case save
var description: String { rawValue } public var description: String { rawValue }
} }
/// Log key management operations /// Log key management operations
@@ -290,8 +294,8 @@ private extension SecureLogger {
/// Helper to migrate from print statements to SecureLogger /// Helper to migrate from print statements to SecureLogger
/// Usage: Replace print(...) with secureLog(...) /// Usage: Replace print(...) with secureLog(...)
func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n", public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n",
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
#if DEBUG #if DEBUG
let message = items.map { String(describing: $0) }.joined(separator: separator) let message = items.map { String(describing: $0) }.joined(separator: separator)
SecureLogger.debug(message, file: file, line: line, function: function) SecureLogger.debug(message, file: file, line: line, function: function)
-236
View File
@@ -1,236 +0,0 @@
name: bitchat
options:
bundleIdPrefix: chat.bitchat
deploymentTarget:
iOS: 16.0
macOS: 13.0
createIntermediateGroups: true
settings:
MARKETING_VERSION: 1.0.0
CURRENT_PROJECT_VERSION: 1
packages:
P256K:
url: https://github.com/21-DOT-DEV/swift-secp256k1
majorVersion: 0.21.1
targets:
bitchat_iOS:
type: application
platform: iOS
sources:
- bitchat
resources:
- bitchat/Assets.xcassets
- bitchat/LaunchScreen.storyboard
info:
path: bitchat/Info.plist
properties:
CFBundleDisplayName: bitchat
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
UIBackgroundModes:
- bluetooth-central
- bluetooth-peripheral
UILaunchStoryboardName: LaunchScreen
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UIRequiresFullScreen: false
CFBundleURLTypes:
- CFBundleURLSchemes:
- bitchat
# xcodegen quirk: include some macOS properties in iOS target
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
PRODUCT_NAME: bitchat
INFOPLIST_FILE: bitchat/Info.plist
ENABLE_PREVIEWS: YES
SWIFT_VERSION: 5.0
IPHONEOS_DEPLOYMENT_TARGET: 16.0
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat.entitlements
dependencies:
- target: bitchatShareExtension
embed: true
- package: P256K
- framework: Frameworks/tor-nolzma.xcframework
embed: true
codeSign: true
- sdk: libz.tbd
bitchat_macOS:
type: application
platform: macOS
sources:
- bitchat
resources:
- bitchat/Assets.xcassets
info:
path: bitchat/Info.plist
properties:
CFBundleDisplayName: bitchat
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
CFBundleURLTypes:
- CFBundleURLSchemes:
- bitchat
# xcodegen quirk: include some iOS properties in macOS target
UIBackgroundModes:
- bluetooth-central
- bluetooth-peripheral
UILaunchStoryboardName: LaunchScreen
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIRequiresFullScreen: true
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
PRODUCT_NAME: bitchat
INFOPLIST_FILE: bitchat/Info.plist
ENABLE_PREVIEWS: NO
SWIFT_VERSION: 5.0
MACOSX_DEPLOYMENT_TARGET: 13.0
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat-macOS.entitlements
dependencies:
- package: P256K
- framework: Frameworks/tor-nolzma.xcframework
embed: true
codeSign: true
- sdk: libz.tbd
bitchatShareExtension:
type: app-extension
platform: iOS
sources:
- bitchatShareExtension
- bitchat/Services/TransportConfig.swift
info:
path: bitchatShareExtension/Info.plist
properties:
CFBundleDisplayName: bitchat
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
NSExtensionAttributes:
NSExtensionActivationRule:
NSExtensionActivationSupportsText: true
NSExtensionActivationSupportsWebURLWithMaxCount: 1
NSExtensionActivationSupportsImageWithMaxCount: 1
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.ShareExtension
INFOPLIST_FILE: bitchatShareExtension/Info.plist
SWIFT_VERSION: 5.0
IPHONEOS_DEPLOYMENT_TARGET: 16.0
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
CODE_SIGN_ENTITLEMENTS: bitchatShareExtension/bitchatShareExtension.entitlements
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
bitchatTests_iOS:
type: bundle.unit-test
platform: iOS
sources:
- bitchatTests
dependencies:
- target: bitchat_iOS
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
INFOPLIST_FILE: bitchatTests/Info.plist
SWIFT_VERSION: 5.0
IPHONEOS_DEPLOYMENT_TARGET: 16.0
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat
BUNDLE_LOADER: $(TEST_HOST)
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
bitchatTests_macOS:
type: bundle.unit-test
platform: macOS
sources:
- bitchatTests
dependencies:
- target: bitchat_macOS
settings:
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
INFOPLIST_FILE: bitchatTests/Info.plist
SWIFT_VERSION: 5.0
MACOSX_DEPLOYMENT_TARGET: 13.0
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat
BUNDLE_LOADER: $(TEST_HOST)
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: L3N5LHJD5Y
schemes:
bitchat (iOS):
build:
targets:
bitchat_iOS: all
bitchatShareExtension: all
run:
config: Debug
executable: bitchat_iOS
test:
config: Debug
targets:
- bitchatTests_iOS
profile:
config: Release
executable: bitchat_iOS
analyze:
config: Debug
archive:
config: Release
bitchat (macOS):
build:
targets:
bitchat_macOS: all
run:
config: Debug
executable: bitchat_macOS
test:
config: Debug
targets:
- bitchatTests_macOS
profile:
config: Release
executable: bitchat_macOS
analyze:
config: Debug
archive:
config: Release
+272 -290
View File
@@ -1,293 +1,275 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
relay.damus.io,37.7621,-122.3971 nostr.tac.lol,47.4748,-122.273
nostr-pub.wellorder.net,45.5229,-122.9898 relay.javi.space,43.4633,11.8796
nostr.mom,50.4779,12.3713 nostr.einundzwanzig.space,50.1109,8.68213
nostr.slothy.win,37.7621,-122.3971 nostr.kalf.org,52.3676,4.90414
nostr.einundzwanzig.space,50.1155,8.6842 wot.dtonon.com,43.6532,-79.3832
nos.lol,50.4779,12.3713 nostr-01.yakihonne.com,1.32123,103.695
relay.nostr.band,60.1695,24.9354 wot.basspistol.org,49.4521,11.0767
no.str.cr,9.9339,-84.0849 relay.puresignal.news,43.6532,-79.3832
nostr.massmux.com,50.1155,8.6842 nostrelites.org,41.8781,-87.6298
nostr-relay.schnitzel.world,39.0437,-77.4875 nostr-relay.nextblockvending.com,47.674,-122.122
relay.nostr.com.au,37.7621,-122.3971
knostr.neutrine.com,48.8534,2.3488
nostr.nodeofsven.com,47.4875,8.2965
nostr.vulpem.com,49.4542,11.0775
nostr-verif.slothy.win,37.7621,-122.3971
relay.lexingtonbitcoin.org,37.7621,-122.3971
nostr-1.nbo.angani.co,-1.2615,36.7903
relay.wellorder.net,45.5229,-122.9898
nostr.easydns.ca,43.7064,-79.3986
relay.dwadziesciajeden.pl,52.2298,21.0118
nostr.data.haus,50.4779,12.3713
nostr.einundzwanzig.space,50.1155,8.6842
nostr.mom,50.4779,12.3713
nos.lol,50.4779,12.3713
relay.nostr.band,60.1695,24.9354
nostr.massmux.com,50.1155,8.6842
nostr.vulpem.com,49.4542,11.0775
relay.damus.io,37.7621,-122.3971
nostr-pub.wellorder.net,45.5229,-122.9898
no.str.cr,9.9339,-84.0849
relay.dwadziesciajeden.pl,52.2298,21.0118
nostr.data.haus,50.4779,12.3713
relay.wellorder.net,45.5229,-122.9898
relay.nostromo.social,49.4542,11.0775
offchain.pub,34.0522,-118.2437
relay.nostr.wirednet.jp,35.9356,139.3044
relay.nostrcheck.me,37.7621,-122.3971
nostrue.com,40.8043,-74.0121
nostr-relay.schnitzel.world,39.0437,-77.4875
nproxy.kristapsk.lv,60.1695,24.9354
nostr.spaceshell.xyz,37.7621,-122.3971
nostr-dev.wellorder.net,45.5229,-122.9898
nostr-verified.wellorder.net,45.5229,-122.9898
nostr.roundrockbitcoiners.com,40.8043,-74.0121
slick.mjex.me,39.0437,-77.4875
nostr.yael.at,52.3740,4.8897
relay.primal.net,37.7621,-122.3971
nostr.oxtr.dev,50.4779,12.3713
nostr.21crypto.ch,46.5160,6.6328
nostr.liberty.fans,38.8003,-90.6265
nostr-02.dorafactory.org,1.2897,103.8501
relay.hodl.ar,-32.9468,-60.6393
nostr.middling.mydns.jp,35.8089,140.1185
nostr.namek.link,37.7621,-122.3971
nostrja-kari.heguro.com,37.7621,-122.3971
nostr.hifish.org,47.3667,8.5500
nostr.rikmeijer.nl,50.4779,12.3713
black.nostrcity.club,41.8119,-87.6873
nostr.hekster.org,37.3924,-121.9623
relay.wavlake.com,41.2619,-95.8608
nostr.sagaciousd.com,49.2497,-123.1193
nostr.fbxl.net,43.7064,-79.3986
ithurtswhenip.ee,50.7990,-1.0913
relay2.nostrchat.io,49.4542,11.0775
relay1.nostrchat.io,60.1695,24.9354
nostr-01.yakihonne.com,1.3215,103.6957
nostr.sathoarder.com,48.5839,7.7455
nostr.overmind.lol,37.7621,-122.3971
relay.verified-nostr.com,37.7621,-122.3971
purplerelay.com,50.1155,8.6842
relay.orangepill.ovh,49.0127,1.9694
nostr-relay.psfoundation.info,39.0437,-77.4875
soloco.nl,37.7621,-122.3971
relay.froth.zone,60.1695,24.9354
nostr.stakey.net,52.5250,5.7181
nostr.2b9t.xyz,34.0522,-118.2437
pyramid.fiatjaf.com,50.1155,8.6842
a.nos.lol,50.4779,12.3713
relay.magiccity.live,25.8130,-80.2320
nostr.notribe.net,40.8344,-74.1377
freelay.sovbit.host,64.1355,-21.8954
relay.credenso.cafe,43.4254,-80.5112
nostr.huszonegy.world,47.4984,19.0404
multiplexer.huszonegy.world,47.4984,19.0404
bucket.coracle.social,37.7621,-122.3971
nostr.kungfu-g.rip,33.7865,-84.4454
relay.artx.market,43.7064,-79.3986
relay.notoshi.win,13.3622,100.9835
vitor.nostr1.com,40.7143,-74.0060
nostr-02.yakihonne.com,1.3215,103.6957
nostr-03.dorafactory.org,1.2897,103.8501
n.ok0.org,-36.8485,174.7635
nostr.0x7e.xyz,47.5056,8.7241
relay.nostr.net,50.4779,12.3713
strfry.openhoofd.nl,51.5717,3.7042
relay.fountain.fm,39.0997,-94.5786
relay.usefusion.ai,38.7135,-78.1594
relay.varke.eu,52.6958,6.1944
nostr.satstralia.com,64.1355,-21.8954
relay.13room.space,37.7621,-122.3971
nostr.myshosholoza.co.za,52.3710,4.9042
nostr.carroarmato0.be,50.8517,3.6089
nostr.dbtc.link,37.7621,-122.3971
orangepiller.org,60.1695,24.9354
adre.su,59.9386,30.3141
relay.sincensura.org,37.7621,-122.3971
relay.freeplace.nl,52.3740,4.8897
bostr.bitcointxoko.com,64.1355,-21.8954
nostr.plantroon.com,50.1025,8.6299
srtrelay.c-stellar.net,37.7621,-122.3971
nostr.jfischer.org,49.4453,11.0222
nostr.novacisko.cz,52.2298,21.0118
relay.lumina.rocks,49.4453,11.0222
nostr.tavux.tech,50.9519,1.8563
relay.nostrhub.fr,50.1155,8.6842
relay.agorist.space,52.3740,4.8897
chorus.pjv.me,45.5229,-122.9898
relay.cosmicbolt.net,37.3924,-121.9623
santo.iguanatech.net,40.8344,-74.1377
relay.tagayasu.xyz,45.4112,-75.6981
relay.mostro.network,40.8344,-74.1377
relay.zone667.com,60.1695,24.9354
relay5.bitransfer.org,37.7621,-122.3971
relay.illuminodes.com,47.6062,-122.3321
relay2.angor.io,50.1155,8.6842
relay.satsdays.com,1.2897,103.8501
relay.angor.io,50.1155,8.6842
orangesync.tech,50.9333,6.9500
nostr-relay.cbrx.io,37.7621,-122.3971
relay.21e6.cz,50.0880,14.4208
nostr.chaima.info,50.1155,8.6842
relay.satlantis.io,32.8546,-79.9748
relay.digitalezukunft.cyou,45.5088,-73.5878
relay.tapestry.ninja,40.8043,-74.0121
relay.minibolt.info,37.7621,-122.3971
nostr.bilthon.dev,25.8130,-80.2320
nostr.makibisskey.work,37.7621,-122.3971
relay.mattybs.lol,37.7621,-122.3971
noxir.kpherox.dev,34.8436,135.5084
sendit.nosflare.com,37.7621,-122.3971
relay.coinos.io,37.7621,-122.3971
relay.nostraddress.com,37.7621,-122.3971
wot.nostr.party,36.1659,-86.7844
nostrelites.org,41.8500,-87.6500
relay.nostriot.com,43.7064,-79.3986
prl.plus,55.7522,37.6156
zap.watch,45.5088,-73.5878
wot.codingarena.top,50.4779,12.3713
nostr.azzamo.net,52.2284,21.0522
wot.sudocarlos.com,43.7064,-79.3986
relay.lnfi.network,45.6241,8.7851
wot.nostr.net,37.7621,-122.3971
relay.nostrdice.com,-33.8678,151.2073
wot.sebastix.social,51.3700,6.1681
wheat.happytavern.co,37.7621,-122.3971
relay.sigit.io,50.4779,12.3713
strfry.bonsai.com,37.8716,-122.2728
travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873
satsage.xyz,37.3924,-121.9623
relay.degmods.com,50.4779,12.3713
nostr.community.ath.cx,45.5088,-73.5878
nostr.coincrowd.fund,39.0437,-77.4875
strfry.shock.network,41.8847,-88.2040
cyberspace.nostr1.com,40.7143,-74.0060
relay02.lnfi.network,39.0997,-94.5786
nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
relay.davidebtc.me,50.1155,8.6842
wot.dtonon.com,37.7621,-122.3971
relay.goodmorningbitcoin.com,37.7621,-122.3971
articles.layer3.news,37.3394,-121.8950
bostr.syobon.net,37.7621,-122.3971
nostr.agentcampfire.com,52.3740,4.8897
nostr.thebiglake.org,32.7244,-96.6755
schnorr.me,37.7621,-122.3971
relay.wolfcoil.com,35.6090,139.7302
nostr.camalolo.com,24.1469,120.6839
nostr.tac.lol,47.4740,-122.2610
dev-relay.lnfi.network,39.0997,-94.5786
relay.bitcoinveneto.org,64.1355,-21.8954
nostr.red5d.dev,37.7621,-122.3971
relay-testnet.k8s.layer3.news,37.3394,-121.8950
promenade.fiatjaf.com,50.1155,8.6842
nostrelay.memory-art.xyz,37.7621,-122.3971
inbox.azzamo.net,52.2284,21.0522
social.proxymana.net,60.1695,24.9354
relay.netstr.io,53.3331,-6.2489
premium.primal.net,37.7621,-122.3971
nostr.lojong.info,37.7621,-122.3971
nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971
relay.stream.labs.h3.se,59.3294,18.0687
tollbooth.stens.dev,51.4566,7.0123
relay.chakany.systems,37.7621,-122.3971
relay.mwaters.net,50.9519,1.8563
nostr-relay.shirogaku.xyz,37.7621,-122.3971
kitchen.zap.cooking,37.7621,-122.3971
relay.arx-ccn.com,50.4779,12.3713
relay.fr13nd5.com,50.1155,8.6842
nostr.tegila.com.br,39.0437,-77.4875
relay.jeffg.fyi,43.7064,-79.3986
relay.bullishbounty.com,37.7621,-122.3971
nostr.spicyz.io,37.7621,-122.3971
relay04.lnfi.network,39.0997,-94.5786
vidono.apps.slidestr.net,48.8534,2.3488
relay03.lnfi.network,39.0997,-94.5786 relay03.lnfi.network,39.0997,-94.5786
communities.nos.social,40.8344,-74.1377 nproxy.kristapsk.lv,60.1699,24.9384
relay.evanverma.com,40.8344,-74.1377 relay.toastr.net,40.8054,-74.0241
nostrelay.circum.space,51.4566,7.0123 relay.davidebtc.me,51.5072,-0.127586
wot.brightbolt.net,47.6928,-116.7850 wot.nostr.net,43.6532,-79.3832
relayrs.notoshi.win,37.7621,-122.3971 relay.mattybs.lol,43.6532,-79.3832
fenrir-s.notoshi.win,37.7621,-122.3971 relay.zone667.com,60.1699,24.9384
relay.nsnip.io,60.1695,24.9354 nostr.kungfu-g.rip,33.7946,-84.4488
x.kojira.io,37.7621,-122.3971 relay.mccormick.cx,52.3563,4.95714
relay.hasenpfeffr.com,39.0437,-77.4875 relay.dwadziesciajeden.pl,52.2297,21.0122
relay01.lnfi.network,39.0997,-94.5786 nostr.data.haus,50.4754,12.3683
nostr.rtvslawenia.com,49.4542,11.0775 vitor.nostr1.com,40.7128,-74.006
relay.g1sms.fr,43.9298,2.1480 purpura.cloud,43.6532,-79.3832
nostr.kalf.org,52.3740,4.8897 relay2.angor.io,48.1046,11.6002
nostr.rblb.it,37.7621,-122.3971 nos.lol,50.4754,12.3683
nostr.4rs.nl,49.4453,11.0222 nostr.rohoss.com,50.1109,8.68213
relay.vrtmrz.net,37.7621,-122.3971 strfry.bonsai.com,37.8715,-122.273
nostr.hoppe-relay.it.com,45.5946,-121.1787 relay.fountain.fm,39.0997,-94.5786
relay-rpi.edufeed.org,49.4453,11.0222 relay.npubhaus.com,43.6532,-79.3832
relay.copylaradio.com,50.7990,-1.0913 relay.nostr.wirednet.jp,34.706,135.493
relay.ru.ac.th,13.7540,100.5014 soloco.nl,43.6532,-79.3832
relay.bitcoinartclock.com,50.4779,12.3713 shu01.shugur.net,21.4902,39.2246
wot.downisontheup.ca,47.6062,-122.3321 nostr.davidebtc.me,51.5072,-0.127586
nostr.coincards.com,43.7064,-79.3986 pyramid.fiatjaf.com,50.1109,8.68213
relay.etch.social,41.2619,-95.8608 relay.ditto.pub,43.6532,-79.3832
relay.mess.ch,47.1345,9.0964 relay.nostr.vet,52.6467,4.7395
relay.holzeis.me,37.7621,-122.3971 relay.wavlake.com,41.2619,-95.8608
relay-admin.thaliyal.com,40.8220,-74.4488 ribo.eu.nostria.app,52.3676,4.90414
nostr.thaliyal.com,40.8220,-74.4488 relay.ngengine.org,43.6532,-79.3832
strfry.felixzieger.de,50.1025,8.6299 relay.bitcoinveneto.org,64.1466,-21.9426
nostr.smut.cloud,37.7621,-122.3971 no.str.cr,9.92857,-84.0528
r.bitcoinhold.net,37.7621,-122.3971 relay.primal.net,43.6532,-79.3832
nostr.blankfors.se,60.1695,24.9354 ynostr.yael.at,60.1699,24.9384
portal-relay.pareto.space,49.4453,11.0222 nostr.camalolo.com,24.1469,120.684
relay.getsafebox.app,43.7064,-79.3986 purplerelay.com,50.1109,8.68213
relay.anzenkodo.workers.dev,37.7621,-122.3971 nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relay.nostrhub.tech,49.4453,11.0222 relay.internationalright-wing.org,-22.5022,-48.7114
nostr.prl.plus,52.3740,4.8897 wheat.happytavern.co,43.6532,-79.3832
nostr-2.21crypto.ch,46.5160,6.6328 nostr.lostr.space,43.6532,-79.3832
nostr.zenon.network,40.7143,-74.0060 relay.tagayasu.xyz,43.6715,-79.38
nostr-relay.amethyst.name,35.7721,-78.6386 relay.varke.eu,52.6921,6.19372
relayone.geektank.ai,17.1210,-61.8433 free.relayted.de,50.1109,8.68213
fanfares.nostr1.com,40.7143,-74.0060 nostr.thebiglake.org,32.71,-96.6745
wot.geektank.ai,17.1210,-61.8433 nostr.lojong.info,43.6532,-79.3832
relay-dev.satlantis.io,40.8344,-74.1377 nostr.now,36.55,139.733
relay.siamdev.cc,13.9178,100.4240 relay.jmoose.rocks,60.1699,24.9384
relay.nosto.re,51.3700,6.1681 relay.holzeis.me,43.6532,-79.3832
wot.soundhsa.com,39.0997,-94.5786 nostr.roundrockbitcoiners.com,40.8054,-74.0241
nostr.n7ekb.net,47.5707,-122.2221 nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relayone.soundhsa.com,39.0997,-94.5786 relay2.ngengine.org,43.6532,-79.3832
relay.puresignal.news,37.7621,-122.3971 nostr.snowbla.de,60.1699,24.9384
relay.nostx.io,37.7621,-122.3971 4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
nostr.now,35.6090,139.7302 shu04.shugur.net,25.2604,55.2989
relay.artiostr.ch,37.7621,-122.3971 relay.fr13nd5.com,52.5233,13.3426
relay.oldenburg.cool,50.1155,8.6842 nostr.vulpem.com,49.4543,11.0746
theoutpost.life,64.1355,-21.8954 temp.iris.to,43.6532,-79.3832
khatru.nostrver.se,51.3700,6.1681 x.kojira.io,43.6532,-79.3832
relay.wavefunc.live,37.7915,-122.4018 adre.su,59.9311,30.3609
nostr-relay.zimage.com,34.0522,-118.2437 nostr-dev.wellorder.net,45.5201,-122.99
relay.javi.space,43.4628,11.8807 nostr.mom,50.4754,12.3683
bostr.shop,42.8865,-78.8784 relay.nostr.place,32.7767,-96.797
relay.letsfo.com,52.2298,21.0118
alien.macneilmediagroup.com,37.7621,-122.3971
rn1.sotiras.org,37.7621,-122.3971
gnostr.com,42.6975,23.3241
relay.conduit.market,37.7621,-122.3971
relay.hivetalk.org,37.3924,-121.9623
nostr.l484.com,30.2960,-97.6396
relay.chorus.community,50.1155,8.6842
nostr-relay.moe.gift,37.7621,-122.3971
relay.nostrcal.com,37.7621,-122.3971
temp.iris.to,37.7621,-122.3971
librerelay.aaroniumii.com,37.7621,-122.3971
nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971
relay.barine.co,37.7621,-122.3971
nostr.rohoss.com,48.1374,11.5755
wot.nostr.place,30.2672,-97.7431 wot.nostr.place,30.2672,-97.7431
relay.utxo.farm,34.7331,135.8183 nostr.carroarmato0.be,50.9928,3.26317
relay.bankless.at,37.7621,-122.3971 nostrelay.circum.space,51.2217,6.77616
relay.toastr.net,40.8043,-74.0121 relay.chorus.community,50.1109,8.68213
nostr.excentered.com,52.5244,13.4105 relay.nostr.net,50.4754,12.3683
relay.mccormick.cx,52.3740,4.8897 relay.nostr-check.me,43.6532,-79.3832
relay.cypherflow.ai,48.8534,2.3488 relay.nostrhub.fr,48.1046,11.6002
relay.laantungir.net,45.3134,-73.8725 relay.nostraddress.com,43.6532,-79.3832
nostr.veladan.dev,37.7621,-122.3971 nostr.rblb.it,43.4633,11.8796
nostr.tadryanom.me,37.7621,-122.3971 nostr.red5d.dev,43.6532,-79.3832
nostr-relay.online,37.7621,-122.3971 santo.iguanatech.net,40.8302,-74.1299
nostr.night7.space,50.4779,12.3713 relay02.lnfi.network,39.0997,-94.5786
dev-nostr.bityacht.io,25.0531,121.5264 relay.21e6.cz,50.1682,14.0546
a.nos.lol,50.4754,12.3683
shu02.shugur.net,21.4902,39.2246
schnorr.me,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
wot.shaving.kiwi,43.6532,-79.3832
dev-nostr.bityacht.io,25.0797,121.234
relay.credenso.cafe,43.1149,-80.7228
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.mess.ch,47.3591,8.55292
inbox.azzamo.net,52.2633,21.0283
prl.plus,55.7623,37.6381
yabu.me,35.6092,139.73
relayrs.notoshi.win,43.6532,-79.3832
premium.primal.net,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
nostr.2b9t.xyz,34.0549,-118.243
nostr.thaliyal.com,40.8218,-74.45
relay.exit.pub,50.4754,12.3683
nostr.jfischer.org,49.0291,8.35696
relay.origin.land,35.6673,139.751
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nostriot.com,41.5695,-83.9786
relay.btcforplebs.com,43.6532,-79.3832
relay.chakany.systems,43.6532,-79.3832
nostr.openhoofd.nl,51.9229,4.40833
nostrcheck.me,43.6532,-79.3832
nostr.plantroon.com,50.1013,8.62643
satsage.xyz,37.3986,-121.964
nostr.faultables.net,43.6532,-79.3832
nostr.calitabby.net,39.9268,-75.0246
relay.freeplace.nl,52.3676,4.90414
relay.nostrhub.tech,49.4543,11.0746
roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683
cyberspace.nostr1.com,40.7128,-74.006
nostr.smut.cloud,43.6532,-79.3832
nostr-02.czas.top,53.471,9.88208
relay.tapestry.ninja,40.8054,-74.0241
relay.mostro.network,40.8302,-74.1299
wot.brightbolt.net,47.6735,-116.781
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.rikmeijer.nl,50.4754,12.3683
relay.artx.market,43.652,-79.3633
strfry.felixzieger.de,50.1013,8.62643
relay.seq1.net,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
relay.electriclifestyle.com,26.2897,-80.1293
r.bitcoinhold.net,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
relay.stream.labs.h3.se,59.4016,17.9455
relay.unknown.cloud,43.6532,-79.3832
nostr-02.yakihonne.com,1.32123,103.695
relay.coinos.io,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
relay-dev.satlantis.io,40.8302,-74.1299
nostream.breadslice.com,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
nostr.oxtr.dev,50.4754,12.3683
nos.xmark.cc,50.6924,3.20113
nostr.mikoshi.de,50.1109,8.68213
relay.magiccity.live,25.8128,-80.2377
nostr-verified.wellorder.net,45.5201,-122.99
nostr.makibisskey.work,43.6532,-79.3832
wot.nostr.party,36.1627,-86.7816
relay.copylaradio.com,51.223,6.78245
nostr.sathoarder.com,48.5734,7.75211
relay.jeffg.fyi,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99
nostr.ovia.to,43.6532,-79.3832
black.nostrcity.club,41.8781,-87.6298
relay.nostrcheck.me,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr-03.dorafactory.org,1.35208,103.82
nostr-relay.zimage.com,34.282,-118.439
relay.sigit.io,50.4754,12.3683
relay.laantungir.net,-19.4692,-42.5315
relay-admin.thaliyal.com,40.8218,-74.45
relay.mwaters.net,50.9871,2.12554
relay.lumina.rocks,49.0291,8.35695
nostr.satstralia.com,64.1476,-21.9392
nostr.sagaciousd.com,49.2827,-123.121
relay.bullishbounty.com,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
relay.vrtmrz.net,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
articles.layer3.news,37.3387,-121.885
relay.digitalezukunft.cyou,45.5019,-73.5674
nostr.notribe.net,40.8302,-74.1299
nostr.21crypto.ch,47.4988,8.72369
relay.lifpay.me,1.35208,103.82
relay.g1sms.fr,43.9432,2.07537
relay.snort.social,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
relay.moinsen.com,50.4754,12.3683
nostr.blankfors.se,60.1699,24.9384
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
orangesync.tech,50.1109,8.68213
relay.ru.ac.th,13.7584,100.622
orangepiller.org,60.1699,24.9384
relay.nostr.band,60.1699,24.9384
wot.soundhsa.com,34.0479,-118.256
khatru.nostrver.se,51.8933,4.42083
nostr.hifish.org,47.4043,8.57398
freelay.sovbit.host,64.1476,-21.9392
nostr.bilthon.dev,25.8128,-80.2377
nostr.night7.space,50.4754,12.3683
relay.illuminodes.com,47.6061,-122.333
relayone.soundhsa.com,34.0479,-118.256
nostr.azzamo.net,52.2633,21.0283
fenrir-s.notoshi.win,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.coincards.com,53.5501,-113.469
alien.macneilmediagroup.com,43.6532,-79.3832
nostrings-relay-dev.fly.dev,41.8781,-87.6298
relay.satlantis.io,32.8769,-80.0114
srtrelay.c-stellar.net,43.6532,-79.3832
relay.agora.social,50.7383,15.0648
relay.bitcoindistrict.org,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
wot.utxo.one,43.6532,-79.3832
relay.bitcoinartclock.com,50.4754,12.3683
nostr.zenon.network,43.5009,-70.4428
wot.codingarena.top,50.4754,12.3683
nostr-02.dorafactory.org,1.35208,103.82
relay.nostromo.social,49.4543,11.0746
theoutpost.life,64.1476,-21.9392
strfry.shock.network,41.8959,-88.2169
strfry.openhoofd.nl,51.9229,4.40833
itanostr.space,52.2931,4.79099
relay.barine.co,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
relay.olas.app,50.4754,12.3683
nostr.girino.org,43.6532,-79.3832
nostr-relay.online,43.6532,-79.3832
relay.evanverma.com,40.8302,-74.1299
relay.angor.io,48.1046,11.6002
relay.nostrdice.com,-33.8688,151.209
ribo.us.nostria.app,41.5868,-93.625
relay01.lnfi.network,39.0997,-94.5786
nostr.namek.link,43.6532,-79.3832
relay.aloftus.io,34.0881,-118.379
ithurtswhenip.ee,51.223,6.78245
ribo.af.nostria.app,-26.2041,28.0473
shu05.shugur.net,48.8566,2.35222
noxir.kpherox.dev,34.8587,135.509
relay.degmods.com,50.4754,12.3683
relay.goodmorningbitcoin.com,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.hasenpfeffr.com,39.0438,-77.4874
fanfares.nostr1.com,40.7128,-74.006
relay.nosto.re,51.8933,4.42083
nostr.liberty.fans,36.9104,-89.5875
nostr-2.21crypto.ch,47.4988,8.72369
relay-rpi.edufeed.org,49.4543,11.0746
offchain.pub,36.1809,-115.241
nostr.88mph.life,43.6532,-79.3832
nostr.luisschwab.net,43.6532,-79.3832
nostr.casa21.space,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
relay.utxo.farm,35.6916,139.768
zap.watch,45.5029,-73.5723
nostr-pub.wellorder.net,45.5201,-122.99
wot.sovbit.host,64.1466,-21.9426
relay.endfiat.money,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
nostr.jerrynya.fun,31.2304,121.474
nostr.spacecitynode.com,29.7057,-95.2706
relay.siamdev.cc,13.9178,100.424
relay.notoshi.win,13.7829,100.546
relay.nostx.io,43.6532,-79.3832
relay.cypherflow.ai,48.8566,2.35222
relay.letsfo.com,51.098,17.0321
librerelay.aaroniumii.com,43.6532,-79.3832
gnostr.com,40.9017,29.1616
nostr.pleb.one,38.6327,-90.1961
nostr.mehdibekhtaoui.com,49.4939,-1.54813
nostr.tadryanom.me,43.6532,-79.3832
relay.orangepill.ovh,49.1689,-0.358841
nostr.stakey.net,52.3676,4.90414
nostr.rtvslawenia.com,49.4543,11.0746
relay.damus.io,43.6532,-79.3832
dev-relay.lnfi.network,39.0997,-94.5786
slick.mjex.me,39.048,-77.4817
wot.sudocarlos.com,51.5072,-0.127586
relay04.lnfi.network,39.0997,-94.5786
relay.13room.space,43.6532,-79.3832
relay.conduit.market,43.6532,-79.3832
nostr.chaima.info,51.223,6.78245
1 Relay URL Latitude Longitude
2 relay.damus.io nostr.tac.lol 37.7621 47.4748 -122.3971 -122.273
3 nostr-pub.wellorder.net relay.javi.space 45.5229 43.4633 -122.9898 11.8796
4 nostr.mom nostr.einundzwanzig.space 50.4779 50.1109 12.3713 8.68213
5 nostr.slothy.win nostr.kalf.org 37.7621 52.3676 -122.3971 4.90414
6 nostr.einundzwanzig.space wot.dtonon.com 50.1155 43.6532 8.6842 -79.3832
7 nos.lol nostr-01.yakihonne.com 50.4779 1.32123 12.3713 103.695
8 relay.nostr.band wot.basspistol.org 60.1695 49.4521 24.9354 11.0767
9 no.str.cr relay.puresignal.news 9.9339 43.6532 -84.0849 -79.3832
10 nostr.massmux.com nostrelites.org 50.1155 41.8781 8.6842 -87.6298
11 nostr-relay.schnitzel.world nostr-relay.nextblockvending.com 39.0437 47.674 -77.4875 -122.122
relay.nostr.com.au 37.7621 -122.3971
knostr.neutrine.com 48.8534 2.3488
nostr.nodeofsven.com 47.4875 8.2965
nostr.vulpem.com 49.4542 11.0775
nostr-verif.slothy.win 37.7621 -122.3971
relay.lexingtonbitcoin.org 37.7621 -122.3971
nostr-1.nbo.angani.co -1.2615 36.7903
relay.wellorder.net 45.5229 -122.9898
nostr.easydns.ca 43.7064 -79.3986
relay.dwadziesciajeden.pl 52.2298 21.0118
nostr.data.haus 50.4779 12.3713
nostr.einundzwanzig.space 50.1155 8.6842
nostr.mom 50.4779 12.3713
nos.lol 50.4779 12.3713
relay.nostr.band 60.1695 24.9354
nostr.massmux.com 50.1155 8.6842
nostr.vulpem.com 49.4542 11.0775
relay.damus.io 37.7621 -122.3971
nostr-pub.wellorder.net 45.5229 -122.9898
no.str.cr 9.9339 -84.0849
relay.dwadziesciajeden.pl 52.2298 21.0118
nostr.data.haus 50.4779 12.3713
relay.wellorder.net 45.5229 -122.9898
relay.nostromo.social 49.4542 11.0775
offchain.pub 34.0522 -118.2437
relay.nostr.wirednet.jp 35.9356 139.3044
relay.nostrcheck.me 37.7621 -122.3971
nostrue.com 40.8043 -74.0121
nostr-relay.schnitzel.world 39.0437 -77.4875
nproxy.kristapsk.lv 60.1695 24.9354
nostr.spaceshell.xyz 37.7621 -122.3971
nostr-dev.wellorder.net 45.5229 -122.9898
nostr-verified.wellorder.net 45.5229 -122.9898
nostr.roundrockbitcoiners.com 40.8043 -74.0121
slick.mjex.me 39.0437 -77.4875
nostr.yael.at 52.3740 4.8897
relay.primal.net 37.7621 -122.3971
nostr.oxtr.dev 50.4779 12.3713
nostr.21crypto.ch 46.5160 6.6328
nostr.liberty.fans 38.8003 -90.6265
nostr-02.dorafactory.org 1.2897 103.8501
relay.hodl.ar -32.9468 -60.6393
nostr.middling.mydns.jp 35.8089 140.1185
nostr.namek.link 37.7621 -122.3971
nostrja-kari.heguro.com 37.7621 -122.3971
nostr.hifish.org 47.3667 8.5500
nostr.rikmeijer.nl 50.4779 12.3713
black.nostrcity.club 41.8119 -87.6873
nostr.hekster.org 37.3924 -121.9623
relay.wavlake.com 41.2619 -95.8608
nostr.sagaciousd.com 49.2497 -123.1193
nostr.fbxl.net 43.7064 -79.3986
ithurtswhenip.ee 50.7990 -1.0913
relay2.nostrchat.io 49.4542 11.0775
relay1.nostrchat.io 60.1695 24.9354
nostr-01.yakihonne.com 1.3215 103.6957
nostr.sathoarder.com 48.5839 7.7455
nostr.overmind.lol 37.7621 -122.3971
relay.verified-nostr.com 37.7621 -122.3971
purplerelay.com 50.1155 8.6842
relay.orangepill.ovh 49.0127 1.9694
nostr-relay.psfoundation.info 39.0437 -77.4875
soloco.nl 37.7621 -122.3971
relay.froth.zone 60.1695 24.9354
nostr.stakey.net 52.5250 5.7181
nostr.2b9t.xyz 34.0522 -118.2437
pyramid.fiatjaf.com 50.1155 8.6842
a.nos.lol 50.4779 12.3713
relay.magiccity.live 25.8130 -80.2320
nostr.notribe.net 40.8344 -74.1377
freelay.sovbit.host 64.1355 -21.8954
relay.credenso.cafe 43.4254 -80.5112
nostr.huszonegy.world 47.4984 19.0404
multiplexer.huszonegy.world 47.4984 19.0404
bucket.coracle.social 37.7621 -122.3971
nostr.kungfu-g.rip 33.7865 -84.4454
relay.artx.market 43.7064 -79.3986
relay.notoshi.win 13.3622 100.9835
vitor.nostr1.com 40.7143 -74.0060
nostr-02.yakihonne.com 1.3215 103.6957
nostr-03.dorafactory.org 1.2897 103.8501
n.ok0.org -36.8485 174.7635
nostr.0x7e.xyz 47.5056 8.7241
relay.nostr.net 50.4779 12.3713
strfry.openhoofd.nl 51.5717 3.7042
relay.fountain.fm 39.0997 -94.5786
relay.usefusion.ai 38.7135 -78.1594
relay.varke.eu 52.6958 6.1944
nostr.satstralia.com 64.1355 -21.8954
relay.13room.space 37.7621 -122.3971
nostr.myshosholoza.co.za 52.3710 4.9042
nostr.carroarmato0.be 50.8517 3.6089
nostr.dbtc.link 37.7621 -122.3971
orangepiller.org 60.1695 24.9354
adre.su 59.9386 30.3141
relay.sincensura.org 37.7621 -122.3971
relay.freeplace.nl 52.3740 4.8897
bostr.bitcointxoko.com 64.1355 -21.8954
nostr.plantroon.com 50.1025 8.6299
srtrelay.c-stellar.net 37.7621 -122.3971
nostr.jfischer.org 49.4453 11.0222
nostr.novacisko.cz 52.2298 21.0118
relay.lumina.rocks 49.4453 11.0222
nostr.tavux.tech 50.9519 1.8563
relay.nostrhub.fr 50.1155 8.6842
relay.agorist.space 52.3740 4.8897
chorus.pjv.me 45.5229 -122.9898
relay.cosmicbolt.net 37.3924 -121.9623
santo.iguanatech.net 40.8344 -74.1377
relay.tagayasu.xyz 45.4112 -75.6981
relay.mostro.network 40.8344 -74.1377
relay.zone667.com 60.1695 24.9354
relay5.bitransfer.org 37.7621 -122.3971
relay.illuminodes.com 47.6062 -122.3321
relay2.angor.io 50.1155 8.6842
relay.satsdays.com 1.2897 103.8501
relay.angor.io 50.1155 8.6842
orangesync.tech 50.9333 6.9500
nostr-relay.cbrx.io 37.7621 -122.3971
relay.21e6.cz 50.0880 14.4208
nostr.chaima.info 50.1155 8.6842
relay.satlantis.io 32.8546 -79.9748
relay.digitalezukunft.cyou 45.5088 -73.5878
relay.tapestry.ninja 40.8043 -74.0121
relay.minibolt.info 37.7621 -122.3971
nostr.bilthon.dev 25.8130 -80.2320
nostr.makibisskey.work 37.7621 -122.3971
relay.mattybs.lol 37.7621 -122.3971
noxir.kpherox.dev 34.8436 135.5084
sendit.nosflare.com 37.7621 -122.3971
relay.coinos.io 37.7621 -122.3971
relay.nostraddress.com 37.7621 -122.3971
wot.nostr.party 36.1659 -86.7844
nostrelites.org 41.8500 -87.6500
relay.nostriot.com 43.7064 -79.3986
prl.plus 55.7522 37.6156
zap.watch 45.5088 -73.5878
wot.codingarena.top 50.4779 12.3713
nostr.azzamo.net 52.2284 21.0522
wot.sudocarlos.com 43.7064 -79.3986
relay.lnfi.network 45.6241 8.7851
wot.nostr.net 37.7621 -122.3971
relay.nostrdice.com -33.8678 151.2073
wot.sebastix.social 51.3700 6.1681
wheat.happytavern.co 37.7621 -122.3971
relay.sigit.io 50.4779 12.3713
strfry.bonsai.com 37.8716 -122.2728
travis-shears-nostr-relay-v2.fly.dev 41.8119 -87.6873
satsage.xyz 37.3924 -121.9623
relay.degmods.com 50.4779 12.3713
nostr.community.ath.cx 45.5088 -73.5878
nostr.coincrowd.fund 39.0437 -77.4875
strfry.shock.network 41.8847 -88.2040
cyberspace.nostr1.com 40.7143 -74.0060
relay02.lnfi.network 39.0997 -94.5786
nostr-rs-relay.dev.fedibtc.com 39.0437 -77.4875
relay.davidebtc.me 50.1155 8.6842
wot.dtonon.com 37.7621 -122.3971
relay.goodmorningbitcoin.com 37.7621 -122.3971
articles.layer3.news 37.3394 -121.8950
bostr.syobon.net 37.7621 -122.3971
nostr.agentcampfire.com 52.3740 4.8897
nostr.thebiglake.org 32.7244 -96.6755
schnorr.me 37.7621 -122.3971
relay.wolfcoil.com 35.6090 139.7302
nostr.camalolo.com 24.1469 120.6839
nostr.tac.lol 47.4740 -122.2610
dev-relay.lnfi.network 39.0997 -94.5786
relay.bitcoinveneto.org 64.1355 -21.8954
nostr.red5d.dev 37.7621 -122.3971
relay-testnet.k8s.layer3.news 37.3394 -121.8950
promenade.fiatjaf.com 50.1155 8.6842
nostrelay.memory-art.xyz 37.7621 -122.3971
inbox.azzamo.net 52.2284 21.0522
social.proxymana.net 60.1695 24.9354
relay.netstr.io 53.3331 -6.2489
premium.primal.net 37.7621 -122.3971
nostr.lojong.info 37.7621 -122.3971
nostr-rs-relay-ishosta.phamthanh.me 37.7621 -122.3971
relay.stream.labs.h3.se 59.3294 18.0687
tollbooth.stens.dev 51.4566 7.0123
relay.chakany.systems 37.7621 -122.3971
relay.mwaters.net 50.9519 1.8563
nostr-relay.shirogaku.xyz 37.7621 -122.3971
kitchen.zap.cooking 37.7621 -122.3971
relay.arx-ccn.com 50.4779 12.3713
relay.fr13nd5.com 50.1155 8.6842
nostr.tegila.com.br 39.0437 -77.4875
relay.jeffg.fyi 43.7064 -79.3986
relay.bullishbounty.com 37.7621 -122.3971
nostr.spicyz.io 37.7621 -122.3971
relay04.lnfi.network 39.0997 -94.5786
vidono.apps.slidestr.net 48.8534 2.3488
12 relay03.lnfi.network 39.0997 -94.5786
13 communities.nos.social nproxy.kristapsk.lv 40.8344 60.1699 -74.1377 24.9384
14 relay.evanverma.com relay.toastr.net 40.8344 40.8054 -74.1377 -74.0241
15 nostrelay.circum.space relay.davidebtc.me 51.4566 51.5072 7.0123 -0.127586
16 wot.brightbolt.net wot.nostr.net 47.6928 43.6532 -116.7850 -79.3832
17 relayrs.notoshi.win relay.mattybs.lol 37.7621 43.6532 -122.3971 -79.3832
18 fenrir-s.notoshi.win relay.zone667.com 37.7621 60.1699 -122.3971 24.9384
19 relay.nsnip.io nostr.kungfu-g.rip 60.1695 33.7946 24.9354 -84.4488
20 x.kojira.io relay.mccormick.cx 37.7621 52.3563 -122.3971 4.95714
21 relay.hasenpfeffr.com relay.dwadziesciajeden.pl 39.0437 52.2297 -77.4875 21.0122
22 relay01.lnfi.network nostr.data.haus 39.0997 50.4754 -94.5786 12.3683
23 nostr.rtvslawenia.com vitor.nostr1.com 49.4542 40.7128 11.0775 -74.006
24 relay.g1sms.fr purpura.cloud 43.9298 43.6532 2.1480 -79.3832
25 nostr.kalf.org relay2.angor.io 52.3740 48.1046 4.8897 11.6002
26 nostr.rblb.it nos.lol 37.7621 50.4754 -122.3971 12.3683
27 nostr.4rs.nl nostr.rohoss.com 49.4453 50.1109 11.0222 8.68213
28 relay.vrtmrz.net strfry.bonsai.com 37.7621 37.8715 -122.3971 -122.273
29 nostr.hoppe-relay.it.com relay.fountain.fm 45.5946 39.0997 -121.1787 -94.5786
30 relay-rpi.edufeed.org relay.npubhaus.com 49.4453 43.6532 11.0222 -79.3832
31 relay.copylaradio.com relay.nostr.wirednet.jp 50.7990 34.706 -1.0913 135.493
32 relay.ru.ac.th soloco.nl 13.7540 43.6532 100.5014 -79.3832
33 relay.bitcoinartclock.com shu01.shugur.net 50.4779 21.4902 12.3713 39.2246
34 wot.downisontheup.ca nostr.davidebtc.me 47.6062 51.5072 -122.3321 -0.127586
35 nostr.coincards.com pyramid.fiatjaf.com 43.7064 50.1109 -79.3986 8.68213
36 relay.etch.social relay.ditto.pub 41.2619 43.6532 -95.8608 -79.3832
37 relay.mess.ch relay.nostr.vet 47.1345 52.6467 9.0964 4.7395
38 relay.holzeis.me relay.wavlake.com 37.7621 41.2619 -122.3971 -95.8608
39 relay-admin.thaliyal.com ribo.eu.nostria.app 40.8220 52.3676 -74.4488 4.90414
40 nostr.thaliyal.com relay.ngengine.org 40.8220 43.6532 -74.4488 -79.3832
41 strfry.felixzieger.de relay.bitcoinveneto.org 50.1025 64.1466 8.6299 -21.9426
42 nostr.smut.cloud no.str.cr 37.7621 9.92857 -122.3971 -84.0528
43 r.bitcoinhold.net relay.primal.net 37.7621 43.6532 -122.3971 -79.3832
44 nostr.blankfors.se ynostr.yael.at 60.1695 60.1699 24.9354 24.9384
45 portal-relay.pareto.space nostr.camalolo.com 49.4453 24.1469 11.0222 120.684
46 relay.getsafebox.app purplerelay.com 43.7064 50.1109 -79.3986 8.68213
47 relay.anzenkodo.workers.dev nostr-rs-relay-ishosta.phamthanh.me 37.7621 43.6532 -122.3971 -79.3832
48 relay.nostrhub.tech relay.internationalright-wing.org 49.4453 -22.5022 11.0222 -48.7114
49 nostr.prl.plus wheat.happytavern.co 52.3740 43.6532 4.8897 -79.3832
50 nostr-2.21crypto.ch nostr.lostr.space 46.5160 43.6532 6.6328 -79.3832
51 nostr.zenon.network relay.tagayasu.xyz 40.7143 43.6715 -74.0060 -79.38
52 nostr-relay.amethyst.name relay.varke.eu 35.7721 52.6921 -78.6386 6.19372
53 relayone.geektank.ai free.relayted.de 17.1210 50.1109 -61.8433 8.68213
54 fanfares.nostr1.com nostr.thebiglake.org 40.7143 32.71 -74.0060 -96.6745
55 wot.geektank.ai nostr.lojong.info 17.1210 43.6532 -61.8433 -79.3832
56 relay-dev.satlantis.io nostr.now 40.8344 36.55 -74.1377 139.733
57 relay.siamdev.cc relay.jmoose.rocks 13.9178 60.1699 100.4240 24.9384
58 relay.nosto.re relay.holzeis.me 51.3700 43.6532 6.1681 -79.3832
59 wot.soundhsa.com nostr.roundrockbitcoiners.com 39.0997 40.8054 -94.5786 -74.0241
60 nostr.n7ekb.net nostr-rs-relay.dev.fedibtc.com 47.5707 39.0438 -122.2221 -77.4874
61 relayone.soundhsa.com relay2.ngengine.org 39.0997 43.6532 -94.5786 -79.3832
62 relay.puresignal.news nostr.snowbla.de 37.7621 60.1699 -122.3971 24.9384
63 relay.nostx.io 4u2ni0zjbjvni.clorecloud.net 37.7621 43.6532 -122.3971 -79.3832
64 nostr.now shu04.shugur.net 35.6090 25.2604 139.7302 55.2989
65 relay.artiostr.ch relay.fr13nd5.com 37.7621 52.5233 -122.3971 13.3426
66 relay.oldenburg.cool nostr.vulpem.com 50.1155 49.4543 8.6842 11.0746
67 theoutpost.life temp.iris.to 64.1355 43.6532 -21.8954 -79.3832
68 khatru.nostrver.se x.kojira.io 51.3700 43.6532 6.1681 -79.3832
69 relay.wavefunc.live adre.su 37.7915 59.9311 -122.4018 30.3609
70 nostr-relay.zimage.com nostr-dev.wellorder.net 34.0522 45.5201 -118.2437 -122.99
71 relay.javi.space nostr.mom 43.4628 50.4754 11.8807 12.3683
72 bostr.shop relay.nostr.place 42.8865 32.7767 -78.8784 -96.797
relay.letsfo.com 52.2298 21.0118
alien.macneilmediagroup.com 37.7621 -122.3971
rn1.sotiras.org 37.7621 -122.3971
gnostr.com 42.6975 23.3241
relay.conduit.market 37.7621 -122.3971
relay.hivetalk.org 37.3924 -121.9623
nostr.l484.com 30.2960 -97.6396
relay.chorus.community 50.1155 8.6842
nostr-relay.moe.gift 37.7621 -122.3971
relay.nostrcal.com 37.7621 -122.3971
temp.iris.to 37.7621 -122.3971
librerelay.aaroniumii.com 37.7621 -122.3971
nostr-relay-1.trustlessenterprise.com 37.7621 -122.3971
relay.barine.co 37.7621 -122.3971
nostr.rohoss.com 48.1374 11.5755
73 wot.nostr.place 30.2672 -97.7431
74 relay.utxo.farm nostr.carroarmato0.be 34.7331 50.9928 135.8183 3.26317
75 relay.bankless.at nostrelay.circum.space 37.7621 51.2217 -122.3971 6.77616
76 relay.toastr.net relay.chorus.community 40.8043 50.1109 -74.0121 8.68213
77 nostr.excentered.com relay.nostr.net 52.5244 50.4754 13.4105 12.3683
78 relay.mccormick.cx relay.nostr-check.me 52.3740 43.6532 4.8897 -79.3832
79 relay.cypherflow.ai relay.nostrhub.fr 48.8534 48.1046 2.3488 11.6002
80 relay.laantungir.net relay.nostraddress.com 45.3134 43.6532 -73.8725 -79.3832
81 nostr.veladan.dev nostr.rblb.it 37.7621 43.4633 -122.3971 11.8796
82 nostr.tadryanom.me nostr.red5d.dev 37.7621 43.6532 -122.3971 -79.3832
83 nostr-relay.online santo.iguanatech.net 37.7621 40.8302 -122.3971 -74.1299
84 nostr.night7.space relay02.lnfi.network 50.4779 39.0997 12.3713 -94.5786
85 dev-nostr.bityacht.io relay.21e6.cz 25.0531 50.1682 121.5264 14.0546
86 a.nos.lol 50.4754 12.3683
87 shu02.shugur.net 21.4902 39.2246
88 schnorr.me 43.6532 -79.3832
89 nostr.n7ekb.net 47.4941 -122.294
90 wot.shaving.kiwi 43.6532 -79.3832
91 dev-nostr.bityacht.io 25.0797 121.234
92 relay.credenso.cafe 43.1149 -80.7228
93 relay-testnet.k8s.layer3.news 37.3387 -121.885
94 relay.mess.ch 47.3591 8.55292
95 inbox.azzamo.net 52.2633 21.0283
96 prl.plus 55.7623 37.6381
97 yabu.me 35.6092 139.73
98 relayrs.notoshi.win 43.6532 -79.3832
99 premium.primal.net 43.6532 -79.3832
100 nostr.coincrowd.fund 39.0438 -77.4874
101 nostr.2b9t.xyz 34.0549 -118.243
102 nostr.thaliyal.com 40.8218 -74.45
103 relay.exit.pub 50.4754 12.3683
104 nostr.jfischer.org 49.0291 8.35696
105 relay.origin.land 35.6673 139.751
106 nostr.myshosholoza.co.za 52.3676 4.90414
107 relay.nostriot.com 41.5695 -83.9786
108 relay.btcforplebs.com 43.6532 -79.3832
109 relay.chakany.systems 43.6532 -79.3832
110 nostr.openhoofd.nl 51.9229 4.40833
111 nostrcheck.me 43.6532 -79.3832
112 nostr.plantroon.com 50.1013 8.62643
113 satsage.xyz 37.3986 -121.964
114 nostr.faultables.net 43.6532 -79.3832
115 nostr.calitabby.net 39.9268 -75.0246
116 relay.freeplace.nl 52.3676 4.90414
117 relay.nostrhub.tech 49.4543 11.0746
118 roles-az-achieving-somebody.trycloudflare.com 43.6532 -79.3832
119 relay.arx-ccn.com 50.4754 12.3683
120 cyberspace.nostr1.com 40.7128 -74.006
121 nostr.smut.cloud 43.6532 -79.3832
122 nostr-02.czas.top 53.471 9.88208
123 relay.tapestry.ninja 40.8054 -74.0241
124 relay.mostro.network 40.8302 -74.1299
125 wot.brightbolt.net 47.6735 -116.781
126 nostr.spaceshell.xyz 43.6532 -79.3832
127 nostr.rikmeijer.nl 50.4754 12.3683
128 relay.artx.market 43.652 -79.3633
129 strfry.felixzieger.de 50.1013 8.62643
130 relay.seq1.net 43.6532 -79.3832
131 relay.cosmicbolt.net 37.3986 -121.964
132 relay.electriclifestyle.com 26.2897 -80.1293
133 r.bitcoinhold.net 43.6532 -79.3832
134 nostr-relay.amethyst.name 39.0067 -77.4291
135 relay.stream.labs.h3.se 59.4016 17.9455
136 relay.unknown.cloud 43.6532 -79.3832
137 nostr-02.yakihonne.com 1.32123 103.695
138 relay.coinos.io 43.6532 -79.3832
139 relay5.bitransfer.org 43.6532 -79.3832
140 relay-dev.satlantis.io 40.8302 -74.1299
141 nostream.breadslice.com 43.6532 -79.3832
142 relay.fundstr.me 42.3601 -71.0589
143 nostr.oxtr.dev 50.4754 12.3683
144 nos.xmark.cc 50.6924 3.20113
145 nostr.mikoshi.de 50.1109 8.68213
146 relay.magiccity.live 25.8128 -80.2377
147 nostr-verified.wellorder.net 45.5201 -122.99
148 nostr.makibisskey.work 43.6532 -79.3832
149 wot.nostr.party 36.1627 -86.7816
150 relay.copylaradio.com 51.223 6.78245
151 nostr.sathoarder.com 48.5734 7.75211
152 relay.jeffg.fyi 43.6532 -79.3832
153 relay.wellorder.net 45.5201 -122.99
154 nostr.ovia.to 43.6532 -79.3832
155 black.nostrcity.club 41.8781 -87.6298
156 relay.nostrcheck.me 43.6532 -79.3832
157 nostr-relay.cbrx.io 43.6532 -79.3832
158 nostr-03.dorafactory.org 1.35208 103.82
159 nostr-relay.zimage.com 34.282 -118.439
160 relay.sigit.io 50.4754 12.3683
161 relay.laantungir.net -19.4692 -42.5315
162 relay-admin.thaliyal.com 40.8218 -74.45
163 relay.mwaters.net 50.9871 2.12554
164 relay.lumina.rocks 49.0291 8.35695
165 nostr.satstralia.com 64.1476 -21.9392
166 nostr.sagaciousd.com 49.2827 -123.121
167 relay.bullishbounty.com 43.6532 -79.3832
168 wot.dergigi.com 64.1476 -21.9392
169 relay.vrtmrz.net 43.6532 -79.3832
170 nostr.0x7e.xyz 47.4988 8.72369
171 articles.layer3.news 37.3387 -121.885
172 relay.digitalezukunft.cyou 45.5019 -73.5674
173 nostr.notribe.net 40.8302 -74.1299
174 nostr.21crypto.ch 47.4988 8.72369
175 relay.lifpay.me 1.35208 103.82
176 relay.g1sms.fr 43.9432 2.07537
177 relay.snort.social 43.6532 -79.3832
178 relay.getsafebox.app 43.6532 -79.3832
179 relay.moinsen.com 50.4754 12.3683
180 nostr.blankfors.se 60.1699 24.9384
181 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
182 orangesync.tech 50.1109 8.68213
183 relay.ru.ac.th 13.7584 100.622
184 orangepiller.org 60.1699 24.9384
185 relay.nostr.band 60.1699 24.9384
186 wot.soundhsa.com 34.0479 -118.256
187 khatru.nostrver.se 51.8933 4.42083
188 nostr.hifish.org 47.4043 8.57398
189 freelay.sovbit.host 64.1476 -21.9392
190 nostr.bilthon.dev 25.8128 -80.2377
191 nostr.night7.space 50.4754 12.3683
192 relay.illuminodes.com 47.6061 -122.333
193 relayone.soundhsa.com 34.0479 -118.256
194 nostr.azzamo.net 52.2633 21.0283
195 fenrir-s.notoshi.win 43.6532 -79.3832
196 nostr.spicyz.io 43.6532 -79.3832
197 nostrelay.memory-art.xyz 43.6532 -79.3832
198 nostr.coincards.com 53.5501 -113.469
199 alien.macneilmediagroup.com 43.6532 -79.3832
200 nostrings-relay-dev.fly.dev 41.8781 -87.6298
201 relay.satlantis.io 32.8769 -80.0114
202 srtrelay.c-stellar.net 43.6532 -79.3832
203 relay.agora.social 50.7383 15.0648
204 relay.bitcoindistrict.org 43.6532 -79.3832
205 relay.0xchat.com 1.35208 103.82
206 wot.utxo.one 43.6532 -79.3832
207 relay.bitcoinartclock.com 50.4754 12.3683
208 nostr.zenon.network 43.5009 -70.4428
209 wot.codingarena.top 50.4754 12.3683
210 nostr-02.dorafactory.org 1.35208 103.82
211 relay.nostromo.social 49.4543 11.0746
212 theoutpost.life 64.1476 -21.9392
213 strfry.shock.network 41.8959 -88.2169
214 strfry.openhoofd.nl 51.9229 4.40833
215 itanostr.space 52.2931 4.79099
216 relay.barine.co 43.6532 -79.3832
217 nostr.middling.mydns.jp 35.8099 140.12
218 relay.olas.app 50.4754 12.3683
219 nostr.girino.org 43.6532 -79.3832
220 nostr-relay.online 43.6532 -79.3832
221 relay.evanverma.com 40.8302 -74.1299
222 relay.angor.io 48.1046 11.6002
223 relay.nostrdice.com -33.8688 151.209
224 ribo.us.nostria.app 41.5868 -93.625
225 relay01.lnfi.network 39.0997 -94.5786
226 nostr.namek.link 43.6532 -79.3832
227 relay.aloftus.io 34.0881 -118.379
228 ithurtswhenip.ee 51.223 6.78245
229 ribo.af.nostria.app -26.2041 28.0473
230 shu05.shugur.net 48.8566 2.35222
231 noxir.kpherox.dev 34.8587 135.509
232 relay.degmods.com 50.4754 12.3683
233 relay.goodmorningbitcoin.com 43.6532 -79.3832
234 nostr.4rs.nl 49.0291 8.35696
235 nostr-relay.psfoundation.info 39.0438 -77.4874
236 relay.hasenpfeffr.com 39.0438 -77.4874
237 fanfares.nostr1.com 40.7128 -74.006
238 relay.nosto.re 51.8933 4.42083
239 nostr.liberty.fans 36.9104 -89.5875
240 nostr-2.21crypto.ch 47.4988 8.72369
241 relay-rpi.edufeed.org 49.4543 11.0746
242 offchain.pub 36.1809 -115.241
243 nostr.88mph.life 43.6532 -79.3832
244 nostr.luisschwab.net 43.6532 -79.3832
245 nostr.casa21.space 43.6532 -79.3832
246 nostr.hekster.org 37.3986 -121.964
247 relay.utxo.farm 35.6916 139.768
248 zap.watch 45.5029 -73.5723
249 nostr-pub.wellorder.net 45.5201 -122.99
250 wot.sovbit.host 64.1466 -21.9426
251 relay.endfiat.money 43.6532 -79.3832
252 nostr2.girino.org 43.6532 -79.3832
253 nostr.jerrynya.fun 31.2304 121.474
254 nostr.spacecitynode.com 29.7057 -95.2706
255 relay.siamdev.cc 13.9178 100.424
256 relay.notoshi.win 13.7829 100.546
257 relay.nostx.io 43.6532 -79.3832
258 relay.cypherflow.ai 48.8566 2.35222
259 relay.letsfo.com 51.098 17.0321
260 librerelay.aaroniumii.com 43.6532 -79.3832
261 gnostr.com 40.9017 29.1616
262 nostr.pleb.one 38.6327 -90.1961
263 nostr.mehdibekhtaoui.com 49.4939 -1.54813
264 nostr.tadryanom.me 43.6532 -79.3832
265 relay.orangepill.ovh 49.1689 -0.358841
266 nostr.stakey.net 52.3676 4.90414
267 nostr.rtvslawenia.com 49.4543 11.0746
268 relay.damus.io 43.6532 -79.3832
269 dev-relay.lnfi.network 39.0997 -94.5786
270 slick.mjex.me 39.048 -77.4817
271 wot.sudocarlos.com 51.5072 -0.127586
272 relay04.lnfi.network 39.0997 -94.5786
273 relay.13room.space 43.6532 -79.3832
274 relay.conduit.market 43.6532 -79.3832
275 nostr.chaima.info 51.223 6.78245
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
echo "bitchat Setup Script"
echo "==================="
# Check if XcodeGen is installed
if command -v xcodegen &> /dev/null; then
echo "✓ XcodeGen found"
echo "Generating Xcode project..."
xcodegen generate
echo "✓ Project generated successfully"
echo ""
echo "To open the project, run:"
echo " open bitchat.xcodeproj"
else
echo "⚠️ XcodeGen not found"
echo ""
echo "You have several options:"
echo "1. Install XcodeGen:"
echo " brew install xcodegen"
echo ""
echo "2. Open with Swift Package Manager:"
echo " open Package.swift"
echo ""
echo "3. Create a new Xcode project manually and add the source files"
fi
echo ""
echo "Project Structure:"
echo "- bitchat/ Main source files"
echo " - BitchatApp.swift App entry point"
echo " - Views/ SwiftUI views"
echo " - ViewModels/ View models"
echo " - Services/ Bluetooth and encryption"
echo " - Protocols/ Protocol definitions"
echo ""
echo "Remember to:"
echo "1. Enable Bluetooth in device settings"
echo "2. Run on physical devices (Bluetooth doesn't work in simulator)"
echo "3. Test with multiple devices for mesh functionality"