From 862e44e2e1624b44ede8acae5ba9f31c22346f9b Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 26 Jul 2026 18:10:50 +0200 Subject: [PATCH] Keep working when the network is hostile, and make the app verifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 3 of a protest-hardening review. The BLE mesh is already properly fenced off from network reachability and needs nothing here; every gap is on the internet side, or in how someone gets a build they can trust. Say when Tor is blocked. The bootstrap poll loop simply ended at its 75-second deadline, leaving isStarting true with no further state, so a network that blocks Tor was indistinguishable from a slow one and the UI said "starting tor…" indefinitely. TorManager now exposes bootstrapDidStall and posts .TorBootstrapDidStall, and the app reports that mesh messaging still works while internet delivery is paused. It is cleared on each new start or restart, so a later attempt can report again. Let relays be added by hand. The four built-in relays are well-known clearnet hostnames, which is four names for a censor to block and no recourse short of a new build. NostrRelaySettings persists up to eight additional relays, normalized, .onion accepted, with a settings editor. They join the same target set as the built-ins and are subject to the same activation policy. Removal reconciles against the previous set: the teardown path iterates the current targets, so without that a removed relay's socket and queued sends would linger — covered by a test that fails without it. The merged list is cached rather than recomputed, because allowedRelayList consults it once per candidate URL and would otherwise read UserDefaults inside that loop. Stop stranding people who denied location. The activation gate required location permission or a mutual favorite, but teleporting into a geohash requires neither, so someone with no permission and no favorites could sit in a channel that never connected while Tor and the relays stayed suppressed and nothing said why. Being in a location channel is now a third arm of the gate, in both the activation service and the relay manager's copy of the policy, and leaving the channel closes it again. Stop burning the Tor timeout when Tor is off. GeoRelayDirectory awaited Tor readiness unconditionally, but with the preference off TorManager has been shut down, so every refresh spent the full bootstrap deadline and the directory froze on its cached copy. It now keys on the preference, not on live readiness: Tor wanted but unavailable must still skip the fetch rather than fall back to clearnet. Say what turning Tor off costs. The toggle's copy described it as hiding your IP "for location channels", understating both scope and consequence. It now names private messages too, and while the toggle is off the settings screen states that every relay can see the device IP. Make builds verifiable. There was no release verification of any kind: no signatures, no checksums, no documented procedure. Post-takedown that is the acute gap, because mirrors appear and people install whatever they can find during a shutdown. source-manifest.yml publishes a per-tag SHA-256 manifest with a provenance attestation, self-checking before it publishes, and docs/VERIFYING-A-BUILD.md explains how to verify source and states plainly that compiled builds from anywhere but the App Store cannot be verified. It also records the gaps honestly: no published signing key, no reproducible build, no non-GitHub mirror. docs/TOR-INTEGRATION.md was substantially stale — it documented a torrc, SOCKSPort and ControlPort that the in-process Arti client does not use, and claimed there are no user-visible settings — so it is rewritten, including the deferred gap below. Deferred: no Tor bridges or pluggable transports. arti-client is built without pt-client or bridge-client and bootstraps from stock config, so in a country that blocks Tor outright there is still no circumvention path — only a clear report that there isn't. Closing it needs the Rust features, bridge config through the FFI, and an xcframework rebuild under the pinned toolchain with a provenance update, which is its own change. Co-Authored-By: Claude Opus 5 --- .github/workflows/source-manifest.yml | 106 + PRIVACY_POLICY.md | 2 + README.md | 6 + bitchat/App/AppArchitecture.swift | 2 + bitchat/App/AppRuntime.swift | 10 + bitchat/Localizable.xcstrings | 2417 +++++++++++++++-- bitchat/Nostr/GeoRelayDirectory.swift | 14 +- bitchat/Nostr/NostrRelayManager.swift | 169 +- bitchat/Nostr/NostrRelaySettings.swift | 92 + .../Services/NetworkActivationService.swift | 53 +- bitchat/ViewModels/ChatViewModel.swift | 5 + .../Extensions/ChatViewModel+Tor.swift | 23 + bitchat/Views/AppInfoView.swift | 147 +- .../Nostr/NostrRelaySettingsTests.swift | 130 + .../NetworkActivationServiceTests.swift | 67 +- .../Services/NostrRelayManagerTests.swift | 68 +- .../Services/TorPreferenceReadTests.swift | 38 + docs/TOR-INTEGRATION.md | 80 +- docs/VERIFYING-A-BUILD.md | 77 + docs/privacy-assessment.md | 5 +- localPackages/Arti/Sources/TorManager.swift | 32 +- .../Arti/Sources/TorNotifications.swift | 3 + 22 files changed, 3253 insertions(+), 293 deletions(-) create mode 100644 .github/workflows/source-manifest.yml create mode 100644 bitchat/Nostr/NostrRelaySettings.swift create mode 100644 bitchatTests/Nostr/NostrRelaySettingsTests.swift create mode 100644 bitchatTests/Services/TorPreferenceReadTests.swift create mode 100644 docs/VERIFYING-A-BUILD.md diff --git a/.github/workflows/source-manifest.yml b/.github/workflows/source-manifest.yml new file mode 100644 index 00000000..6f8a0c60 --- /dev/null +++ b/.github/workflows/source-manifest.yml @@ -0,0 +1,106 @@ +name: Source manifest + +# Publishes a hash manifest for every tagged release so a copy of the source +# obtained from somewhere other than this repository can be checked against it. +# +# This exists because the repository has been the target of takedown demands. +# When that succeeds, mirrors appear, and without a manifest there is no way to +# tell a faithful mirror from a modified one. The manifest is attested to this +# workflow run, so its own provenance is verifiable with `gh attestation verify`. +# +# Scope, stated plainly: this verifies SOURCE. It does not verify any compiled +# app. See docs/VERIFYING-A-BUILD.md. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + ref: + description: 'Tag or commit to produce a manifest for' + required: true + +permissions: + contents: read + +jobs: + manifest: + runs-on: ubuntu-latest + permissions: + contents: write # attach the manifest to the release + id-token: write # provenance attestation + attestations: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + # Full history so the commit the tag names can be recorded exactly. + fetch-depth: 0 + + - name: Build manifest + id: build + run: | + set -euo pipefail + ref_name="${{ github.event.inputs.ref || github.ref_name }}" + commit="$(git rev-parse HEAD)" + tree="$(git rev-parse HEAD^{tree})" + + # Hash every tracked file, in a stable order, with NUL separation so + # paths containing spaces or newlines cannot shift the columns. + git ls-files -z \ + | sort -z \ + | xargs -0 sha256sum \ + > files.sha256 + + { + echo "# bitchat source manifest" + echo "#" + echo "# ref: ${ref_name}" + echo "# commit: ${commit}" + echo "# tree: ${tree}" + echo "# files: $(wc -l < files.sha256 | tr -d ' ')" + echo "#" + echo "# Verify a checkout of this ref with:" + echo "# shasum -a 256 -c files.sha256" + echo "# The git tree hash above is the single value that covers all of it:" + echo "# git rev-parse HEAD^{tree}" + echo "#" + } > SOURCE-MANIFEST.txt + cat files.sha256 >> SOURCE-MANIFEST.txt + + echo "commit=${commit}" >> "$GITHUB_OUTPUT" + echo "tree=${tree}" >> "$GITHUB_OUTPUT" + + - name: Self-check the manifest + run: | + set -euo pipefail + # A manifest that does not validate against the tree it was made from + # is worse than none, so fail loudly rather than publishing it. + grep -v '^#' SOURCE-MANIFEST.txt > check.sha256 + sha256sum -c check.sha256 > /dev/null + echo "manifest validates against this checkout" + + - name: Attest the manifest + uses: actions/attest-build-provenance@v1 + with: + subject-path: SOURCE-MANIFEST.txt + + - uses: actions/upload-artifact@v4 + with: + name: source-manifest + path: SOURCE-MANIFEST.txt + + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # A tag may be pushed before its release exists; only attach when + # there is a release to attach to, and never fail the run over it. + if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then + gh release upload "${{ github.ref_name }}" SOURCE-MANIFEST.txt --clobber + else + echo "no release for ${{ github.ref_name }} yet; manifest is available as a workflow artifact" + fi diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 8a6f8128..7d8deead 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -81,6 +81,8 @@ Internet-backed features are optional. When enabled or used: Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy. +You can add relays yourself in settings, including `.onion` addresses. Added relays are stored locally, are limited in number, and are erased by panic wipe. Tor routing is on by default; while it is off, every relay you connect to can see your IP address, including relays carrying your private messages. + ## Location and Apple Services Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels. diff --git a/README.md b/README.md index 7c4f6a45..2290ffb8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) +### Getting a copy you can trust + +Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get. + +This matters more than it usually would: this repository has been the target of takedown demands, and when a repository or releases page disappears, mirrors appear that nobody can check. + ## License This project is released into the public domain. See the [LICENSE](LICENSE) file for details. diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index 05b8b5e2..233544eb 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -13,6 +13,8 @@ enum TorLifecycleEvent: String, Sendable, Equatable { case willRestart case didBecomeReady case preferenceChanged + /// Bootstrap ran out its deadline without completing. + case bootstrapDidStall } enum AppEvent: Sendable, Equatable { diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index bc21d4fe..8ae1a714 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -319,6 +319,16 @@ private extension AppRuntime { } .store(in: &cancellables) + NotificationCenter.default.publisher(for: .TorBootstrapDidStall) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } + self?.record(.torLifecycleChanged(.bootstrapDidStall)) + self?.chatViewModel.handleTorBootstrapDidStall() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: .TorUserPreferenceChanged) .receive(on: DispatchQueue.main) .sink { [weak self] notification in diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index c5f6e84d..5d379eb9 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -12468,6 +12468,2052 @@ } } }, + "app_info.settings.relays.add" : { + "comment" : "Button that adds the typed relay address to the list", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "যোগ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "add" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "añadir" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "افزودن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "idagdag" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajouter" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जोड़ें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambah" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "aggiungi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambah" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "थप" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodaj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "добавить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lägg till" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சேர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "додати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "شامل کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入" + } + } + } + }, + "app_info.settings.relays.built_in" : { + "comment" : "Label marking a relay as one of the built-in relays, which cannot be removed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مدمج" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বিল্ট-ইন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "eingebaut" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "built in" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "داخلی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "built in" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "intégré" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מובנה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अंतर्निर्मित" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bawaan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "組み込み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 제공" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "terbina dalam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पूर्वनिर्मित" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingebouwd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wbudowany" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "встроенное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inbyggt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "உள்ளமைந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มาพร้อมแอป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yerleşik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "вбудований" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلٹ اِن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tích hợp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "內建" + } + } + } + }, + "app_info.settings.relays.error.duplicate" : { + "comment" : "Error shown when the typed relay address is already in the list", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هذا المرحّل موجود في القائمة بالفعل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই রিলে আগেই তালিকায় আছে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dieses relay steht schon in der liste." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "that relay is already in the list." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ese relay ya está en la lista." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این رله از قبل در فهرست است." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nasa listahan na ang relay na iyon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ce relais est déjà dans la liste." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הממסר הזה כבר ברשימה." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वह रिले पहले से सूची में है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay itu sudah ada di daftar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "quel relay è già nell'elenco." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "そのリレーはすでにリストにあります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그 릴레이는 이미 목록에 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay itu sudah ada dalam senarai." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "त्यो रिले पहिले नै सूचीमा छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "die relay staat al in de lijst." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ten relay już jest na liście." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "esse relé já está na lista." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "esse relay já está na lista." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "это реле уже в списке." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "det reläet finns redan i listan." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அந்த ரிலே ஏற்கெனவே பட்டியலில் உள்ளது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รีเลย์นี้อยู่ในรายการแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu röle listede zaten var." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "цей релей уже в списку." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ ریلے پہلے ہی فہرست میں ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay đó đã có trong danh sách." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该中继已在列表中。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該中繼已在清單中。" + } + } + } + }, + "app_info.settings.relays.error.limit" : { + "comment" : "Error shown when the relay list is already at its maximum size; %d is that maximum", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يمكنك إضافة %d مرحّلات كحد أقصى." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আপনি সর্বোচ্চ %d টি রিলে যোগ করতে পারেন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "du kannst bis zu %d relays hinzufügen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you can add up to %d relays." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "puedes añadir hasta %d relays." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "می‌توانید تا %d رله اضافه کنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hanggang %d relay lang ang maaari mong idagdag." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tu peux ajouter jusqu'à %d relais." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אפשר להוסיף עד %d ממסרים." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आप %d रिले तक जोड़ सकते हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kamu bisa menambahkan hingga %d relay." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "puoi aggiungere fino a %d relay." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーは最大%d件まで追加できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이는 최대 %d개까지 추가할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "anda boleh menambah sehingga %d relay." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तिमी %d रिलेसम्म थप्न सक्छौ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "je kunt tot %d relays toevoegen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "możesz dodać maksymalnie %d relay." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "podes adicionar até %d relés." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "você pode adicionar até %d relays." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "можно добавить до %d реле." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "du kan lägga till upp till %d reläer." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ரிலேகள் வரை சேர்க்கலாம்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่มรีเลย์ได้สูงสุด %d รายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "en fazla %d röle ekleyebilirsiniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "можна додати до %d релеїв." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آپ %d ریلے تک شامل کر سکتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bạn có thể thêm tối đa %d relay." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最多可以添加 %d 个中继。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "最多可以加入 %d 個中繼。" + } + } + } + }, + "app_info.settings.relays.error.malformed" : { + "comment" : "Error shown when a typed relay address cannot be parsed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يبدو هذا عنوان مرحّل. جرّب wss://host." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এটি রিলের ঠিকানার মতো মনে হচ্ছে না। wss://host দিয়ে চেষ্টা করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "das sieht nicht wie eine relay-adresse aus. versuch wss://host." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "that doesn't look like a relay address. try wss://host." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eso no parece una dirección de relay. prueba wss://host." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این شبیه نشانی رله نیست. wss://host را امتحان کنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mukhang hindi ito address ng relay. subukan ang wss://host." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ça ne ressemble pas à une adresse de relais. essaie wss://host." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זה לא נראה כמו כתובת של ממסר. נסה wss://host." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह रिले का पता नहीं लगता। wss://host आज़माएँ।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "itu tidak tampak seperti alamat relay. coba wss://host." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "non sembra l'indirizzo di un relay. prova wss://host." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーのアドレスではないようです。wss://host を試してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 주소가 아닌 것 같습니다. wss://host 형식으로 시도하세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "itu tidak kelihatan seperti alamat relay. cuba wss://host." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो रिलेको ठेगाना जस्तो देखिँदैन। wss://host प्रयास गर।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dat lijkt niet op een relay-adres. probeer wss://host." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "to nie wygląda na adres relay. spróbuj wss://host." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "isso não parece um endereço de relé. tenta wss://host." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "isso não parece um endereço de relay. tente wss://host." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "это не похоже на адрес реле. попробуй wss://host." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "det ser inte ut som en reläadress. prova wss://host." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இது ரிலே முகவரி போல் தெரியவில்லை. wss://host முயற்சிக்கவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ดูเหมือนไม่ใช่ที่อยู่รีเลย์ ลอง wss://host" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu bir röle adresine benzemiyor. wss://host deneyin." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "це не схоже на адресу релея. спробуй wss://host." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ ریلے کا پتہ نہیں لگتا۔ wss://host آزمائیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đó không giống địa chỉ relay. hãy thử wss://host." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这看起来不像中继地址。试试 wss://host。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這看起來不像中繼位址。試試 wss://host。" + } + } + } + }, + "app_info.settings.relays.placeholder" : { + "comment" : "Placeholder text in the field for adding a relay address", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + } + } + }, + "app_info.settings.relays.remove" : { + "comment" : "Accessibility label for the button that removes an added relay", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إزالة المرحّل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রিলে সরান" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay entfernen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remove relay" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quitar relay" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف رله" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "alisin ang relay" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "retirer le relais" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הסר ממסר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिले हटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus relay" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rimuovi relay" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーを削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 제거" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "buang relay" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिले हटाउ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "usuń relay" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "remover relé" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "remover relay" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "удалить реле" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ta bort relä" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ரிலேயை நீக்கு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลบรีเลย์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "röleyi kaldır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "видалити релей" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریلے ہٹائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa relay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除中继" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除中繼" + } + } + } + }, + "app_info.settings.relays.subtitle" : { + "comment" : "Subtitle explaining what the relay list is for and why someone would add a relay", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عندما لا تصل شبكة mesh إلى شخص، تنتقل الرسائل الخاصة عبر هذه المرحّلات. المرحّلات المدمجة عناوين معروفة يمكن لمرشّح الشبكة حجبها — لذا يمكنك إضافة مرحّلاتك الخاصة، بما في ذلك عناوين .onion." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ কারও কাছে পৌঁছাতে না পারলে ব্যক্তিগত বার্তা এই রিলেগুলোর মধ্য দিয়ে যায়। বিল্ট-ইন রিলেগুলো সুপরিচিত ঠিকানা, যা নেটওয়ার্ক ফিল্টার আটকে দিতে পারে — তাই আপনি নিজের রিলে যোগ করতে পারেন, .onion ঠিকানাও।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "wenn das mesh jemanden nicht erreicht, laufen private nachrichten über diese relays. die eingebauten sind bekannte adressen, die ein netzwerkfilter blockieren kann — du kannst also eigene hinzufügen, auch .onion-adressen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "when the mesh can't reach someone, private messages travel through these relays. the built-in ones are well-known addresses that a network filter can block, so you can add your own — including .onion addresses." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuando el mesh no llega a alguien, los mensajes privados viajan por estos relays. los integrados son direcciones muy conocidas que un filtro de red puede bloquear, así que puedes añadir los tuyos — incluidas direcciones .onion." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وقتی مش به کسی نمی‌رسد، پیام‌های خصوصی از این رله‌ها می‌گذرند. رله‌های داخلی نشانی‌های شناخته‌شده‌ای هستند که یک صافی شبکه می‌تواند مسدودشان کند — پس می‌توانید رله‌های خودتان را اضافه کنید، از جمله نشانی‌های .onion." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kapag hindi maabot ng mesh ang isang tao, dumadaan ang mga pribadong mensahe sa mga relay na ito. ang mga built-in ay kilalang-kilalang address na kayang harangin ng filter ng network — kaya maaari kang magdagdag ng sarili mo, kasama ang mga .onion address." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "quand le mesh ne joint pas quelqu'un, les messages privés passent par ces relais. ceux intégrés sont des adresses bien connues qu'un filtre réseau peut bloquer — tu peux donc ajouter les tiens, y compris des adresses .onion." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כשה-mesh לא מגיע למישהו, הודעות פרטיות עוברות דרך הממסרים האלה. הממסרים המובנים הם כתובות מוכרות שמסנן רשת יכול לחסום — אפשר להוסיף ממסרים משלך, כולל כתובות .onion." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जब मेश किसी तक नहीं पहुँच पाता, तब निजी संदेश इन रिले से होकर जाते हैं। अंतर्निर्मित रिले जाने-पहचाने पते हैं जिन्हें नेटवर्क फ़िल्टर रोक सकता है — इसलिए आप अपने रिले जोड़ सकते हैं, .onion पते भी।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "saat mesh tidak bisa menjangkau seseorang, pesan pribadi lewat relay ini. relay bawaan adalah alamat yang sudah dikenal luas dan bisa diblokir filter jaringan — jadi kamu bisa menambahkan relay sendiri, termasuk alamat .onion." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "quando la mesh non raggiunge qualcuno, i messaggi privati passano da questi relay. quelli integrati sono indirizzi molto noti che un filtro di rete può bloccare — puoi quindi aggiungere i tuoi, anche indirizzi .onion." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshで相手に届かないとき、プライベートメッセージはこれらのリレーを通ります。組み込みのリレーはよく知られたアドレスで、ネットワークのフィルターにブロックされることがあります — 自分のリレー、.onionアドレスも追加できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh로 상대에게 닿지 않으면 비공개 메시지는 이 릴레이를 거칩니다. 기본 릴레이는 널리 알려진 주소여서 네트워크 필터가 차단할 수 있습니다 — 그래서 .onion 주소를 포함해 직접 릴레이를 추가할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "apabila mesh tidak dapat mencapai seseorang, pesan peribadi bergerak melalui relay ini. relay terbina dalam ialah alamat yang terkenal dan boleh dihalang oleh penapis rangkaian — jadi anda boleh menambah relay sendiri, termasuk alamat .onion." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh कसैसम्म पुग्न सक्दैन भने निजी सन्देश यी रिले मार्फत जान्छन्। पूर्वनिर्मित रिले सबैलाई थाहा भएका ठेगाना हुन्, जसलाई नेटवर्क फिल्टरले रोक्न सक्छ — त्यसैले तिमी आफ्नै रिले थप्न सक्छौ, .onion ठेगाना पनि।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "als de mesh iemand niet bereikt, gaan privéberichten via deze relays. de ingebouwde relays zijn bekende adressen die een netwerkfilter kan blokkeren — je kunt dus je eigen relays toevoegen, ook .onion-adressen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gdy mesh nie dosięga kogoś, wiadomości prywatne idą przez te relay. wbudowane to powszechnie znane adresy, które filtr sieciowy może zablokować — możesz więc dodać własne, także adresy .onion." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "quando a mesh não chega a alguém, as mensagens privadas seguem por estes relés. os integrados são endereços bem conhecidos que um filtro de rede pode bloquear — por isso podes acrescentar os teus, incluindo endereços .onion." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "quando o mesh não alcança alguém, as mensagens privadas passam por estes relays. os integrados são endereços conhecidos que um filtro de rede pode bloquear — então você pode adicionar os seus, inclusive endereços .onion." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "когда mesh не достаёт до человека, приватные сообщения идут через эти реле. встроенные — это широко известные адреса, которые может заблокировать сетевой фильтр, поэтому можно добавить свои, в том числе .onion-адреса." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "när mesh inte når fram till någon går privata meddelanden via dessa reläer. de inbyggda är välkända adresser som ett nätverksfilter kan blockera — du kan därför lägga till egna, även .onion-adresser." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh ஒருவரை எட்ட முடியாதபோது, தனிப்பட்ட செய்திகள் இந்த ரிலேகள் வழியாகச் செல்கின்றன. உள்ளமைந்த ரிலேகள் நன்கு அறியப்பட்ட முகவரிகள், அவற்றை நெட்வொர்க் வடிகட்டி தடுக்க முடியும் — எனவே .onion முகவரிகள் உட்பட உங்கள் சொந்த ரிலேகளைச் சேர்க்கலாம்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เมื่อ mesh ไปไม่ถึงใคร ข้อความส่วนตัวจะเดินทางผ่านรีเลย์เหล่านี้ รีเลย์ที่มาพร้อมแอปเป็นที่อยู่ที่รู้จักกันดีและตัวกรองเครือข่ายปิดกั้นได้ — คุณจึงเพิ่มรีเลย์ของตัวเองได้ รวมถึงที่อยู่ .onion" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh birine ulaşamadığında özel mesajlar bu rölelerden geçer. yerleşik röleler herkesin bildiği adreslerdir ve bir ağ filtresi bunları engelleyebilir — bu yüzden .onion adresleri de dahil kendi rölelerinizi ekleyebilirsiniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "коли mesh не дотягується до людини, приватні повідомлення йдуть через ці релеї. вбудовані — це добре відомі адреси, які може заблокувати мережевий фільтр, тож можна додати власні, зокрема .onion-адреси." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "جب mesh کسی تک نہ پہنچ سکے تو نجی پیغامات ان ریلے سے گزرتے ہیں۔ بلٹ اِن ریلے مشہور پتے ہیں جنہیں نیٹ ورک فلٹر روک سکتا ہے — اس لیے آپ اپنے ریلے شامل کر سکتے ہیں، .onion پتے بھی۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khi mesh không tới được ai đó, tin nhắn riêng đi qua các relay này. các relay tích hợp là những địa chỉ ai cũng biết nên bộ lọc mạng có thể chặn — vì vậy bạn có thể thêm relay của mình, kể cả địa chỉ .onion." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当 mesh 无法触达某人时,私密消息会经这些中继传递。内置中继是众所周知的地址,网络过滤可以封锁它们 — 所以你可以添加自己的中继,包括 .onion 地址。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當 mesh 無法觸及某人時,私密訊息會經這些中繼傳遞。內建中繼是眾所周知的位址,網路過濾可以封鎖它們 — 所以你可以加入自己的中繼,包括 .onion 位址。" + } + } + } + }, + "app_info.settings.relays.title" : { + "comment" : "Title of the relay list editor in settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرحّلات الرسائل الخاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যক্তিগত বার্তার রিলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays für private nachrichten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "private message relays" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays para mensajes privados" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رله‌های پیام خصوصی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga relay para sa pribadong mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relais pour messages privés" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ממסרים להודעות פרטיות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "निजी संदेश रिले" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay pesan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay per messaggi privati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プライベートメッセージのリレー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비공개 메시지 릴레이" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay pesan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "निजी सन्देशका रिले" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays voor privéberichten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay dla wiadomości prywatnych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "relés para mensagens privadas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays para mensagens privadas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "реле для приватных сообщений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "reläer för privata meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தனிப்பட்ட செய்திகளுக்கான ரிலேகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รีเลย์สำหรับข้อความส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "özel mesaj röleleri" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "релеї для приватних повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نجی پیغامات کے ریلے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay cho tin nhắn riêng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "私密消息中继" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "私密訊息中繼" + } + } + } + }, + "app_info.settings.tor.off_warning" : { + "comment" : "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor متوقف: كل مرحّل تتصل به يمكنه رؤية عنوان IP الخاص بك، بما في ذلك المرحّلات التي تحمل رسائلك الخاصة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor বন্ধ: আপনি যে রিলেতেই সংযুক্ত হন সেটি আপনার IP ঠিকানা দেখতে পায়, আপনার ব্যক্তিগত বার্তা বহনকারী রিলেগুলোও।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor ist aus: jedes relay, mit dem du dich verbindest, kann deine ip-adresse sehen, auch die relays, die deine privaten nachrichten tragen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor está desactivado: cada relay al que te conectas puede ver tu dirección IP, incluidos los relays que llevan tus mensajes privados." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor خاموش است: هر رله‌ای که به آن وصل می‌شوید می‌تواند نشانی IP شما را ببیند، از جمله رله‌هایی که پیام‌های خصوصی شما را می‌برند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-off ang tor: nakikita ng bawat relay na kinokonekta mo ang iyong IP address, kasama ang mga relay na nagdadala ng iyong mga pribadong mensahe." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor est désactivé : chaque relais auquel tu te connectes voit ton adresse ip, y compris les relais qui transportent tes messages privés." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor כבוי: כל ממסר שאתה מתחבר אליו רואה את כתובת ה-ip שלך, כולל ממסרים שנושאים את ההודעות הפרטיות שלך." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor बंद है: आप जिस भी रिले से जुड़ते हैं वह आपका IP पता देख सकता है, उनमें वे रिले भी शामिल हैं जो आपके निजी संदेश ले जाते हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor nonaktif: setiap relay yang kamu hubungi bisa melihat alamat ip-mu, termasuk relay yang membawa pesan pribadimu." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor è disattivato: ogni relay a cui ti colleghi può vedere il tuo indirizzo ip, compresi i relay che trasportano i tuoi messaggi privati." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "torはオフです: 接続するすべてのリレーがあなたのipアドレスを見られます。プライベートメッセージを運ぶリレーも同じです。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor가 꺼져 있습니다: 연결하는 모든 릴레이가 IP 주소를 볼 수 있고, 비공개 메시지를 운반하는 릴레이도 마찬가지입니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak aktif: setiap relay yang anda sambung boleh melihat alamat ip anda, termasuk relay yang membawa pesan peribadi anda." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor बन्द छ: तिमी जोडिने हरेक रिलेले तिम्रो ip ठेगाना देख्न सक्छ, तिम्रा निजी सन्देश बोक्ने रिले पनि।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor staat uit: elke relay waarmee je verbindt kan je IP-adres zien, ook de relays die je privéberichten vervoeren." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor jest wyłączony: każdy relay, z którym się łączysz, widzi twój adres IP, także te przenoszące twoje wiadomości prywatne." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o Tor está desligado: cada relé a que te ligas consegue ver o teu endereço IP, incluindo os relés que transportam as tuas mensagens privadas." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor está desligado: cada relay a que você se conecta consegue ver seu endereço ip, inclusive os relays que carregam suas mensagens privadas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor выключен: каждое реле, к которому ты подключаешься, видит твой ip-адрес, включая реле, через которые идут твои приватные сообщения." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor är av: varje relä du ansluter till kan se din IP-adress, även reläerna som bär dina privata meddelanden." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor அணைந்திருக்கிறது: நீங்கள் இணைக்கும் ஒவ்வொரு ரிலேயும் உங்கள் IP முகவரியைப் பார்க்க முடியும், உங்கள் தனிப்பட்ட செய்திகளை எடுத்துச் செல்லும் ரிலேகளும் அதில் அடங்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor ปิดอยู่: ทุกรีเลย์ที่คุณเชื่อมต่อเห็นที่อยู่ IP ของคุณ รวมถึงรีเลย์ที่ส่งข้อความส่วนตัวของคุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor kapalı: bağlandığınız her röle IP adresinizi görebilir, özel mesajlarınızı taşıyan röleler de dahil." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor вимкнено: кожен релей, до якого ти підключаєшся, бачить твою ip-адресу, включно з релеями, що несуть твої приватні повідомлення." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor بند ہے: آپ جس ریلے سے بھی جڑتے ہیں وہ آپ کا IP پتہ دیکھ سکتا ہے، اُن ریلے سمیت جو آپ کے نجی پیغامات لے جاتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor đang tắt: mọi relay bạn kết nối đều thấy địa chỉ IP của bạn, kể cả những relay mang tin nhắn riêng của bạn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 已关闭:你连接的每个中继都能看到你的 IP 地址,包括承载你私密消息的中继。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 已關閉:你連線的每個中繼都能看到你的 IP 位址,包括承載你私密訊息的中繼。" + } + } + } + }, + "app_info.settings.tor.subtitle" : { + "comment" : "Subtitle for the tor routing toggle in settings, explaining what it covers", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يرسل حركة الإنترنت عبر tor، فيرى مشغّلو المرحّلات عنوان tor بدلاً من عنوانك. يشمل قنوات الموقع والرسائل الخاصة المسلَّمة عبر الإنترنت. الموصى به: تشغيل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ইন্টারনেট ট্রাফিক Tor দিয়ে পাঠায়, তাই রিলে পরিচালকেরা আপনার ঠিকানার বদলে Tor-এর ঠিকানা দেখে। লোকেশন চ্যানেল ও ইন্টারনেটে পাঠানো ব্যক্তিগত বার্তা এর আওতায় পড়ে। সুপারিশ: চালু।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "sendet internetverkehr über tor, sodass relay-betreiber die adresse von tor statt deiner sehen. gilt für standortkanäle und private nachrichten, die über das internet zugestellt werden. empfohlen: an." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sends internet traffic through tor, so relay operators see tor's address instead of yours. covers location channels and private messages delivered over the internet. recommended: on." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "envía el tráfico de internet a través de Tor, así quienes gestionan los relays ven la dirección de Tor en lugar de la tuya. cubre los canales de ubicación y los mensajes privados entregados por internet. recomendado: activado." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ترافیک اینترنت را از طریق Tor می‌فرستد، بنابراین گردانندگان رله به‌جای نشانی شما نشانی Tor را می‌بینند. کانال‌های موقعیت و پیام‌های خصوصی که از اینترنت تحویل می‌شوند را پوشش می‌دهد. توصیه: روشن." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "ipinapadala ang trapiko sa internet sa pamamagitan ng tor, kaya nakikita ng mga nagpapatakbo ng relay ang address ng tor at hindi ang sa iyo. saklaw nito ang mga channel ng lokasyon at ang mga pribadong mensaheng ipinapadala sa internet. inirerekomenda: naka-on." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "envoie le trafic internet par tor, donc ceux qui gèrent les relais voient l'adresse de tor et pas la tienne. couvre les canaux de localisation et les messages privés livrés par internet. recommandé : activé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שולח את תעבורת האינטרנט דרך tor, כך שמפעילי ממסרים רואים את הכתובת של tor במקום שלך. חל על ערוצי מיקום ועל הודעות פרטיות שנשלחות דרך האינטרנט. מומלץ: פעיל." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इंटरनेट ट्रैफ़िक Tor से भेजता है, जिससे रिले चलाने वालों को आपके पते की जगह Tor का पता दिखता है। यह लोकेशन चैनलों और इंटरनेट से पहुँचाए जाने वाले निजी संदेशों पर लागू होता है। अनुशंसित: चालू।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mengirim lalu lintas internet lewat tor, jadi pengelola relay melihat alamat tor bukan alamatmu. berlaku untuk kanal lokasi dan pesan pribadi yang dikirim lewat internet. disarankan: aktif." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "invia il traffico internet attraverso tor, così chi gestisce i relay vede l'indirizzo di tor invece del tuo. riguarda i canali posizione e i messaggi privati consegnati via internet. consigliato: attivo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インターネット通信をtor経由で送るため、リレーの運営者にはあなたの代わりにtorのアドレスが見えます。ロケーションチャンネルと、インターネット経由で届くプライベートメッセージが対象です。推奨: オン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인터넷 트래픽을 tor를 통해 보내므로 릴레이를 운영하는 쪽에는 내 주소가 아니라 tor의 주소가 보입니다. 위치 채널과 인터넷으로 전달되는 비공개 메시지에 적용됩니다. 권장: 켜기." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghantar trafik internet melalui tor, jadi pengendali relay melihat alamat tor dan bukan alamat anda. ia merangkumi kanal lokasi dan pesan peribadi yang dihantar melalui internet. disarankan: aktif." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "इन्टरनेट ट्राफिक tor मार्फत पठाउँछ, त्यसैले रिले चलाउनेहरूले तिम्रो ठेगानाको सट्टा tor को ठेगाना देख्छन्। यो स्थान च्यानल र इन्टरनेटबाट पुग्ने निजी सन्देशमा लागू हुन्छ। सिफारिस: अन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "stuurt internetverkeer via tor, zodat relaybeheerders het adres van tor zien in plaats van het jouwe. geldt voor locatiekanalen en privéberichten die via internet worden bezorgd. aanbevolen: aan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wysyła ruch internetowy przez tor, więc osoby prowadzące relay widzą adres tor, a nie twój. dotyczy kanałów lokalizacji i wiadomości prywatnych dostarczanych przez internet. zalecane: włączone." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "envia o tráfego de internet através do Tor, por isso quem opera os relés vê o endereço do Tor em vez do teu. abrange os canais de localização e as mensagens privadas entregues pela internet. recomendado: ligado." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "envia o tráfego de internet pelo tor, então quem opera os relays vê o endereço do tor em vez do seu. vale para os canais de localização e as mensagens privadas entregues pela internet. recomendado: ligado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "отправляет интернет-трафик через tor, поэтому операторы реле видят адрес tor, а не твой. распространяется на каналы локации и приватные сообщения, доставляемые через интернет. рекомендуем включить." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "skickar internettrafik via tor, så de som driver reläer ser tors adress i stället för din. gäller platskanaler och privata meddelanden som levereras över internet. rekommenderas: på." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இணைய போக்குவரத்தை tor வழியாக அனுப்புகிறது, அதனால் ரிலேகளை நடத்துபவர்கள் உங்கள் முகவரிக்குப் பதிலாக tor இன் முகவரியைப் பார்க்கிறார்கள். இட சேனல்களுக்கும் இணையம் வழியாக வழங்கப்படும் தனிப்பட்ட செய்திகளுக்கும் இது பொருந்தும். பரிந்துரை: இயக்கவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่งทราฟฟิกอินเทอร์เน็ตผ่าน tor ผู้ดูแลรีเลย์จึงเห็นที่อยู่ของ tor แทนที่อยู่ของคุณ ครอบคลุมช่องตามตำแหน่งและข้อความส่วนตัวที่ส่งผ่านอินเทอร์เน็ต แนะนำให้เปิด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "internet trafiğini Tor üzerinden gönderir; böylece röle işletenler sizin adresiniz yerine Tor'un adresini görür. konum kanallarını ve internet üzerinden iletilen özel mesajları kapsar. önerilen: açık." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "надсилає інтернет-трафік через tor, тож оператори релеїв бачать адресу tor, а не твою. охоплює канали локації та приватні повідомлення, що доставляються через інтернет. рекомендовано ввімкнути." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "انٹرنیٹ ٹریفک tor کے ذریعے بھیجتا ہے، اس لیے ریلے چلانے والوں کو آپ کے پتے کی جگہ tor کا پتہ نظر آتا ہے۔ اس میں لوکیشن چینلز اور انٹرنیٹ سے پہنچائے جانے والے نجی پیغامات شامل ہیں۔ تجویز: آن رکھیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "gửi lưu lượng internet qua tor, nên bên vận hành relay thấy địa chỉ của tor thay vì địa chỉ của bạn. áp dụng cho kênh vị trí và tin nhắn riêng được gửi qua internet. khuyến nghị: bật." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 tor 发送互联网流量,中继运营方看到的是 tor 的地址而不是你的。适用于位置频道和经互联网投递的私密消息。推荐:开启。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過 tor 傳送網際網路流量,中繼營運方看到的是 tor 的位址而不是你的。適用於位置頻道和經網際網路投遞的私密訊息。推薦:開啟。" + } + } + } + }, "app_info.tab.info" : { "extractionState" : "manual", "localizations" : { @@ -50450,191 +52496,6 @@ } } }, - "location_channels.tor.subtitle" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يخفي ip لقنوات الموقع. الموصى به: تشغيل." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "লোকেশন চ্যানেলের জন্য আপনার IP লুকায়। সুপারিশ: চালু রাখুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbirgt deine ip für standortkanäle. empfohlen: an." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hides your IP for location channels. recommended: on." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "oculta tu IP para los canales de ubicación. Recomendado: activado." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP شما را برای کانال‌های موقعیت پنهان می‌کند. توصیه: روشن." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "itinatago ang iyong IP para sa mga channel ng lokasyon. inirerekomenda: naka-on." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "cache ton ip pour les canaux localisation. recommandé : activé." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מסתיר את ה-ip שלך לערוצי מיקום. מומלץ: פעיל." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "लोकेशन चैनलों के लिए आपका IP छुपाता है। अनुशंसित: चालू रखें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nasconde il tuo ip per i canali posizione. consigliato: attivo." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ロケーションチャンネル用にipを隠します。推奨: オン" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "위치 채널에서 IP를 숨깁니다. 권장: 켜기." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान च्यानलका लागि तिम्रो ip लुकाउँछ। सिफारिस: अन।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbergt je IP voor locatiekanalen. aanbevolen: aan." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ukrywa twój IP dla kanałów lokalizacji. zalecane: włączone." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "oculta o teu IP para canais de localização. recomendado: ligado." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "oculta seu ip para canais de localização. recomendado: ligado." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "скрывает твой ip для каналов локации. рекомендуем включить." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "döljer din IP för platskanaler. rekommenderas: på." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இட சேனல்களுக்கு உங்கள் IP ஐ மறைக்கும். பரிந்துரை: இயக்கப்பட்டது." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ซ่อน IP ของคุณสำหรับช่องตำแหน่ง แนะนำให้เปิด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "konum kanalları için IP'nizi gizler. önerilen: açık." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "приховує твій ip для каналів локації. рекомендовано ввімкнути." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "لوکیشن چینلز کیلئے آپ کا IP چھپاتا ہے۔ تجویز: آن رکھیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ẩn IP của bạn cho kênh vị trí. khuyến nghị: bật." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "为位置频道隐藏你的 IP。推荐:开启。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "為位置頻道隱藏你的 IP。推薦:開啟。" - } - } - } - }, "location_channels.tor.title" : { "extractionState" : "manual", "localizations" : { @@ -68437,6 +70298,192 @@ } } }, + "system.tor.blocked" : { + "comment" : "System message shown when Tor bootstrap runs out its deadline without connecting, which is what a network that blocks Tor looks like", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم يتمكن tor من الاتصال — قد تكون هذه الشبكة تحجبه. المراسلة عبر mesh لا تزال تعمل؛ أما قنوات الموقع والتسليم عبر الإنترنت فمتوقفة حتى ينجح tor." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor সংযোগ করতে পারল না — এই নেটওয়ার্ক হয়তো এটি আটকাচ্ছে। মেশে বার্তা পাঠানো এখনও কাজ করে; Tor চালু না হওয়া পর্যন্ত লোকেশন চ্যানেল ও ইন্টারনেটে পাঠানো থেমে থাকবে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor konnte sich nicht verbinden — dieses netzwerk blockiert es möglicherweise. mesh-nachrichten funktionieren weiter; standortkanäle und zustellung über das internet pausieren, bis tor durchkommt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor could not connect — this network may be blocking it. mesh messaging still works; location channels and internet delivery are paused until tor gets through." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor no pudo conectarse — puede que esta red lo esté bloqueando. la mensajería por mesh sigue funcionando; los canales de ubicación y la entrega por internet quedan en pausa hasta que Tor pase." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor نتوانست وصل شود — شاید این شبکه آن را مسدود کرده است. پیام‌رسانی مش همچنان کار می‌کند؛ کانال‌های موقعیت و تحویل از طریق اینترنت تا عبور Tor متوقف است." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi makakonekta ang tor — maaaring hinaharangan ito ng network na ito. gumagana pa rin ang pagmemensahe sa mesh; nakahinto ang mga channel ng lokasyon at ang paghatid sa internet hanggang makalusot ang tor." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor n'a pas pu se connecter — ce réseau le bloque peut-être. la messagerie mesh fonctionne toujours ; les canaux de localisation et la livraison par internet sont en pause jusqu'à ce que tor passe." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor לא הצליח להתחבר — ייתכן שהרשת הזאת חוסמת אותו. הודעות ב-mesh עדיין עובדות; ערוצי מיקום ומשלוח דרך האינטרנט מושהים עד ש-tor יעבור." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor कनेक्ट नहीं हो सका — यह नेटवर्क इसे रोक रहा हो सकता है। मेश पर संदेश भेजना अभी भी काम करता है; Tor जुड़ने तक लोकेशन चैनल और इंटरनेट से डिलीवरी रुकी रहेगी।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak bisa terhubung — jaringan ini mungkin memblokirnya. perpesanan mesh tetap jalan; kanal lokasi dan pengiriman lewat internet dijeda sampai tor tembus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor non è riuscito a connettersi — questa rete potrebbe bloccarlo. i messaggi sulla mesh funzionano ancora; canali posizione e consegna via internet restano in pausa finché tor non passa." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "torが接続できませんでした — このネットワークがブロックしている可能性があります。meshでのやり取りは使えます。ロケーションチャンネルとインターネット経由の配信は、torがつながるまで停止します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor가 연결되지 않았습니다 — 이 네트워크가 차단하고 있을 수 있습니다. mesh 메시지는 계속 작동하며, 위치 채널과 인터넷 전달은 tor가 연결될 때까지 멈춥니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak dapat menyambung — rangkaian ini mungkin menghalangnya. pemesejan mesh masih berfungsi; kanal lokasi dan penghantaran melalui internet dijeda sehingga tor berjaya." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor जडान हुन सकेन — यो नेटवर्कले रोकेको हुन सक्छ। mesh मा सन्देश पठाउने काम अझै चल्छ; tor नजोडिँदासम्म स्थान च्यानल र इन्टरनेटबाट पठाउने काम रोकिन्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kon geen verbinding maken — dit netwerk blokkeert het misschien. berichten via de mesh werken nog; locatiekanalen en bezorging via internet staan stil tot tor erdoor komt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor nie mógł się połączyć — ta sieć może go blokować. wiadomości w mesh nadal działają; kanały lokalizacji i dostarczanie przez internet są wstrzymane, dopóki tor nie przejdzie." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o Tor não conseguiu ligar-se — esta rede pode estar a bloqueá-lo. as mensagens pela mesh continuam a funcionar; os canais de localização e a entrega pela internet ficam em pausa até o Tor passar." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor não conseguiu conectar — esta rede pode estar bloqueando. as mensagens pelo mesh continuam funcionando; os canais de localização e a entrega pela internet ficam pausados até o tor passar." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не смог подключиться — возможно, эта сеть его блокирует. сообщения по mesh работают; каналы локации и доставка через интернет приостановлены, пока tor не пробьётся." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kunde inte ansluta — nätverket kan blockera det. meddelanden över mesh fungerar fortfarande; platskanaler och leverans över internet pausas tills tor kommer igenom." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor இணைய முடியவில்லை — இந்த நெட்வொர்க் அதைத் தடுக்கலாம். mesh வழி செய்திகள் இன்னும் வேலை செய்கின்றன; tor இணையும் வரை இட சேனல்களும் இணையம் வழி வழங்கலும் நிறுத்தப்படும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor เชื่อมต่อไม่ได้ — เครือข่ายนี้อาจปิดกั้นอยู่ การส่งข้อความผ่าน mesh ยังใช้ได้ ช่องตามตำแหน่งและการส่งผ่านอินเทอร์เน็ตจะหยุดไว้จนกว่า tor จะเชื่อมต่อได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor bağlanamadı — bu ağ onu engelliyor olabilir. mesh üzerinden mesajlaşma çalışmaya devam ediyor; konum kanalları ve internet üzerinden iletim, Tor geçene kadar duraklatıldı." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не зміг підключитися — можливо, ця мережа його блокує. повідомлення через mesh працюють; канали локації та доставка через інтернет на паузі, доки tor не проб'ється." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor جڑ نہیں سکا — یہ نیٹ ورک اسے روک رہا ہو سکتا ہے۔ mesh پر پیغام رسانی اب بھی کام کرتی ہے؛ tor کے جڑنے تک لوکیشن چینلز اور انٹرنیٹ سے ڈیلیوری رکی رہے گی۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor không kết nối được — mạng này có thể đang chặn. tin nhắn qua mesh vẫn hoạt động; kênh vị trí và việc gửi qua internet tạm dừng đến khi tor kết nối được." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 无法连接 — 此网络可能正在封锁它。mesh 消息仍可使用;位置频道和经互联网的投递会暂停,直到 tor 连上。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 無法連線 — 此網路可能正在封鎖它。mesh 訊息仍可使用;位置頻道和經網際網路的投遞會暫停,直到 tor 連上。" + } + } + } + }, "system.tor.dev_bypass" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index 2f4b20c2..ca115536 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -75,7 +75,19 @@ private extension GeoRelayDirectoryDependencies { refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, - awaitTorReady: { await TorManager.shared.awaitReady() }, + // Only wait for Tor when Tor is switched on. With it off, the fetch + // is meant to go direct through the same unproxied session the relay + // sockets already use — and `TorManager` has been shut down, so + // awaiting readiness would spend the whole bootstrap timeout on + // every refresh and freeze the directory on its cached copy. + // + // Deliberately keyed on the preference rather than live readiness: + // if Tor is wanted but not ready, this must keep returning false so + // the fetch is skipped instead of silently leaking the IP. + awaitTorReady: { + guard NetworkActivationService.persistedTorPreference() else { return true } + return await TorManager.shared.awaitReady() + }, makeFetchData: { let session = TorURLSession.shared.session return { request in diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index c1485819..5873247e 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -76,6 +76,18 @@ struct NostrRelayManagerDependencies { /// Uniform random value in [0, 1) used to jitter reconnect backoff. /// Injectable so tests can pin or sweep the jitter deterministically. var jitterUnit: () -> Double + /// Where relay-settings changes are observed. Injectable so a test can use + /// its own center instead of racing the process-wide one. + var notificationCenter: NotificationCenter = .default + /// Relays added by hand, merged with the built-in set. Injectable so tests + /// do not have to write to shared preferences. + var customRelays: () -> [String] = { NostrRelaySettings.customRelays() } + /// Whether a location channel is currently open. Mirrors the third arm of + /// `NetworkActivationService`'s gate: teleporting into a geohash needs no + /// location permission, and without this the relays would stay filtered out + /// for someone who denied location and has no mutual favorites. + var isInLocationChannel: () -> Bool = { false } + var selectedChannelPublisher: AnyPublisher = Empty().eraseToAnyPublisher() } private extension NostrRelayManagerDependencies { @@ -104,7 +116,12 @@ private extension NostrRelayManagerDependencies { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) }, now: Date.init, - jitterUnit: { Double.random(in: 0..<1) } + jitterUnit: { Double.random(in: 0..<1) }, + isInLocationChannel: { + if case .location = LocationChannelManager.shared.selectedChannel { return true } + return false + }, + selectedChannelPublisher: LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher() ) } } @@ -134,15 +151,40 @@ final class NostrRelayManager: ObservableObject { var nextReconnectTime: Date? } - // Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059. - private static let defaultRelays = [ + // Built-in relays carry private-message envelopes, so avoid relays known to + // reject the kinds they use. + private static let builtInRelays = [ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", "wss://offchain.pub" // For local testing, you can add: "ws://localhost:8080" ] - private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) }) + private static let builtInRelaySet = Set(builtInRelays.compactMap { NostrRelayURL.normalized($0) }) + + /// The relays private messages target: the built-in set plus any added by + /// hand. Four hardcoded hostnames are four names for a censor to block, so + /// the added ones are what keeps this reachable without a new build. + /// + /// Cached rather than computed per access: `allowedRelayList` consults the + /// set once per candidate URL, and recomputing would mean a `UserDefaults` + /// read and a fresh normalize-and-dedupe pass inside that loop. Refreshed + /// from `reloadDefaultRelays()` on construction and whenever the relay + /// settings change. + private var defaultRelays: [String] = [] + private var defaultRelaySet: Set = [] + + private func reloadDefaultRelays() { + var seen = Set() + defaultRelays = (Self.builtInRelays + dependencies.customRelays()) + .compactMap { NostrRelayURL.normalized($0) } + .filter { seen.insert($0).inserted } + defaultRelaySet = Set(defaultRelays) + } + + /// Exposed so the relay settings UI can reject re-adding a built-in. + /// `nonisolated` because it is an immutable constant with no actor state. + nonisolated static var builtInRelayURLs: Set { builtInRelaySet } @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false @@ -276,37 +318,15 @@ final class NostrRelayManager: ObservableObject { // off-main; the yield stays cheap. private let inboundRouter = InboundFrameRouter() - init() { - self.dependencies = .live() - hasMutualFavorites = dependencies.hasMutualFavorites() - hasLocationPermission = dependencies.hasLocationPermission() - applyDefaultRelayPolicy(force: true) - // Deterministic JSON shape for outbound requests - self.encoder.outputFormatting = .sortedKeys - dependencies.mutualFavoritesPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] favorites in - guard let self = self else { return } - self.hasMutualFavorites = !favorites.isEmpty - self.applyDefaultRelayPolicy() - } - .store(in: &cancellables) - dependencies.locationPermissionPublisher - .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) + convenience init() { + self.init(dependencies: .live()) } internal init(dependencies: NostrRelayManagerDependencies) { self.dependencies = dependencies hasMutualFavorites = dependencies.hasMutualFavorites() hasLocationPermission = dependencies.hasLocationPermission() + reloadDefaultRelays() applyDefaultRelayPolicy(force: true) // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys @@ -328,6 +348,28 @@ final class NostrRelayManager: ObservableObject { self.applyDefaultRelayPolicy() } .store(in: &cancellables) + dependencies.selectedChannelPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.applyDefaultRelayPolicy() + } + .store(in: &cancellables) + // Adding or removing a relay by hand changes the target set, so + // reconcile connections now rather than at the next send. + dependencies.notificationCenter + .publisher(for: NostrRelaySettings.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + // Reconcile against the previous set: a removed relay is no + // longer in `defaultRelays`, so nothing downstream would ever + // close its socket or drop its queued sends. + let previous = self.defaultRelaySet + self.reloadDefaultRelays() + self.dropRelays(previous.subtracting(self.defaultRelaySet)) + self.applyDefaultRelayPolicy(force: true) + } + .store(in: &cancellables) } deinit { @@ -509,13 +551,13 @@ final class NostrRelayManager: ObservableObject { // event locally so it survives a slow bootstrap (queued sends flush // when relays connect), then kick off connection setup, which itself // waits for Tor readiness. - let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays) + let targetRelays = allowedRelayList(from: relayUrls ?? defaultRelays) guard !targetRelays.isEmpty else { return } enqueuePendingSend(event, pendingRelays: Set(targetRelays)) ensureConnections(to: targetRelays) return } - let requestedRelays = relayUrls ?? Self.defaultRelays + let requestedRelays = relayUrls ?? defaultRelays let targetRelays = allowedRelayList(from: requestedRelays) guard !targetRelays.isEmpty else { return } ensureConnections(to: targetRelays) @@ -554,7 +596,7 @@ final class NostrRelayManager: ObservableObject { return } - let requestedRelays = relayUrls ?? Self.defaultRelays + let requestedRelays = relayUrls ?? defaultRelays let targetRelays = allowedRelayList(from: requestedRelays) let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in guard let connection = connectedConnection(for: relayUrl) else { return nil } @@ -723,7 +765,7 @@ final class NostrRelayManager: ObservableObject { // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) // Target specific relays if provided; else default. Filter permanently failed relays. - let baseUrls = relayUrls ?? Self.defaultRelays + let baseUrls = relayUrls ?? defaultRelays let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) } let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls)) if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) { @@ -765,50 +807,57 @@ final class NostrRelayManager: ObservableObject { } private func applyDefaultRelayPolicy(force: Bool = false) { - let shouldAllow = hasMutualFavorites || hasLocationPermission + let shouldAllow = hasMutualFavorites || hasLocationPermission || dependencies.isInLocationChannel() 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) { + for url in defaultRelays where !existing.contains(url) { relays.append(Relay(url: url)) existing.insert(url) } if dependencies.activationAllowed() { - ensureConnections(to: Self.defaultRelays) + ensureConnections(to: defaultRelays) } } else { - for url in Self.defaultRelays { - if let connection = connections[url] { - connection.cancel(with: .goingAway, reason: nil) - } - connections.removeValue(forKey: url) - teardownRelayInboundPipeline(for: url) - subscriptions.removeValue(forKey: url) - pendingSubscriptions.removeValue(forKey: url) - } - messageQueueLock.lock() - for index in (0..) { + guard !urls.isEmpty else { return } + for url in urls { + if let connection = connections[url] { + connection.cancel(with: .goingAway, reason: nil) + } + connections.removeValue(forKey: url) + teardownRelayInboundPipeline(for: url) + subscriptions.removeValue(forKey: url) + pendingSubscriptions.removeValue(forKey: url) + } + messageQueueLock.lock() + for index in (0.. [String] { var seen = Set() var result: [String] = [] for rawURL in urls { guard let url = NostrRelayURL.normalized(rawURL) else { continue } - if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue } + if !allowDefaultRelays && defaultRelaySet.contains(url) { continue } if seen.insert(url).inserted { result.append(url) } @@ -1366,7 +1415,7 @@ final class NostrRelayManager: ObservableObject { isConnected = relays.contains { $0.isConnected } // Relay URLs are normalized before entries are created, so direct // set membership is sound. - isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) } + isDMRelayConnected = relays.contains { $0.isConnected && defaultRelaySet.contains($0.url) } } /// A relay that drops before sending EOSE must not stall initial-load diff --git a/bitchat/Nostr/NostrRelaySettings.swift b/bitchat/Nostr/NostrRelaySettings.swift new file mode 100644 index 00000000..19cf1c46 --- /dev/null +++ b/bitchat/Nostr/NostrRelaySettings.swift @@ -0,0 +1,92 @@ +// +// NostrRelaySettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Relays someone has added by hand, alongside the built-in set. +/// +/// The built-in relays are four well-known clearnet hostnames, so a censor +/// blocking four names ends internet-delivered private messages for everyone. +/// Adding relays — including `.onion` addresses, or a relay run by whoever +/// needs it — is the escape hatch that does not require shipping a new build. +/// +/// Stored normalized so comparisons against connection keys and the built-in +/// set are exact, and bounded so a long list cannot turn every send into a +/// fan-out across dozens of sockets. +enum NostrRelaySettings { + /// Enough to add a personal relay, an onion address, and a couple of + /// regional fallbacks without letting the connection fan-out grow unbounded. + static let maxCustomRelays = 8 + + private static let storageKey = "nostr.customRelays" + + static let didChangeNotification = Notification.Name("bitchat.nostrRelaySettingsDidChange") + + enum AddFailure: Error, Equatable { + case malformed + case alreadyPresent + case limitReached + } + + /// Normalized relay URLs, in the order they were added. + static func customRelays(in defaults: UserDefaults = .standard) -> [String] { + let stored = defaults.stringArray(forKey: storageKey) ?? [] + // Re-normalize on read: a value written by an older build, or edited + // outside the app, must not reach the connection layer unchecked. + var seen = Set() + return stored.compactMap { NostrRelayURL.normalized($0) } + .filter { seen.insert($0).inserted } + } + + /// Adds a relay, returning the normalized URL or why it was rejected. + @discardableResult + static func add( + _ rawValue: String, + builtIn: Set, + in defaults: UserDefaults = .standard + ) -> Result { + // Bare hostnames are the common way people quote a relay, and wss is + // the only sensible assumption for one. + guard let normalized = NostrRelayURL.normalized(rawValue, defaultScheme: "wss") else { + return .failure(.malformed) + } + + var current = customRelays(in: defaults) + guard !current.contains(normalized), !builtIn.contains(normalized) else { + return .failure(.alreadyPresent) + } + guard current.count < maxCustomRelays else { + return .failure(.limitReached) + } + + current.append(normalized) + write(current, in: defaults) + return .success(normalized) + } + + static func remove(_ url: String, in defaults: UserDefaults = .standard) { + // Same default scheme as `add`, so a relay entered as a bare hostname + // can be removed the way it was typed. + guard let normalized = NostrRelayURL.normalized(url, defaultScheme: "wss") else { return } + let remaining = customRelays(in: defaults).filter { $0 != normalized } + write(remaining, in: defaults) + } + + /// Panic-wipe hook: an added relay names somewhere someone chose to route + /// through, which is exactly the kind of trace a wipe should not leave. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: storageKey) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } + + private static func write(_ relays: [String], in defaults: UserDefaults) { + defaults.set(relays, forKey: storageKey) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } +} diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index e4633d59..acc1634b 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -42,13 +42,18 @@ final class NetworkActivationService: ObservableObject { private var cancellables = Set() private var started = false - private let torPreferenceKey = "networkActivationService.userTorEnabled" + /// Storage key for the Tor preference. Exposed as a `nonisolated` constant + /// so off-main callers can read the preference without hopping to the main + /// actor; see `persistedTorPreference(in:)`. + nonisolated static let torPreferenceKey = "networkActivationService.userTorEnabled" private var torAutoStartDesired: Bool = false private let storage: UserDefaults private let locationPermissionPublisher: AnyPublisher private let mutualFavoritesPublisher: AnyPublisher, Never> + private let selectedChannelPublisher: AnyPublisher private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set + private let locationChannelSelectedProvider: () -> Bool private let reachabilityMonitor: NetworkReachabilityMonitoring private let torController: NetworkActivationTorControlling // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared @@ -63,8 +68,13 @@ final class NetworkActivationService: ObservableObject { storage = .standard locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher() mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() + selectedChannelPublisher = LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher() permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } + locationChannelSelectedProvider = { + if case .location = LocationChannelManager.shared.selectedChannel { return true } + return false + } reachabilityMonitor = NWPathReachabilityMonitor() torController = TorManager.shared relayControllerProvider = { NostrRelayManager.shared } @@ -78,6 +88,8 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher: AnyPublisher, Never>, permissionProvider: @escaping () -> LocationChannelManager.PermissionState, mutualFavoritesProvider: @escaping () -> Set, + selectedChannelPublisher: AnyPublisher = Empty().eraseToAnyPublisher(), + locationChannelSelectedProvider: @escaping () -> Bool = { false }, reachabilityMonitor: NetworkReachabilityMonitoring, torController: NetworkActivationTorControlling, relayController: NetworkActivationRelayControlling, @@ -89,6 +101,8 @@ final class NetworkActivationService: ObservableObject { self.mutualFavoritesPublisher = mutualFavoritesPublisher self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider + self.selectedChannelPublisher = selectedChannelPublisher + self.locationChannelSelectedProvider = locationChannelSelectedProvider self.reachabilityMonitor = reachabilityMonitor self.torController = torController self.relayControllerProvider = { relayController } @@ -96,11 +110,25 @@ final class NetworkActivationService: ObservableObject { self.notificationCenter = notificationCenter } + /// Whether Tor routing is switched on, read without main-actor isolation. + /// + /// This is the *preference*, not live Tor readiness. Background work that + /// must decide whether waiting for Tor is even meaningful needs the + /// preference: when someone has deliberately turned Tor off, requests are + /// intended to go direct, so waiting on a client that has been shut down + /// would only burn the bootstrap timeout. When the preference is on, callers + /// must still wait for readiness rather than falling back to clearnet. + nonisolated static func persistedTorPreference( + in defaults: UserDefaults = .standard + ) -> Bool { + defaults.object(forKey: torPreferenceKey) as? Bool ?? true + } + func start() { guard !started else { return } started = true - if let stored = storage.object(forKey: torPreferenceKey) as? Bool { + if let stored = storage.object(forKey: Self.torPreferenceKey) as? Bool { userTorEnabled = stored } else { userTorEnabled = true @@ -138,6 +166,16 @@ final class NetworkActivationService: ObservableObject { } .store(in: &cancellables) + // React to entering or leaving a location channel, which can flip the + // gate on its own for someone with no location permission and no + // mutual favorites. + selectedChannelPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reevaluate() + } + .store(in: &cancellables) + // React to network reachability changes (debounced, unsatisfied-only). reachabilityMonitor.reachabilityPublisher .receive(on: DispatchQueue.main) @@ -170,7 +208,7 @@ final class NetworkActivationService: ObservableObject { func setUserTorEnabled(_ enabled: Bool) { guard enabled != userTorEnabled else { return } userTorEnabled = enabled - storage.set(enabled, forKey: torPreferenceKey) + storage.set(enabled, forKey: Self.torPreferenceKey) notificationCenter.post( name: .TorUserPreferenceChanged, object: nil, @@ -211,7 +249,14 @@ final class NetworkActivationService: ObservableObject { private func basePolicyAllowed() -> Bool { let permOK = permissionProvider() == .authorized let hasMutual = !mutualFavoritesProvider().isEmpty - return permOK || hasMutual + // Being in a location channel counts too. Teleporting into a geohash + // needs no location permission, so someone who denied location and has + // no mutual favorites could sit in a channel that never connects: the + // gate suppressed Tor and the relays, and nothing said why. The channel + // is itself an internet feature in active use, which is exactly what + // this gate is meant to detect. + let inLocationChannel = locationChannelSelectedProvider() + return permOK || hasMutual || inLocationChannel } /// Effective gate: base policy AND a usable network path. When there is diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 79fabd87..bdff71db 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -363,6 +363,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // Track whether a Tor restart is pending so we only announce // "tor restarted" after an actual restart, not the first launch. var torRestartPending: Bool = false + // Announce a stalled bootstrap once per attempt, not once per poll. + var torStallAnnounced: Bool = false // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? @@ -1628,6 +1630,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage GeohashChatActivityTracker.shared.clear() MeshSightingsTracker.shared.clear() MeshEchoSettings.reset() + // A hand-added relay names an operator someone chose to route through, + // which is the kind of trace a wipe should not leave behind. + NostrRelaySettings.reset() // Drop private group keys and rosters (keychain + disk) groupStore.wipe() diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index a0142cde..52eaca7c 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -15,6 +15,8 @@ extension ChatViewModel { @objc func handleTorWillStart() { Task { @MainActor in + // A fresh attempt can stall again, so let it be reported again. + self.torStallAnnounced = false if !self.torStatusAnnounced && TorManager.shared.torEnforced { self.torStatusAnnounced = true // Post only in geohash channels (queue if not active) @@ -37,6 +39,7 @@ extension ChatViewModel { @objc func handleTorDidBecomeReady() { Task { @MainActor in + self.torStallAnnounced = false // Only announce "restarted" if we actually restarted this session if self.torRestartPending { // Post only in geohash channels (queue if not active) @@ -54,11 +57,31 @@ extension ChatViewModel { } } + /// Bootstrap spent its whole deadline without connecting. Say so rather + /// than leaving "starting tor…" on screen indefinitely: on a network that + /// blocks Tor this is the terminal state, and someone needs to know that + /// internet features are stalled while the mesh still works. + @objc func handleTorBootstrapDidStall() { + Task { @MainActor in + guard TorManager.shared.torEnforced else { return } + guard !self.torStallAnnounced else { return } + self.torStallAnnounced = true + self.addGeohashOnlySystemMessage( + String( + localized: "system.tor.blocked", + defaultValue: "tor could not connect — this network may be blocking it. mesh messaging still works; location channels and internet delivery are paused until tor gets through.", + comment: "System message shown when Tor bootstrap runs out its deadline without connecting, which is what a network that blocks Tor looks like" + ) + ) + } + } + @objc func handleTorPreferenceChanged(_: Notification) { Task { @MainActor in self.torStatusAnnounced = false self.torInitialReadyAnnounced = false self.torRestartPending = false + self.torStallAnnounced = false } } } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index d29c2888..f6bec6b3 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -21,6 +21,9 @@ struct AppInfoView: View { @State private var showTopology = false @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled @State private var locationNotesEnabled = LocationNotesSettings.enabled + @State private var customRelays = NostrRelaySettings.customRelays() + @State private var relayInput = "" + @State private var relayError: String? @ObservedObject private var locationManager = LocationChannelManager.shared /// Sticky across opens: first-ever open lands on Info (the gentler /// introduction), and afterwards the sheet reopens wherever it was left. @@ -79,7 +82,34 @@ struct AppInfoView: View { // internet-gateway toggle is gone: the bridge switch drives all // internet sharing, including geohash-channel gatewaying.) static let torTitle: LocalizedStringKey = "location_channels.tor.title" - static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + // Replaces `location_channels.tor.subtitle`, which described the + // setting as location-channels-only. It covers private messages and + // relay-directory refreshes too, and said nothing about the cost of + // switching it off. + static let torSubtitle = String(localized: "app_info.settings.tor.subtitle", defaultValue: "sends internet traffic through tor, so relay operators see tor's address instead of yours. covers location channels and private messages delivered over the internet. recommended: on.", comment: "Subtitle for the tor routing toggle in settings, explaining what it covers") + static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address") + + static let relaysTitle = String(localized: "app_info.settings.relays.title", defaultValue: "private message relays", comment: "Title of the relay list editor in settings") + static let relaysSubtitle = String(localized: "app_info.settings.relays.subtitle", defaultValue: "when the mesh can't reach someone, private messages travel through these relays. the built-in ones are well-known addresses that a network filter can block, so you can add your own — including .onion addresses.", comment: "Subtitle explaining what the relay list is for and why someone would add a relay") + static let relayBuiltIn = String(localized: "app_info.settings.relays.built_in", defaultValue: "built in", comment: "Label marking a relay as one of the built-in relays, which cannot be removed") + static let relayPlaceholder = String(localized: "app_info.settings.relays.placeholder", defaultValue: "wss://relay.example.com", comment: "Placeholder text in the field for adding a relay address") + static let relayAdd = String(localized: "app_info.settings.relays.add", defaultValue: "add", comment: "Button that adds the typed relay address to the list") + static let relayRemove = String(localized: "app_info.settings.relays.remove", defaultValue: "remove relay", comment: "Accessibility label for the button that removes an added relay") + + static func relayError(_ failure: NostrRelaySettings.AddFailure) -> String { + switch failure { + case .malformed: + return String(localized: "app_info.settings.relays.error.malformed", defaultValue: "that doesn't look like a relay address. try wss://host.", comment: "Error shown when a typed relay address cannot be parsed") + case .alreadyPresent: + return String(localized: "app_info.settings.relays.error.duplicate", defaultValue: "that relay is already in the list.", comment: "Error shown when the typed relay address is already in the list") + case .limitReached: + return String( + format: String(localized: "app_info.settings.relays.error.limit", defaultValue: "you can add up to %d relays.", comment: "Error shown when the relay list is already at its maximum size; %d is that maximum"), + locale: .current, + NostrRelaySettings.maxCustomRelays + ) + } + } static let toggleOn: LocalizedStringKey = "common.toggle.on" static let toggleOff: LocalizedStringKey = "common.toggle.off" @@ -410,11 +440,22 @@ struct AppInfoView: View { settingsCard { settingToggle( title: Text(Strings.Settings.torTitle), - subtitle: Text(Strings.Settings.torSubtitle), + subtitle: Text(verbatim: Strings.Settings.torSubtitle), isOn: torToggleBinding ) + // Turning tor off is not a location-channels-only choice, so + // say what it costs while it is off rather than in the + // subtitle everyone skims. + if !locationChannelsModel.userTorEnabled { + Text(verbatim: Strings.Settings.torOffWarning) + .bitchatFont(size: 11) + .foregroundColor(palette.alertRed) + .fixedSize(horizontal: false, vertical: true) + } } + relaySettingsCard + // Location notes / dead drops (merged from main's flat // layout into the shared card + pill style). Turning it on // may need the location prompt; the permission control below @@ -538,6 +579,108 @@ struct AppInfoView: View { ) } + /// Relay list editor. The built-in relays are four well-known hostnames, so + /// a filter blocking four names ends internet-delivered private messages; + /// adding one here is the only fix that does not need a new build. + @ViewBuilder + private var relaySettingsCard: some View { + settingsCard { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: Strings.Settings.relaysTitle) + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(textColor) + Text(verbatim: Strings.Settings.relaysSubtitle) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + + ForEach(NostrRelayManager.builtInRelayURLs.sorted(), id: \.self) { relay in + HStack(spacing: 6) { + Text(verbatim: relay) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Text(verbatim: Strings.Settings.relayBuiltIn) + .bitchatFont(size: 10) + .foregroundColor(secondaryTextColor) + } + } + + ForEach(customRelays, id: \.self) { relay in + HStack(spacing: 6) { + Text(verbatim: relay) + .bitchatFont(size: 11) + .foregroundColor(textColor) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Button { + NostrRelaySettings.remove(relay) + reloadCustomRelays() + } label: { + Image(systemName: "minus.circle") + .foregroundColor(palette.alertRed) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.Settings.relayRemove) + } + } + + if customRelays.count < NostrRelaySettings.maxCustomRelays { + HStack(spacing: 6) { + TextField(Strings.Settings.relayPlaceholder, text: $relayInput) + .textFieldStyle(.plain) + .bitchatFont(size: 11) + .foregroundColor(textColor) + .autocorrectionDisabled(true) + #if os(iOS) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + #endif + .onSubmit(addRelay) + Button(action: addRelay) { + Text(verbatim: Strings.Settings.relayAdd) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.accent) + } + .buttonStyle(.plain) + .disabled(relayInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + if let relayError { + Text(verbatim: relayError) + .bitchatFont(size: 11) + .foregroundColor(palette.alertRed) + .fixedSize(horizontal: false, vertical: true) + } + } + // The store can change from outside this view — a panic wipe clears it — + // so follow it rather than trusting the value read at creation. + .onReceive(NotificationCenter.default.publisher(for: NostrRelaySettings.didChangeNotification)) { _ in + reloadCustomRelays() + } + } + + private func addRelay() { + let candidate = relayInput + switch NostrRelaySettings.add(candidate, builtIn: NostrRelayManager.builtInRelayURLs) { + case .success: + relayInput = "" + relayError = nil + reloadCustomRelays() + case .failure(let failure): + relayError = Strings.Settings.relayError(failure) + } + } + + private func reloadCustomRelays() { + customRelays = NostrRelaySettings.customRelays() + } + private var torToggleBinding: Binding { Binding( get: { locationChannelsModel.userTorEnabled }, diff --git a/bitchatTests/Nostr/NostrRelaySettingsTests.swift b/bitchatTests/Nostr/NostrRelaySettingsTests.swift new file mode 100644 index 00000000..30c647ae --- /dev/null +++ b/bitchatTests/Nostr/NostrRelaySettingsTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import bitchat + +/// The built-in relay set is four well-known hostnames, so a filter blocking +/// four names ends internet-delivered private messages. These cover the +/// hand-added relays that make that recoverable without shipping a build. +struct NostrRelaySettingsTests { + /// Each case gets its own suite so nothing touches the real preferences or + /// races another case. + private func makeDefaults() -> UserDefaults { + let suite = "bitchat.tests.relays.\(UUID().uuidString)" + return UserDefaults(suiteName: suite)! + } + + private let builtIn: Set = [ + "wss://relay.damus.io", + "wss://nos.lol" + ] + + @Test func addNormalizesABareHostname() { + let defaults = makeDefaults() + + // A bare hostname is how relays are usually quoted; wss is the only + // sensible assumption. + let result = NostrRelaySettings.add("relay.example.com", builtIn: builtIn, in: defaults) + + #expect(result == .success("wss://relay.example.com")) + #expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://relay.example.com"]) + } + + @Test func addAcceptsAnOnionAddress() { + let defaults = makeDefaults() + + // The reason this feature exists: an onion relay is not blockable by + // hostname or SNI filtering. + let result = NostrRelaySettings.add( + "wss://exampleonionaddressxyz234567.onion", + builtIn: builtIn, + in: defaults + ) + + #expect(result == .success("wss://exampleonionaddressxyz234567.onion")) + } + + @Test func addRejectsMalformedInput() { + let defaults = makeDefaults() + + #expect(NostrRelaySettings.add("", builtIn: builtIn, in: defaults) == .failure(.malformed)) + #expect(NostrRelaySettings.add(" ", builtIn: builtIn, in: defaults) == .failure(.malformed)) + // A scheme the relay layer cannot dial must not be stored. + #expect(NostrRelaySettings.add("ftp://relay.example.com", builtIn: builtIn, in: defaults) == .failure(.malformed)) + #expect(NostrRelaySettings.customRelays(in: defaults).isEmpty) + } + + @Test func addRejectsDuplicatesAndBuiltIns() { + let defaults = makeDefaults() + #expect(NostrRelaySettings.add("wss://relay.example.com", builtIn: builtIn, in: defaults) == .success("wss://relay.example.com")) + + // Same relay written differently still normalizes to the same URL. + #expect(NostrRelaySettings.add("relay.example.com", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) + #expect(NostrRelaySettings.add("WSS://Relay.Example.com", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) + // Re-adding a built-in would double-count it in the target list. + #expect(NostrRelaySettings.add("wss://nos.lol", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) + + #expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://relay.example.com"]) + } + + @Test func addStopsAtTheLimit() { + let defaults = makeDefaults() + for index in 0..(.mesh) + let context = makeService( + permission: .denied, + favorites: [], + selectedChannelSubject: channelSubject + ) + + context.service.start() + XCTAssertFalse(context.service.activationAllowed) + + channelSubject.send(.location(GeohashChannel(level: .city, geohash: "u4pruy"))) + + let activated = await waitUntil { context.service.activationAllowed } + XCTAssertTrue(activated) + } + + /// Leaving the channel must close the gate again, or the exception would + /// quietly become permanent for the rest of the session. + func test_selectedChannelPublisher_deactivatesOnReturningToMesh() async { + let channelSubject = CurrentValueSubject( + .location(GeohashChannel(level: .city, geohash: "u4pruy")) + ) + let context = makeService( + permission: .denied, + favorites: [], + selectedChannelSubject: channelSubject + ) + + context.service.start() + XCTAssertTrue(context.service.activationAllowed) + + channelSubject.send(.mesh) + + let deactivated = await waitUntil { !context.service.activationAllowed } + XCTAssertTrue(deactivated) + } + private func makeService( permission: LocationChannelManager.PermissionState, - favorites: Set + favorites: Set, + selectedChannel: ChannelID = .mesh, + selectedChannelSubject: CurrentValueSubject? = nil ) -> NetworkActivationTestContext { let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)" let storage = UserDefaults(suiteName: suiteName)! @@ -148,6 +206,8 @@ final class NetworkActivationServiceTests: XCTestCase { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) + let channelSubject = selectedChannelSubject + ?? CurrentValueSubject(selectedChannel) let torController = MockNetworkActivationTorController() let relayController = MockNetworkActivationRelayController() let proxyController = MockNetworkActivationProxyController() @@ -159,6 +219,11 @@ final class NetworkActivationServiceTests: XCTestCase { mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, + selectedChannelPublisher: channelSubject.eraseToAnyPublisher(), + locationChannelSelectedProvider: { + if case .location = channelSubject.value { return true } + return false + }, reachabilityMonitor: reachability, torController: torController, relayController: relayController, diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index e1566d66..c50d1ba4 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1735,6 +1735,58 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertGreaterThan(Set(factors).count, 1) } + // MARK: - Hand-added relays + + /// Adding a relay has to take effect without a restart: the whole point is + /// recovering reachability when the built-in hostnames are blocked. + @MainActor + func testAddedRelayJoinsTheTargetSetOnSettingsChange() async { + let center = NotificationCenter() + let custom = MutableRelayList(urls: []) + let context = makeContext( + permission: .authorized, + notificationCenter: center, + customRelays: custom + ) + + XCTAssertFalse(context.manager.relays.contains { $0.url == "wss://added.example.com" }) + + custom.urls = ["wss://added.example.com"] + center.post(name: NostrRelaySettings.didChangeNotification, object: nil) + + let joined = await waitUntil { + context.manager.relays.contains { $0.url == "wss://added.example.com" } + } + XCTAssertTrue(joined) + } + + /// Removing a relay must actually close it. The teardown path iterates the + /// current target list, and a removed relay is no longer in it, so without + /// an explicit reconcile against the previous set its socket and queued + /// sends would linger. + @MainActor + func testRemovedRelayLeavesTheTargetSet() async { + let center = NotificationCenter() + let custom = MutableRelayList(urls: ["wss://added.example.com"]) + let context = makeContext( + permission: .authorized, + notificationCenter: center, + customRelays: custom + ) + + XCTAssertTrue(context.manager.relays.contains { $0.url == "wss://added.example.com" }) + + custom.urls = [] + center.post(name: NostrRelaySettings.didChangeNotification, object: nil) + + let dropped = await waitUntil { + !context.manager.relays.contains { $0.url == "wss://added.example.com" } + } + XCTAssertTrue(dropped) + // The built-in relays are untouched by a custom-relay removal. + XCTAssertTrue(context.manager.relays.contains { $0.url == "wss://nos.lol" }) + } + private func makeContext( permission: LocationChannelManager.PermissionState, favorites: Set = [], @@ -1743,6 +1795,8 @@ final class NostrRelayManagerTests: XCTestCase { torEnforced: Bool = false, torIsReady: Bool = true, torIsForeground: Bool = true, + notificationCenter: NotificationCenter = NotificationCenter(), + customRelays: MutableRelayList = MutableRelayList(urls: []), jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) @@ -1770,7 +1824,9 @@ final class NostrRelayManagerTests: XCTestCase { scheduler.schedule(delay: delay, action: action) }, now: { clock.now }, - jitterUnit: jitterUnit + jitterUnit: jitterUnit, + notificationCenter: notificationCenter, + customRelays: { customRelays.urls } ) ) return RelayManagerTestContext( @@ -1845,6 +1901,16 @@ private final class MutableClock { } } +/// Stand-in for the persisted hand-added relay list, so tests can change it +/// without writing to shared preferences. +private final class MutableRelayList { + var urls: [String] + + init(urls: [String]) { + self.urls = urls + } +} + /// Deterministic jitter source: returns the queued values in order, then a /// neutral 0.5 (jitter factor 1.0) once exhausted. private final class JitterSequence { diff --git a/bitchatTests/Services/TorPreferenceReadTests.swift b/bitchatTests/Services/TorPreferenceReadTests.swift new file mode 100644 index 00000000..b9f198e3 --- /dev/null +++ b/bitchatTests/Services/TorPreferenceReadTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import bitchat + +/// The geo-relay directory refresh runs off the main actor and has to decide +/// whether waiting for Tor is meaningful. It previously waited unconditionally, +/// so with Tor switched off — and `TorManager` therefore shut down — every +/// refresh spent the full bootstrap timeout and the directory froze on its +/// cached copy. +struct TorPreferenceReadTests { + private func makeDefaults() -> UserDefaults { + UserDefaults(suiteName: "bitchat.tests.tor.\(UUID().uuidString)")! + } + + @Test func defaultsToOnWhenNothingHasBeenStored() { + // Fail safe: an unwritten preference must not read as "Tor off", which + // would let a fetch go direct. + #expect(NetworkActivationService.persistedTorPreference(in: makeDefaults())) + } + + @Test func reflectsTheStoredPreference() { + let defaults = makeDefaults() + + defaults.set(false, forKey: NetworkActivationService.torPreferenceKey) + #expect(!NetworkActivationService.persistedTorPreference(in: defaults)) + + defaults.set(true, forKey: NetworkActivationService.torPreferenceKey) + #expect(NetworkActivationService.persistedTorPreference(in: defaults)) + } + + @Test func nonBooleanStoredValueReadsAsOn() { + let defaults = makeDefaults() + defaults.set("nonsense", forKey: NetworkActivationService.torPreferenceKey) + + // Same fail-safe direction: anything unrecognized means keep using Tor. + #expect(NetworkActivationService.persistedTorPreference(in: defaults)) + } +} diff --git a/docs/TOR-INTEGRATION.md b/docs/TOR-INTEGRATION.md index 165acfe3..46f80951 100644 --- a/docs/TOR-INTEGRATION.md +++ b/docs/TOR-INTEGRATION.md @@ -1,47 +1,53 @@ -Tor-by-default integration (scaffold) +# Tor integration -Overview -- All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isn’t ready. There are no user-visible settings. -- This repo vendors an Arti-backed Swift package under `localPackages/Arti`, including a Rust static-library xcframework linked by SwiftPM. +## Overview -Key pieces -- TorManager - - Boots Tor, manages a DataDirectory under Application Support, exposes SOCKS at 127.0.0.1:39050, and provides awaitReady(). - - Fails closed by default until Tor is bootstrapped. For local development only, define BITCHAT_DEV_ALLOW_CLEARNET to bypass Tor. -- TorURLSession - - Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready. - - NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity. +Internet traffic — Nostr relay sockets and the geo-relay directory fetch — is routed through Tor by default, fail-closed: when Tor is wanted but not ready, requests queue rather than falling back to clearnet. -Artifact maintenance -- Binary provenance, rebuild steps, and current hashes are documented in `docs/ARTI-BINARY-PROVENANCE.md`. +Tor is provided by **Arti, in-process**, vendored as a Swift package under `localPackages/Arti` wrapping a Rust static-library xcframework. There is no `tor` binary, no `torrc`, and no control port. A SOCKS5 listener on `127.0.0.1:39050` is the only interface. + +## Key pieces + +- **`TorManager`** — owns the Arti client and its data directory under Application Support, exposes the SOCKS port, and provides `awaitReady()`. + - `torEnforced` is compile-time: true unless `BITCHAT_DEV_ALLOW_CLEARNET` is defined. It is not set anywhere in `Configs/` or the project file, so release builds enforce. + - `isStarting`, `bootstrapProgress`, and `bootstrapSummary` describe an attempt in flight. + - `bootstrapDidStall` becomes true when an attempt spends its whole 75-second deadline without completing, and posts `.TorBootstrapDidStall`. This is the state a network that blocks Tor produces, and it is deliberately distinct from `isStarting`: without it the UI says "starting tor…" indefinitely. It is cleared on each new start or restart. +- **`TorURLSession`** — a shared `URLSession` with the SOCKS proxy configured when proxying is on, and an unproxied session when it is off. `setProxyMode(useTor:)` is the switch, driven by `NetworkActivationService`. +- **`NetworkActivationService`** — decides whether Tor may run at all. Tor starts when the activation policy permits it *and* the Tor preference is on. `persistedTorPreference(in:)` is a `nonisolated` read of that preference for callers off the main actor. + +Both network call sites go through `TorURLSession`: `NostrRelayManager` (relay websockets) and `GeoRelayDirectory` (directory CSV refresh). There is no other outbound network in the app or the share extension. + +## The Tor preference is user-visible + +The earlier version of this document said there are no user-visible settings. There is one: a **tor routing** toggle in settings, persisted under `networkActivationService.userTorEnabled`, defaulting to on. + +Turning it off is a real change in exposure, not a performance tweak. Every fail-closed guard is conditioned on the preference, so with it off: + +- relay websockets connect directly, and every relay operator sees the device IP — including relays carrying private messages; +- the geo-relay directory fetch also goes direct. + +The settings UI states this while the toggle is off. + +`GeoRelayDirectory` keys its Tor wait on the *preference*, not on live readiness, and this distinction is load-bearing. With Tor off, waiting for a client that has been shut down would spend the full bootstrap timeout on every refresh and freeze the directory on its cached copy. With Tor on but not ready, the wait must still fail so the fetch is skipped rather than silently leaking the IP. + +## Relays + +Private messages target the built-in relay set plus any relays added by hand (`NostrRelaySettings`, capped at 8, `.onion` addresses accepted). The built-in set is four well-known clearnet hostnames, so a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped. + +## Artifact maintenance + +- Binary provenance, rebuild steps, and current hashes: `docs/ARTI-BINARY-PROVENANCE.md`, enforced by `.github/workflows/arti-provenance.yml`. - The xcframework must include iOS device, iOS simulator, and macOS arm64 slices. -- Any refresh should review the Rust source, `Cargo.lock`, generated header, build script, and new hashes together. +- Any refresh reviews the Rust source, `Cargo.lock`, generated header, build script, and new hashes together. A binary-only update is not acceptable. -Verification - - On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady(). - - NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared. - - GeoRelayDirectory.fetchRemote() awaits readiness, then fetches via TorURLSession.shared. +## Known gap: no bridges or pluggable transports -Optional macOS optimization - - Detect a system Tor binary (e.g., /opt/homebrew/bin/tor) and run it as a subprocess to avoid bundling. Keep the embedded fallback for portability. +`arti-client` is built with `default-features = false` and features `["tokio", "rustls"]` only — no `pt-client`, no `bridge-client` — and `arti-bitchat/src/lib.rs` bootstraps from stock configuration with no bridge lines and no configurable directory authorities. -torrc template -The generated torrc (under Application Support/bitchat/tor/torrc) is: +So in a country that blocks Tor by blocking the public relays and directory authorities, bootstrap never completes. The app reports that clearly now instead of appearing to start forever, and the BLE mesh is unaffected, but there is no circumvention path: obfs4, snowflake, and meek are all unavailable. - DataDirectory /bitchat/tor - ClientOnly 1 - SOCKSPort 127.0.0.1:39050 - ControlPort 127.0.0.1:39051 - CookieAuthentication 1 - AvoidDiskWrites 1 - MaxClientCircuitsPending 8 +Closing this means enabling the pluggable-transport features, plumbing bridge configuration through the FFI and a settings surface, and rebuilding the xcframework under the pinned toolchain with a provenance-manifest update. That is the single largest remaining gap in censorship resilience for the internet transport. -Dev bypass (local only) -- To temporarily allow direct network without Tor for local development: - - Add Swift compiler flag: BITCHAT_DEV_ALLOW_CLEARNET - - This enables a clearnet session in TorURLSession when Tor isn’t present. - - Never enable this in release builds. +## Dev bypass (local only) -Notes -- We intentionally do not change any app-level APIs: consumers simply use TorURLSession via existing code paths. -- When Tor is missing in release builds, the app will not connect (fail-closed), logging a clear reason. +Define the Swift compiler flag `BITCHAT_DEV_ALLOW_CLEARNET` to allow direct network access without Tor while developing. Never enable it in release builds. diff --git a/docs/VERIFYING-A-BUILD.md b/docs/VERIFYING-A-BUILD.md new file mode 100644 index 00000000..818c831b --- /dev/null +++ b/docs/VERIFYING-A-BUILD.md @@ -0,0 +1,77 @@ +# Verifying bitchat + +This document is about a specific risk: getting a copy of bitchat that someone has modified. + +It matters because the repository has been the target of takedown demands. When a repository or a releases page becomes unavailable, mirrors appear, and people who need the app during a shutdown install whatever they can find. That is exactly the moment a trojaned build reaches the people with the most to lose. A modified bitchat can log plaintext, ship keys off the device, or weaken the mesh, and it will look and behave normally while doing it. + +The honest summary is short. **Source can be verified. Compiled apps cannot, unless they come from the App Store.** Everything below elaborates on that. + +## Getting the app + +In order of how much verification is possible: + +1. **The App Store.** Apple verifies the developer signature, and the binary cannot be altered without breaking it. This is the only channel where a compiled build is verifiable end to end, and it is the right recommendation for almost everyone. +2. **Build it yourself from verified source.** See below. Requires a Mac and Xcode, and gives you the strongest guarantee if you can do it. +3. **A compiled build from anywhere else.** Not verifiable. See "Builds from other sources". + +## Verifying source + +Every tagged release has a `SOURCE-MANIFEST.txt` produced by `.github/workflows/source-manifest.yml`. It records the tag, the commit, the git tree hash, and a SHA-256 for every tracked file. + +Check a copy of the source against it: + +```sh +# From the root of the source you obtained +grep -v '^#' SOURCE-MANIFEST.txt > /tmp/files.sha256 +shasum -a 256 -c /tmp/files.sha256 +``` + +Any `FAILED` line means that file differs from the released source. Investigate before building. + +The single value that covers the whole tree is the git tree hash in the manifest header: + +```sh +git rev-parse HEAD^{tree} # must equal the "tree:" line in the manifest +``` + +The manifest itself carries a provenance attestation tying it to the workflow run that produced it, so a manifest handed to you along with a mirror is checkable too: + +```sh +gh attestation verify SOURCE-MANIFEST.txt --repo permissionlesstech/bitchat +``` + +That last step is what makes this resistant to a hostile mirror. Without it, whoever gives you the source can give you a matching manifest. + +### If the manifest is unavailable + +Compare against a commit instead. Git object hashes cover content and history, so if you can obtain the expected commit hash through any channel you trust — a second mirror, a maintainer's post elsewhere, someone who cloned earlier — then: + +```sh +git fetch --tags +git rev-parse v1.2.3 # compare against the hash you trust +git verify-tag v1.2.3 # if the tag is signed +``` + +A mirror whose history matches a commit hash you trust from elsewhere is a faithful mirror. + +## Builds from other sources + +If you have an `.ipa`, an `.apk`, or a Mac app from a forum, a chat group, a file locker, or any mirror, you cannot verify it, and this project cannot help you verify it. There is no published signing key for compiled builds and no reproducible-build pipeline, so there is nothing to compare a binary against. + +What to do instead, in order of preference: install from the App Store; build from verified source; or, if neither is possible, treat that build as untrusted — assume anything you type into it may be disclosed, do not use it for anything sensitive, and do not carry it somewhere it being on your phone is itself a risk. + +Do not rely on the app looking right. A modified build has no reason to look different. + +## For maintainers + +Cutting a release: + +- Push the tag. `source-manifest.yml` runs and attaches `SOURCE-MANIFEST.txt` to the release; if the release does not exist yet, collect the manifest from the workflow artifact and attach it when you publish. +- Sign the tag (`git tag -s`). A signed tag lets anyone verify the release came from a key you control, independent of GitHub. This needs a published key fingerprint to be useful — see the gap below. +- Note the commit hash somewhere outside this repository. If the repository is taken down, a hash recorded elsewhere is what lets people verify a mirror. + +Known gaps, so nobody assumes more protection than exists: + +- **No published signing key.** Tags are not currently verifiable against a known key. Publishing a fingerprint through channels independent of GitHub, and signing tags with it from then on, is the missing piece. +- **No verifiable compiled builds outside the App Store.** Closing this needs either a signed-and-notarized release pipeline or a reproducible build, and until one exists the guidance above stands. +- **No non-GitHub source mirror.** Every remote for this project is on the platform the takedown demands were served to. A mirror on independent infrastructure, published before it is needed, would mean a takedown does not remove the ability to verify. diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index 561bfb18..0ae7b86c 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -60,6 +60,9 @@ Public archives contain content already intended for public mesh/board distribut - When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants. - A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event. +- Relays added by hand persist in local preferences (`nostr.customRelays`, at most 8, normalized on read) and are wiped on panic. An added relay names an operator someone chose to route through, so it is treated as sensitive local state rather than inert configuration. `.onion` addresses are accepted, which is the point: the four built-in relays are well-known clearnet hostnames and a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped. +- Turning the Tor preference off routes relay sockets and the relay-directory fetch directly, disclosing the device IP to every relay operator including those carrying private messages. The settings UI states this while the preference is off. + Residual risk: Nostr relay retention and logging are outside project control. Public events may be copied indefinitely. Timing, coarse location, and participation can be correlated even when content is encrypted or per-cell identities are used. ## Location @@ -88,7 +91,7 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu ## Panic Wipe Coverage -The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test. +The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, hand-added relays, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test. ## Release Review Checklist diff --git a/localPackages/Arti/Sources/TorManager.swift b/localPackages/Arti/Sources/TorManager.swift index 384f5239..1bb42b63 100644 --- a/localPackages/Arti/Sources/TorManager.swift +++ b/localPackages/Arti/Sources/TorManager.swift @@ -48,6 +48,15 @@ public final class TorManager: ObservableObject { @Published private(set) var lastError: Error? @Published private(set) var bootstrapProgress: Int = 0 @Published private(set) var bootstrapSummary: String = "" + /// True once a bootstrap attempt has spent its whole deadline without + /// completing. + /// + /// This separates "still starting" from "not getting through", which are + /// indistinguishable from `isStarting` alone. The second is what a network + /// that blocks Tor looks like from inside the app, and without it the UI + /// says "starting tor…" indefinitely while nothing is happening. Cleared on + /// each new start attempt. + @Published private(set) public var bootstrapDidStall: Bool = false // Internal readiness trackers private var socksReady: Bool = false { didSet { recomputeReady() } } @@ -96,6 +105,7 @@ public final class TorManager: ObservableObject { guard !didStart else { return } didStart = true isStarting = true + bootstrapDidStall = false startedAt = Date() // Track startup time for grace period SecureLogger.debug("TorManager: startIfNeeded() - startedAt set", category: .session) lastError = nil @@ -265,6 +275,7 @@ public final class TorManager: ObservableObject { private func bootstrapPollLoop() async { let deadline = Date().addingTimeInterval(75) + var didComplete = false while Date() < deadline { let progress = Int(arti_bootstrap_progress()) let summary = getBootstrapSummary() @@ -276,9 +287,27 @@ public final class TorManager: ObservableObject { self.recomputeReady() } - if progress >= 100 { break } + if progress >= 100 { + didComplete = true + break + } try? await Task.sleep(nanoseconds: 1_000_000_000) } + + // Running out the deadline is a reportable outcome, not silence. The + // loop previously just ended, leaving `isStarting` true forever, so a + // blocked network was indistinguishable from a slow one. + if !didComplete { + await MainActor.run { + self.isStarting = false + self.bootstrapDidStall = true + SecureLogger.warning( + "TorManager: bootstrap did not complete within its deadline (progress=\(self.bootstrapProgress)); network may be blocking Tor", + category: .session + ) + NotificationCenter.default.post(name: .TorBootstrapDidStall, object: nil) + } + } } private func getBootstrapSummary() -> String { @@ -374,6 +403,7 @@ public final class TorManager: ObservableObject { self.bootstrapProgress = 0 self.bootstrapSummary = "" self.isStarting = true + self.bootstrapDidStall = false self.lastRestartAt = Date() } diff --git a/localPackages/Arti/Sources/TorNotifications.swift b/localPackages/Arti/Sources/TorNotifications.swift index e96bce3b..569bf404 100644 --- a/localPackages/Arti/Sources/TorNotifications.swift +++ b/localPackages/Arti/Sources/TorNotifications.swift @@ -5,4 +5,7 @@ public extension Notification.Name { static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillStart = Notification.Name("TorWillStart") static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged") + /// A bootstrap attempt ran out its deadline without completing — the + /// signature of a network that blocks Tor. + static let TorBootstrapDidStall = Notification.Name("TorBootstrapDidStall") }