mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 05:25:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d373100d9 | ||
|
|
19c06f360d |
@@ -1,228 +1,42 @@
|
|||||||
name: Propose GeoRelay Data Update
|
name: Fetch GeoRelays Data
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 6 * * 0"
|
- cron: '0 6 * * 0'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Default to read-only. The publishing job receives only the scopes required
|
|
||||||
# to push its branch and publish either a PR or a tracking issue.
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
concurrency:
|
|
||||||
group: georelay-data-update
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
|
|
||||||
UPDATE_BRANCH: automation/georelay-data
|
|
||||||
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
propose-relay-data:
|
update-relay-data:
|
||||||
name: Validate and propose relay data
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout reviewed base
|
- name: Checkout repository
|
||||||
# Pinned actions/checkout v5 so a mutable action tag cannot change the
|
uses: actions/checkout@v4
|
||||||
# code that receives this job's write-capable token.
|
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
|
||||||
with:
|
with:
|
||||||
ref: main
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# Do not expose the write token to fetch/validation subprocesses.
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Test GeoRelay validator
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Fetch candidate over pinned HTTPS policy
|
- name: Check for changes
|
||||||
id: upstream
|
id: git-check
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||||
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
|
|
||||||
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
- name: Commit and push changes
|
||||||
echo "::error::Could not resolve an immutable upstream commit"
|
if: steps.git-check.outputs.changes == 'true'
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
|
|
||||||
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
|
|
||||||
if [[ "$effective_url" != "$source_url" ]]; then
|
|
||||||
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Validate candidate against reviewed baseline
|
|
||||||
id: validation
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
git config --local user.email "action@github.com"
|
||||||
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT"
|
git config --local user.name "GitHub Action"
|
||||||
|
git add relays/online_relays_gps.csv
|
||||||
- name: Check for a reviewed-file change
|
git commit -m "Automated update of relay data - $(date -u)"
|
||||||
id: changes
|
git push
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if git diff --quiet -- relays/online_relays_gps.csv; then
|
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
else
|
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
git diff --stat -- relays/online_relays_gps.csv
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push automation branch and publish review request
|
|
||||||
if: steps.changes.outputs.changed == 'true'
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
|
|
||||||
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
|
|
||||||
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
|
|
||||||
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
|
|
||||||
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Scope credential exposure to this final publishing step.
|
|
||||||
gh auth setup-git
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
git switch -C "$UPDATE_BRANCH"
|
|
||||||
git add -- relays/online_relays_gps.csv
|
|
||||||
git diff --cached --quiet && {
|
|
||||||
echo "::error::Expected a staged GeoRelay data change"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
|
|
||||||
|
|
||||||
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
|
|
||||||
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
|
|
||||||
remote_sha=$(git rev-parse "$remote_ref")
|
|
||||||
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
|
||||||
else
|
|
||||||
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
body_file="$RUNNER_TEMP/georelay-pr-body.md"
|
|
||||||
{
|
|
||||||
echo "## Automated GeoRelay data proposal"
|
|
||||||
echo
|
|
||||||
echo "- Source: $SOURCE_URL"
|
|
||||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
|
||||||
echo "- Data rows: $DATA_ROWS"
|
|
||||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
|
||||||
echo "- SHA-256: $DATA_SHA256"
|
|
||||||
echo
|
|
||||||
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
|
|
||||||
echo
|
|
||||||
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
|
|
||||||
} > "$body_file"
|
|
||||||
|
|
||||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
|
||||||
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
|
|
||||||
pr_url=""
|
|
||||||
if [[ -n "$existing_pr" ]]; then
|
|
||||||
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
|
|
||||||
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
|
|
||||||
pr_url="$created_pr_url"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
|
||||||
tracking_issues=()
|
|
||||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
|
||||||
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$pr_url" ]]; then
|
|
||||||
for issue_number in "${tracking_issues[@]}"; do
|
|
||||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
|
|
||||||
done
|
|
||||||
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
|
|
||||||
if [[ -s "$pr_error" ]]; then
|
|
||||||
cat "$pr_error" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
|
|
||||||
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
|
|
||||||
{
|
|
||||||
echo "## Validated GeoRelay update awaiting review"
|
|
||||||
echo
|
|
||||||
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
|
|
||||||
echo
|
|
||||||
echo "- Compare and create PR: $compare_url"
|
|
||||||
echo "- Automation branch: $UPDATE_BRANCH"
|
|
||||||
echo "- Source: $SOURCE_URL"
|
|
||||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
|
||||||
echo "- Data rows: $DATA_ROWS"
|
|
||||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
|
||||||
echo "- SHA-256: $DATA_SHA256"
|
|
||||||
echo
|
|
||||||
echo "The snapshot passed the repository's strict validator before the branch was pushed."
|
|
||||||
} > "$issue_body_file"
|
|
||||||
|
|
||||||
if (( ${#tracking_issues[@]} > 0 )); then
|
|
||||||
primary_issue="${tracking_issues[0]}"
|
|
||||||
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
|
|
||||||
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
|
||||||
for duplicate_issue in "${tracking_issues[@]:1}"; do
|
|
||||||
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
|
|
||||||
done
|
|
||||||
else
|
|
||||||
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Do not claim success until the fallback issue was confirmed.
|
|
||||||
[[ -n "$issue_url" ]]
|
|
||||||
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
- name: Clean obsolete automation review state
|
|
||||||
if: steps.changes.outputs.changed == 'false'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
gh auth setup-git
|
|
||||||
|
|
||||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
|
||||||
if [[ -n "$existing_pr" ]]; then
|
|
||||||
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
|
|
||||||
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
fi
|
|
||||||
|
|
||||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
|
||||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
|
||||||
while IFS= read -r issue_number; do
|
|
||||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
|
|
||||||
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
done <<< "$tracking_issue_numbers"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
|
|
||||||
git push origin --delete "$UPDATE_BRANCH"
|
|
||||||
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
else
|
|
||||||
ls_remote_status=$?
|
|
||||||
if (( ls_remote_status != 2 )); then
|
|
||||||
echo "::error::Could not inspect the obsolete automation branch"
|
|
||||||
exit "$ls_remote_status"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
+1
-1
@@ -22,7 +22,7 @@ bitchat is designed for private, account-free communication. This policy describ
|
|||||||
|
|
||||||
2. **Nickname, preferences, and relationships**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||||
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
|
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
|
||||||
|
|
||||||
3. **Private group state**
|
3. **Private group state**
|
||||||
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||||
|
|||||||
Generated
+1
@@ -70,6 +70,7 @@
|
|||||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
Services/SharedContentHandoff.swift,
|
||||||
Services/TransportConfig.swift,
|
Services/TransportConfig.swift,
|
||||||
);
|
);
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import BitFoundation
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum SharedContentKind: String, Sendable, Equatable {
|
|
||||||
case text
|
|
||||||
case url
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||||
case active
|
case active
|
||||||
case inactive
|
case inactive
|
||||||
@@ -25,7 +20,7 @@ enum AppEvent: Sendable, Equatable {
|
|||||||
case startupCompleted
|
case startupCompleted
|
||||||
case scenePhaseChanged(RuntimeScenePhase)
|
case scenePhaseChanged(RuntimeScenePhase)
|
||||||
case openedURL(String)
|
case openedURL(String)
|
||||||
case sharedContentAccepted(SharedContentKind)
|
case sharedContentReadyForReview(SharedContentKind)
|
||||||
case notificationOpened(peerID: PeerID?)
|
case notificationOpened(peerID: PeerID?)
|
||||||
case deepLinkOpened(String)
|
case deepLinkOpened(String)
|
||||||
case torLifecycleChanged(TorLifecycleEvent)
|
case torLifecycleChanged(TorLifecycleEvent)
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ final class AppChromeModel: ObservableObject {
|
|||||||
@Published var showScreenshotPrivacyWarning = false
|
@Published var showScreenshotPrivacyWarning = false
|
||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let onPanicWipe: () -> Void
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
|
|
||||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
privateInboxModel: PrivateInboxModel,
|
||||||
|
onPanicWipe: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
|
self.onPanicWipe = onPanicWipe
|
||||||
self.nickname = chatViewModel.nickname
|
self.nickname = chatViewModel.nickname
|
||||||
|
|
||||||
bind(privateInboxModel: privateInboxModel)
|
bind(privateInboxModel: privateInboxModel)
|
||||||
@@ -98,6 +104,7 @@ final class AppChromeModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
|
onPanicWipe()
|
||||||
chatViewModel.panicClearAllData()
|
chatViewModel.panicClearAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
let peerListModel: PeerListModel
|
let peerListModel: PeerListModel
|
||||||
let appChromeModel: AppChromeModel
|
let appChromeModel: AppChromeModel
|
||||||
let boardAlertsModel: BoardAlertsModel
|
let boardAlertsModel: BoardAlertsModel
|
||||||
|
let sharedContentImportModel: SharedContentImportModel
|
||||||
|
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
idBridge: NostrIdentityBridge = NostrIdentityBridge(),
|
||||||
|
sharedContentStore: SharedContentStore? = nil
|
||||||
) {
|
) {
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
let conversations = ConversationStore()
|
let conversations = ConversationStore()
|
||||||
@@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject {
|
|||||||
peerIdentityStore: peerIdentityStore,
|
peerIdentityStore: peerIdentityStore,
|
||||||
locationPresenceStore: locationPresenceStore
|
locationPresenceStore: locationPresenceStore
|
||||||
)
|
)
|
||||||
|
let resolvedSharedContentStore: SharedContentStore?
|
||||||
|
if let sharedContentStore {
|
||||||
|
resolvedSharedContentStore = sharedContentStore
|
||||||
|
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
|
||||||
|
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
|
||||||
|
} else {
|
||||||
|
resolvedSharedContentStore = nil
|
||||||
|
}
|
||||||
|
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
|
||||||
|
self.sharedContentImportModel = sharedContentImportModel
|
||||||
self.appChromeModel = AppChromeModel(
|
self.appChromeModel = AppChromeModel(
|
||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
privateInboxModel: self.privateInboxModel
|
privateInboxModel: self.privateInboxModel,
|
||||||
|
onPanicWipe: { sharedContentImportModel.discardAll() }
|
||||||
)
|
)
|
||||||
let chatViewModel = self.chatViewModel
|
let chatViewModel = self.chatViewModel
|
||||||
self.boardAlertsModel = BoardAlertsModel(
|
self.boardAlertsModel = BoardAlertsModel(
|
||||||
@@ -106,7 +119,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
bindRuntimeObservers()
|
bindRuntimeObservers()
|
||||||
NotificationDelegate.shared.runtime = self
|
NotificationDelegate.shared.runtime = self
|
||||||
@@ -313,44 +325,22 @@ private extension AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkForSharedContent() {
|
func checkForSharedContent() {
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
let previousID = sharedContentImportModel.offer?.id
|
||||||
let clearSharedContent = {
|
guard let payload = sharedContentImportModel.refresh(
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
destination: currentSharedContentDestination
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
) else { return }
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
|
||||||
|
if previousID != payload.id {
|
||||||
|
record(.sharedContentReadyForReview(payload.kind))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
var currentSharedContentDestination: SharedContentDestination {
|
||||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
SharedContentDestination.resolve(
|
||||||
// A partial or malformed handoff must not linger in the shared
|
selectedPrivatePeerID: privateConversationModel.selectedPeerID,
|
||||||
// app-group container indefinitely.
|
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||||
clearSharedContent()
|
activeChannel: locationChannelsModel.selectedChannel
|
||||||
return
|
)
|
||||||
}
|
|
||||||
|
|
||||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
|
||||||
clearSharedContent()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
|
||||||
|
|
||||||
clearSharedContent()
|
|
||||||
|
|
||||||
switch contentKind {
|
|
||||||
case .url:
|
|
||||||
if let data = sharedContent.data(using: .utf8),
|
|
||||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
|
||||||
let url = urlData["url"] {
|
|
||||||
chatViewModel.sendMessage(url)
|
|
||||||
} else {
|
|
||||||
chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
case .text:
|
|
||||||
chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
record(.sharedContentAccepted(contentKind))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SharedContentDestination: Sendable, Equatable {
|
||||||
|
case mesh
|
||||||
|
case geohash(String)
|
||||||
|
case privateConversation(peerID: PeerID, displayName: String)
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
selectedPrivatePeerID: PeerID?,
|
||||||
|
privateDisplayName: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> SharedContentDestination {
|
||||||
|
if let selectedPrivatePeerID {
|
||||||
|
let fallback = String(selectedPrivatePeerID.id.prefix(12))
|
||||||
|
return .privateConversation(
|
||||||
|
peerID: selectedPrivatePeerID,
|
||||||
|
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
return .mesh
|
||||||
|
case .location(let channel):
|
||||||
|
return .geohash(channel.geohash.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .mesh:
|
||||||
|
return "#mesh"
|
||||||
|
case .geohash(let geohash):
|
||||||
|
return "#\(geohash)"
|
||||||
|
case .privateConversation(_, let displayName):
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharedContentOffer: Identifiable, Sendable, Equatable {
|
||||||
|
let payload: SharedContentPayload
|
||||||
|
let destination: SharedContentDestination
|
||||||
|
|
||||||
|
var id: UUID { payload.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds a pending extension handoff until the user chooses a destination and
|
||||||
|
/// explicitly adds it to the composer. This type has no send dependency by
|
||||||
|
/// design: confirming an import can never transmit a message.
|
||||||
|
@MainActor
|
||||||
|
final class SharedContentImportModel: ObservableObject {
|
||||||
|
@Published private(set) var offer: SharedContentOffer?
|
||||||
|
|
||||||
|
private let store: SharedContentStore?
|
||||||
|
|
||||||
|
init(store: SharedContentStore?) {
|
||||||
|
self.store = store
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func refresh(
|
||||||
|
destination: SharedContentDestination,
|
||||||
|
now: Date = Date()
|
||||||
|
) -> SharedContentPayload? {
|
||||||
|
guard let payload = store?.pending(now: now) else {
|
||||||
|
offer = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
|
||||||
|
if offer != nextOffer {
|
||||||
|
offer = nextOffer
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDestination(_ destination: SharedContentDestination) {
|
||||||
|
guard let offer, offer.destination != destination else { return }
|
||||||
|
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns composer text only when the currently displayed destination is
|
||||||
|
/// still current and the reviewed envelope is still the stored envelope.
|
||||||
|
/// A destination change updates the prompt and requires another tap.
|
||||||
|
func confirm(
|
||||||
|
destination: SharedContentDestination,
|
||||||
|
now: Date = Date()
|
||||||
|
) -> String? {
|
||||||
|
guard let offer else { return nil }
|
||||||
|
guard offer.destination == destination else {
|
||||||
|
updateDestination(destination)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let payload = store?.consume(id: offer.id, now: now) else {
|
||||||
|
_ = refresh(destination: destination, now: now)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.offer = nil
|
||||||
|
return payload.composerText
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(destination: SharedContentDestination, now: Date = Date()) {
|
||||||
|
guard let offer else { return }
|
||||||
|
store?.discard(id: offer.id)
|
||||||
|
self.offer = nil
|
||||||
|
// If a newer share replaced the reviewed envelope, surface it rather
|
||||||
|
// than losing it with the older cancellation.
|
||||||
|
_ = refresh(destination: destination, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardAll() {
|
||||||
|
store?.discardAll()
|
||||||
|
offer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(runtime.peerListModel)
|
.environmentObject(runtime.peerListModel)
|
||||||
.environmentObject(runtime.appChromeModel)
|
.environmentObject(runtime.appChromeModel)
|
||||||
.environmentObject(runtime.boardAlertsModel)
|
.environmentObject(runtime.boardAlertsModel)
|
||||||
|
.environmentObject(runtime.sharedContentImportModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
appDelegate.runtime = runtime
|
||||||
runtime.start()
|
runtime.start()
|
||||||
|
|||||||
@@ -69564,6 +69564,111 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"share_import.review.message" : {
|
||||||
|
"comment" : "Explains that shared content replaces the named destination's composer and is not sent automatically",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "هل تريد استبدال مسودة %@ بهذا المحتوى؟ لن يتم إرسال أي شيء تلقائيًا." } },
|
||||||
|
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "%@-এর খসড়াটি এই কনটেন্ট দিয়ে বদলাবেন? কিছুই স্বয়ংক্রিয়ভাবে পাঠানো হবে না।" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Entwurf für %@ durch diesen Inhalt ersetzen? Es wird nichts automatisch gesendet." } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Replace the draft for %@ with this content? Nothing will be sent automatically." } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "¿Reemplazar el borrador de %@ con este contenido? No se enviará nada automáticamente." } },
|
||||||
|
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Palitan ng content na ito ang draft para sa %@? Walang awtomatikong ipapadala." } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Remplacer le brouillon pour %@ par ce contenu ? Rien ne sera envoyé automatiquement." } },
|
||||||
|
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "להחליף את הטיוטה עבור %@ בתוכן הזה? שום דבר לא יישלח אוטומטית." } },
|
||||||
|
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ का ड्राफ़्ट इस सामग्री से बदलें? कुछ भी अपने आप नहीं भेजा जाएगा।" } },
|
||||||
|
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Ganti draf untuk %@ dengan konten ini? Tidak ada yang akan dikirim otomatis." } },
|
||||||
|
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Sostituire la bozza per %@ con questo contenuto? Nulla verrà inviato automaticamente." } },
|
||||||
|
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ の下書きをこの内容で置き換えますか?自動的には送信されません。" } },
|
||||||
|
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "%@의 초안을 이 콘텐츠로 바꾸시겠습니까? 자동으로 전송되지 않습니다." } },
|
||||||
|
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gantikan draf untuk %@ dengan kandungan ini? Tiada apa-apa akan dihantar secara automatik." } },
|
||||||
|
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ को मस्यौदा यो सामग्रीले बदल्ने? केही पनि स्वचालित रूपमा पठाइने छैन।" } },
|
||||||
|
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Concept voor %@ vervangen door deze inhoud? Er wordt niets automatisch verzonden." } },
|
||||||
|
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Zastąpić szkic dla %@ tą treścią? Nic nie zostanie wysłane automatycznie." } },
|
||||||
|
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho para %@ por este conteúdo? Nada será enviado automaticamente." } },
|
||||||
|
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho de %@ por este conteúdo? Nada será enviado automaticamente." } },
|
||||||
|
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Заменить черновик для %@ этим содержимым? Ничего не будет отправлено автоматически." } },
|
||||||
|
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Ersätta utkastet för %@ med detta innehåll? Inget skickas automatiskt." } },
|
||||||
|
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ க்கான வரைவைக் இந்த உள்ளடக்கத்தால் மாற்றவா? எதுவும் தானாக அனுப்பப்படாது." } },
|
||||||
|
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "แทนที่ฉบับร่างสำหรับ %@ ด้วยเนื้อหานี้หรือไม่? จะไม่มีการส่งอัตโนมัติ" } },
|
||||||
|
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ taslağı bu içerikle değiştirilsin mi? Hiçbir şey otomatik olarak gönderilmez." } },
|
||||||
|
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Замінити чернетку для %@ цим вмістом? Нічого не буде надіслано автоматично." } },
|
||||||
|
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ کا مسودہ اس مواد سے بدلیں؟ کچھ بھی خودکار طور پر نہیں بھیجا جائے گا۔" } },
|
||||||
|
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Thay bản nháp cho %@ bằng nội dung này? Không có gì được tự động gửi." } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此内容替换 %@ 的草稿吗?内容不会自动发送。" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此內容取代 %@ 的草稿嗎?內容不會自動傳送。" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share_import.review.title" : {
|
||||||
|
"comment" : "Title for reviewing content received from the share extension",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "مراجعة المحتوى المشترك" } },
|
||||||
|
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "শেয়ার করা কনটেন্ট পর্যালোচনা করুন" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Geteilte Inhalte prüfen" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Review shared content" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar contenido compartido" } },
|
||||||
|
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Suriin ang ibinahaging content" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Vérifier le contenu partagé" } },
|
||||||
|
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "בדיקת תוכן משותף" } },
|
||||||
|
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "शेयर की गई सामग्री की समीक्षा करें" } },
|
||||||
|
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tinjau konten yang dibagikan" } },
|
||||||
|
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Controlla il contenuto condiviso" } },
|
||||||
|
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "共有コンテンツを確認" } },
|
||||||
|
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "공유 콘텐츠 검토" } },
|
||||||
|
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Semak kandungan yang dikongsi" } },
|
||||||
|
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "साझा सामग्री समीक्षा गर्नुहोस्" } },
|
||||||
|
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Gedeelde inhoud bekijken" } },
|
||||||
|
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Sprawdź udostępnioną treść" } },
|
||||||
|
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Rever conteúdo partilhado" } },
|
||||||
|
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar conteúdo compartilhado" } },
|
||||||
|
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Проверить общий контент" } },
|
||||||
|
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Granska delat innehåll" } },
|
||||||
|
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "பகிரப்பட்ட உள்ளடக்கத்தை மதிப்பாய்வு செய்" } },
|
||||||
|
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ตรวจสอบเนื้อหาที่แชร์" } },
|
||||||
|
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Paylaşılan içeriği gözden geçir" } },
|
||||||
|
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Переглянути спільний вміст" } },
|
||||||
|
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "شیئر کردہ مواد کا جائزہ لیں" } },
|
||||||
|
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Xem lại nội dung được chia sẻ" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "查看共享内容" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "查看分享內容" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share_import.review.use_in_composer" : {
|
||||||
|
"comment" : "Action that places reviewed shared content in the composer without sending it",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "استخدام في مسودة الرسالة" } },
|
||||||
|
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "বার্তার খসড়ায় ব্যবহার করুন" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Im Nachrichtenentwurf verwenden" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Use in composer" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar en el borrador" } },
|
||||||
|
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Gamitin sa draft" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Utiliser dans le brouillon" } },
|
||||||
|
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "שימוש בטיוטה" } },
|
||||||
|
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "संदेश के ड्राफ़्ट में उपयोग करें" } },
|
||||||
|
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan di draf" } },
|
||||||
|
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Usa nella bozza" } },
|
||||||
|
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "下書きで使用" } },
|
||||||
|
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "초안에 사용" } },
|
||||||
|
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan dalam draf" } },
|
||||||
|
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "सन्देशको मस्यौदामा प्रयोग गर्नुहोस्" } },
|
||||||
|
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "In concept gebruiken" } },
|
||||||
|
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Użyj w szkicu" } },
|
||||||
|
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
|
||||||
|
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
|
||||||
|
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Использовать в черновике" } },
|
||||||
|
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Använd i utkast" } },
|
||||||
|
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "செய்தி வரைவில் பயன்படுத்து" } },
|
||||||
|
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ใช้ในฉบับร่าง" } },
|
||||||
|
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Taslakta kullan" } },
|
||||||
|
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Використати в чернетці" } },
|
||||||
|
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "پیغام کے مسودے میں استعمال کریں" } },
|
||||||
|
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Dùng trong bản nháp" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "用于草稿" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "用於草稿" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
|
|||||||
var retrySleep: (TimeInterval) async -> Void
|
var retrySleep: (TimeInterval) async -> Void
|
||||||
var activeNotificationName: Notification.Name?
|
var activeNotificationName: Notification.Name?
|
||||||
var autoStart: Bool
|
var autoStart: Bool
|
||||||
var validationPolicy: GeoRelayDirectoryValidationPolicy
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GeoRelayDirectoryValidationPolicy: Sendable {
|
|
||||||
let maximumBytes: Int
|
|
||||||
let maximumRows: Int
|
|
||||||
let maximumEntries: Int
|
|
||||||
let minimumRemoteEntries: Int
|
|
||||||
let minimumRetainedFraction: Double
|
|
||||||
|
|
||||||
static let live = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 512 * 1024,
|
|
||||||
maximumRows: 5_000,
|
|
||||||
maximumEntries: 5_000,
|
|
||||||
minimumRemoteEntries: 50,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension GeoRelayDirectoryDependencies {
|
private extension GeoRelayDirectoryDependencies {
|
||||||
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
#else
|
#else
|
||||||
let activeNotificationName: Notification.Name? = nil
|
let activeNotificationName: Notification.Name? = nil
|
||||||
#endif
|
#endif
|
||||||
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
|
|
||||||
|
|
||||||
return Self(
|
return Self(
|
||||||
userDefaults: .standard,
|
userDefaults: .standard,
|
||||||
notificationCenter: .default,
|
notificationCenter: .default,
|
||||||
now: Date.init,
|
now: Date.init,
|
||||||
// Runtime refreshes only from bitchat's reviewed copy. Upstream
|
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||||
// georelays/main is imported by a validator-backed pull request,
|
|
||||||
// so an upstream mutation cannot immediately retarget clients.
|
|
||||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
|
|
||||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
makeFetchData: {
|
makeFetchData: {
|
||||||
let session = TorURLSession.shared.session
|
let session = TorURLSession.shared.session
|
||||||
return { request in
|
return { request in
|
||||||
let (bytes, response) = try await session.bytes(for: request)
|
let (data, _) = try await session.data(for: request)
|
||||||
guard let response = response as? HTTPURLResponse,
|
|
||||||
(200...299).contains(response.statusCode),
|
|
||||||
response.url == request.url else {
|
|
||||||
throw URLError(.badServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
let maximumBytes = validationPolicy.maximumBytes
|
|
||||||
guard response.expectedContentLength <= Int64(maximumBytes) else {
|
|
||||||
throw URLError(.dataLengthExceedsMaximum)
|
|
||||||
}
|
|
||||||
var data = Data()
|
|
||||||
if response.expectedContentLength > 0 {
|
|
||||||
data.reserveCapacity(Int(response.expectedContentLength))
|
|
||||||
}
|
|
||||||
for try await byte in bytes {
|
|
||||||
guard data.count < maximumBytes else {
|
|
||||||
throw URLError(.dataLengthExceedsMaximum)
|
|
||||||
}
|
|
||||||
data.append(byte)
|
|
||||||
}
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
)
|
)
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
// v2 ignores caches populated from the old direct-upstream
|
return dir.appendingPathComponent("georelays_cache.csv")
|
||||||
// trust path and subjects every load to strict validation.
|
|
||||||
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
|
|
||||||
try? FileManager.default.removeItem(at: legacyCache)
|
|
||||||
return dir.appendingPathComponent("georelays_cache_v2.csv")
|
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
},
|
},
|
||||||
activeNotificationName: activeNotificationName,
|
activeNotificationName: activeNotificationName,
|
||||||
autoStart: true,
|
autoStart: true
|
||||||
validationPolicy: validationPolicy
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum DetachedFetchOutcome: Sendable {
|
private enum DetachedFetchOutcome: Sendable {
|
||||||
case success(entries: [Entry], csv: Data)
|
case success(entries: [Entry], csv: String)
|
||||||
case torNotReady
|
case torNotReady
|
||||||
case invalidData
|
case invalidData
|
||||||
case network(String)
|
case network(String)
|
||||||
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
|
|||||||
)
|
)
|
||||||
let awaitTorReady = dependencies.awaitTorReady
|
let awaitTorReady = dependencies.awaitTorReady
|
||||||
let fetchData = dependencies.makeFetchData()
|
let fetchData = dependencies.makeFetchData()
|
||||||
let validationPolicy = dependencies.validationPolicy
|
|
||||||
let baselineEntries = Set(entries)
|
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
|
|||||||
let outcome = await Self.fetchRemoteOutcome(
|
let outcome = await Self.fetchRemoteOutcome(
|
||||||
request: request,
|
request: request,
|
||||||
awaitTorReady: awaitTorReady,
|
awaitTorReady: awaitTorReady,
|
||||||
fetchData: fetchData,
|
fetchData: fetchData
|
||||||
validationPolicy: validationPolicy,
|
|
||||||
baselineEntries: baselineEntries
|
|
||||||
)
|
)
|
||||||
|
|
||||||
switch outcome {
|
switch outcome {
|
||||||
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated private static func fetchRemoteOutcome(
|
nonisolated private static func fetchRemoteOutcome(
|
||||||
request: URLRequest,
|
request: URLRequest,
|
||||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
|
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||||
validationPolicy: GeoRelayDirectoryValidationPolicy,
|
|
||||||
baselineEntries: Set<Entry>
|
|
||||||
) async -> DetachedFetchOutcome {
|
) async -> DetachedFetchOutcome {
|
||||||
await Task.detached(priority: .utility) {
|
await Task.detached(priority: .utility) {
|
||||||
let ready = await awaitTorReady()
|
let ready = await awaitTorReady()
|
||||||
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try await fetchData(request)
|
let data = try await fetchData(request)
|
||||||
guard let parsed = Self.validatedEntries(
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
from: data,
|
|
||||||
policy: validationPolicy,
|
|
||||||
minimumEntries: validationPolicy.minimumRemoteEntries,
|
|
||||||
baselineEntries: baselineEntries
|
|
||||||
) else {
|
|
||||||
return .invalidData
|
return .invalidData
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(entries: parsed, csv: data)
|
let parsed = Self.parseCSV(text)
|
||||||
|
guard !parsed.isEmpty else {
|
||||||
|
return .invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(entries: parsed, csv: text)
|
||||||
} catch {
|
} catch {
|
||||||
return .network(error.localizedDescription)
|
return .network(error.localizedDescription)
|
||||||
}
|
}
|
||||||
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||||
entries = parsed
|
entries = parsed
|
||||||
persistCache(csv)
|
persistCache(csv)
|
||||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||||
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
|
|||||||
cleanupState.retryTask = nil
|
cleanupState.retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ data: Data) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = dependencies.cacheURL() else { return }
|
guard let url = dependencies.cacheURL() else { return }
|
||||||
|
guard let data = text.data(using: .utf8) else { return }
|
||||||
do {
|
do {
|
||||||
try dependencies.writeData(data, url)
|
try dependencies.writeData(data, url)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
|
|||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = dependencies.cacheURL(),
|
if let cache = dependencies.cacheURL(),
|
||||||
let data = dependencies.readData(cache),
|
let data = dependencies.readData(cache),
|
||||||
let entries = Self.validatedEntries(
|
let text = String(data: data, encoding: .utf8) {
|
||||||
from: data,
|
let arr = Self.parseCSV(text)
|
||||||
policy: dependencies.validationPolicy,
|
if !arr.isEmpty { return arr }
|
||||||
minimumEntries: 1
|
|
||||||
) {
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = dependencies.readData(url),
|
if let data = dependencies.readData(url),
|
||||||
let entries = Self.validatedEntries(
|
let text = String(data: data, encoding: .utf8) {
|
||||||
from: data,
|
let arr = Self.parseCSV(text)
|
||||||
policy: dependencies.validationPolicy,
|
if !arr.isEmpty { return arr }
|
||||||
minimumEntries: 1
|
|
||||||
) {
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = dependencies.currentDirectoryPath(),
|
if let cwd = dependencies.currentDirectoryPath(),
|
||||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let entries = Self.validatedEntries(
|
let text = String(data: data, encoding: .utf8) {
|
||||||
from: data,
|
return Self.parseCSV(text)
|
||||||
policy: dependencies.validationPolicy,
|
|
||||||
minimumEntries: 1
|
|
||||||
) {
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the fixed three-column format as an all-or-nothing trust unit.
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
/// One malformed or conflicting row rejects the complete dataset rather
|
var result: Set<Entry> = []
|
||||||
/// than silently shrinking or partially replacing the current directory.
|
|
||||||
nonisolated static func validatedEntries(
|
|
||||||
from data: Data,
|
|
||||||
policy: GeoRelayDirectoryValidationPolicy,
|
|
||||||
minimumEntries: Int,
|
|
||||||
baselineEntries: Set<Entry>? = nil
|
|
||||||
) -> [Entry]? {
|
|
||||||
guard !data.isEmpty, data.count <= policy.maximumBytes,
|
|
||||||
let text = String(data: data, encoding: .utf8),
|
|
||||||
!text.hasPrefix("\u{feff}") else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
for (idx, raw) in lines.enumerated() {
|
||||||
.filter { !$0.isEmpty }
|
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||||
guard let header = lines.first,
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
lines.count - 1 <= policy.maximumRows else {
|
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||||
return nil
|
guard parts.count >= 3 else { continue }
|
||||||
|
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||||
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
}
|
}
|
||||||
|
return Array(result)
|
||||||
let headerParts = header
|
|
||||||
.split(separator: ",", omittingEmptySubsequences: false)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
|
||||||
let supportedHeaders = [
|
|
||||||
["relay url", "latitude", "longitude"],
|
|
||||||
["relay url", "lat", "lon"]
|
|
||||||
]
|
|
||||||
guard supportedHeaders.contains(headerParts) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var entriesByHost: [String: Entry] = [:]
|
|
||||||
for line in lines.dropFirst() {
|
|
||||||
let parts = line
|
|
||||||
.split(separator: ",", omittingEmptySubsequences: false)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard parts.count == 3,
|
|
||||||
let host = validatedDirectoryAddress(parts[0]),
|
|
||||||
let latitude = Double(parts[1]), latitude.isFinite,
|
|
||||||
(-90.0...90.0).contains(latitude),
|
|
||||||
let longitude = Double(parts[2]), longitude.isFinite,
|
|
||||||
(-180.0...180.0).contains(longitude) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = Entry(host: host, lat: latitude, lon: longitude)
|
|
||||||
if let existing = entriesByHost[host], existing != entry {
|
|
||||||
// One endpoint cannot truthfully occupy two coordinates. Do
|
|
||||||
// not let row ordering choose which location clients trust.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
entriesByHost[host] = entry
|
|
||||||
guard entriesByHost.count <= policy.maximumEntries else { return nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedEntries = Set(entriesByHost.values)
|
|
||||||
guard parsedEntries.count >= minimumEntries else { return nil }
|
|
||||||
|
|
||||||
if let baselineEntries {
|
|
||||||
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
|
|
||||||
let requiredOverlap = Int(
|
|
||||||
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
|
|
||||||
)
|
|
||||||
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedEntries.sorted {
|
|
||||||
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
|
|
||||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !value.isEmpty,
|
|
||||||
value.unicodeScalars.allSatisfy({
|
|
||||||
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
|
|
||||||
}) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidate = value.contains("://") ? value : "wss://\(value)"
|
|
||||||
guard let components = URLComponents(string: candidate),
|
|
||||||
let scheme = components.scheme?.lowercased(),
|
|
||||||
scheme == "wss" || scheme == "https",
|
|
||||||
components.user == nil,
|
|
||||||
components.password == nil,
|
|
||||||
components.query == nil,
|
|
||||||
components.fragment == nil,
|
|
||||||
components.path.isEmpty || components.path == "/",
|
|
||||||
let rawHost = components.host else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let host = rawHost.lowercased()
|
|
||||||
guard !host.isEmpty, host.count <= 253,
|
|
||||||
host.unicodeScalars.allSatisfy({ $0.isASCII }),
|
|
||||||
!host.hasSuffix("."),
|
|
||||||
host != "localhost",
|
|
||||||
!host.hasSuffix(".localhost"),
|
|
||||||
!host.hasSuffix(".local"),
|
|
||||||
!host.hasSuffix(".internal") else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
|
|
||||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
|
|
||||||
guard labels.count >= 2,
|
|
||||||
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
|
|
||||||
labels.allSatisfy({ label in
|
|
||||||
(1...63).contains(label.count) &&
|
|
||||||
label.first != "-" &&
|
|
||||||
label.last != "-" &&
|
|
||||||
label.unicodeScalars.allSatisfy { allowed.contains($0) }
|
|
||||||
}) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let port = components.port {
|
|
||||||
guard (1...65_535).contains(port) else { return nil }
|
|
||||||
if port != 443 { return "\(host):\(port)" }
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
// MARK: - Observers & Timers
|
||||||
|
|||||||
@@ -39,4 +39,13 @@ enum NostrRelayURL {
|
|||||||
|
|
||||||
return components.string
|
return components.string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func directoryAddress(_ rawValue: String) -> String? {
|
||||||
|
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||||
|
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||||
|
normalized.removeFirst(prefix.count)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SharedContentKind: String, Codable, Sendable, Equatable {
|
||||||
|
case text
|
||||||
|
case url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single, bounded payload handed from the share extension to the app.
|
||||||
|
///
|
||||||
|
/// The app-group store intentionally contains at most one envelope. A newer
|
||||||
|
/// share replaces an older one, which prevents unbounded shared-container
|
||||||
|
/// growth while still surviving suspension and a later app launch.
|
||||||
|
struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
static let currentVersion = 1
|
||||||
|
static let maxContentBytes = 16_000
|
||||||
|
static let maxTitleBytes = 512
|
||||||
|
static let maxEnvelopeBytes = 24_000
|
||||||
|
static let retentionSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
static let allowedFutureSkewSeconds: TimeInterval = 5 * 60
|
||||||
|
|
||||||
|
let version: Int
|
||||||
|
let id: UUID
|
||||||
|
let kind: SharedContentKind
|
||||||
|
let content: String
|
||||||
|
let title: String?
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
init(
|
||||||
|
version: Int = Self.currentVersion,
|
||||||
|
id: UUID = UUID(),
|
||||||
|
kind: SharedContentKind,
|
||||||
|
content: String,
|
||||||
|
title: String? = nil,
|
||||||
|
createdAt: Date = Date()
|
||||||
|
) {
|
||||||
|
self.version = version
|
||||||
|
self.id = id
|
||||||
|
self.kind = kind
|
||||||
|
self.content = content
|
||||||
|
self.title = title
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload {
|
||||||
|
SharedContentPayload(kind: .text, content: content, createdAt: createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var composerText: String { content }
|
||||||
|
|
||||||
|
var preview: String {
|
||||||
|
let normalized = content
|
||||||
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
.replacingOccurrences(of: "\r", with: "\n")
|
||||||
|
guard normalized.count > 240 else { return normalized }
|
||||||
|
return String(normalized.prefix(240)) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(now: Date = Date()) throws {
|
||||||
|
guard version == Self.currentVersion else {
|
||||||
|
throw SharedContentHandoffError.unsupportedVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
throw SharedContentHandoffError.emptyContent
|
||||||
|
}
|
||||||
|
guard content.utf8.count <= Self.maxContentBytes else {
|
||||||
|
throw SharedContentHandoffError.contentTooLarge
|
||||||
|
}
|
||||||
|
if let title {
|
||||||
|
guard title.utf8.count <= Self.maxTitleBytes else {
|
||||||
|
throw SharedContentHandoffError.titleTooLarge
|
||||||
|
}
|
||||||
|
guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else {
|
||||||
|
throw SharedContentHandoffError.invalidCharacters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let age = now.timeIntervalSince(createdAt)
|
||||||
|
guard age >= -Self.allowedFutureSkewSeconds,
|
||||||
|
age <= Self.retentionSeconds else {
|
||||||
|
throw SharedContentHandoffError.expired
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case .text:
|
||||||
|
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else {
|
||||||
|
throw SharedContentHandoffError.invalidCharacters
|
||||||
|
}
|
||||||
|
case .url:
|
||||||
|
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false),
|
||||||
|
let components = URLComponents(string: content),
|
||||||
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
scheme == "http" || scheme == "https",
|
||||||
|
components.host?.isEmpty == false else {
|
||||||
|
throw SharedContentHandoffError.unsupportedURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func containsDisallowedControl(
|
||||||
|
in value: String,
|
||||||
|
allowsTextLayout: Bool
|
||||||
|
) -> Bool {
|
||||||
|
value.unicodeScalars.contains { scalar in
|
||||||
|
guard CharacterSet.controlCharacters.contains(scalar) else { return false }
|
||||||
|
if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SharedContentHandoffError: Error, Equatable {
|
||||||
|
case unsupportedVersion
|
||||||
|
case emptyContent
|
||||||
|
case contentTooLarge
|
||||||
|
case titleTooLarge
|
||||||
|
case invalidCharacters
|
||||||
|
case expired
|
||||||
|
case unsupportedURL
|
||||||
|
case envelopeTooLarge
|
||||||
|
case encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable, single-item app-group storage used by both the extension and app.
|
||||||
|
final class SharedContentStore {
|
||||||
|
static let storageKey = "sharedContentEnvelopeV1"
|
||||||
|
|
||||||
|
private static let legacyKeys = [
|
||||||
|
"sharedContent",
|
||||||
|
"sharedContentType",
|
||||||
|
"sharedContentDate"
|
||||||
|
]
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
init(defaults: UserDefaults) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.encoder = JSONEncoder()
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces any older pending share with a validated, bounded envelope.
|
||||||
|
func stage(_ payload: SharedContentPayload, now: Date = Date()) throws {
|
||||||
|
try payload.validate(now: now)
|
||||||
|
guard let encoded = try? encoder.encode(payload) else {
|
||||||
|
throw SharedContentHandoffError.encodingFailed
|
||||||
|
}
|
||||||
|
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else {
|
||||||
|
throw SharedContentHandoffError.envelopeTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults.set(encoded, forKey: Self.storageKey)
|
||||||
|
clearLegacyKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the pending share without consuming it. Invalid and expired data
|
||||||
|
/// is removed immediately so malformed app-group state cannot linger.
|
||||||
|
func pending(now: Date = Date()) -> SharedContentPayload? {
|
||||||
|
clearLegacyKeys()
|
||||||
|
|
||||||
|
guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil }
|
||||||
|
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||||
|
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else {
|
||||||
|
defaults.removeObject(forKey: Self.storageKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try payload.validate(now: now)
|
||||||
|
return payload
|
||||||
|
} catch {
|
||||||
|
defaults.removeObject(forKey: Self.storageKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes only the envelope the user actually reviewed. If a newer share
|
||||||
|
/// already replaced it, the newer content remains pending.
|
||||||
|
func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? {
|
||||||
|
guard let payload = pending(now: now), payload.id == id else { return nil }
|
||||||
|
defaults.removeObject(forKey: Self.storageKey)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit cancellation has the same identity guard as consumption so it
|
||||||
|
/// can never discard a newer share that arrived while a prompt was open.
|
||||||
|
func discard(id: UUID) {
|
||||||
|
guard let encoded = defaults.data(forKey: Self.storageKey),
|
||||||
|
encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||||
|
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded),
|
||||||
|
payload.id == id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaults.removeObject(forKey: Self.storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardAll() {
|
||||||
|
defaults.removeObject(forKey: Self.storageKey)
|
||||||
|
clearLegacyKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearLegacyKeys() {
|
||||||
|
for key in Self.legacyKeys {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,7 +314,6 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Share extension
|
// Share extension
|
||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
// Gossip Sync Configuration
|
// Gossip Sync Configuration
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct ContentView: View {
|
|||||||
@EnvironmentObject private var verificationModel: VerificationModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
|
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
|
||||||
|
|
||||||
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@@ -69,6 +70,14 @@ struct ContentView: View {
|
|||||||
privateConversationModel.selectedPeerID
|
privateConversationModel.selectedPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sharedContentDestination: SharedContentDestination {
|
||||||
|
SharedContentDestination.resolve(
|
||||||
|
selectedPrivatePeerID: selectedPrivatePeerID,
|
||||||
|
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||||
|
activeChannel: locationChannelsModel.selectedChannel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -85,6 +94,7 @@ struct ContentView: View {
|
|||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||||
}
|
}
|
||||||
.onChange(of: colorScheme) { newValue in
|
.onChange(of: colorScheme) { newValue in
|
||||||
conversationUIModel.setCurrentColorScheme(newValue)
|
conversationUIModel.setCurrentColorScheme(newValue)
|
||||||
@@ -101,6 +111,10 @@ struct ContentView: View {
|
|||||||
if newValue != nil {
|
if newValue != nil {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
}
|
}
|
||||||
|
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||||
|
}
|
||||||
|
.onChange(of: locationChannelsModel.selectedChannel) { _ in
|
||||||
|
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||||
}
|
}
|
||||||
.sheet(
|
.sheet(
|
||||||
isPresented: Binding(
|
isPresented: Binding(
|
||||||
@@ -227,6 +241,34 @@ struct ContentView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(appChromeModel.bluetoothAlertMessage)
|
Text(appChromeModel.bluetoothAlertMessage)
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"),
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { sharedContentImportModel.offer != nil },
|
||||||
|
set: { _ in }
|
||||||
|
),
|
||||||
|
presenting: sharedContentImportModel.offer
|
||||||
|
) { _ in
|
||||||
|
Button("common.cancel", role: .cancel) {
|
||||||
|
sharedContentImportModel.cancel(destination: sharedContentDestination)
|
||||||
|
}
|
||||||
|
Button("share_import.review.use_in_composer") {
|
||||||
|
guard let importedText = sharedContentImportModel.confirm(
|
||||||
|
destination: sharedContentDestination
|
||||||
|
) else { return }
|
||||||
|
// Replacing is deliberate and called out in the prompt. It
|
||||||
|
// avoids combining a stale draft from another conversation
|
||||||
|
// with newly shared content.
|
||||||
|
messageText = importedText
|
||||||
|
isTextFieldFocused = true
|
||||||
|
}
|
||||||
|
} message: { offer in
|
||||||
|
let format = String(
|
||||||
|
localized: "share_import.review.message",
|
||||||
|
comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically"
|
||||||
|
)
|
||||||
|
Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview)
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"share.status.failed_to_encode" : {
|
"share.status.failed_to_encode" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -782,7 +782,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"share.status.shared_link" : {
|
"share.status.shared_link" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -977,7 +977,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"share.status.shared_text" : {
|
"share.status.shared_text" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1170,6 +1170,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"share.status.failed_to_save" : {
|
||||||
|
"comment" : "Shown when content cannot be staged for the main app",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } },
|
||||||
|
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } },
|
||||||
|
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible d’enregistrer dans bitchat" } },
|
||||||
|
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } },
|
||||||
|
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } },
|
||||||
|
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
|
||||||
|
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } },
|
||||||
|
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } },
|
||||||
|
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } },
|
||||||
|
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
|
||||||
|
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } },
|
||||||
|
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } },
|
||||||
|
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } },
|
||||||
|
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } },
|
||||||
|
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } },
|
||||||
|
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } },
|
||||||
|
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } },
|
||||||
|
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } },
|
||||||
|
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } },
|
||||||
|
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat’e kaydedilemedi" } },
|
||||||
|
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } },
|
||||||
|
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } },
|
||||||
|
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share.status.saved_for_review" : {
|
||||||
|
"comment" : "Shown after content is staged for review in the main app",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } },
|
||||||
|
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } },
|
||||||
|
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } },
|
||||||
|
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } },
|
||||||
|
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } },
|
||||||
|
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez l’app pour vérifier" } },
|
||||||
|
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } },
|
||||||
|
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } },
|
||||||
|
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } },
|
||||||
|
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri l’app per controllare" } },
|
||||||
|
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } },
|
||||||
|
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } },
|
||||||
|
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } },
|
||||||
|
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } },
|
||||||
|
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } },
|
||||||
|
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } },
|
||||||
|
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } },
|
||||||
|
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } },
|
||||||
|
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } },
|
||||||
|
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } },
|
||||||
|
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } },
|
||||||
|
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } },
|
||||||
|
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat’e kaydedildi — incelemek için uygulamayı açın" } },
|
||||||
|
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } },
|
||||||
|
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } },
|
||||||
|
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } },
|
||||||
|
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } },
|
||||||
|
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ final class ShareViewController: UIViewController {
|
|||||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||||
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
||||||
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
||||||
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
|
static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app")
|
||||||
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
|
static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app")
|
||||||
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let statusLabel: UILabel = {
|
private let statusLabel: UILabel = {
|
||||||
@@ -44,9 +43,7 @@ final class ShareViewController: UIViewController {
|
|||||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||||
])
|
])
|
||||||
DispatchQueue.global().async {
|
processShare()
|
||||||
self.processShare()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Processing
|
// MARK: - Processing
|
||||||
@@ -151,30 +148,33 @@ final class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
// MARK: - Save + Finish
|
// MARK: - Save + Finish
|
||||||
private func saveAndFinish(url: URL, title: String?) {
|
private func saveAndFinish(url: URL, title: String?) {
|
||||||
let payload: [String: String] = [
|
let payload = SharedContentPayload(
|
||||||
"url": url.absoluteString,
|
kind: .url,
|
||||||
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
content: url.absoluteString,
|
||||||
]
|
title: title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
)
|
||||||
let s = String(data: json, encoding: .utf8) {
|
stageAndFinish(payload)
|
||||||
saveToSharedDefaults(content: s, type: "url")
|
|
||||||
finishWithMessage(Strings.sharedLinkConfirmation)
|
|
||||||
} else {
|
|
||||||
finishWithMessage(Strings.failedToEncode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveAndFinish(text: String) {
|
private func saveAndFinish(text: String) {
|
||||||
saveToSharedDefaults(content: text, type: "text")
|
stageAndFinish(.text(text))
|
||||||
finishWithMessage(Strings.sharedTextConfirmation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func stageAndFinish(_ payload: SharedContentPayload) {
|
||||||
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
|
guard let defaults = UserDefaults(suiteName: Self.groupID) else {
|
||||||
userDefaults.set(content, forKey: "sharedContent")
|
finishWithMessage(Strings.failedToSave)
|
||||||
userDefaults.set(type, forKey: "sharedContentType")
|
return
|
||||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
}
|
||||||
// No need to force synchronize; the system persists changes
|
let store = SharedContentStore(defaults: defaults)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try store.stage(payload)
|
||||||
|
// Staging is not sending. The main app will require a second,
|
||||||
|
// destination-labelled confirmation before filling its composer.
|
||||||
|
finishWithMessage(Strings.savedForReview)
|
||||||
|
} catch {
|
||||||
|
finishWithMessage(Strings.failedToSave)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishWithMessage(_ msg: String) {
|
private func finishWithMessage(_ msg: String) {
|
||||||
|
|||||||
@@ -5,25 +5,19 @@ import XCTest
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GeoRelayDirectoryTests: XCTestCase {
|
final class GeoRelayDirectoryTests: XCTestCase {
|
||||||
private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] {
|
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
|
||||||
GeoRelayDirectory.validatedEntries(
|
|
||||||
from: Data(csv.utf8),
|
|
||||||
policy: .live,
|
|
||||||
minimumEntries: 1
|
|
||||||
) ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() {
|
|
||||||
let csv = """
|
let csv = """
|
||||||
relay url,lat,lon
|
relay url,lat,lon
|
||||||
wss://one.example/,10,20
|
wss://one.example/,10,20
|
||||||
https://one.example,10,20
|
https://one.example,10,20
|
||||||
wss://one.example:443/,10,20
|
wss://one.example:443/,10,20
|
||||||
two.example,11,21
|
http://two.example/,11,21
|
||||||
wss://two.example:443,11,21
|
wss://two.example:443,11,21
|
||||||
|
invalid row
|
||||||
|
ws://three.example,not-a-lat,22
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let parsed = Set(parse(csv))
|
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
parsed,
|
parsed,
|
||||||
@@ -34,136 +28,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_parseCSV_rejectsWholeDatasetWhenAnyRowOrHeaderIsUnsafe() {
|
|
||||||
let invalidCSVs = [
|
|
||||||
"relay,lat,lon\nrelay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1\n",
|
|
||||||
"relay url,lat,lon\nhttp://relay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://user@relay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://relay.example/path,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://relay.example?,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://relay.example#,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example:0,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example:99999,1,2\n",
|
|
||||||
"relay url,lat,lon\nlocalhost,1,2\n",
|
|
||||||
"relay url,lat,lon\nr\u{00e9}lay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay\u{202e}.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,NaN,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1_0,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,\u{0661}\u{0660},2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,\u{ff11}\u{ff10},2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,91,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1,181\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1,2\nrelay.example,3,4\n"
|
|
||||||
]
|
|
||||||
|
|
||||||
for csv in invalidCSVs {
|
|
||||||
XCTAssertTrue(parse(csv).isEmpty, csv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_validatedEntries_enforcesByteRowEntryAndRetentionLimits() {
|
|
||||||
let restrictive = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 100,
|
|
||||||
maximumRows: 2,
|
|
||||||
maximumEntries: 2,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
let one = Data("relay url,lat,lon\none.example,1,2\n".utf8)
|
|
||||||
let three = Data("relay url,lat,lon\none.example,1,2\ntwo.example,3,4\nthree.example,5,6\n".utf8)
|
|
||||||
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: one,
|
|
||||||
policy: restrictive,
|
|
||||||
minimumEntries: 2
|
|
||||||
))
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: Data(repeating: 0x41, count: 101),
|
|
||||||
policy: restrictive,
|
|
||||||
minimumEntries: 1
|
|
||||||
))
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: three,
|
|
||||||
policy: restrictive,
|
|
||||||
minimumEntries: 1
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_validatedEntries_requiresExactBaselineEntryOverlap() throws {
|
|
||||||
let policy = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 1_000,
|
|
||||||
maximumRows: 10,
|
|
||||||
maximumEntries: 10,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
let baseline = Set(try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
one.example,1,1
|
|
||||||
two.example,2,2
|
|
||||||
three.example,3,3
|
|
||||||
""".utf8),
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1
|
|
||||||
)))
|
|
||||||
let disjoint = Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
four.example,1,1
|
|
||||||
five.example,2,2
|
|
||||||
six.example,3,3
|
|
||||||
""".utf8)
|
|
||||||
let rewrittenCoordinates = Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
one.example,11,11
|
|
||||||
two.example,12,12
|
|
||||||
three.example,13,13
|
|
||||||
""".utf8)
|
|
||||||
let halfRetained = Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
wss://one.example:443/,1,1
|
|
||||||
https://two.example/,2,2
|
|
||||||
replacement.example,4,4
|
|
||||||
""".utf8)
|
|
||||||
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: disjoint,
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1,
|
|
||||||
baselineEntries: baseline
|
|
||||||
))
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: rewrittenCoordinates,
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1,
|
|
||||||
baselineEntries: baseline
|
|
||||||
))
|
|
||||||
XCTAssertNotNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: halfRetained,
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1,
|
|
||||||
baselineEntries: baseline
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_bundledReviewedCSV_passesStrictProductionValidation() throws {
|
|
||||||
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
let data = try Data(
|
|
||||||
contentsOf: repositoryRoot.appendingPathComponent("relays/online_relays_gps.csv")
|
|
||||||
)
|
|
||||||
|
|
||||||
let entries = try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: data,
|
|
||||||
policy: .live,
|
|
||||||
minimumEntries: GeoRelayDirectoryValidationPolicy.live.minimumRemoteEntries
|
|
||||||
))
|
|
||||||
XCTAssertGreaterThan(entries.count, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
||||||
let harness = makeHarness(
|
let harness = makeHarness(
|
||||||
cacheCSV: """
|
cacheCSV: """
|
||||||
@@ -379,53 +243,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
XCTAssertFalse(directory.debugHasRetryTask)
|
XCTAssertFalse(directory.debugHasRetryTask)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async {
|
|
||||||
let cached = """
|
|
||||||
relay url,lat,lon
|
|
||||||
old-one.example,1,1
|
|
||||||
old-two.example,2,2
|
|
||||||
old-three.example,3,3
|
|
||||||
"""
|
|
||||||
let truncated = """
|
|
||||||
relay url,lat,lon
|
|
||||||
attacker.example,9,9
|
|
||||||
"""
|
|
||||||
let recovered = """
|
|
||||||
relay url,lat,lon
|
|
||||||
old-one.example,1,1
|
|
||||||
old-two.example,2,2
|
|
||||||
new-three.example,6,6
|
|
||||||
"""
|
|
||||||
let harness = makeHarness(
|
|
||||||
cacheCSV: cached,
|
|
||||||
fetchResults: [
|
|
||||||
.success(Data(truncated.utf8)),
|
|
||||||
.success(Data(recovered.utf8))
|
|
||||||
],
|
|
||||||
validationPolicy: GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 64 * 1024,
|
|
||||||
maximumRows: 1_000,
|
|
||||||
maximumEntries: 1_000,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
)
|
|
||||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
|
||||||
|
|
||||||
directory.prefetchIfNeeded()
|
|
||||||
|
|
||||||
let refreshed = await waitUntil {
|
|
||||||
directory.entries.contains(where: { $0.host == "new-three.example" })
|
|
||||||
}
|
|
||||||
XCTAssertTrue(refreshed)
|
|
||||||
XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" }))
|
|
||||||
let requestCount = await harness.fetcher.recordedRequestCount()
|
|
||||||
let retryDelays = await harness.retryRecorder.recordedDelays()
|
|
||||||
XCTAssertEqual(requestCount, 2)
|
|
||||||
XCTAssertEqual(retryDelays, [5])
|
|
||||||
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
||||||
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
||||||
let harness = makeHarness(
|
let harness = makeHarness(
|
||||||
@@ -472,14 +289,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
||||||
fetchObserver: (@Sendable () async -> Void)? = nil,
|
fetchObserver: (@Sendable () async -> Void)? = nil,
|
||||||
autoStart: Bool = false,
|
autoStart: Bool = false,
|
||||||
activeNotificationName: Notification.Name? = nil,
|
activeNotificationName: Notification.Name? = nil
|
||||||
validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 64 * 1024,
|
|
||||||
maximumRows: 1_000,
|
|
||||||
maximumEntries: 1_000,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0
|
|
||||||
)
|
|
||||||
) -> GeoRelayHarness {
|
) -> GeoRelayHarness {
|
||||||
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
||||||
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
||||||
@@ -537,8 +347,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
await retryRecorder.record(delay)
|
await retryRecorder.record(delay)
|
||||||
},
|
},
|
||||||
activeNotificationName: activeNotificationName,
|
activeNotificationName: activeNotificationName,
|
||||||
autoStart: autoStart,
|
autoStart: autoStart
|
||||||
validationPolicy: validationPolicy
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return GeoRelayHarness(
|
return GeoRelayHarness(
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("Share extension handoff", .serialized)
|
||||||
|
struct SharedContentHandoffTests {
|
||||||
|
private func makeStore() -> (suite: String, defaults: UserDefaults, store: SharedContentStore) {
|
||||||
|
let suite = "SharedContentHandoffTests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
return (suite, defaults, SharedContentStore(defaults: defaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A staged share survives an inactive app and a late open")
|
||||||
|
func stagedShareSurvivesLateOpen() throws {
|
||||||
|
let context = makeStore()
|
||||||
|
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||||
|
let stagedAt = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
let payload = SharedContentPayload.text("review me later", createdAt: stagedAt)
|
||||||
|
|
||||||
|
try context.store.stage(payload, now: stagedAt)
|
||||||
|
|
||||||
|
#expect(context.store.pending(now: stagedAt.addingTimeInterval(60 * 60)) == payload)
|
||||||
|
#expect(context.defaults.data(forKey: SharedContentStore.storageKey) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Malformed, oversized, unsupported, and expired payloads are rejected and cleared")
|
||||||
|
func invalidPayloadsAreRejectedAndCleared() throws {
|
||||||
|
let context = makeStore()
|
||||||
|
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||||
|
let now = Date(timeIntervalSince1970: 2_000_000)
|
||||||
|
|
||||||
|
context.defaults.set(Data("not-json".utf8), forKey: SharedContentStore.storageKey)
|
||||||
|
#expect(context.store.pending(now: now) == nil)
|
||||||
|
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||||
|
|
||||||
|
context.defaults.set(
|
||||||
|
Data(repeating: 0x41, count: SharedContentPayload.maxEnvelopeBytes + 1),
|
||||||
|
forKey: SharedContentStore.storageKey
|
||||||
|
)
|
||||||
|
#expect(context.store.pending(now: now) == nil)
|
||||||
|
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||||
|
|
||||||
|
let oversized = SharedContentPayload.text(
|
||||||
|
String(repeating: "x", count: SharedContentPayload.maxContentBytes + 1),
|
||||||
|
createdAt: now
|
||||||
|
)
|
||||||
|
#expect(throws: SharedContentHandoffError.contentTooLarge) {
|
||||||
|
try context.store.stage(oversized, now: now)
|
||||||
|
}
|
||||||
|
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||||
|
|
||||||
|
let unsupportedURL = SharedContentPayload(
|
||||||
|
kind: .url,
|
||||||
|
content: "file:///private/tmp/secret.txt",
|
||||||
|
createdAt: now
|
||||||
|
)
|
||||||
|
#expect(throws: SharedContentHandoffError.unsupportedURL) {
|
||||||
|
try context.store.stage(unsupportedURL, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
let misleadingControl = SharedContentPayload.text("safe\u{202E}txt", createdAt: now)
|
||||||
|
#expect(throws: SharedContentHandoffError.invalidCharacters) {
|
||||||
|
try context.store.stage(misleadingControl, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
let expired = SharedContentPayload.text(
|
||||||
|
"too old",
|
||||||
|
createdAt: now.addingTimeInterval(-SharedContentPayload.retentionSeconds - 1)
|
||||||
|
)
|
||||||
|
context.defaults.set(try JSONEncoder().encode(expired), forKey: SharedContentStore.storageKey)
|
||||||
|
#expect(context.store.pending(now: now) == nil)
|
||||||
|
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mesh, geohash, and stale private selections resolve to explicit destinations")
|
||||||
|
func destinationsAreExplicit() {
|
||||||
|
let geohashChannel = ChannelID.location(
|
||||||
|
GeohashChannel(level: .city, geohash: "9Q8YY")
|
||||||
|
)
|
||||||
|
let stalePeer = PeerID(str: "0011223344556677")
|
||||||
|
|
||||||
|
#expect(SharedContentDestination.resolve(
|
||||||
|
selectedPrivatePeerID: nil,
|
||||||
|
privateDisplayName: nil,
|
||||||
|
activeChannel: .mesh
|
||||||
|
) == .mesh)
|
||||||
|
#expect(SharedContentDestination.resolve(
|
||||||
|
selectedPrivatePeerID: nil,
|
||||||
|
privateDisplayName: nil,
|
||||||
|
activeChannel: geohashChannel
|
||||||
|
) == .geohash("9q8yy"))
|
||||||
|
#expect(SharedContentDestination.resolve(
|
||||||
|
selectedPrivatePeerID: stalePeer,
|
||||||
|
privateDisplayName: "alice",
|
||||||
|
activeChannel: geohashChannel
|
||||||
|
) == .privateConversation(peerID: stalePeer, displayName: "alice"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A destination change requires a new confirmation and never consumes on the stale tap")
|
||||||
|
@MainActor
|
||||||
|
func staleDestinationCannotBeConfirmed() throws {
|
||||||
|
let context = makeStore()
|
||||||
|
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||||
|
let now = Date(timeIntervalSince1970: 3_000_000)
|
||||||
|
let payload = SharedContentPayload.text("do not auto-send", createdAt: now)
|
||||||
|
let peer = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let privateDestination = SharedContentDestination.privateConversation(
|
||||||
|
peerID: peer,
|
||||||
|
displayName: "alice"
|
||||||
|
)
|
||||||
|
let model = SharedContentImportModel(store: context.store)
|
||||||
|
try context.store.stage(payload, now: now)
|
||||||
|
model.refresh(destination: privateDestination, now: now)
|
||||||
|
|
||||||
|
#expect(model.confirm(destination: .mesh, now: now) == nil)
|
||||||
|
#expect(model.offer?.destination == .mesh)
|
||||||
|
#expect(context.store.pending(now: now) == payload)
|
||||||
|
|
||||||
|
#expect(model.confirm(destination: .mesh, now: now) == payload.content)
|
||||||
|
#expect(model.offer == nil)
|
||||||
|
#expect(context.store.pending(now: now) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Confirmation consumes once and cancellation explicitly clears without producing composer text")
|
||||||
|
@MainActor
|
||||||
|
func oneTimeConfirmationAndCancellation() throws {
|
||||||
|
let context = makeStore()
|
||||||
|
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||||
|
let now = Date(timeIntervalSince1970: 4_000_000)
|
||||||
|
let model = SharedContentImportModel(store: context.store)
|
||||||
|
|
||||||
|
let first = SharedContentPayload.text("confirmed", createdAt: now)
|
||||||
|
try context.store.stage(first, now: now)
|
||||||
|
model.refresh(destination: .geohash("u4pruy"), now: now)
|
||||||
|
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == "confirmed")
|
||||||
|
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == nil)
|
||||||
|
|
||||||
|
let second = SharedContentPayload.text("cancelled", createdAt: now)
|
||||||
|
try context.store.stage(second, now: now)
|
||||||
|
model.refresh(destination: .mesh, now: now)
|
||||||
|
model.cancel(destination: .mesh, now: now)
|
||||||
|
#expect(model.offer == nil)
|
||||||
|
#expect(context.store.pending(now: now) == nil)
|
||||||
|
|
||||||
|
let third = SharedContentPayload.text("panic-wiped", createdAt: now)
|
||||||
|
try context.store.stage(third, now: now)
|
||||||
|
model.refresh(destination: .mesh, now: now)
|
||||||
|
model.discardAll()
|
||||||
|
#expect(model.offer == nil)
|
||||||
|
#expect(context.store.pending(now: now) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cancelling an old review never deletes a newer staged share")
|
||||||
|
@MainActor
|
||||||
|
func cancellationPreservesNewerShare() throws {
|
||||||
|
let context = makeStore()
|
||||||
|
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||||
|
let now = Date(timeIntervalSince1970: 5_000_000)
|
||||||
|
let model = SharedContentImportModel(store: context.store)
|
||||||
|
let old = SharedContentPayload.text("old", createdAt: now)
|
||||||
|
let newer = SharedContentPayload.text("new", createdAt: now)
|
||||||
|
|
||||||
|
try context.store.stage(old, now: now)
|
||||||
|
model.refresh(destination: .mesh, now: now)
|
||||||
|
try context.store.stage(newer, now: now)
|
||||||
|
model.cancel(destination: .mesh, now: now)
|
||||||
|
|
||||||
|
#expect(model.offer?.payload == newer)
|
||||||
|
#expect(context.store.pending(now: now) == newer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ private struct SmokeFeatureModels {
|
|||||||
let conversationUIModel: ConversationUIModel
|
let conversationUIModel: ConversationUIModel
|
||||||
let peerListModel: PeerListModel
|
let peerListModel: PeerListModel
|
||||||
let boardAlertsModel: BoardAlertsModel
|
let boardAlertsModel: BoardAlertsModel
|
||||||
|
let sharedContentImportModel: SharedContentImportModel
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -99,7 +100,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
|||||||
verificationModel: verificationModel,
|
verificationModel: verificationModel,
|
||||||
conversationUIModel: conversationUIModel,
|
conversationUIModel: conversationUIModel,
|
||||||
peerListModel: peerListModel,
|
peerListModel: peerListModel,
|
||||||
boardAlertsModel: boardAlertsModel
|
boardAlertsModel: boardAlertsModel,
|
||||||
|
sharedContentImportModel: SharedContentImportModel(store: nil)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ private func installSmokeEnvironment<V: View>(
|
|||||||
.environmentObject(featureModels.conversationUIModel)
|
.environmentObject(featureModels.conversationUIModel)
|
||||||
.environmentObject(featureModels.peerListModel)
|
.environmentObject(featureModels.peerListModel)
|
||||||
.environmentObject(featureModels.boardAlertsModel)
|
.environmentObject(featureModels.boardAlertsModel)
|
||||||
|
.environmentObject(featureModels.sharedContentImportModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/fetch_georelays.yml"
|
|
||||||
|
|
||||||
|
|
||||||
class FetchGeoRelaysWorkflowTests(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def test_write_capable_checkout_action_is_immutable(self) -> None:
|
|
||||||
checkout = re.search(r"uses: actions/checkout@([0-9a-f]+)", self.workflow)
|
|
||||||
self.assertIsNotNone(checkout)
|
|
||||||
self.assertRegex(checkout.group(1), r"^[0-9a-f]{40}$")
|
|
||||||
self.assertIn("persist-credentials: false", self.workflow)
|
|
||||||
|
|
||||||
def test_pr_failure_has_single_issue_fallback_with_review_metadata(self) -> None:
|
|
||||||
required_fragments = [
|
|
||||||
"issues: write",
|
|
||||||
"TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request",
|
|
||||||
"gh pr create",
|
|
||||||
"gh issue create",
|
|
||||||
"gh issue edit",
|
|
||||||
"compare/main...${UPDATE_BRANCH}?expand=1",
|
|
||||||
"Upstream commit: $SOURCE_COMMIT",
|
|
||||||
"Data rows: $DATA_ROWS",
|
|
||||||
"Unique normalized relays: $UNIQUE_RELAYS",
|
|
||||||
"SHA-256: $DATA_SHA256",
|
|
||||||
'[[ -n "$issue_url" ]]',
|
|
||||||
]
|
|
||||||
for fragment in required_fragments:
|
|
||||||
with self.subTest(fragment=fragment):
|
|
||||||
self.assertIn(fragment, self.workflow)
|
|
||||||
|
|
||||||
confirmed = self.workflow.index('[[ -n "$issue_url" ]]')
|
|
||||||
success_summary = self.workflow.index(
|
|
||||||
"Published GeoRelay tracking issue fallback: $issue_url"
|
|
||||||
)
|
|
||||||
self.assertLess(confirmed, success_summary)
|
|
||||||
|
|
||||||
def test_obsolete_review_state_is_cleaned_without_pushing_main(self) -> None:
|
|
||||||
self.assertIn("gh pr close", self.workflow)
|
|
||||||
self.assertIn("gh issue close", self.workflow)
|
|
||||||
self.assertIn('git push origin --delete "$UPDATE_BRANCH"', self.workflow)
|
|
||||||
self.assertIn('git switch -C "$UPDATE_BRANCH"', self.workflow)
|
|
||||||
self.assertNotIn("git push origin main", self.workflow)
|
|
||||||
self.assertNotIn("git push --force origin main", self.workflow)
|
|
||||||
|
|
||||||
def test_workflow_runs_all_validator_tests(self) -> None:
|
|
||||||
self.assertIn(
|
|
||||||
'python3 -m unittest discover -s scripts/tests -p "test_*.py" -v',
|
|
||||||
self.workflow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
import validate_georelays as validator
|
|
||||||
|
|
||||||
|
|
||||||
def csv_bytes(rows: list[str]) -> bytes:
|
|
||||||
return ("Relay URL,Latitude,Longitude\n" + "\n".join(rows) + "\n").encode()
|
|
||||||
|
|
||||||
|
|
||||||
class ValidateGeoRelaysTests(unittest.TestCase):
|
|
||||||
def test_validates_and_deduplicates_secure_relay_addresses(self) -> None:
|
|
||||||
data = csv_bytes(
|
|
||||||
[
|
|
||||||
"relay.example.com,10,20",
|
|
||||||
"wss://relay.example.com:443/,10,20",
|
|
||||||
"https://second.example.org,11,21",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
summary = validator.validate_bytes(data, minimum_unique_relays=2)
|
|
||||||
|
|
||||||
self.assertEqual(summary.data_rows, 3)
|
|
||||||
self.assertEqual(summary.unique_relays, 2)
|
|
||||||
|
|
||||||
def test_rejects_insecure_or_non_host_relay_urls(self) -> None:
|
|
||||||
bad_addresses = [
|
|
||||||
"http://relay.example.com",
|
|
||||||
"ws://relay.example.com",
|
|
||||||
"wss://user@relay.example.com",
|
|
||||||
"wss://relay.example.com/path",
|
|
||||||
"wss://relay.example.com?",
|
|
||||||
"wss://relay.example.com#",
|
|
||||||
"relay.example.com:0",
|
|
||||||
"relay.example.com:99999",
|
|
||||||
"localhost",
|
|
||||||
"127.0.0.1",
|
|
||||||
"relay_example.com",
|
|
||||||
"relay\u202e.example.com",
|
|
||||||
]
|
|
||||||
|
|
||||||
for address in bad_addresses:
|
|
||||||
with self.subTest(address=address):
|
|
||||||
with self.assertRaises(validator.ValidationError):
|
|
||||||
validator.validate_bytes(
|
|
||||||
csv_bytes([f"{address},10,20"]),
|
|
||||||
minimum_unique_relays=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rejects_malformed_rows_and_unsafe_coordinates(self) -> None:
|
|
||||||
bad_rows = [
|
|
||||||
"relay.example.com,10",
|
|
||||||
"relay.example.com,NaN,20",
|
|
||||||
"relay.example.com,1_0,20",
|
|
||||||
"relay.example.com,\u0661\u0660,20",
|
|
||||||
"relay.example.com,\uff11\uff10,20",
|
|
||||||
"relay.example.com,91,20",
|
|
||||||
"relay.example.com,10,-181",
|
|
||||||
"relay.example.com,10,20,extra",
|
|
||||||
'"relay.example.com",10,20',
|
|
||||||
]
|
|
||||||
|
|
||||||
for row in bad_rows:
|
|
||||||
with self.subTest(row=row):
|
|
||||||
with self.assertRaises(validator.ValidationError):
|
|
||||||
validator.validate_bytes(csv_bytes([row]), minimum_unique_relays=1)
|
|
||||||
|
|
||||||
def test_accepts_ascii_coordinate_forms_supported_by_swift_double(self) -> None:
|
|
||||||
summary = validator.validate_bytes(
|
|
||||||
csv_bytes(
|
|
||||||
[
|
|
||||||
"one.example.com,+1,-.5",
|
|
||||||
"two.example.com,1.e1,2E+1",
|
|
||||||
"three.example.com,01,20.",
|
|
||||||
]
|
|
||||||
),
|
|
||||||
minimum_unique_relays=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(summary.unique_relays, 3)
|
|
||||||
|
|
||||||
def test_rejects_conflicts_limits_and_large_baseline_deltas(self) -> None:
|
|
||||||
with self.assertRaises(validator.ValidationError):
|
|
||||||
validator.validate_bytes(
|
|
||||||
csv_bytes(["relay.example.com,10,20", "relay.example.com,11,21"]),
|
|
||||||
minimum_unique_relays=1,
|
|
||||||
)
|
|
||||||
with self.assertRaises(validator.ValidationError):
|
|
||||||
validator.validate_bytes(b"x" * 20, maximum_bytes=10, minimum_unique_relays=1)
|
|
||||||
with self.assertRaises(validator.ValidationError):
|
|
||||||
validator.validate_bytes(
|
|
||||||
csv_bytes(["one.example.com,1,1", "two.example.com,2,2"]),
|
|
||||||
minimum_unique_relays=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
baseline = csv_bytes(
|
|
||||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(120)]
|
|
||||||
)
|
|
||||||
shrunken = csv_bytes(
|
|
||||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(59)]
|
|
||||||
)
|
|
||||||
with self.assertRaises(validator.ValidationError):
|
|
||||||
validator.validate_update(shrunken, baseline)
|
|
||||||
|
|
||||||
smaller_baseline = csv_bytes(
|
|
||||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
|
|
||||||
)
|
|
||||||
expanded = csv_bytes(
|
|
||||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(121)]
|
|
||||||
)
|
|
||||||
with self.assertRaises(validator.ValidationError):
|
|
||||||
validator.validate_update(expanded, smaller_baseline)
|
|
||||||
|
|
||||||
def test_update_requires_exact_normalized_baseline_entry_overlap(self) -> None:
|
|
||||||
baseline_rows = [
|
|
||||||
f"relay-{index}.example.com,{index % 80},{index % 170}"
|
|
||||||
for index in range(60)
|
|
||||||
]
|
|
||||||
baseline = csv_bytes(baseline_rows)
|
|
||||||
disjoint = csv_bytes(
|
|
||||||
[
|
|
||||||
f"attacker-{index}.example.com,{index % 80},{index % 170}"
|
|
||||||
for index in range(60)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
rewritten_coordinates = csv_bytes(
|
|
||||||
[
|
|
||||||
f"relay-{index}.example.com,{(index % 80) + 0.5},{index % 170}"
|
|
||||||
for index in range(60)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
for candidate in (disjoint, rewritten_coordinates):
|
|
||||||
with self.subTest(candidate=candidate[:80]):
|
|
||||||
with self.assertRaisesRegex(
|
|
||||||
validator.ValidationError,
|
|
||||||
"exact relay-coordinate entries",
|
|
||||||
):
|
|
||||||
validator.validate_update(candidate, baseline)
|
|
||||||
|
|
||||||
half_retained = csv_bytes(
|
|
||||||
[
|
|
||||||
f"wss://relay-{index}.example.com:443/,{index % 80},{index % 170}"
|
|
||||||
for index in range(30)
|
|
||||||
]
|
|
||||||
+ [
|
|
||||||
f"replacement-{index}.example.com,{index % 80},{index % 170}"
|
|
||||||
for index in range(30)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
summary = validator.validate_update(half_retained, baseline)
|
|
||||||
self.assertEqual(summary.unique_relays, 60)
|
|
||||||
|
|
||||||
def test_cli_copies_only_validated_data_and_emits_review_metadata(self) -> None:
|
|
||||||
rows = [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
|
|
||||||
data = csv_bytes(rows)
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
root = Path(directory)
|
|
||||||
candidate = root / "candidate.csv"
|
|
||||||
baseline = root / "baseline.csv"
|
|
||||||
output = root / "output.csv"
|
|
||||||
github_output = root / "github-output.txt"
|
|
||||||
candidate.write_bytes(data)
|
|
||||||
baseline.write_bytes(data)
|
|
||||||
|
|
||||||
result = validator.main(
|
|
||||||
[
|
|
||||||
"--input", str(candidate),
|
|
||||||
"--baseline", str(baseline),
|
|
||||||
"--output", str(output),
|
|
||||||
"--github-output", str(github_output),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(output.read_bytes(), data)
|
|
||||||
metadata = github_output.read_text()
|
|
||||||
self.assertIn("unique_relays=60", metadata)
|
|
||||||
self.assertIn("sha256=", metadata)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Strict validator for the reviewed georelay CSV update workflow."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import unicodedata
|
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
|
|
||||||
MAX_BYTES = 512 * 1024
|
|
||||||
MAX_ROWS = 5_000
|
|
||||||
MAX_UNIQUE_RELAYS = 5_000
|
|
||||||
MIN_UNIQUE_RELAYS = 50
|
|
||||||
MIN_BASELINE_FRACTION = 0.5
|
|
||||||
MAX_BASELINE_MULTIPLIER = 2.0
|
|
||||||
EXPECTED_HEADER = ("relay url", "latitude", "longitude")
|
|
||||||
ASCII_DECIMAL_PATTERN = re.compile(
|
|
||||||
r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ValidationSummary:
|
|
||||||
data_rows: int
|
|
||||||
unique_relays: int
|
|
||||||
sha256: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _ValidatedDataset:
|
|
||||||
summary: ValidationSummary
|
|
||||||
entries: frozenset[tuple[str, float, float]]
|
|
||||||
|
|
||||||
|
|
||||||
def _has_disallowed_control(value: str) -> bool:
|
|
||||||
return any(
|
|
||||||
unicodedata.category(character) in {"Cc", "Cf"}
|
|
||||||
and character not in {"\r", "\n", "\t"}
|
|
||||||
for character in value
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_relay_address(raw_value: str) -> str:
|
|
||||||
value = raw_value.strip()
|
|
||||||
if not value or _has_disallowed_control(value):
|
|
||||||
raise ValidationError("relay address is empty or contains control characters")
|
|
||||||
# urlsplit cannot distinguish an absent query/fragment from an explicitly
|
|
||||||
# empty one. Reject the delimiters themselves so this validator matches
|
|
||||||
# URLComponents in the client and reviewed data cannot fail closed there.
|
|
||||||
if "?" in value or "#" in value:
|
|
||||||
raise ValidationError(f"relay query or fragment is not allowed: {value}")
|
|
||||||
|
|
||||||
candidate = value if "://" in value else f"wss://{value}"
|
|
||||||
try:
|
|
||||||
parsed = urlsplit(candidate)
|
|
||||||
port = parsed.port
|
|
||||||
except ValueError as error:
|
|
||||||
raise ValidationError(f"invalid relay URL: {value}") from error
|
|
||||||
|
|
||||||
if parsed.scheme.lower() not in {"wss", "https"}:
|
|
||||||
raise ValidationError(f"relay must use wss/https or a bare hostname: {value}")
|
|
||||||
if parsed.username is not None or parsed.password is not None:
|
|
||||||
raise ValidationError(f"relay credentials are not allowed: {value}")
|
|
||||||
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
|
||||||
raise ValidationError(f"relay path, query, or fragment is not allowed: {value}")
|
|
||||||
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
if not host or len(host) > 253 or not host.isascii():
|
|
||||||
raise ValidationError(f"relay hostname is missing or non-ASCII: {value}")
|
|
||||||
if host.endswith(".") or host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
|
|
||||||
raise ValidationError(f"local or absolute relay hostname is not allowed: {value}")
|
|
||||||
|
|
||||||
labels = host.split(".")
|
|
||||||
if len(labels) < 2 or all(label.isdigit() for label in labels):
|
|
||||||
raise ValidationError(f"relay must use a public DNS hostname: {value}")
|
|
||||||
for label in labels:
|
|
||||||
if not 1 <= len(label) <= 63:
|
|
||||||
raise ValidationError(f"invalid DNS label length: {value}")
|
|
||||||
if label[0] == "-" or label[-1] == "-":
|
|
||||||
raise ValidationError(f"DNS labels cannot start or end with '-': {value}")
|
|
||||||
if any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label):
|
|
||||||
raise ValidationError(f"invalid DNS hostname character: {value}")
|
|
||||||
|
|
||||||
if port is not None and not 1 <= port <= 65_535:
|
|
||||||
raise ValidationError(f"invalid relay port: {value}")
|
|
||||||
if port in {None, 443}:
|
|
||||||
return host
|
|
||||||
return f"{host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _validated_dataset(
|
|
||||||
data: bytes,
|
|
||||||
*,
|
|
||||||
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
|
|
||||||
maximum_bytes: int = MAX_BYTES,
|
|
||||||
maximum_rows: int = MAX_ROWS,
|
|
||||||
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
|
|
||||||
) -> _ValidatedDataset:
|
|
||||||
if not data or len(data) > maximum_bytes:
|
|
||||||
raise ValidationError(f"CSV must contain 1..{maximum_bytes} bytes")
|
|
||||||
|
|
||||||
try:
|
|
||||||
text = data.decode("utf-8")
|
|
||||||
except UnicodeDecodeError as error:
|
|
||||||
raise ValidationError("CSV is not valid UTF-8") from error
|
|
||||||
if text.startswith("\ufeff"):
|
|
||||||
raise ValidationError("UTF-8 BOM is not allowed")
|
|
||||||
if _has_disallowed_control(text):
|
|
||||||
raise ValidationError("CSV contains disallowed control characters")
|
|
||||||
# Runtime intentionally implements the fixed three-field schema without
|
|
||||||
# general CSV quoting. Reject quoted variants here so reviewed workflow
|
|
||||||
# output and client-side validation cannot disagree.
|
|
||||||
if '"' in text:
|
|
||||||
raise ValidationError("quoted CSV fields are not allowed")
|
|
||||||
|
|
||||||
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
|
|
||||||
try:
|
|
||||||
header = next(reader)
|
|
||||||
except (StopIteration, csv.Error) as error:
|
|
||||||
raise ValidationError("CSV header is missing") from error
|
|
||||||
normalized_header = tuple(field.strip().lower() for field in header)
|
|
||||||
if normalized_header != EXPECTED_HEADER:
|
|
||||||
raise ValidationError(f"unexpected CSV header: {header!r}")
|
|
||||||
|
|
||||||
data_rows = 0
|
|
||||||
relays: dict[str, tuple[float, float]] = {}
|
|
||||||
try:
|
|
||||||
for row in reader:
|
|
||||||
if not row or all(not field.strip() for field in row):
|
|
||||||
continue
|
|
||||||
data_rows += 1
|
|
||||||
if data_rows > maximum_rows:
|
|
||||||
raise ValidationError(f"CSV exceeds {maximum_rows} data rows")
|
|
||||||
if len(row) != 3:
|
|
||||||
raise ValidationError(f"row {reader.line_num} must contain exactly 3 columns")
|
|
||||||
|
|
||||||
address = normalize_relay_address(row[0])
|
|
||||||
latitude_text = row[1].strip()
|
|
||||||
longitude_text = row[2].strip()
|
|
||||||
if not ASCII_DECIMAL_PATTERN.fullmatch(latitude_text) or not ASCII_DECIMAL_PATTERN.fullmatch(longitude_text):
|
|
||||||
raise ValidationError(
|
|
||||||
f"row {reader.line_num} coordinates must be ASCII decimal numbers"
|
|
||||||
)
|
|
||||||
latitude = float(latitude_text)
|
|
||||||
longitude = float(longitude_text)
|
|
||||||
if not math.isfinite(latitude) or not -90 <= latitude <= 90:
|
|
||||||
raise ValidationError(f"row {reader.line_num} latitude is out of range")
|
|
||||||
if not math.isfinite(longitude) or not -180 <= longitude <= 180:
|
|
||||||
raise ValidationError(f"row {reader.line_num} longitude is out of range")
|
|
||||||
|
|
||||||
coordinates = (latitude, longitude)
|
|
||||||
previous = relays.get(address)
|
|
||||||
if previous is not None and previous != coordinates:
|
|
||||||
raise ValidationError(f"relay {address} has conflicting coordinates")
|
|
||||||
relays[address] = coordinates
|
|
||||||
if len(relays) > maximum_unique_relays:
|
|
||||||
raise ValidationError(f"CSV exceeds {maximum_unique_relays} unique relays")
|
|
||||||
except csv.Error as error:
|
|
||||||
raise ValidationError(f"malformed CSV near line {reader.line_num}") from error
|
|
||||||
|
|
||||||
if len(relays) < minimum_unique_relays:
|
|
||||||
raise ValidationError(
|
|
||||||
f"CSV has {len(relays)} unique relays; minimum is {minimum_unique_relays}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return _ValidatedDataset(
|
|
||||||
summary=ValidationSummary(
|
|
||||||
data_rows=data_rows,
|
|
||||||
unique_relays=len(relays),
|
|
||||||
sha256=hashlib.sha256(data).hexdigest(),
|
|
||||||
),
|
|
||||||
entries=frozenset(
|
|
||||||
(address, coordinates[0], coordinates[1])
|
|
||||||
for address, coordinates in relays.items()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_bytes(
|
|
||||||
data: bytes,
|
|
||||||
*,
|
|
||||||
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
|
|
||||||
maximum_bytes: int = MAX_BYTES,
|
|
||||||
maximum_rows: int = MAX_ROWS,
|
|
||||||
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
|
|
||||||
) -> ValidationSummary:
|
|
||||||
return _validated_dataset(
|
|
||||||
data,
|
|
||||||
minimum_unique_relays=minimum_unique_relays,
|
|
||||||
maximum_bytes=maximum_bytes,
|
|
||||||
maximum_rows=maximum_rows,
|
|
||||||
maximum_unique_relays=maximum_unique_relays,
|
|
||||||
).summary
|
|
||||||
|
|
||||||
|
|
||||||
def validate_update(candidate: bytes, baseline: bytes) -> ValidationSummary:
|
|
||||||
baseline_dataset = _validated_dataset(baseline, minimum_unique_relays=1)
|
|
||||||
candidate_dataset = _validated_dataset(candidate)
|
|
||||||
baseline_summary = baseline_dataset.summary
|
|
||||||
candidate_summary = candidate_dataset.summary
|
|
||||||
|
|
||||||
minimum_from_baseline = math.ceil(
|
|
||||||
baseline_summary.unique_relays * MIN_BASELINE_FRACTION
|
|
||||||
)
|
|
||||||
maximum_from_baseline = math.floor(
|
|
||||||
baseline_summary.unique_relays * MAX_BASELINE_MULTIPLIER
|
|
||||||
)
|
|
||||||
if candidate_summary.unique_relays < minimum_from_baseline:
|
|
||||||
raise ValidationError(
|
|
||||||
"candidate loses more than half of the baseline's unique relays "
|
|
||||||
f"({candidate_summary.unique_relays} < {minimum_from_baseline})"
|
|
||||||
)
|
|
||||||
if candidate_summary.unique_relays > maximum_from_baseline:
|
|
||||||
raise ValidationError(
|
|
||||||
"candidate more than doubles the baseline's unique relays "
|
|
||||||
f"({candidate_summary.unique_relays} > {maximum_from_baseline})"
|
|
||||||
)
|
|
||||||
|
|
||||||
retained_entries = len(baseline_dataset.entries & candidate_dataset.entries)
|
|
||||||
if retained_entries < minimum_from_baseline:
|
|
||||||
raise ValidationError(
|
|
||||||
"candidate retains fewer than half of the baseline's exact relay-coordinate entries "
|
|
||||||
f"({retained_entries} < {minimum_from_baseline})"
|
|
||||||
)
|
|
||||||
return candidate_summary
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--input", required=True, type=Path)
|
|
||||||
parser.add_argument("--baseline", required=True, type=Path)
|
|
||||||
parser.add_argument("--output", required=True, type=Path)
|
|
||||||
parser.add_argument("--github-output", type=Path)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
try:
|
|
||||||
candidate = args.input.read_bytes()
|
|
||||||
baseline = args.baseline.read_bytes()
|
|
||||||
summary = validate_update(candidate, baseline)
|
|
||||||
args.output.write_bytes(candidate)
|
|
||||||
if args.github_output is not None:
|
|
||||||
with args.github_output.open("a", encoding="utf-8") as output:
|
|
||||||
output.write(f"data_rows={summary.data_rows}\n")
|
|
||||||
output.write(f"unique_relays={summary.unique_relays}\n")
|
|
||||||
output.write(f"sha256={summary.sha256}\n")
|
|
||||||
except (OSError, ValidationError) as error:
|
|
||||||
print(f"georelay validation failed: {error}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"validated {summary.unique_relays} unique relays across "
|
|
||||||
f"{summary.data_rows} rows (sha256 {summary.sha256})"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user