Compare commits
50
Commits
@@ -1 +1 @@
|
||||
d07ed14ac4daace8fb959187b2df181f8f6152be
|
||||
4b4042f72880758ee2dea7713f7a2cb63fa12f73
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Why this repository pins flamenet-e2e
|
||||
|
||||
`.e2e-ref` is the commit of [flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e)
|
||||
`.e2e-ref` is the commit of [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e)
|
||||
that the copies in `Tests/Vectors/` are checked against. See
|
||||
`Tests/Vectors/PROVENANCE.md` for why they are copies at all.
|
||||
|
||||
@@ -16,7 +16,7 @@ the vectors move and nothing says so, `ProtocolVectorTests` quietly goes on
|
||||
checking last month's protocol and reports green, which is precisely the
|
||||
failure that suite exists to prevent, reintroduced one level up.
|
||||
|
||||
That is what `vectors-head` is for, and why it is `allow_failure: true`.
|
||||
That is what `vectors-head` is for, and why it is `continue-on-error: true` in `.gitea/workflows/ci.yml`.
|
||||
Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop.
|
||||
|
||||
## Taking an update
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# CI for the iOS app on Gitea Actions. Translated from .gitlab-ci.yml on
|
||||
# 2026-10-13; every gate it had is kept.
|
||||
#
|
||||
# Builds and tests only. TestFlight and App Store uploads stay manual on the
|
||||
# MacBook Air and the iMac; no signing material is in CI.
|
||||
#
|
||||
# Runners: the Xcode jobs need `pauls-imac` (host mode, Xcode 27, capacity 1,
|
||||
# so they run one after the other). The three jobs that only read source run
|
||||
# on mac1/mac2 (`ubuntu-latest`) to keep the iMac free.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# A timed-out iOS job is the normal failure here, and re-running it needed a
|
||||
# commit because there was no other trigger. The unit job can take 90
|
||||
# minutes before it says anything, so "push something to try again" is an
|
||||
# expensive way to ask a question.
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_BASE: https://git.extant2000.com/extant2000
|
||||
|
||||
jobs:
|
||||
# The repository is public, so this sweep is the last thing between a pasted
|
||||
# key and the internet. It was red for nine days in 2026-09 because nobody
|
||||
# ran it. No submodule fetch: a sweep of this repo is about this repo.
|
||||
secret-sweep:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash Scripts/secret-sweep.sh
|
||||
|
||||
unit-tests:
|
||||
runs-on: pauls-imac
|
||||
# 90, not 30. The first run of this workflow died at exactly 30 minutes
|
||||
# with "context deadline exceeded", 15 seconds past the vector gate and
|
||||
# still inside xcodebuild. The suite is 1289 tests since the 0.32 merge,
|
||||
# against 741 when 30 was chosen, and a cold `ci-build` adds a full
|
||||
# compile of the app, its tests and the obfs4 package on top.
|
||||
#
|
||||
# 90 was not enough either: the 2026-10-14 run spent all 90 minutes
|
||||
# inside xcodebuild and printed no `Executed` line at all, so the cold
|
||||
# compile alone outlasts it on this runner. The same commit builds and
|
||||
# runs all 1289 tests in about 37 minutes on an M-series Mac, so this is
|
||||
# the Intel compile, not the suite. 180 leaves room for a cold build;
|
||||
# a warm `ci-build` in the reused workspace should come in far under it.
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
# The obfs4 transport is the Packages/Obfs4 submodule
|
||||
# (flamenet-elligator2, public on Gitea). Without it package resolution
|
||||
# fails with "the package manifest at Packages/Obfs4/Package.swift
|
||||
# cannot be accessed".
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Unit tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The selected Xcode, never a hardcoded beta path (see
|
||||
# xcode-is-the-release-now). Printed so the log says what built.
|
||||
export DEVELOPER_DIR="$(xcode-select -p)"
|
||||
# 🔴 Boot the simulator HERE, and wait for it, because xcodebuild
|
||||
# waits for it forever.
|
||||
#
|
||||
# Shutting the device down and leaving xcodebuild to boot it is what
|
||||
# burned two runs: the suite compiled, xcodebuild handed off to a
|
||||
# device that never came up, and the step was cancelled at the
|
||||
# timeout with no error. The 90-minute and the 180-minute run both
|
||||
# produced ci-test.log at 889,54x bytes, ending at
|
||||
# `PruneExplicitPrecompiledModules`, which is the end of a
|
||||
# successful build. Raising the timeout twice was treating a hang as
|
||||
# slowness.
|
||||
#
|
||||
# `simctl bootstatus -b` boots the device and blocks until it is
|
||||
# usable, and exits non-zero when it cannot. A device that will not
|
||||
# boot is now a message rather than three silent hours.
|
||||
xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
|
||||
if ! xcrun simctl bootstatus "iPhone 17" -b; then
|
||||
echo "GATE: the iPhone 17 simulator would not boot on this runner"
|
||||
xcrun simctl list devices available
|
||||
exit 1
|
||||
fi
|
||||
xcodebuild -version
|
||||
|
||||
# Vectors are copied into Tests/Vectors and pinned by .e2e-ref.
|
||||
# Check the copy against the pinned flamenet-e2e commit.
|
||||
# `vectors-head` below is the advisory half that watches HEAD.
|
||||
FLAMENET_E2E_PIN=$(cat .e2e-ref)
|
||||
rm -rf ci-vectors
|
||||
git clone -q "$GITEA_BASE/flamenet-e2e.git" ci-vectors
|
||||
git -C ci-vectors checkout -q "$FLAMENET_E2E_PIN"
|
||||
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 \
|
||||
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1 \
|
||||
content-kinds-v1 today-v1 hello-link-v1; do
|
||||
if ! diff -q "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
|
||||
echo "GATE: Tests/Vectors/$f.json differs from flamenet-e2e@$FLAMENET_E2E_PIN."
|
||||
echo "GATE: the vectors moved. Read what changed, then refresh the copy:"
|
||||
echo "GATE: cp ci-vectors/test/vectors/*.json Tests/Vectors/"
|
||||
diff "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -20 || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "GATE: protocol vectors match flamenet-e2e@$FLAMENET_E2E_PIN"
|
||||
|
||||
# Scripts/generate.sh, never bare `xcodegen generate`: only the script
|
||||
# copies the pinned Package.resolved into the generated project.
|
||||
Scripts/generate.sh
|
||||
# Keep the ~700 MB build output out of Spotlight; indexing it drove
|
||||
# the runner host to a load average in the hundreds.
|
||||
mkdir -p ci-build && touch ci-build/.metadata_never_index
|
||||
|
||||
# Build and test are two xcodebuild calls, each under its own cap, so
|
||||
# the log says which phase stopped. The 2026-10-16 run proved why:
|
||||
# ci-test.log reached 889,595 bytes and ended at
|
||||
# PruneExplicitPrecompiledModules, which is the end of a clean build,
|
||||
# and then xcodebuild sat for 150 minutes without printing "Testing
|
||||
# started". One call could not tell a slow compile from a test phase
|
||||
# that never begins. Two can.
|
||||
#
|
||||
# The watchdog needs nothing but the shell: coreutils is on one Mac
|
||||
# runner and not the other, and the run that printed "runs uncapped"
|
||||
# burned the whole 180-minute deadline and uploaded a log nobody
|
||||
# read. It holds xcodebuild's own pid because the log is a redirect
|
||||
# rather than a pipe.
|
||||
#
|
||||
# `-collect-test-diagnostics never`: the default is on-failure, and
|
||||
# on a failure xcodebuild runs `simctl diagnose`, a sysdiagnose plus
|
||||
# 8 minutes of simulator logs with a 600s timeout each, before it
|
||||
# reports anything. Reproduced on an M3 by watching that pid.
|
||||
capped() {
|
||||
local limit=$1 label=$2; shift 2
|
||||
"$@" >> ci-test.log 2>&1 &
|
||||
local xcb=$!
|
||||
( sleep "$limit"
|
||||
kill -0 "$xcb" 2>/dev/null || exit 0
|
||||
echo "GATE: $label gave no result after ${limit}s, killing xcodebuild"
|
||||
kill -TERM "$xcb" 2>/dev/null
|
||||
sleep 20
|
||||
kill -KILL "$xcb" 2>/dev/null ) &
|
||||
local dog=$!
|
||||
wait "$xcb"; local rc=$?
|
||||
kill "$dog" 2>/dev/null || true
|
||||
return $rc
|
||||
}
|
||||
|
||||
XC=(xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger
|
||||
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17"
|
||||
-collect-test-diagnostics never -derivedDataPath ci-build)
|
||||
|
||||
: > ci-test.log
|
||||
# Progress in the job log while it runs; ci-test.log keeps everything.
|
||||
( tail -f ci-test.log 2>/dev/null \
|
||||
| grep --line-buffered -E "Executed|TEST (SUCCEEDED|FAILED)|TEST BUILD" ) &
|
||||
TAIL=$!
|
||||
|
||||
capped 5400 build-for-testing "${XC[@]}" build-for-testing || true
|
||||
if ! grep -q '\*\* TEST BUILD SUCCEEDED \*\*' ci-test.log; then
|
||||
kill "$TAIL" 2>/dev/null || true
|
||||
echo "GATE: the test build did not succeed"
|
||||
tail -40 ci-test.log; exit 1
|
||||
fi
|
||||
echo "GATE: test build succeeded, running FlamenetMessengerTests"
|
||||
|
||||
capped 3600 test-without-building "${XC[@]}" test-without-building -only-testing:FlamenetMessengerTests || true
|
||||
kill "$TAIL" 2>/dev/null || true
|
||||
|
||||
# Assert on the LOG, never on grep's exit status: a failing run
|
||||
# prints "TEST FAILED", grep matches it and exits 0.
|
||||
grep -q '\*\* TEST SUCCEEDED \*\*' ci-test.log || {
|
||||
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
|
||||
tail -40 ci-test.log; exit 1; }
|
||||
grep -qE 'Executed [1-9][0-9]* tests, with 0 failures' ci-test.log || {
|
||||
echo "GATE: no run of at least one test with zero failures"
|
||||
tail -40 ci-test.log; exit 1; }
|
||||
# Floor below today's count, so a suite that drops from 1289 to 12
|
||||
# cannot pass. Raise it when it stops meaning anything.
|
||||
COUNT=$(grep -oE 'Executed [0-9]+ tests, with 0 failures' ci-test.log \
|
||||
| tail -1 | grep -oE '[0-9]+' | head -1)
|
||||
if [ "${COUNT:-0}" -lt 1150 ]; then
|
||||
echo "GATE: only ${COUNT:-0} tests ran; the suite is not all there"
|
||||
tail -40 ci-test.log; exit 1
|
||||
fi
|
||||
echo "GATE: Executed $COUNT tests, with 0 failures"
|
||||
- if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ci-test-log
|
||||
path: ci-test.log
|
||||
retention-days: 7
|
||||
|
||||
# The 14 UI tests that need no live relay (69 more skip without one).
|
||||
# Gating since 2026-09-25. Its own job so a UI failure reads as one.
|
||||
ui-tests:
|
||||
runs-on: pauls-imac
|
||||
# pauls-imac has capacity 1, so this waits for unit-tests and then builds
|
||||
# again from its own derived data. Raised with the unit job for the same
|
||||
# reason: the cold compile on this runner is the cost, not the tests.
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: UI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DEVELOPER_DIR="$(xcode-select -p)"
|
||||
# 🔴 Boot the simulator HERE, and wait for it, because xcodebuild
|
||||
# waits for it forever.
|
||||
#
|
||||
# Shutting the device down and leaving xcodebuild to boot it is what
|
||||
# burned two runs: the suite compiled, xcodebuild handed off to a
|
||||
# device that never came up, and the step was cancelled at the
|
||||
# timeout with no error. The 90-minute and the 180-minute run both
|
||||
# produced ci-test.log at 889,54x bytes, ending at
|
||||
# `PruneExplicitPrecompiledModules`, which is the end of a
|
||||
# successful build. Raising the timeout twice was treating a hang as
|
||||
# slowness.
|
||||
#
|
||||
# `simctl bootstatus -b` boots the device and blocks until it is
|
||||
# usable, and exits non-zero when it cannot. A device that will not
|
||||
# boot is now a message rather than three silent hours.
|
||||
xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
|
||||
if ! xcrun simctl bootstatus "iPhone 17" -b; then
|
||||
echo "GATE: the iPhone 17 simulator would not boot on this runner"
|
||||
xcrun simctl list devices available
|
||||
exit 1
|
||||
fi
|
||||
Scripts/generate.sh
|
||||
mkdir -p ci-build && touch ci-build/.metadata_never_index
|
||||
# Build and test are two xcodebuild calls, each under its own cap, so
|
||||
# the log says which phase stopped. The 2026-10-16 run proved why:
|
||||
# ci-test.log reached 889,595 bytes and ended at
|
||||
# PruneExplicitPrecompiledModules, which is the end of a clean build,
|
||||
# and then xcodebuild sat for 150 minutes without printing "Testing
|
||||
# started". One call could not tell a slow compile from a test phase
|
||||
# that never begins. Two can.
|
||||
#
|
||||
# The watchdog needs nothing but the shell: coreutils is on one Mac
|
||||
# runner and not the other, and the run that printed "runs uncapped"
|
||||
# burned the whole 180-minute deadline and uploaded a log nobody
|
||||
# read. It holds xcodebuild's own pid because the log is a redirect
|
||||
# rather than a pipe.
|
||||
#
|
||||
# `-collect-test-diagnostics never`: the default is on-failure, and
|
||||
# on a failure xcodebuild runs `simctl diagnose`, a sysdiagnose plus
|
||||
# 8 minutes of simulator logs with a 600s timeout each, before it
|
||||
# reports anything. Reproduced on an M3 by watching that pid.
|
||||
capped() {
|
||||
local limit=$1 label=$2; shift 2
|
||||
"$@" >> ci-ui.log 2>&1 &
|
||||
local xcb=$!
|
||||
( sleep "$limit"
|
||||
kill -0 "$xcb" 2>/dev/null || exit 0
|
||||
echo "GATE: $label gave no result after ${limit}s, killing xcodebuild"
|
||||
kill -TERM "$xcb" 2>/dev/null
|
||||
sleep 20
|
||||
kill -KILL "$xcb" 2>/dev/null ) &
|
||||
local dog=$!
|
||||
wait "$xcb"; local rc=$?
|
||||
kill "$dog" 2>/dev/null || true
|
||||
return $rc
|
||||
}
|
||||
|
||||
XC=(xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger
|
||||
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17"
|
||||
-collect-test-diagnostics never -derivedDataPath ci-build)
|
||||
|
||||
: > ci-ui.log
|
||||
# Progress in the job log while it runs; ci-ui.log keeps everything.
|
||||
( tail -f ci-ui.log 2>/dev/null \
|
||||
| grep --line-buffered -E "Executed|TEST (SUCCEEDED|FAILED)|TEST BUILD" ) &
|
||||
TAIL=$!
|
||||
|
||||
capped 4200 build-for-testing "${XC[@]}" build-for-testing || true
|
||||
if ! grep -q '\*\* TEST BUILD SUCCEEDED \*\*' ci-ui.log; then
|
||||
kill "$TAIL" 2>/dev/null || true
|
||||
echo "GATE: the test build did not succeed"
|
||||
tail -40 ci-ui.log; exit 1
|
||||
fi
|
||||
echo "GATE: test build succeeded, running FlamenetMessengerUITests"
|
||||
|
||||
capped 2400 test-without-building "${XC[@]}" test-without-building -only-testing:FlamenetMessengerUITests || true
|
||||
kill "$TAIL" 2>/dev/null || true
|
||||
grep -q '\*\* TEST SUCCEEDED \*\*' ci-ui.log || {
|
||||
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
|
||||
tail -60 ci-ui.log; exit 1; }
|
||||
# A floor on what actually RAN. Both summary shapes are read:
|
||||
# Executed 83 tests, with 69 tests skipped and 0 failures ...
|
||||
# Executed 14 tests, with 0 failures ...
|
||||
RAN=$(grep -oE 'Executed [0-9]+ tests' ci-ui.log | tail -1 | grep -oE '[0-9]+' || true)
|
||||
SKIPPED=$(grep -oE '[0-9]+ tests skipped' ci-ui.log | tail -1 | grep -oE '[0-9]+' || true)
|
||||
ACTUAL=$(( ${RAN:-0} - ${SKIPPED:-0} ))
|
||||
if [ "$ACTUAL" -lt 10 ]; then
|
||||
echo "GATE: only ${ACTUAL} UI test(s) actually ran; the rest skipped"
|
||||
tail -60 ci-ui.log; exit 1
|
||||
fi
|
||||
echo "GATE: ${ACTUAL} UI tests ran, ${SKIPPED:-0} skipped for want of a live relay"
|
||||
- if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ci-ui-log
|
||||
path: ci-ui.log
|
||||
retention-days: 7
|
||||
|
||||
# The pin's conscience: compares the carried vectors with flamenet-e2e HEAD.
|
||||
# Allowed to fail. A red run means an update is waiting; see .e2e-ref.md.
|
||||
vectors-head:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Compare vectors with flamenet-e2e HEAD
|
||||
run: |
|
||||
rm -rf ci-vectors-head
|
||||
git clone --depth 1 "$GITEA_BASE/flamenet-e2e.git" ci-vectors-head
|
||||
echo "pinned: $(cat .e2e-ref)"
|
||||
echo "at HEAD: $(git -C ci-vectors-head rev-parse HEAD)"
|
||||
behind=0
|
||||
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 \
|
||||
poll-tally-v1 padding-v1 sync-merge-v1 handle-cert-v1 links-v1 bridge-offer-v1 \
|
||||
content-kinds-v1 today-v1 hello-link-v1; do
|
||||
if ! diff -q "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
|
||||
echo "ADVISORY: $f.json has moved at HEAD:"
|
||||
diff "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -30 || true
|
||||
behind=1
|
||||
fi
|
||||
done
|
||||
if [ "$behind" = "1" ]; then
|
||||
echo "ADVISORY: the published vectors have changed. This is not a failure of"
|
||||
echo "ADVISORY: this branch; it means an update is waiting. See .e2e-ref.md."
|
||||
exit 1
|
||||
fi
|
||||
echo "ADVISORY: vectors are current with flamenet-e2e HEAD"
|
||||
|
||||
# The weekly scrub, on every change. Its checks are the silent ones: an
|
||||
# envelope kind one app sends and the other cannot parse shows nothing at
|
||||
# either end. The siblings are public, so they clone with no credentials.
|
||||
parity-scrub:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Parity scrub
|
||||
run: |
|
||||
rm -rf ci-scrub && mkdir -p ci-scrub
|
||||
git clone --depth 1 "$GITEA_BASE/flamenet-messenger-android.git" ci-scrub/android
|
||||
git clone --depth 1 "$GITEA_BASE/flamenet-relay.git" ci-scrub/relay
|
||||
# Non-zero on a HIGH, which is the whole contract.
|
||||
python3 Scripts/parity-scrub.py --ios . --android ci-scrub/android --relay ci-scrub/relay
|
||||
-390
@@ -1,390 +0,0 @@
|
||||
# T4: the Mac is in the merge path. Runs on the project's own macOS runner
|
||||
# (tag mac-local), the unit suite that used to run only when someone
|
||||
# remembered to. UI tests stay manual: they need a local relay and a booted
|
||||
# pair of simulators.
|
||||
stages: [test]
|
||||
|
||||
# The repository is public, so this sweep is the last thing between a pasted
|
||||
# key and the internet. Until 2026-09-17 it only ran when somebody remembered
|
||||
# to, and it had been red for nine days without anyone noticing, because a
|
||||
# formatter carried an allow marker off the line it covered. A check nobody
|
||||
# runs protects nothing; here it runs on every merge request and every push
|
||||
# to main, and it finishes in seconds.
|
||||
# No submodule fetch: the obfs4 transport is a private repository with its
|
||||
# own tree, and a sweep of this one is about this one.
|
||||
secret-sweep:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
script:
|
||||
- bash Scripts/secret-sweep.sh
|
||||
timeout: 5m
|
||||
|
||||
unit-tests:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
# The obfs4 transport is a submodule, and the runner skips those by
|
||||
# default. Without this the job fails at package resolution with
|
||||
# "the package manifest at Packages/Obfs4/Package.swift cannot be
|
||||
# accessed", which reads as a missing file rather than a missing clone.
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
# And this, because the submodule is a PRIVATE repo. It rewrites the
|
||||
# submodule's URL to use `CI_JOB_TOKEN`, which is the only credential this
|
||||
# job has. It also requires flamenet-elligator2 to allow this project in its
|
||||
# CI job-token allowlist, a setting on the *other* project, so a failure
|
||||
# here looks like a permissions problem with no local cause.
|
||||
GIT_SUBMODULE_FORCE_HTTPS: "true"
|
||||
GIT_SUBMODULE_DEPTH: "1"
|
||||
script:
|
||||
# The selected Xcode, not a hardcoded beta path. The beta became the
|
||||
# release on 2026-09-15 and /Applications/Xcode-beta.app went with it,
|
||||
# and since `git` on a Mac is an xcrun shim, this job then died before its
|
||||
# first real command, on every pipeline for a day, with nothing in the
|
||||
# summary but "failed". The version is printed so the log says what built.
|
||||
- export DEVELOPER_DIR="$(xcode-select -p)"
|
||||
# Shut the target simulator down before xcodebuild asks for it.
|
||||
#
|
||||
# A cancelled pipeline can leave CoreSimulator holding a booted, wedged
|
||||
# `iPhone 17`, and the NEXT job then waits on a device that never becomes
|
||||
# usable. That is not a hypothetical: on 2026-09-25 this job sat for
|
||||
# thirty minutes and was killed by the job timeout, having printed no test
|
||||
# output at all, five minutes after a pipeline was cancelled mid-build.
|
||||
# Its normal run is under four minutes.
|
||||
#
|
||||
# `shutdown all` would be the usual line and is deliberately NOT used: this
|
||||
# runner is a workstation, and a simulator somebody is debugging in is not
|
||||
# this job's to close. Only the device the job is about to drive.
|
||||
- xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
|
||||
- xcodebuild -version
|
||||
# `Scripts/generate.sh`, never bare `xcodegen generate`. `.gitignore`
|
||||
# excludes the whole `.xcodeproj`, and SwiftPM keeps `Package.resolved`
|
||||
# INSIDE it, so the pin git can actually see lives in `Scripts/`, and only
|
||||
# that script copies it into the generated workspace. Running xcodegen
|
||||
# directly here meant CI resolved every dependency fresh against the
|
||||
# network while local builds were correctly pinned, which is why this only
|
||||
# ever broke on CI. It broke when upstream MOVED the
|
||||
# `151.0.0` tag of stasel/WebRTC, a prebuilt XCFramework, from
|
||||
# 19aa8c1f to 3e43fa9b. The pin is what noticed.
|
||||
# The protocol vectors are copied into `Tests/Vectors`, so check the
|
||||
# copy is current. They are copied rather than resolved from a sibling
|
||||
# checkout because that is exactly how Android lost twenty conformance tests
|
||||
# its engine looks for `../../../flamenet-e2e/...`, which exists on a
|
||||
# developer's machine and never in a build directory, so its suite failed
|
||||
# here from the day it was written without anyone seeing
|
||||
# it. A copy in the test bundle cannot be absent. What a copy CAN do is go
|
||||
# stale, so this is the other half of that bargain.
|
||||
#
|
||||
# Pinned, not tip. flamenet-relay#56 is the write-up of what tip costs: a
|
||||
# merge in flamenet-e2e turned that repo's `main` red in a pipeline nobody
|
||||
# was watching and blocked six MRs, and was misdiagnosed as runner
|
||||
# starvation. The commit lives in `.e2e-ref`; `.e2e-ref.md` says how to move
|
||||
# it.
|
||||
#
|
||||
# A pin ALONE is worse than tip, which is what the first version of
|
||||
# this got wrong. Pinning silently means the vectors can move and
|
||||
# `ProtocolVectorTests` goes on checking last month's protocol, reporting
|
||||
# green, the very failure that suite exists to prevent, reintroduced one
|
||||
# level up. `vectors-head` below is the other half.
|
||||
- FLAMENET_E2E_PIN=$(cat .e2e-ref)
|
||||
- rm -rf ci-vectors
|
||||
- git clone -q https://gitlab.com/extant2000/flamenet-e2e.git ci-vectors
|
||||
- git -C ci-vectors checkout -q "$FLAMENET_E2E_PIN"
|
||||
- |
|
||||
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 content-kinds-v1 today-v1 hello-link-v1; do
|
||||
if ! diff -q "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
|
||||
echo "GATE: Tests/Vectors/$f.json differs from flamenet-e2e@$FLAMENET_E2E_PIN."
|
||||
echo "GATE: the vectors moved. Read what changed, then refresh the copy:"
|
||||
echo "GATE: cp ci-vectors/test/vectors/*.json Tests/Vectors/"
|
||||
diff "ci-vectors/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -20
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Inside the block, not a bare `- echo "GATE: ..."` list item. An
|
||||
# unquoted `: ` in a plain scalar makes YAML read the item as a MAPPING,
|
||||
# and the whole file becomes invalid, which GitLab reports as a pipeline
|
||||
# that "failed" with zero jobs and no yaml_errors on the pipeline object.
|
||||
# Nothing in that says "your CI file is malformed". `ci/lint` does.
|
||||
echo "GATE: protocol vectors match flamenet-e2e@$FLAMENET_E2E_PIN"
|
||||
- Scripts/generate.sh
|
||||
# Keep the build output out of Spotlight.
|
||||
#
|
||||
# `ci-build` reaches ~700 MB and is rewritten every job. Indexing it drives
|
||||
# this host, which is the mac-local runner and therefore in the merge path
|
||||
# for both app repos, to a load average in the hundreds: `mds_stores` and
|
||||
# `mdworker_shared` sit at the top of CPU while no `xcodebuild` process is
|
||||
# near it. That makes every job slower and the machine unpleasant to use
|
||||
# while CI runs.
|
||||
#
|
||||
# The marker has to be written HERE rather than committed, because the
|
||||
# directory it belongs in is created by the build and removed between runs.
|
||||
- mkdir -p ci-build && touch ci-build/.metadata_never_index
|
||||
- |
|
||||
# `|| true`, deliberately: the log is the gate, not this pipeline's
|
||||
# exit status, and the checks below need to run so they can print the
|
||||
# tail of the log when something goes wrong.
|
||||
set -o pipefail
|
||||
xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger \
|
||||
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17" \
|
||||
-derivedDataPath ci-build test -only-testing:FlamenetMessengerTests 2>&1 \
|
||||
| tee ci-test.log \
|
||||
| grep -E "Executed|TEST (SUCCEEDED|FAILED)" || true
|
||||
- |
|
||||
# Assert on the LOG, never on grep's exit status. This job used to
|
||||
# end in `| grep -E "Executed|TEST (SUCCEEDED|FAILED)" || exit 1`, and a
|
||||
# pipeline exits with the status of its LAST command. A failing test run
|
||||
# prints " TEST FAILED ", grep matched it, grep exited 0, and the
|
||||
# job went green on a red suite. Every pass this job ever reported was
|
||||
# unverified. It only ever went red when the build died before printing
|
||||
# anything at all, which is failing for the wrong reason.
|
||||
grep -q '\*\* TEST SUCCEEDED \*\*' ci-test.log || {
|
||||
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
|
||||
tail -40 ci-test.log; exit 1; }
|
||||
# `[1-9][0-9]*`, so a suite that ran nothing cannot pass. A tree that
|
||||
# silently stops building tests otherwise reports the same green as one
|
||||
# that runs them all.
|
||||
grep -qE 'Executed [1-9][0-9]* tests, with 0 failures' ci-test.log || {
|
||||
echo "GATE: no run of at least one test with zero failures"
|
||||
tail -40 ci-test.log; exit 1; }
|
||||
# A floor, because "at least one test" is not a gate. Twice now a
|
||||
# green pipeline here has had to be checked by hand against main's count
|
||||
# ios!133 and ios!134 both printed a truncated log with no sign of the
|
||||
# tests that had just been added. A suite that silently dropped from 741
|
||||
# to 12 passes the check above and looks identical in the log.
|
||||
#
|
||||
# Set below today's count, not equal to it: this must not need editing
|
||||
# every time somebody adds a test. Raise it when it stops meaning
|
||||
# anything.
|
||||
COUNT=$(grep -oE 'Executed [0-9]+ tests, with 0 failures' ci-test.log \
|
||||
| tail -1 | grep -oE '[0-9]+' | head -1)
|
||||
if [ "${COUNT:-0}" -lt 700 ]; then
|
||||
echo "GATE: only ${COUNT:-0} tests ran; the suite is not all there"
|
||||
tail -40 ci-test.log; exit 1
|
||||
fi
|
||||
echo "GATE: Executed $COUNT tests, with 0 failures"
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths: [ci-test.log]
|
||||
expire_in: 1 week
|
||||
timeout: 30m
|
||||
|
||||
# The rules engines for the games in a chat (§6.33), a local SwiftPM package
|
||||
# with its own tests and the vectors in games-v1.json, which Android's `:games`
|
||||
# module runs too. `swift test` needs no simulator; a cold run here took 12
|
||||
# seconds. Its own job, so a rules failure reads as one.
|
||||
games-engine:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
script:
|
||||
- export DEVELOPER_DIR="$(xcode-select -p)"
|
||||
# Keep the build output out of Spotlight, as in `unit-tests`.
|
||||
- mkdir -p Packages/FlamenetGames/.build && touch Packages/FlamenetGames/.build/.metadata_never_index
|
||||
- swift test --package-path Packages/FlamenetGames
|
||||
timeout: 15m
|
||||
|
||||
# The 83 tests in `UITests/` that nothing ran.
|
||||
#
|
||||
# `unit-tests` above passes `-only-testing:FlamenetMessengerTests`, so the UI
|
||||
# target was compiled by nobody and run by nobody on the way to main. That is
|
||||
# the surface a person actually touches: sign-up, placing a call, the files
|
||||
# vault, the iPad layout.
|
||||
#
|
||||
# Most of it cannot run here and is not meant to. 69 of the 83 gate on a live
|
||||
# relay and real credentials, `XCTSkipUnless(relayIsUp(...))`, and skip cleanly
|
||||
# without them; those exist to be pointed at a real deployment by hand, which
|
||||
# is what `Scripts/` has drivers for. The remaining 14 need none of that and
|
||||
# take about three and a half minutes.
|
||||
#
|
||||
# Its own job rather than folded into `unit-tests`, so that a UI failure reads
|
||||
# as a UI failure. A swipe that lands early is a different kind of news from a
|
||||
# unit test going red, and the floor logic in that job counts XCTest cases,
|
||||
# which UI runs would muddle.
|
||||
#
|
||||
# GATING, since 2026-09-25. It did not for a while, and the reason is worth
|
||||
# keeping: two local runs of this target produced three failures, all of them
|
||||
# `app.launch()` timing out with "Error getting main window", in tests nobody
|
||||
# had touched, while the box sat at a load average of 115. That was the
|
||||
# machine rather than the tests, and it is the same host CI runs on, so
|
||||
# gating then would have handed the merge path to whatever else was running.
|
||||
#
|
||||
# The flag came off on evidence: three consecutive CI runs at 4.6, 5.0 and
|
||||
# 5.2 minutes, the last of them immediately after an unrelated job on this
|
||||
# host hung for thirty minutes, so it was not a quiet machine that earned it.
|
||||
# Those three were BRANCH pipelines, which is all this job used to run in;
|
||||
# see the note on `rules` below for why that matters.
|
||||
#
|
||||
# A caution for whoever reads this next, because it nearly bought the wrong
|
||||
# fix: two mac-local runners are registered, which LOOKS like this job and
|
||||
# `unit-tests` can collide over one simulator. Their start and end times say
|
||||
# they have always run strictly one after the other. The thirty-minute hang
|
||||
# above was a wedged simulator left by a cancelled pipeline, which is what the
|
||||
# `simctl shutdown` line in both jobs now clears. Check the timestamps before
|
||||
# believing in a race.
|
||||
#
|
||||
# One real flake WAS found and fixed on the way:
|
||||
# `FilesVaultUITests.testAFileCanBeDeletedOnItsOwn` failed one run in three by
|
||||
# swiping a row the list had not finished settling. A swipe that lands early
|
||||
# does nothing and leaves no trace, so waiting longer cannot help. It now waits
|
||||
# for the row and retries the gesture once.
|
||||
ui-tests:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
# Without these, this job is not in a merge request pipeline AT ALL.
|
||||
#
|
||||
# A job with no `rules` and no `only` runs on branch pipelines and is left
|
||||
# out of merge request ones, and every other job in this file carries the
|
||||
# two conditions below. So for as long as it had neither, `allow_failure`
|
||||
# on this job was decorative: removing the flag changed nothing, because
|
||||
# the job was never in the pipeline that gates a merge. Its clean runs were
|
||||
# all branch pipelines, main and hand-triggered, none of them in anybody's
|
||||
# merge path.
|
||||
#
|
||||
# Caught by reading the job list of the merge request pipeline rather than
|
||||
# the YAML: the flag was gone and the job was absent.
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
script:
|
||||
- export DEVELOPER_DIR=$(xcode-select -p)
|
||||
# Shut the target simulator down before xcodebuild asks for it.
|
||||
#
|
||||
# A cancelled pipeline can leave CoreSimulator holding a booted, wedged
|
||||
# `iPhone 17`, and the NEXT job then waits on a device that never becomes
|
||||
# usable. That is not a hypothetical: on 2026-09-25 this job sat for
|
||||
# thirty minutes and was killed by the job timeout, having printed no test
|
||||
# output at all, five minutes after a pipeline was cancelled mid-build.
|
||||
# Its normal run is under four minutes.
|
||||
#
|
||||
# `shutdown all` would be the usual line and is deliberately NOT used: this
|
||||
# runner is a workstation, and a simulator somebody is debugging in is not
|
||||
# this job's to close. Only the device the job is about to drive.
|
||||
- xcrun simctl shutdown "iPhone 17" 2>/dev/null || true
|
||||
- Scripts/generate.sh
|
||||
- mkdir -p ci-build && touch ci-build/.metadata_never_index
|
||||
- |
|
||||
set -o pipefail
|
||||
xcodebuild -project FlamenetMessenger.xcodeproj -scheme FlamenetMessenger \
|
||||
-sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17" \
|
||||
-derivedDataPath ci-build test -only-testing:FlamenetMessengerUITests 2>&1 \
|
||||
| tee ci-ui.log \
|
||||
| grep -E "Executed|TEST (SUCCEEDED|FAILED)" || true
|
||||
- |
|
||||
# Assert on the LOG, never on grep's exit status, for the reason spelled
|
||||
# out in `unit-tests`: a failing run PRINTS " TEST FAILED ", grep matches
|
||||
# it, grep exits 0, and the job goes green on a red suite.
|
||||
grep -q '\*\* TEST SUCCEEDED \*\*' ci-ui.log || {
|
||||
echo "GATE: xcodebuild did not report '** TEST SUCCEEDED **'"
|
||||
tail -60 ci-ui.log; exit 1; }
|
||||
# A floor on what actually RAN, not on what was scheduled. Every one of
|
||||
# these can skip, so "0 executed, 0 failures" is a perfectly green log
|
||||
# for a target that did nothing, and that is the failure this job exists
|
||||
# to not have.
|
||||
# Both shapes of the summary line, because the skipped clause is only
|
||||
# there when something skipped:
|
||||
# Executed 83 tests, with 69 tests skipped and 0 failures ...
|
||||
# Executed 14 tests, with 0 failures ...
|
||||
# Reading only the first shape would make a run with NOTHING skipped
|
||||
# come out as zero and fail this job for being healthy.
|
||||
RAN=$(grep -oE 'Executed [0-9]+ tests' ci-ui.log | tail -1 | grep -oE '[0-9]+')
|
||||
SKIPPED=$(grep -oE '[0-9]+ tests skipped' ci-ui.log | tail -1 | grep -oE '[0-9]+')
|
||||
ACTUAL=$(( ${RAN:-0} - ${SKIPPED:-0} ))
|
||||
# Set below today's 14, not equal to it, so adding a test that needs a
|
||||
# relay does not need this number edited. Raise it when it stops meaning
|
||||
# anything.
|
||||
if [ "$ACTUAL" -lt 10 ]; then
|
||||
echo "GATE: only ${ACTUAL} UI test(s) actually ran; the rest skipped"
|
||||
tail -60 ci-ui.log; exit 1
|
||||
fi
|
||||
echo "GATE: ${ACTUAL} UI tests ran, ${SKIPPED:-0} skipped for want of a live relay"
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths: [ci-ui.log]
|
||||
expire_in: 1 week
|
||||
timeout: 30m
|
||||
|
||||
# The pin's conscience. Compares the vectors this repo carries against
|
||||
# flamenet-e2e's actual HEAD, and is allowed to fail. Without it, `.e2e-ref` is
|
||||
# permission to fall behind forever with a green board.
|
||||
#
|
||||
# Deliberately does NOT build the app. The question it answers, "did the
|
||||
# published vectors move?", needs a clone and a diff, and nothing else. A job
|
||||
# that took twenty minutes to say "you are two commits behind" would be turned
|
||||
# off within a week.
|
||||
#
|
||||
# A red run here means an update is waiting. Take it per `.e2e-ref.md`.
|
||||
vectors-head:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
allow_failure: true
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
script:
|
||||
- rm -rf ci-vectors-head
|
||||
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-e2e.git ci-vectors-head
|
||||
- |
|
||||
echo "pinned: $(cat .e2e-ref)"
|
||||
echo "at HEAD: $(git -C ci-vectors-head rev-parse HEAD)"
|
||||
behind=0
|
||||
for f in protocol-v1 pqx3dh-v2 account-kdf-v1 backup-v1 backup-payload-v1 content-v1 content-kinds-v1 today-v1 hello-link-v1; do
|
||||
if ! diff -q "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" >/dev/null; then
|
||||
echo "ADVISORY: $f.json has moved at HEAD:"
|
||||
diff "ci-vectors-head/test/vectors/$f.json" "Tests/Vectors/$f.json" | head -30
|
||||
behind=1
|
||||
fi
|
||||
done
|
||||
if [ "$behind" = "1" ]; then
|
||||
echo "ADVISORY: the published vectors have changed. This is not a failure of"
|
||||
echo "ADVISORY: this branch; it means an update is waiting. See .e2e-ref.md."
|
||||
exit 1
|
||||
fi
|
||||
echo "ADVISORY: vectors are current with flamenet-e2e HEAD"
|
||||
timeout: 10m
|
||||
|
||||
# The weekly scrub, on every merge request instead of weekly.
|
||||
#
|
||||
# `Scripts/SCRUB.md` describes it as something to run with the three checkouts
|
||||
# side by side, and its own docstring says the checks it carries are the ones
|
||||
# whose failure mode is SILENT: an envelope kind one app sends and the other
|
||||
# cannot parse shows nothing at either end and tells the sender nothing.
|
||||
#
|
||||
# A check that only runs when remembered stops running. On 2026-09-20 it had
|
||||
# been reporting four HIGHs that were all wrong -- it knew two of the four
|
||||
# shapes iOS uses to decide what an envelope is -- which is the state its own
|
||||
# documentation says is worse than not having it, because people learn to skim
|
||||
# a scrub that cries wolf.
|
||||
#
|
||||
# All three repositories are public, so the two siblings clone with no
|
||||
# credentials, the same way `vectors-head` above takes flamenet-e2e. Shallow:
|
||||
# this reads source, not history.
|
||||
parity-scrub:
|
||||
stage: test
|
||||
tags: [mac-local]
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
variables:
|
||||
GIT_DEPTH: "1"
|
||||
script:
|
||||
- rm -rf ci-scrub
|
||||
- mkdir -p ci-scrub
|
||||
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-messenger-android.git ci-scrub/android
|
||||
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-relay.git ci-scrub/relay
|
||||
# Non-zero on a HIGH, which is the whole contract.
|
||||
- python3 Scripts/parity-scrub.py --ios . --android ci-scrub/android --relay ci-scrub/relay
|
||||
timeout: 10m
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "Packages/Obfs4"]
|
||||
path = Packages/Obfs4
|
||||
url = https://gitlab.com/extant2000/flamenet-elligator2.git
|
||||
url = https://git.extant2000.com/extant2000/flamenet-elligator2.git
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ Repeatable via `Scripts/android-signup.sh` and `RelayTypesUITests`.
|
||||
|
||||
## 3, cross-relay messaging
|
||||
|
||||
**The relays do it.** `test/federation.mjs` (in [flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e) since the split) against the live fleet:
|
||||
**The relays do it.** `test/federation.mjs` (in [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e) since the split) against the live fleet:
|
||||
**11 passed, 0 failed**: relay1 fetches bob's devices from relay2, sends
|
||||
across the boundary, bob's relay accepts exactly one envelope, he polls and it
|
||||
is there, it carries no sender attribution across the boundary, a relay off
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// How the sessions this package builds are configured. The app sets it
|
||||
/// once, at launch, so they go through its bridges like every other session.
|
||||
public enum FilesTransport {
|
||||
nonisolated(unsafe) public static var configure: @Sendable (URLSessionConfiguration) -> Void = { _ in }
|
||||
}
|
||||
|
||||
/// A very small HTTP layer.
|
||||
struct HTTP: Sendable {
|
||||
let session: URLSession
|
||||
@@ -13,6 +19,8 @@ struct HTTP: Sendable {
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.timeoutIntervalForRequest = 30
|
||||
// The host app's transport: a bridge when one is set (SPEC §24.6).
|
||||
FilesTransport.configure(configuration)
|
||||
self.session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ pipeline:
|
||||
|
||||
| Repo | What it is |
|
||||
|---|---|
|
||||
| [flamenet-e2e](https://gitlab.com/extant2000/flamenet-e2e) | The protocol: JS engine, specification, published vectors, the interop driver |
|
||||
| [flamenet-relay](https://gitlab.com/extant2000/flamenet-relay) | The server |
|
||||
| [flamenet-messenger-android](https://gitlab.com/extant2000/flamenet-messenger-android) | The Android app |
|
||||
| [flamenet-e2e](https://git.extant2000.com/extant2000/flamenet-e2e) | The protocol: JS engine, specification, published vectors, the interop driver |
|
||||
| [flamenet-relay](https://git.extant2000.com/extant2000/flamenet-relay) | The server |
|
||||
| [flamenet-messenger-android](https://git.extant2000.com/extant2000/flamenet-messenger-android) | The Android app |
|
||||
|
||||
those three lived here as subtrees, and the public
|
||||
repos were rsync extracts of them, refreshed by hand. The extracts drifted for
|
||||
@@ -97,7 +97,7 @@ tick.
|
||||
The server is its own repository now:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.com/extant2000/flamenet-relay.git
|
||||
git clone https://git.extant2000.com/extant2000/flamenet-relay.git
|
||||
cd flamenet-relay
|
||||
cp .env.example .env # then fill in the secrets it asks for
|
||||
docker compose up -d
|
||||
|
||||
@@ -7,5 +7,13 @@
|
||||
breaks debug builds, which then never receive a token. -->
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<!-- Section 8.14: https://<relay>/add#... opens the app. The relay serves
|
||||
the matching apple-app-site-association (team M5923Y84P5, /add*).
|
||||
The App ID needs the Associated Domains capability to sign. -->
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:relay.flamenet.io</string>
|
||||
<string>applinks:relay1.flamenet.io</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>io.flamenet.messenger.contact-link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>flamenet</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"CFBundleDisplayName" : {
|
||||
"comment" : "App name under the icon. Brand, not translated.",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Flamenet"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Flamenet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "FlamenetMessenger"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSCameraUsageDescription" : {
|
||||
"comment" : "Permission prompt. DRAFT, needs native review.",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Flamenet uses the camera to scan a contact's address code. Nothing is recorded and no image leaves your device."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Flamenet usa la cámara para escanear el código de dirección de un contacto. No se graba nada y ninguna imagen sale de tu dispositivo."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSFaceIDUsageDescription" : {
|
||||
"comment" : "Permission prompt. DRAFT, needs native review.",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Flamenet can require Face ID before opening the app, so somebody holding your unlocked phone cannot read your conversations."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Flamenet puede pedir Face ID antes de abrir la aplicación, para que nadie que tenga tu teléfono desbloqueado en la mano pueda leer tus conversaciones."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSMicrophoneUsageDescription" : {
|
||||
"comment" : "Permission prompt. DRAFT, needs native review.",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Flamenet uses the microphone for voice notes and voice calls, which are end-to-end encrypted before they leave your device."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Flamenet usa el micrófono para las notas de voz y las llamadas de voz, que se cifran de extremo a extremo antes de salir de tu dispositivo."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSPhotoLibraryAddUsageDescription" : {
|
||||
"comment" : "Permission prompt. DRAFT, needs native review.",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Flamenet can save a story you posted yourself to your photos, so you keep it after it disappears for everyone else. It only ever adds; it cannot see the photos you already have."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Flamenet puede guardar en tus fotos una historia que publicaste tú, para que la conserves cuando desaparezca para todos los demás. Solo puede agregar; no puede ver las fotos que ya tienes."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Every string the compiler extracts must be in Resources/Localizable.xcstrings.
|
||||
|
||||
Spanish (es) is a DRAFT, needs native review.
|
||||
|
||||
xcodebuild compiles the catalog but does not add new keys to it; only Xcode's
|
||||
editor does that. So a SwiftUI literal or String(localized:) added from the
|
||||
command line reaches the app with no entry, and it shows in English in every
|
||||
language with nothing failing. SpanishCatalogTests can only check the keys the
|
||||
catalog already has. This closes the other half: it reads the .stringsdata the
|
||||
Swift compiler writes (SWIFT_EMIT_LOC_STRINGS=YES in project.yml) and compares.
|
||||
|
||||
xcodebuild ... -derivedDataPath build/dd build
|
||||
Scripts/check-spanish-catalog.py build/dd
|
||||
|
||||
Exit 1 when a key in the code is missing from the catalog. Keys in the catalog
|
||||
that the code no longer uses are listed, not failed.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
derived = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, 'build')
|
||||
catalog = json.load(open(os.path.join(here, 'Resources', 'Localizable.xcstrings'), encoding='utf-8'))['strings']
|
||||
|
||||
code = {}
|
||||
seen = set()
|
||||
for f in glob.glob(os.path.join(derived, '**', '*.stringsdata'), recursive=True):
|
||||
d = json.load(open(f))
|
||||
src = d.get('source', '')
|
||||
rel = os.path.relpath(os.path.realpath(src), os.path.realpath(here))
|
||||
if rel in seen or not rel.startswith('Sources' + os.sep):
|
||||
continue
|
||||
seen.add(rel)
|
||||
for e in d.get('tables', {}).get('Localizable', []):
|
||||
code.setdefault(e['key'], '%s:%s' % (rel, e.get('location', {}).get('startingLine', '?')))
|
||||
|
||||
if not code:
|
||||
sys.exit('no .stringsdata found under %s; build first with SWIFT_EMIT_LOC_STRINGS=YES' % derived)
|
||||
|
||||
missing = sorted(k for k in code if k not in catalog)
|
||||
unused = sorted(k for k, v in catalog.items() if k not in code and v.get('extractionState') != 'manual')
|
||||
print('%d keys in code, %d in the catalog, %d missing, %d unused' % (len(code), len(catalog), len(missing), len(unused)))
|
||||
for k in missing:
|
||||
print('MISSING %s (%s)' % (k, code[k]))
|
||||
for k in unused:
|
||||
print('unused %s' % k)
|
||||
sys.exit(1 if missing else 0)
|
||||
@@ -4,8 +4,10 @@
|
||||
# FN_SHOT_HANDLE=… FN_SHOT_PASSPHRASE=… Scripts/store-shots.sh [device type]
|
||||
#
|
||||
# One simulator, one sign-in, one shot per tab. The device decides the pixel
|
||||
# size, so the default is an iPhone 13 Pro Max: 1284 x 2778, which is a size
|
||||
# App Store Connect accepts. Results land in build/store-shots/ and the script
|
||||
# size, so the default is an iPhone 16 Pro Max: 1320 x 2868, the 6.9 inch
|
||||
# slot. Since 2026-10-20 App Store Connect refuses 1284 x 2778 there. For
|
||||
# iPad pass com.apple.CoreSimulator.SimDeviceType.iPad-Pro-12-9-inch-6th-generation-8GB
|
||||
# (2048 x 2732). Results land in build/store-shots/ and the script
|
||||
# prints the size of each, so a wrong device is visible without opening one.
|
||||
#
|
||||
# The conversation shot is not here. It needs a second phone to talk to, so it
|
||||
@@ -13,7 +15,7 @@
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DEVICE="${1:-com.apple.CoreSimulator.SimDeviceType.iPhone-13-Pro-Max}"
|
||||
DEVICE="${1:-com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro-Max}"
|
||||
RELAY="${FN_SHOT_RELAY:-https://relay1.flamenet.io}"
|
||||
HANDLE="${FN_SHOT_HANDLE:-}"
|
||||
PASS="${FN_SHOT_PASSPHRASE:-}"
|
||||
|
||||
@@ -53,6 +53,10 @@ final class AppEnvironment: ObservableObject {
|
||||
/// search box picks it up.
|
||||
@Published var pendingAddress: String?
|
||||
|
||||
/// Section 8.14: a contact link somebody opened, waiting for the person
|
||||
/// to write a first message.
|
||||
@Published var pendingContactLink: ContactLink?
|
||||
|
||||
/// The unlocked Files vault, while there is one.
|
||||
@Published var filesVault: LocalVault?
|
||||
|
||||
@@ -206,9 +210,17 @@ final class AppEnvironment: ObservableObject {
|
||||
}
|
||||
|
||||
/// Block or unblock somebody, and take their sealed capabilities with it.
|
||||
///
|
||||
/// Section 8.9d: a block is held on this device (and its linked devices),
|
||||
/// where it drops anything that still arrives, AND on the relay, which
|
||||
/// refuses their unsealed messages until stage 2-E. The relay is told
|
||||
/// first: if it cannot be, nothing changes and the caller says so.
|
||||
func setBlocked(_ blocked: Bool, userID: Int) async throws {
|
||||
guard let api else { throw MessengerError.notAuthenticated }
|
||||
try await api.setBlocked(blocked, userID: userID)
|
||||
// Kept on this device as well, as the `blocked/` register: the relay
|
||||
// cannot refuse a SEALED message from them, so the inbox has to.
|
||||
e2eInbox?.setSender(userID, blocked: blocked)
|
||||
guard blocked else { return }
|
||||
// After the block has landed, and never allowed to fail it: the block
|
||||
// itself is the safety action, and failing it because a follow-up call
|
||||
@@ -234,11 +246,95 @@ final class AppEnvironment: ObservableObject {
|
||||
"blocked %{public}d, but revoking their delivery tokens failed: %{public}@",
|
||||
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
|
||||
type: .error, userID, String(describing: error))
|
||||
lastError =
|
||||
"Blocked. Their sealed-delivery capability could not be revoked right now, "
|
||||
+ "so a message they already prepared may still arrive. Blocking again "
|
||||
+ "retries it."
|
||||
lastError = String(
|
||||
localized:
|
||||
"Blocked. Their sealed-delivery capability could not be revoked right now, so a message they already prepared may still arrive. Blocking again retries it."
|
||||
)
|
||||
}
|
||||
// The third part of a block: a new delivery key, so the old one they
|
||||
// hold stops opening this inbox. Pushed to everybody else at once.
|
||||
if let inbox = e2eInbox { Task { await inbox.rotateDeliveryKey() } }
|
||||
}
|
||||
|
||||
// MARK: - Contact links (section 8.14)
|
||||
|
||||
/// Open a link: a contact link goes to the first-message sheet. Anything
|
||||
/// else is not ours and is ignored.
|
||||
@discardableResult
|
||||
func open(_ url: URL) -> Bool {
|
||||
guard let link = ContactLink(text: url.absoluteString) else { return false }
|
||||
pendingContactLink = link
|
||||
return true
|
||||
}
|
||||
|
||||
func applyPendingListingChoice() async {
|
||||
guard ListingChoice.pendingUnlisted, let api, let inbox = e2eInbox,
|
||||
await inbox.relayFeatures().unlisted == true
|
||||
else { return }
|
||||
do {
|
||||
try await api.setDiscoverable(false)
|
||||
ListingChoice.pendingUnlisted = false
|
||||
} catch {
|
||||
os_log(
|
||||
"the Unlisted choice made at sign-up was not applied yet: %{public}@",
|
||||
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
|
||||
type: .error, String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Requests and contacts (section 14.5, 8.9d)
|
||||
|
||||
/// Accept somebody whose first message is in Requests: they become a
|
||||
/// conversation and a contact.
|
||||
///
|
||||
/// Acceptance itself is held on the device. The contact is added to the
|
||||
/// sealed list when the relay no longer keeps one, and otherwise to the
|
||||
/// relay's, which is also where a device still on 0.31 reads acceptance.
|
||||
/// Throws when the contact could not be added; the acceptance stands.
|
||||
func acceptRequest(_ id: Int) async throws {
|
||||
guard let inbox = e2eInbox else { throw MessengerError.notAuthenticated }
|
||||
inbox.accept(id)
|
||||
let name = await nameFor(stranger: id)
|
||||
if inbox.rosterOffServer {
|
||||
inbox.rosterAdd(id: id, handle: name.handle ?? "", displayName: name.display)
|
||||
} else if let api, let handle = name.handle, !handle.isEmpty {
|
||||
try await api.addBuddy(username: handle, fromPagePath: siteConfig.messengerPagePath)
|
||||
try? await api.acceptConversation(buddyID: id)
|
||||
if let fresh = try? await api.fetchBuddies(fromPagePath: siteConfig.messengerPagePath) {
|
||||
knownBuddies = fresh
|
||||
knownBuddyIDs = fresh.map(\.id)
|
||||
}
|
||||
} else {
|
||||
// No name to add them under. Kept on the sealed list rather than
|
||||
// lost; the relay's list picks them up when a name is known.
|
||||
inbox.rosterAdd(id: id, handle: "", displayName: name.display)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a request without blocking: the conversation goes, and a later
|
||||
/// message from them is a request again.
|
||||
func deleteRequest(_ id: Int) {
|
||||
e2eInbox?.clearThread(for: id)
|
||||
}
|
||||
|
||||
/// A name for somebody who is not a contact, for Requests and the call
|
||||
/// screen. From a handle certificate when one arrived (section 14.5,
|
||||
/// stage C2); otherwise the relay is asked, which is the question C2
|
||||
/// retires.
|
||||
func nameFor(stranger id: Int) async -> (handle: String?, display: String) {
|
||||
if let certified = e2eInbox?.certifiedHandle(for: id) {
|
||||
return (certified, certified)
|
||||
}
|
||||
if let known = knownBuddies.first(where: { $0.id == id }) {
|
||||
return (known.screenName, known.displayName)
|
||||
}
|
||||
// Stage C2: a relay that signs handle certificates is never asked who
|
||||
// somebody is; the name comes from their certificate or not at all.
|
||||
let retired = await e2eInbox?.relayFeatures().introductions == true
|
||||
if !retired, let info = (try? await api?.userInfo(id: id)) ?? nil {
|
||||
return (info.username, info.username ?? info.displayName)
|
||||
}
|
||||
return (nil, e2eInbox?.displayName(for: id) ?? String(localized: "Someone"))
|
||||
}
|
||||
|
||||
private var registrationWatch: AnyCancellable?
|
||||
@@ -294,18 +390,20 @@ final class AppEnvironment: ObservableObject {
|
||||
inbox.restoreExtraHomes(restPrefix: config.restPrefix)
|
||||
inbox.start()
|
||||
self.e2eInbox = inbox
|
||||
// Section 8.14: an Unlisted choice made at sign-up, applied once the
|
||||
// relay can take it.
|
||||
Task { await self.applyPendingListingChoice() }
|
||||
self.callManager = CallManager(inbox: inbox)
|
||||
// Who is calling, when they are not a contact. The relay can answer an
|
||||
// id with a handle, and adopting them puts that name on the call
|
||||
// screen, in the call log, and on the conversation that follows.
|
||||
self.callManager?.resolveCaller = { [weak self] id in
|
||||
guard let self else { return nil }
|
||||
await self.adoptStrangers(including: [id])
|
||||
if let known = self.knownBuddies.first(where: { $0.id == id }) {
|
||||
return known.displayName
|
||||
}
|
||||
guard let info = (try? await self.api?.userInfo(id: id)) ?? nil else { return nil }
|
||||
return info.username ?? info.displayName
|
||||
// Named, not added: a stranger who calls is not a contact
|
||||
// (section 14.5).
|
||||
let name = await self.nameFor(stranger: id)
|
||||
return name.handle == nil && self.knownBuddies.first(where: { $0.id == id }) == nil
|
||||
? nil : name.display
|
||||
}
|
||||
|
||||
// Accepting a changed identity anywhere ends the call screen's review,
|
||||
@@ -341,9 +439,10 @@ final class AppEnvironment: ObservableObject {
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.signOut()
|
||||
self.lastError =
|
||||
"This device was removed from the account, so it has "
|
||||
+ "signed out. Your messages on this phone were left alone."
|
||||
self.lastError = String(
|
||||
localized:
|
||||
"This device was removed from the account, so it has signed out. Your messages on this phone were left alone."
|
||||
)
|
||||
}
|
||||
|
||||
revocationWatch = inbox.$deviceRevoked
|
||||
@@ -357,10 +456,10 @@ final class AppEnvironment: ObservableObject {
|
||||
self.e2eInbox?.purgeLocalHistory()
|
||||
self.signOut()
|
||||
store?.wipe()
|
||||
self.lastError =
|
||||
"This device was removed from the account, so "
|
||||
+ "its local messages were cleared. Signing in again "
|
||||
+ "registers it as a new device."
|
||||
self.lastError = String(
|
||||
localized:
|
||||
"This device was removed from the account, so its local messages were cleared. Signing in again registers it as a new device."
|
||||
)
|
||||
}
|
||||
|
||||
// Registration is per session, because the token is per device AND the
|
||||
@@ -401,16 +500,30 @@ final class AppEnvironment: ObservableObject {
|
||||
: told
|
||||
return copy
|
||||
}
|
||||
// Section 14.5: the usernames this device may introduce
|
||||
// itself by when a send has nothing else to seal with.
|
||||
self.e2eInbox?.updateKnownHandles(
|
||||
Dictionary(
|
||||
merged.compactMap { b in b.screenName.map { (b.id, $0) } },
|
||||
uniquingKeysWith: { a, _ in a }))
|
||||
await MainActor.run {
|
||||
self.knownBuddies = merged
|
||||
// Something worked, so an older run of refusals is over.
|
||||
self.noteCredentialAccepted()
|
||||
}
|
||||
// A stranger's first message arrives as a thread and
|
||||
// nothing else. Adopting here rather than from a screen is
|
||||
// what makes their chat appear while you are reading a
|
||||
// different tab.
|
||||
await self.adoptStrangers()
|
||||
// Stage 2-B: seed acceptance and blocks from the relay's
|
||||
// answer, once, then stop asking.
|
||||
if let inbox = self.e2eInbox, !inbox.store.accountStateSeededFromRelay,
|
||||
let blocked = try? await api.fetchBlocked()
|
||||
{
|
||||
inbox.seedAccountStateFromRelayIfNeeded(
|
||||
acceptedBuddies: fetched.filter(\.accepted).map(\.id),
|
||||
blocked: blocked.map(\.id))
|
||||
}
|
||||
// A stranger's first message lands in Requests. Naming
|
||||
// them here, rather than from a screen, is what puts a
|
||||
// name on it while you are reading a different tab.
|
||||
await self.nameStrangers()
|
||||
os_log(
|
||||
"presence refreshed: %{public}@",
|
||||
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
|
||||
@@ -447,11 +560,10 @@ final class AppEnvironment: ObservableObject {
|
||||
.first()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.lastError =
|
||||
"Could not finish setting this device up on "
|
||||
+ "the relay, so it is not receiving messages yet. Your "
|
||||
+ "account and messages are untouched, this usually clears "
|
||||
+ "on its own; check your connection and try again."
|
||||
self?.lastError = String(
|
||||
localized:
|
||||
"Could not finish setting this device up on the relay, so it is not receiving messages yet. Your account and messages are untouched, this usually clears on its own; check your connection and try again."
|
||||
)
|
||||
}
|
||||
|
||||
PushRegistrar.shared.upload = { [weak inbox] token in
|
||||
@@ -520,70 +632,32 @@ final class AppEnvironment: ObservableObject {
|
||||
VoIPCallReceiver.shared.start()
|
||||
}
|
||||
|
||||
/// People this device has a thread with who are not on the contact list.
|
||||
///
|
||||
/// The contact list is one-directional and sealed sender means the relay
|
||||
/// cannot annotate anything, so somebody who adds you and writes puts
|
||||
/// nothing in your list. Their thread is the only trace, and adopting them
|
||||
/// is what turns it into a conversation with a name on it.
|
||||
private var adoptingStrangers: Set<Int> = []
|
||||
/// Requests this session has already looked up a name for.
|
||||
private var namedStrangers: Set<Int> = []
|
||||
|
||||
/// Put whoever just contacted us onto the contact list, properly named.
|
||||
/// Put a name to everybody in Requests, without adding anybody.
|
||||
///
|
||||
/// Runs wherever the person is, not from one screen's `.task`, so a
|
||||
/// first message from somebody new is adopted whichever tab is open.
|
||||
/// This used to ADOPT them: anybody whose first message arrived was added
|
||||
/// to the contact list by the app itself, which made a stranger's first
|
||||
/// message a contact. Section 14.5 (decision DC2) ends that: they wait in
|
||||
/// Requests until the person accepts or blocks. Only the name is fetched,
|
||||
/// so the Requests list says who wrote.
|
||||
@discardableResult
|
||||
/// - Parameter including: ids to adopt whether or not they have written,
|
||||
/// which is how somebody who only CALLED gets a name.
|
||||
func adoptStrangers(including extra: Set<Int> = []) async -> Bool {
|
||||
guard let api, let inbox = e2eInbox else { return false }
|
||||
func nameStrangers(including extra: Set<Int> = []) async -> Bool {
|
||||
guard let inbox = e2eInbox else { return false }
|
||||
let known = Set(knownBuddies.map(\.id))
|
||||
let strangers =
|
||||
Set(
|
||||
inbox.threads
|
||||
.filter { !$0.value.isEmpty }
|
||||
.keys
|
||||
)
|
||||
.union(extra)
|
||||
.filter { !known.contains($0) && !adoptingStrangers.contains($0) }
|
||||
let strangers = Set(inbox.requestIDs).union(extra)
|
||||
.filter { !known.contains($0) && !namedStrangers.contains($0) }
|
||||
guard !strangers.isEmpty else { return false }
|
||||
adoptingStrangers.formUnion(strangers)
|
||||
// Both the lookup and the add are idempotent, so a failure here is
|
||||
// retried on the next pass rather than remembered as done.
|
||||
defer { adoptingStrangers.subtract(strangers) }
|
||||
// No block list, no adopting. This was `(try? ...) ?? []`, so a
|
||||
// request that failed produced an EMPTY set of blocked people and
|
||||
// every stranger passed the check below -- including the ones the user
|
||||
// had blocked, who were then added back to the contact list by the app
|
||||
// itself, silently, because a fetch timed out.
|
||||
//
|
||||
// Skipping costs nothing: this whole pass is idempotent and runs again
|
||||
// on the next message, next tab switch and next launch. Adopting
|
||||
// somebody who was blocked costs them noticing and undoing it, if they
|
||||
// notice.
|
||||
guard let blockedUsers = try? await api.fetchBlocked() else { return false }
|
||||
let blocked = Set(blockedUsers.map(\.id))
|
||||
var added = false
|
||||
var names: [Int: String] = [:]
|
||||
for id in strangers {
|
||||
guard !blocked.contains(id) else { continue }
|
||||
guard let info = (try? await api.userInfo(id: id)) ?? nil,
|
||||
let username = info.username, !username.isEmpty
|
||||
else { continue }
|
||||
// `auto: true`, this add is the app's doing, not the user's.
|
||||
if (try? await api.addBuddy(
|
||||
username: username,
|
||||
fromPagePath: siteConfig.messengerPagePath, auto: true)) != nil
|
||||
{
|
||||
added = true
|
||||
}
|
||||
}
|
||||
guard added else { return false }
|
||||
if let fresh = try? await api.fetchBuddies(
|
||||
fromPagePath: siteConfig.messengerPagePath)
|
||||
{
|
||||
knownBuddies = fresh
|
||||
knownBuddyIDs = fresh.map(\.id)
|
||||
let name = await nameFor(stranger: id)
|
||||
guard name.handle != nil else { continue }
|
||||
names[id] = name.display
|
||||
namedStrangers.insert(id)
|
||||
}
|
||||
guard !names.isEmpty else { return false }
|
||||
inbox.updateBuddyNames(names)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -648,9 +722,10 @@ final class AppEnvironment: ObservableObject {
|
||||
// which. Repairing in a loop against it is how an app comes up looking
|
||||
// signed in and silently unable to do anything.
|
||||
guard refusal != .deviceLimitReached else {
|
||||
lastError =
|
||||
"This account already has as many devices as it allows. Remove one "
|
||||
+ "in Settings, Devices, then sign in here again."
|
||||
lastError = String(
|
||||
localized:
|
||||
"This account already has as many devices as it allows. Remove one in Settings, Devices, then sign in here again."
|
||||
)
|
||||
lastErrorCode = "device_limit"
|
||||
return
|
||||
}
|
||||
@@ -832,8 +907,10 @@ final class AppEnvironment: ObservableObject {
|
||||
case .relayOnly, .sharedAccountRemoved:
|
||||
return nil
|
||||
case .sharedAccountFailed(let why):
|
||||
return
|
||||
"Your messenger account is deleted. Your Flamenet account is still there. \(why) Sign in at the account service to remove it."
|
||||
return String(
|
||||
localized:
|
||||
"Your messenger account is deleted. Your Flamenet account is still there. \(why) Sign in at the account service to remove it."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,7 +1057,7 @@ final class AppEnvironment: ObservableObject {
|
||||
.appendingPathComponent("flamenet/v1")
|
||||
var req = URLRequest(url: probe)
|
||||
req.timeoutInterval = 8
|
||||
guard let (data, resp) = try? await URLSession.shared.data(for: req),
|
||||
guard let (data, resp) = try? await BridgedSession.preSignIn.session.data(for: req),
|
||||
let http = resp as? HTTPURLResponse,
|
||||
(200...499).contains(http.statusCode), http.statusCode != 404
|
||||
else { continue }
|
||||
|
||||
@@ -129,8 +129,8 @@ final class AppLock: ObservableObject {
|
||||
let seconds = max(1, Int(until.timeIntervalSinceNow.rounded(.up)))
|
||||
failureMessage =
|
||||
seconds >= 60
|
||||
? "Too many attempts. Try again in \(Int((Double(seconds) / 60).rounded(.up))) minutes."
|
||||
: "Too many attempts. Try again in \(seconds) seconds."
|
||||
? String(localized: "Too many attempts. Try again in \(Int((Double(seconds) / 60).rounded(.up))) minutes.")
|
||||
: String(localized: "Too many attempts. Try again in \(seconds) seconds.")
|
||||
return
|
||||
}
|
||||
authenticating = true
|
||||
@@ -138,11 +138,11 @@ final class AppLock: ObservableObject {
|
||||
defer { authenticating = false }
|
||||
|
||||
let context = LAContext()
|
||||
context.localizedCancelTitle = "Cancel"
|
||||
context.localizedCancelTitle = String(localized: "Cancel")
|
||||
do {
|
||||
let ok = try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthentication,
|
||||
localizedReason: "Unlock Flamenet Messenger")
|
||||
localizedReason: String(localized: "Unlock Flamenet Messenger"))
|
||||
if ok {
|
||||
isLocked = false
|
||||
leftAt = nil
|
||||
@@ -158,7 +158,7 @@ final class AppLock: ObservableObject {
|
||||
case .userFallback:
|
||||
failureMessage = nil
|
||||
case .biometryLockout:
|
||||
failureMessage = "Too many attempts. Use your device passcode."
|
||||
failureMessage = String(localized: "Too many attempts. Use your device passcode.")
|
||||
case .passcodeNotSet:
|
||||
// The switch should not have been available, but a passcode can
|
||||
// be removed after the fact -- and refusing to open the app
|
||||
@@ -172,13 +172,13 @@ final class AppLock: ObservableObject {
|
||||
recordFailure()
|
||||
failureMessage =
|
||||
secondsToWait > 0
|
||||
? "Too many attempts. Try again in \(secondsToWait) seconds."
|
||||
: "That didn't match. Try again."
|
||||
? String(localized: "Too many attempts. Try again in \(secondsToWait) seconds.")
|
||||
: String(localized: "That didn't match. Try again.")
|
||||
default:
|
||||
failureMessage = "Couldn't unlock. Try again."
|
||||
failureMessage = String(localized: "Couldn't unlock. Try again.")
|
||||
}
|
||||
} catch {
|
||||
failureMessage = "Couldn't unlock. Try again."
|
||||
failureMessage = String(localized: "Couldn't unlock. Try again.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ struct FlamenetMessengerApp: App {
|
||||
@State private var linkNotice: LinkNotice?
|
||||
|
||||
init() {
|
||||
// The Files package builds its own sessions; they follow the bridges.
|
||||
Obfs4Proxy.shared.routeFilesClient()
|
||||
// A UI test asks for a clean install without reinstalling the app.
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-FNMSGResetState") {
|
||||
@@ -168,6 +170,12 @@ struct FlamenetMessengerApp: App {
|
||||
.preferredColorScheme(
|
||||
(AppearanceMode(rawValue: appearanceRaw) ?? .system).colorScheme
|
||||
)
|
||||
// Section 8.14: a contact link opened from outside the app.
|
||||
.onOpenURL { url in environment.open(url) }
|
||||
// The same link as a universal link (associated domains).
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||
if let webURL = activity.webpageURL { _ = environment.open(webURL) }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fnmsgOpenChat)) { note in
|
||||
if let id = note.userInfo?["buddyID"] as? Int {
|
||||
environment.deepLinkBuddyID = id
|
||||
|
||||
@@ -117,8 +117,8 @@ final class NotificationManager {
|
||||
content.title = "Flamenet"
|
||||
content.body =
|
||||
preview && name != nil
|
||||
? "Still no reply from \(name!)"
|
||||
: "Still no reply"
|
||||
? String(localized: "Still no reply from \(name!)")
|
||||
: String(localized: "Still no reply")
|
||||
content.sound = .default
|
||||
content.userInfo = ["buddyID": buddyID]
|
||||
|
||||
@@ -148,10 +148,9 @@ final class NotificationManager {
|
||||
// Not while the app is in front.
|
||||
guard UIApplication.shared.applicationState != .active else { return }
|
||||
|
||||
let kind = video ? "video call" : "call"
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = preview ? fromName : "Flamenet"
|
||||
content.body = "Incoming \(kind)"
|
||||
content.body = video ? String(localized: "Incoming video call") : String(localized: "Incoming call")
|
||||
content.sound = .defaultRingtone
|
||||
content.userInfo = ["buddyID": buddyID, "call": true]
|
||||
|
||||
@@ -220,7 +219,7 @@ final class NotificationManager {
|
||||
let content = UNMutableNotificationContent()
|
||||
// The name is part of the preview.
|
||||
content.title = preview ? fromName : "Flamenet"
|
||||
content.body = preview ? body : "New message"
|
||||
content.body = preview ? body : String(localized: "New message")
|
||||
content.sound = quiet ? nil : .default
|
||||
content.userInfo = ["buddyID": buddyID]
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ enum FlamenetAccountKDF {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedKDF(let id):
|
||||
return "This app doesn't know how to derive keys with method \(id). Update the app."
|
||||
return String(localized: "This app doesn't know how to derive keys with method \(String(id)). Update the app.")
|
||||
case .derivationFailed(let status):
|
||||
return "Key derivation failed (\(status))."
|
||||
return String(localized: "Key derivation failed (\(String(Int(status)))).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import Foundation
|
||||
struct FlamenetAccountsClient {
|
||||
|
||||
let baseURL: URL
|
||||
var session: URLSession = .shared
|
||||
/// Through the bridges when any is set, like everything before sign-in.
|
||||
var session: URLSession = BridgedSession.preSignIn.session
|
||||
|
||||
/// The product name this app authenticates as.
|
||||
static let product = "messenger"
|
||||
@@ -59,17 +60,17 @@ struct FlamenetAccountsClient {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badCredentials: return "That handle or passphrase isn't right."
|
||||
case .badCredentials: return String(localized: "That handle or passphrase isn't right.")
|
||||
case .sessionExpired:
|
||||
return "Your session expired. Sign out and back in to keep going."
|
||||
return String(localized: "Your session expired. Sign out and back in to keep going.")
|
||||
case .rateLimited(let reason):
|
||||
return reason ?? "Too many attempts. Wait a little and try again."
|
||||
return reason ?? String(localized: "Too many attempts. Wait a little and try again.")
|
||||
// "Username", because that is the word on the field somebody just
|
||||
// typed into. The account service calls it a handle; making the
|
||||
// reader translate is the app's job, not theirs.
|
||||
case .handleTaken: return "That username is already taken, try another."
|
||||
case .service(let c): return "The account service returned an error (\(c))."
|
||||
case .transport(let m): return "Couldn't reach the account service: \(m)"
|
||||
case .handleTaken: return String(localized: "That username is already taken, try another.")
|
||||
case .service(let c): return String(localized: "The account service returned an error (\(c)).")
|
||||
case .transport(let m): return String(localized: "Couldn't reach the account service: \(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +110,7 @@ struct FlamenetAccountsClient {
|
||||
let status = salt.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, $0.count, $0.baseAddress!)
|
||||
}
|
||||
guard status == errSecSuccess else { throw Error.transport("no secure randomness") }
|
||||
guard status == errSecSuccess else { throw Error.transport(String(localized: "no secure randomness")) }
|
||||
|
||||
let params = FlamenetAccountKDF.Params.pbkdf2V1
|
||||
let secret = try FlamenetAccountKDF.authSecret(
|
||||
@@ -202,7 +203,7 @@ struct FlamenetAccountsClient {
|
||||
decoder.dataDecodingStrategy = .base64
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
do { return try decoder.decode(T.self, from: data) } catch {
|
||||
throw Error.transport("unreadable response")
|
||||
throw Error.transport(String(localized: "unreadable response"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +220,7 @@ struct FlamenetAccountsClient {
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw Error.transport("not an HTTP response")
|
||||
throw Error.transport(String(localized: "not an HTTP response"))
|
||||
}
|
||||
switch http.statusCode {
|
||||
case 200..<300: return data
|
||||
|
||||
@@ -11,16 +11,12 @@ enum PassphraseRules {
|
||||
guard !pass.isEmpty, !user.isEmpty else { return nil }
|
||||
|
||||
if pass == user {
|
||||
return "your passphrase can't be your username, your address is "
|
||||
+ "derived from it, so anyone who knows your username could work "
|
||||
+ "out your account"
|
||||
return String(localized: "your passphrase can't be your username, your address is derived from it, so anyone who knows your username could work out your account")
|
||||
}
|
||||
// Wrapping the username in a few characters leaves a space somebody can
|
||||
// simply run through.
|
||||
if pass.contains(user), pass.count - user.count < 8 {
|
||||
return "your passphrase can't be your username with a little added, "
|
||||
+ "your address is derived from it, so that is only a few guesses "
|
||||
+ "away from your username"
|
||||
return String(localized: "your passphrase can't be your username with a little added, your address is derived from it, so that is only a few guesses away from your username")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
extension RelayDiscovery {
|
||||
/// Fetch a relay's descriptor, through the bridges when one is saved.
|
||||
static func describe(_ baseURL: URL) async -> RelayDescriptor? {
|
||||
await describe(baseURL, session: BridgedSession.preSignIn.session)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,11 @@ enum RelayDiscovery {
|
||||
}
|
||||
|
||||
/// Fetch a relay's descriptor.
|
||||
static func describe(_ baseURL: URL, session: URLSession = .shared) async -> RelayDescriptor? {
|
||||
///
|
||||
/// The app calls the one-argument form in `RelayDiscovery+Bridge.swift`,
|
||||
/// which passes the bridged session. This file is also compiled into
|
||||
/// flamenet-e2e's interop driver, which has no bridge code.
|
||||
static func describe(_ baseURL: URL, session: URLSession) async -> RelayDescriptor? {
|
||||
let url = baseURL.appendingPathComponent(".well-known/flamenet/relay")
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 10
|
||||
|
||||
@@ -26,7 +26,7 @@ enum SignupProofOfWork {
|
||||
// MARK: - Asking
|
||||
|
||||
/// Ask what work this server wants, if any.
|
||||
static func fetch(from restURL: URL, session: URLSession = .shared) async -> Challenge? {
|
||||
static func fetch(from restURL: URL, session: URLSession = BridgedSession.preSignIn.session) async -> Challenge? {
|
||||
var request = URLRequest(
|
||||
url: restURL.appendingPathComponent("flamenet/v1/messenger/challenge"))
|
||||
request.httpMethod = "GET"
|
||||
@@ -86,7 +86,7 @@ enum SignupProofOfWork {
|
||||
// MARK: - Both at once
|
||||
|
||||
/// Fetch and solve, for the caller that just wants fields to send.
|
||||
static func satisfy(for restURL: URL, session: URLSession = .shared) async -> Solution? {
|
||||
static func satisfy(for restURL: URL, session: URLSession = BridgedSession.preSignIn.session) async -> Solution? {
|
||||
guard let issued = await fetch(from: restURL, session: session) else { return nil }
|
||||
guard issued.bits > 0 else { return nil }
|
||||
// Off the cooperative pool's main thread and off the main actor: this
|
||||
|
||||
@@ -121,7 +121,11 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// not just where the button is drawn, a deep link or a retry path must
|
||||
// hit the same wall the UI does.
|
||||
if ParentalControls.callsDisabled {
|
||||
state = .ended("Calls are turned off on this device")
|
||||
state = .ended(String(localized: "Calls are turned off on this device"))
|
||||
return
|
||||
}
|
||||
if let refusal = Self.bridgeRefusal() {
|
||||
state = .ended(refusal)
|
||||
return
|
||||
}
|
||||
wasConnected = false
|
||||
@@ -182,9 +186,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// Name the trust gate when it is the trust gate.
|
||||
if case E2EError.identityChanged = error {
|
||||
identityReviewPeer = buddyID
|
||||
finish(reason: "Their security code changed", notifyPeer: false)
|
||||
finish(reason: String(localized: "Their security code changed"), notifyPeer: false)
|
||||
} else {
|
||||
finish(reason: "Couldn't start the call", notifyPeer: false)
|
||||
finish(reason: String(localized: "Couldn't start the call"), notifyPeer: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +247,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
case .offer:
|
||||
// Supervision: a device with calls off does not ring, the caller is
|
||||
// answered busy, same as any other unavailable state.
|
||||
if ParentalControls.callsDisabled {
|
||||
if ParentalControls.callsDisabled || Self.bridgeRefusal() != nil {
|
||||
ignored("calls disabled by supervision", signal.callID)
|
||||
noteEnded(signal.callID)
|
||||
Task {
|
||||
@@ -336,7 +340,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
clearDeclineWanted()
|
||||
clearAnswerWanted()
|
||||
tellOwnDevices("declined-elsewhere")
|
||||
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
|
||||
finish(reason: String(localized: "Declined"), notifyPeer: true, endReason: "declined")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -359,7 +363,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
RTCSessionDescription(type: .answer, sdp: sdp))
|
||||
// The delegate flips to .active when ICE actually connects.
|
||||
} catch {
|
||||
finish(reason: "Couldn't connect", notifyPeer: true)
|
||||
finish(reason: String(localized: "Couldn't connect"), notifyPeer: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,14 +438,14 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
let label: String
|
||||
switch signal.reason {
|
||||
case "declined": label = "Declined"
|
||||
case "busy": label = "Busy"
|
||||
case "timeout": label = "No answer"
|
||||
case "declined": label = String(localized: "Declined")
|
||||
case "busy": label = String(localized: "Busy")
|
||||
case "timeout": label = String(localized: "No answer")
|
||||
// They cannot answer until they have looked at the security code
|
||||
// that changed. Saying so is the difference between somebody
|
||||
// re-dialling for ten minutes and somebody sending a message.
|
||||
case "keys": label = "They need to check your security code first"
|
||||
default: label = "Call ended"
|
||||
case "keys": label = String(localized: "They need to check your security code first")
|
||||
default: label = String(localized: "Call ended")
|
||||
}
|
||||
finish(reason: label, notifyPeer: false)
|
||||
}
|
||||
@@ -515,7 +519,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// The other device still has the call, so the peer is told
|
||||
// nothing at all: an end here would hang up a call that is
|
||||
// still running somewhere else.
|
||||
finish(reason: "Couldn't take the call", notifyPeer: false)
|
||||
finish(reason: String(localized: "Couldn't take the call"), notifyPeer: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -554,7 +558,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
"renegotiation failed: %{public}@",
|
||||
log: OSLog(subsystem: "io.flamenet.messenger", category: "calls"),
|
||||
type: .error, String(describing: error))
|
||||
finish(reason: "Call ended", notifyPeer: true)
|
||||
finish(reason: String(localized: "Call ended"), notifyPeer: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,7 +594,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
return ignored("a handoff taken for another call", signal.callID)
|
||||
}
|
||||
clearHandoffOffer()
|
||||
finish(reason: "Moved to another device", notifyPeer: false)
|
||||
finish(reason: String(localized: "Moved to another device"), notifyPeer: false)
|
||||
return
|
||||
}
|
||||
// An offer that is gone before anybody took it.
|
||||
@@ -606,11 +610,11 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
if signal.reason == "declined-elsewhere" {
|
||||
finish(
|
||||
reason: "Declined on another device", notifyPeer: false,
|
||||
reason: String(localized: "Declined on another device"), notifyPeer: false,
|
||||
endReason: "declined-elsewhere")
|
||||
} else {
|
||||
finish(
|
||||
reason: "Answered on another device", notifyPeer: false,
|
||||
reason: String(localized: "Answered on another device"), notifyPeer: false,
|
||||
endReason: "answered-elsewhere")
|
||||
}
|
||||
}
|
||||
@@ -697,6 +701,10 @@ final class CallManager: NSObject, ObservableObject {
|
||||
guard case .ringing = state, let pending = pendingOffer,
|
||||
let sdp = pending.signal.sdp
|
||||
else { return }
|
||||
if let refusal = Self.bridgeRefusal() {
|
||||
state = .ended(refusal)
|
||||
return
|
||||
}
|
||||
// First, and synchronously: the other devices are ringing NOW, and
|
||||
// every moment between picking up here and their going quiet is a
|
||||
// moment somebody is listening to a phone that has already been
|
||||
@@ -754,10 +762,10 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// which is a thing they already know; all this says is
|
||||
// that this end has not accepted them yet.
|
||||
finish(
|
||||
reason: "Their security code changed",
|
||||
reason: String(localized: "Their security code changed"),
|
||||
notifyPeer: true, endReason: "keys")
|
||||
} else {
|
||||
finish(reason: "Couldn't connect", notifyPeer: true)
|
||||
finish(reason: String(localized: "Couldn't connect"), notifyPeer: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -804,11 +812,11 @@ final class CallManager: NSObject, ObservableObject {
|
||||
func declineIncomingCall() {
|
||||
guard case .ringing = state else { return }
|
||||
tellOwnDevices("declined-elsewhere")
|
||||
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
|
||||
finish(reason: String(localized: "Declined"), notifyPeer: true, endReason: "declined")
|
||||
}
|
||||
|
||||
func hangUp() {
|
||||
finish(reason: "Call ended", notifyPeer: true)
|
||||
finish(reason: String(localized: "Call ended"), notifyPeer: true)
|
||||
}
|
||||
|
||||
/// End the call because the system asked, from the lock screen or from
|
||||
@@ -839,9 +847,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
case .ringing:
|
||||
tellOwnDevices("declined-elsewhere")
|
||||
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
|
||||
finish(reason: String(localized: "Declined"), notifyPeer: true, endReason: "declined")
|
||||
case .dialing, .connecting, .active:
|
||||
finish(reason: "Call ended", notifyPeer: true)
|
||||
finish(reason: String(localized: "Call ended"), notifyPeer: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,7 +899,15 @@ final class CallManager: NSObject, ObservableObject {
|
||||
|
||||
// MARK: - Peer connection plumbing
|
||||
|
||||
/// Why a call cannot connect now, or nil. A call's media goes to the
|
||||
/// other person or a TURN server, never through a bridge (§24.6).
|
||||
nonisolated static func bridgeRefusal() -> String? {
|
||||
AroundTheBridge.active ? AroundTheBridge.callsOff : nil
|
||||
}
|
||||
|
||||
private func makePeerConnection(video: Bool = false) async throws -> RTCPeerConnection {
|
||||
// The one place every call path builds its connection.
|
||||
if let refusal = Self.bridgeRefusal() { throw AroundTheBridge.Refused(message: refusal) }
|
||||
let config = RTCConfiguration()
|
||||
config.sdpSemantics = .unifiedPlan
|
||||
// Non-trickle (§6.8): gather once, send the complete SDP.
|
||||
@@ -1059,7 +1075,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
try? await Task.sleep(nanoseconds: UInt64(45 * 1_000_000_000))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if case .dialing = self.state {
|
||||
self.finish(reason: "No answer", notifyPeer: true, endReason: "timeout")
|
||||
self.finish(reason: String(localized: "No answer"), notifyPeer: true, endReason: "timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1087,7 +1103,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// call was answered on another one, since nothing tells it, and
|
||||
// a timeout end sent from here reached the caller while it was
|
||||
// mid-call with that other device and hung the call up.
|
||||
self.finish(reason: "Missed call", notifyPeer: false)
|
||||
self.finish(reason: String(localized: "Missed call"), notifyPeer: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1121,7 +1137,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// `notifyPeer` stays true: this end believes the call is over,
|
||||
// and if the far end is still on it, it should hear so. A
|
||||
// duplicate end costs nothing, a missing one costs the call.
|
||||
self.finish(reason: "Call ended", notifyPeer: true)
|
||||
self.finish(reason: String(localized: "Call ended"), notifyPeer: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1137,7 +1153,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
try? await Task.sleep(nanoseconds: UInt64(30 * 1_000_000_000))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if case .connecting = self.state {
|
||||
self.finish(reason: "Couldn't connect", notifyPeer: true, endReason: "timeout")
|
||||
self.finish(reason: String(localized: "Couldn't connect"), notifyPeer: true, endReason: "timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,7 +1276,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
func teardown() {
|
||||
if case .idle = state {
|
||||
} else {
|
||||
finish(reason: "Call ended", notifyPeer: true)
|
||||
finish(reason: String(localized: "Call ended"), notifyPeer: true)
|
||||
}
|
||||
inbox.onCallSignal = nil
|
||||
state = .idle
|
||||
@@ -1341,7 +1357,7 @@ extension CallManager: RTCPeerConnectionDelegate {
|
||||
}
|
||||
case .failed, .closed:
|
||||
self.cancelMediaGone()
|
||||
self.finish(reason: "Call ended", notifyPeer: true)
|
||||
self.finish(reason: String(localized: "Call ended"), notifyPeer: true)
|
||||
case .disconnected:
|
||||
// The far end hanging up looks exactly like this, and so does a
|
||||
// lift doorway. WebRTC calls `.disconnected` transient and only
|
||||
|
||||
@@ -294,7 +294,7 @@ final class VoIPCallReceiver: NSObject {
|
||||
let spare = UUID()
|
||||
let spent = CXCallUpdate()
|
||||
spent.hasVideo = false
|
||||
spent.localizedCallerName = "Flamenet call"
|
||||
spent.localizedCallerName = String(localized: "Flamenet call")
|
||||
spent.remoteHandle = CXHandle(type: .generic, value: "Flamenet")
|
||||
provider.reportNewIncomingCall(with: spare, update: spent) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
@@ -309,7 +309,7 @@ final class VoIPCallReceiver: NSObject {
|
||||
currentCallID = id
|
||||
let update = CXCallUpdate()
|
||||
update.hasVideo = false
|
||||
update.localizedCallerName = "Flamenet call"
|
||||
update.localizedCallerName = String(localized: "Flamenet call")
|
||||
update.remoteHandle = CXHandle(type: .generic, value: "Flamenet")
|
||||
provider.reportNewIncomingCall(with: id, update: update) { [weak self] error in
|
||||
if let error {
|
||||
@@ -339,16 +339,16 @@ final class VoIPCallReceiver: NSObject {
|
||||
switch CXErrorCodeIncomingCallError.Code(rawValue: code) {
|
||||
case .filteredByDoNotDisturb:
|
||||
return
|
||||
"A Focus mode silenced an incoming call. The call can still be answered from inside the app."
|
||||
String(localized: "A Focus mode silenced an incoming call. The call can still be answered from inside the app.")
|
||||
case .filteredByBlockList:
|
||||
return "An incoming call was blocked by this phone's block list."
|
||||
return String(localized: "An incoming call was blocked by this phone's block list.")
|
||||
case .callUUIDAlreadyExists:
|
||||
return
|
||||
"An incoming call could not be shown because a call with the same id was already up."
|
||||
String(localized: "An incoming call could not be shown because a call with the same id was already up.")
|
||||
case .unentitled:
|
||||
return "This build is not entitled to show calls on the lock screen."
|
||||
return String(localized: "This build is not entitled to show calls on the lock screen.")
|
||||
default:
|
||||
return "CallKit refused an incoming call (code \(code))."
|
||||
return String(localized: "CallKit refused an incoming call (code \(String(code))).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ enum BuddyCommand: String, CaseIterable {
|
||||
switch self {
|
||||
case .help, .back, .here, .invisible, .buddies, .star, .archive:
|
||||
return nil
|
||||
case .me: return "does something"
|
||||
case .away, .status: return "message"
|
||||
case .add, .remove, .block, .unblock, .whois: return "username"
|
||||
case .me: return String(localized: "does something")
|
||||
case .away, .status: return String(localized: "message", comment: "Noun: the status message a command takes")
|
||||
case .add, .remove, .block, .unblock, .whois: return String(localized: "username")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,20 +65,20 @@ enum BuddyCommand: String, CaseIterable {
|
||||
/// Only these search as you type.
|
||||
var placeholder: String {
|
||||
switch self {
|
||||
case .help: return "list the commands"
|
||||
case .me: return "an action, in the third person"
|
||||
case .away: return "mark yourself away"
|
||||
case .back, .here: return "come back online"
|
||||
case .invisible: return "go invisible"
|
||||
case .status: return "set or clear your status message"
|
||||
case .add: return "add to your buddy list"
|
||||
case .remove: return "take off your buddy list"
|
||||
case .block: return "block (defaults to this conversation)"
|
||||
case .unblock: return "unblock"
|
||||
case .whois: return "who somebody is"
|
||||
case .buddies: return "how many buddies you have"
|
||||
case .star: return "star this conversation"
|
||||
case .archive: return "archive this conversation"
|
||||
case .help: return String(localized: "list the commands")
|
||||
case .me: return String(localized: "an action, in the third person")
|
||||
case .away: return String(localized: "mark yourself away")
|
||||
case .back, .here: return String(localized: "come back online")
|
||||
case .invisible: return String(localized: "go invisible")
|
||||
case .status: return String(localized: "set or clear your status message")
|
||||
case .add: return String(localized: "add to your buddy list")
|
||||
case .remove: return String(localized: "take off your buddy list")
|
||||
case .block: return String(localized: "block (defaults to this conversation)")
|
||||
case .unblock: return String(localized: "unblock")
|
||||
case .whois: return String(localized: "who somebody is")
|
||||
case .buddies: return String(localized: "how many buddies you have")
|
||||
case .star: return String(localized: "star this conversation")
|
||||
case .archive: return String(localized: "archive this conversation")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ final class AppDiagnostics: NSObject, MXMetricManagerSubscriber {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .crash: return "Crash"
|
||||
case .hang: return "Frozen screen"
|
||||
case .diskWrite: return "Heavy disk use"
|
||||
case .cpuException: return "Heavy CPU use"
|
||||
case .launch: return "Slow launch"
|
||||
case .crash: return String(localized: "Crash")
|
||||
case .hang: return String(localized: "Frozen screen")
|
||||
case .diskWrite: return String(localized: "Heavy disk use")
|
||||
case .cpuException: return String(localized: "Heavy CPU use")
|
||||
case .launch: return String(localized: "Slow launch")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,20 +111,20 @@ final class AppDiagnostics: NSObject, MXMetricManagerSubscriber {
|
||||
found.append(
|
||||
report(
|
||||
kind: .hang, began: began, ended: ended, from: hang,
|
||||
summary: "The screen was unresponsive for "
|
||||
+ Self.seconds(hang.hangDuration)))
|
||||
summary: String(
|
||||
localized: "The screen was unresponsive for \(Self.seconds(hang.hangDuration))")))
|
||||
}
|
||||
for cpu in payload.cpuExceptionDiagnostics ?? [] {
|
||||
found.append(
|
||||
report(
|
||||
kind: .cpuException, began: began, ended: ended, from: cpu,
|
||||
summary: "The app used enough processor time for iOS to complain"))
|
||||
summary: String(localized: "The app used enough processor time for iOS to complain")))
|
||||
}
|
||||
for disk in payload.diskWriteExceptionDiagnostics ?? [] {
|
||||
found.append(
|
||||
report(
|
||||
kind: .diskWrite, began: began, ended: ended, from: disk,
|
||||
summary: "The app wrote enough to disk for iOS to complain"))
|
||||
summary: String(localized: "The app wrote enough to disk for iOS to complain")))
|
||||
}
|
||||
}
|
||||
guard !found.isEmpty else { return }
|
||||
@@ -153,14 +153,14 @@ final class AppDiagnostics: NSObject, MXMetricManagerSubscriber {
|
||||
return reason
|
||||
}
|
||||
if let signal = crash.signal {
|
||||
return "The app was stopped by the system (signal \(signal))"
|
||||
return String(localized: "The app was stopped by the system (signal \(String(signal.intValue)))")
|
||||
}
|
||||
return "The app stopped unexpectedly"
|
||||
return String(localized: "The app stopped unexpectedly")
|
||||
}
|
||||
|
||||
private static func seconds(_ measurement: Measurement<UnitDuration>) -> String {
|
||||
let s = measurement.converted(to: .seconds).value
|
||||
return String(format: "%.1f seconds", s)
|
||||
return String(localized: "\(String(format: "%.1f", s)) seconds")
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Section 8.13, stage D2: reads about OTHER people that name nobody.
|
||||
///
|
||||
/// Every read here is a POST with its target in the body, never in the URL,
|
||||
/// so the target is in no proxy's access log; and it goes out on its own
|
||||
/// HTTP client: no `Authorization` header, no cookies, no cache, its own
|
||||
/// connection pool, a generic User-Agent. A connection that carried an
|
||||
/// authenticated request never carries one of these.
|
||||
///
|
||||
/// What the relay still sees, and the app says so where it matters: the IP
|
||||
/// address, which is usually the one this device polls from seconds apart.
|
||||
/// What changes is what is stored.
|
||||
final class AnonymousRelayClient {
|
||||
|
||||
private let base: URL
|
||||
/// Follows the bridge setting (`BridgedSession`).
|
||||
private let sessions: BridgedSession
|
||||
var session: URLSession { sessions.session }
|
||||
|
||||
/// - Parameter base: the relay's `…/flamenet/v1` root.
|
||||
init(base: URL, protocolClasses: [AnyClass]? = nil) {
|
||||
self.base = base
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.httpCookieStorage = nil
|
||||
cfg.httpShouldSetCookies = false
|
||||
cfg.httpCookieAcceptPolicy = .never
|
||||
cfg.urlCache = nil
|
||||
cfg.urlCredentialStorage = nil
|
||||
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
cfg.timeoutIntervalForRequest = 25
|
||||
cfg.timeoutIntervalForResource = 120
|
||||
cfg.httpAdditionalHeaders = ["User-Agent": "FlameNet"]
|
||||
if let protocolClasses { cfg.protocolClasses = protocolClasses }
|
||||
self.sessions = BridgedSession { cfg.copy() as! URLSessionConfiguration }
|
||||
}
|
||||
|
||||
/// The most tokens one presence request may carry (decision DD3).
|
||||
static let presenceBatch = 8
|
||||
|
||||
/// Presence for up to eight tokens, keyed by token.
|
||||
func presence(tokens: [String]) async throws -> [String: String] {
|
||||
precondition(tokens.count <= Self.presenceBatch)
|
||||
struct Row: Decodable {
|
||||
let token: String
|
||||
let presence: String
|
||||
}
|
||||
struct Answer: Decodable { let presence: [Row] }
|
||||
let answer: Answer = try await post("/e2e/anon/presence", ["tokens": tokens])
|
||||
return Dictionary(
|
||||
answer.presence.map { ($0.token, $0.presence) }, uniquingKeysWith: { a, _ in a })
|
||||
}
|
||||
|
||||
/// Identity keys for an account, no times. `cap` is a presence token the
|
||||
/// account issued this one, which the relay meters by instead of the IP.
|
||||
func devices(of userID: Int, cap: String?) async throws -> E2EDevicesResponse {
|
||||
var body: [String: Any] = ["user_id": userID]
|
||||
if let cap { body["cap"] = cap }
|
||||
return try await post("/e2e/anon/devices", body)
|
||||
}
|
||||
|
||||
/// Prekey bundles for an account, popping one prekey per device.
|
||||
func bundles(of userID: Int, cap: String?) async throws -> E2EKeysResponse {
|
||||
var body: [String: Any] = ["user_id": userID]
|
||||
if let cap { body["cap"] = cap }
|
||||
return try await post("/e2e/anon/bundles", body)
|
||||
}
|
||||
|
||||
func introChallenge() async throws -> E2EIntroChallenge {
|
||||
var req = URLRequest(url: base.appendingPathComponent("/e2e/intro/challenge"))
|
||||
req.httpMethod = "GET"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
return try await perform(req)
|
||||
}
|
||||
|
||||
/// An introduction by handle (after the proof of work) or by a reusable
|
||||
/// link (section 14.5). Nil when the relay answers "no such account",
|
||||
/// which is also its answer for an unlisted handle and a revoked link.
|
||||
func intro(handle: String, challenge: String, nonce: String) async throws -> E2EIntroAnswer? {
|
||||
try await intro(["handle": handle, "challenge": challenge, "nonce": nonce])
|
||||
}
|
||||
|
||||
func intro(link: Data) async throws -> E2EIntroAnswer? {
|
||||
try await intro(["link": Base64URL.encode(link)])
|
||||
}
|
||||
|
||||
private func intro(_ body: [String: Any]) async throws -> E2EIntroAnswer? {
|
||||
var req = URLRequest(url: base.appendingPathComponent("/e2e/intro"))
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw MessengerError.transport("Non-HTTP response")
|
||||
}
|
||||
if http.statusCode == 404 { return nil }
|
||||
if http.statusCode == 429 {
|
||||
throw MessengerError.rateLimited(
|
||||
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode),
|
||||
let o = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let uid = JSONValue(any: o["user_id"])?.intValue,
|
||||
let introB64 = o["intro"] as? String, let intro = Data(base64Encoded: introB64),
|
||||
intro.count == 32,
|
||||
let exp = JSONValue(any: o["exp"])?.intValue,
|
||||
let rawDevices = o["devices"]
|
||||
else { throw MessengerError.httpStatus(http.statusCode) }
|
||||
let devices = try JSONDecoder().decode(
|
||||
[E2EKeysResponse.Bundle].self,
|
||||
from: JSONSerialization.data(withJSONObject: rawDevices))
|
||||
return E2EIntroAnswer(
|
||||
userID: Int(uid), fnid: o["fnid"] as? String, intro: intro, exp: Int(exp),
|
||||
handleCert: (o["handle_cert"] as? [String: Any]).flatMap(HandleCertificate.init(json:)),
|
||||
devices: devices)
|
||||
}
|
||||
|
||||
private func post<T: Decodable>(_ path: String, _ body: [String: Any]) async throws -> T {
|
||||
var req = URLRequest(url: base.appendingPathComponent(path))
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
return try await perform(req)
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ req: URLRequest) async throws -> T {
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw MessengerError.transport("Non-HTTP response")
|
||||
}
|
||||
if http.statusCode == 429 {
|
||||
throw MessengerError.rateLimited(
|
||||
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw MessengerError.httpStatus(http.statusCode)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw MessengerError.decoding(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,22 @@ public enum DeviceClock {
|
||||
public static func warning(now: Date = Date()) -> String? {
|
||||
guard let skew = usableSkew(now: now), abs(skew) > suspectThreshold else { return nil }
|
||||
let minutes = Int((abs(skew) / 60).rounded())
|
||||
let direction = skew > 0 ? "behind" : "ahead"
|
||||
return "This phone's clock is about \(minutes) minutes \(direction). "
|
||||
+ "Stories and disappearing messages may vanish early or linger, "
|
||||
+ "because both are timed. Setting the date and time automatically "
|
||||
+ "in Settings fixes it."
|
||||
if skew > 0 {
|
||||
return String(
|
||||
localized: """
|
||||
This phone's clock is about \(minutes) minutes behind. \
|
||||
Stories and disappearing messages may vanish early or linger, \
|
||||
because both are timed. Setting the date and time automatically \
|
||||
in Settings fixes it.
|
||||
""")
|
||||
}
|
||||
return String(
|
||||
localized: """
|
||||
This phone's clock is about \(minutes) minutes ahead. \
|
||||
Stories and disappearing messages may vanish early or linger, \
|
||||
because both are timed. Setting the date and time automatically \
|
||||
in Settings fixes it.
|
||||
""")
|
||||
}
|
||||
|
||||
/// The time to judge an expiry against.
|
||||
|
||||
@@ -42,9 +42,11 @@ struct DeviceWipe: Codable, Equatable {
|
||||
}
|
||||
|
||||
/// What a client may honestly say about it.
|
||||
static let honestDescription =
|
||||
"The instruction is stored for that device and it erases what it holds the "
|
||||
+ "next time it connects. If it never connects, switched off, or offline, "
|
||||
+ "nothing happens to it. This stops things getting worse; it is not proof "
|
||||
+ "that the messages are gone."
|
||||
static let honestDescription = String(
|
||||
localized: """
|
||||
The instruction is stored for that device and it erases what it holds the \
|
||||
next time it connects. If it never connects, switched off, or offline, \
|
||||
nothing happens to it. This stops things getting worse; it is not proof \
|
||||
that the messages are gone.
|
||||
""")
|
||||
}
|
||||
|
||||
@@ -330,35 +330,40 @@ enum E2EError: LocalizedError {
|
||||
/// A short, contentless reason for the diagnostics reading and the bubble.
|
||||
var shortReason: String {
|
||||
switch self {
|
||||
case .identityChanged: return "their security keys changed"
|
||||
case .protocolDowngrade: return "the contact's app answered with weaker encryption"
|
||||
case .peerLacksPQ: return "the contact's app cannot do the encryption you require"
|
||||
case .sealingRequired: return "this could not be sent sealed, and you require sealing"
|
||||
case .prekeyMissing: return "the contact has no key available to receive it"
|
||||
case .noSession: return "no secure session with the contact yet"
|
||||
case .ratchetState: return "the secure session needs repair"
|
||||
case .crypto: return "encryption failed"
|
||||
case .identityChanged: return String(localized: "their security keys changed")
|
||||
case .protocolDowngrade: return String(localized: "the contact's app answered with weaker encryption")
|
||||
case .peerLacksPQ: return String(localized: "the contact's app cannot do the encryption you require")
|
||||
case .sealingRequired: return String(localized: "this could not be sent sealed, and you require sealing")
|
||||
case .prekeyMissing: return String(localized: "the contact has no key available to receive it")
|
||||
case .noSession: return String(localized: "no secure session with the contact yet")
|
||||
case .ratchetState: return String(localized: "the secure session needs repair")
|
||||
case .crypto: return String(localized: "encryption failed")
|
||||
}
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .ratchetState(let m): return "Ratchet state: \(m)"
|
||||
case .crypto(let m): return "Crypto: \(m)"
|
||||
case .prekeyMissing: return "No prekey available for the recipient device."
|
||||
case .noSession: return "No E2E session established yet."
|
||||
case .ratchetState(let m): return String(localized: "Ratchet state: \(m)")
|
||||
case .crypto(let m): return String(localized: "Crypto: \(m)")
|
||||
case .prekeyMissing: return String(localized: "No prekey available for the recipient device.")
|
||||
case .noSession: return String(localized: "No E2E session established yet.")
|
||||
case .identityChanged:
|
||||
return
|
||||
"This contact's security keys changed. Verify their safety number before sending."
|
||||
return String(
|
||||
localized:
|
||||
"This contact's security keys changed. Verify their safety number before sending.")
|
||||
case .protocolDowngrade:
|
||||
return
|
||||
"This contact's device stopped offering post-quantum encryption. This can mean the connection is being tampered with."
|
||||
return String(
|
||||
localized:
|
||||
"This contact's device stopped offering post-quantum encryption. This can mean the connection is being tampered with.")
|
||||
case .peerLacksPQ:
|
||||
return "This contact's device does not support post-quantum encryption."
|
||||
return String(localized: "This contact's device does not support post-quantum encryption.")
|
||||
case .sealingRequired:
|
||||
return "Not sent. This message could not be sealed, so the server would "
|
||||
+ "have seen that you sent it, and you asked Flamenet to refuse "
|
||||
+ "rather than send anyway. It will seal again once they message you."
|
||||
return String(
|
||||
localized: """
|
||||
Not sent. This message could not be sealed, so the server would \
|
||||
have seen that you sent it, and you asked Flamenet to refuse \
|
||||
rather than send anyway. It will seal again once they message you.
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+160
-26
@@ -35,6 +35,13 @@ struct E2EAttachmentRef: Hashable, Codable {
|
||||
/// File only: the label it was filed under, travelling with it.
|
||||
var classification: Int16 = 0
|
||||
var compartments: [Int] = []
|
||||
|
||||
/// Section 23.9, how many bytes of the blob to open; the rest is a random
|
||||
/// tail that pads the blob to a ladder step. Nil for a blob with no tail,
|
||||
/// which is every blob sent before section 23. A `clen` that arrived as
|
||||
/// something other than an integer is held as -1, so it fails the range
|
||||
/// check in `open` and the attachment is refused before decrypting.
|
||||
var clen: Int? = nil
|
||||
}
|
||||
|
||||
/// One envelope belonging to a poll (§6.29): the question, one voter's share,
|
||||
@@ -94,7 +101,7 @@ enum E2EAttachment {
|
||||
/// Set iff this envelope belongs to a poll (§6.29).
|
||||
var poll: PollPart? = nil
|
||||
|
||||
/// Set iff a contact offered a bridge line (§6.19).
|
||||
/// Set iff a contact offered a bridge line that §24.5 keeps.
|
||||
var bridgeLine: String? = nil
|
||||
/// Set iff this is the sender's profile picture.
|
||||
var isAvatar: Bool = false
|
||||
@@ -252,7 +259,7 @@ enum E2EAttachment {
|
||||
let cls: Int16?
|
||||
let cmp: [Int]?
|
||||
|
||||
/// §6.19, a bridge line a contact is offering.
|
||||
/// §24.3, a bridge line a contact is offering.
|
||||
let line: String?
|
||||
|
||||
/// Mentioned user ids.
|
||||
@@ -412,6 +419,39 @@ enum E2EAttachment {
|
||||
let w: Int
|
||||
let h: Int
|
||||
let dur: Double? // audio only
|
||||
/// Section 23.9, present only on a padded blob.
|
||||
var clen: Length? = nil
|
||||
}
|
||||
|
||||
/// `clen` as it arrived. Anything but an integer decodes as `.invalid`
|
||||
/// rather than failing the whole envelope: the message still shows,
|
||||
/// and only the attachment is refused.
|
||||
enum Length: Codable, Equatable {
|
||||
case bytes(Int)
|
||||
case invalid
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let n = try? c.decode(Int.self) {
|
||||
self = .bytes(n)
|
||||
} else {
|
||||
self = .invalid
|
||||
}
|
||||
}
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .bytes(let n): try c.encode(n)
|
||||
case .invalid: try c.encodeNil()
|
||||
}
|
||||
}
|
||||
/// What `E2EAttachmentRef.clen` holds.
|
||||
var value: Int {
|
||||
switch self {
|
||||
case .bytes(let n): return n
|
||||
case .invalid: return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,22 +459,68 @@ enum E2EAttachment {
|
||||
|
||||
/// Seal media bytes for upload: fresh random key per attachment,
|
||||
/// AES-256-GCM, blob = `nonce || ct || tag` (CryptoKit's `combined`).
|
||||
static func seal(_ data: Data) throws -> (blob: Data, key: Data, digest: Data) {
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
guard let blob = try AES.GCM.seal(data, using: key).combined else {
|
||||
///
|
||||
/// With `pad`, a random tail takes the blob to the next step of the
|
||||
/// attachment ladder, `clen` says where the sealed bytes end, and the
|
||||
/// digest covers the whole blob, tail included (section 23.9). `pad` is the
|
||||
/// relay's `features.padAttachments` and nothing else: a receiver that
|
||||
/// predates `clen` opens the whole blob and fails. Over 10 MiB is refused
|
||||
/// either way, as the relay would. `key`, `nonce` and `tailSource` exist
|
||||
/// for the known-answer vector only.
|
||||
static func seal(
|
||||
_ data: Data, pad: Bool = false,
|
||||
key: SymmetricKey? = nil, nonce: AES.GCM.Nonce? = nil,
|
||||
tailSource: (Int) -> Data = Padding.randomTail
|
||||
) throws -> (blob: Data, key: Data, digest: Data, clen: Int?) {
|
||||
let key = key ?? SymmetricKey(size: .bits256)
|
||||
guard let sealed = try AES.GCM.seal(data, using: key, nonce: nonce ?? AES.GCM.Nonce()).combined
|
||||
else {
|
||||
throw E2EError.crypto("attachment seal produced no combined box")
|
||||
}
|
||||
let clen = sealed.count
|
||||
var blob = sealed
|
||||
if pad {
|
||||
let step = try Padding.attachmentStep(clen: clen)
|
||||
let tail = tailSource(step - clen)
|
||||
guard tail.count == step - clen else {
|
||||
throw Padding.Failure.wrongLength(want: step - clen, got: tail.count)
|
||||
}
|
||||
blob.append(tail)
|
||||
guard blob.count == step else {
|
||||
throw Padding.Failure.wrongLength(want: step, got: blob.count)
|
||||
}
|
||||
} else if clen > Padding.attachmentLadder[Padding.attachmentLadder.count - 1] {
|
||||
throw Padding.Failure.attachmentTooLarge(clen)
|
||||
}
|
||||
let digest = Data(SHA256.hash(data: blob))
|
||||
return (blob, key.withUnsafeBytes { Data($0) }, digest)
|
||||
return (blob, key.withUnsafeBytes { Data($0) }, digest, pad ? clen : nil)
|
||||
}
|
||||
|
||||
/// Open a downloaded blob.
|
||||
static func open(blob: Data, ref: E2EAttachmentRef) throws -> Data {
|
||||
guard Data(SHA256.hash(data: blob)) == ref.digest else {
|
||||
throw E2EError.crypto("attachment digest mismatch")
|
||||
try open(blob: blob, key: ref.key, digest: ref.digest, clen: ref.clen)
|
||||
}
|
||||
|
||||
/// Section 23.9: the digest over the whole blob first, then only the first
|
||||
/// `clen` bytes opened. No `clen` opens the whole blob, the format before
|
||||
/// section 23. A `clen` under the AES-GCM overhead or past the end of the
|
||||
/// blob is refused before anything is decrypted, never clamped.
|
||||
static func open(blob: Data, key: Data, digest: Data, clen: Int?) throws -> Data {
|
||||
guard Data(SHA256.hash(data: blob)) == digest else {
|
||||
throw E2EError.crypto(String(localized: "attachment digest mismatch"))
|
||||
}
|
||||
let box = try AES.GCM.SealedBox(combined: blob)
|
||||
return try AES.GCM.open(box, using: SymmetricKey(data: ref.key))
|
||||
var end = blob.count
|
||||
if let clen {
|
||||
guard clen >= Padding.attachmentOverhead, clen <= blob.count else {
|
||||
throw E2EError.crypto("attachment length is not valid")
|
||||
}
|
||||
end = clen
|
||||
}
|
||||
guard end >= Padding.attachmentOverhead else {
|
||||
throw E2EError.crypto("attachment length is not valid")
|
||||
}
|
||||
let box = try AES.GCM.SealedBox(combined: blob.prefix(end))
|
||||
return try AES.GCM.open(box, using: SymmetricKey(data: key))
|
||||
}
|
||||
|
||||
// MARK: - Content envelope codec (plaintext layer)
|
||||
@@ -460,7 +546,8 @@ enum E2EAttachment {
|
||||
id: ref.id, key: ref.key.e2eBase64, digest: ref.digest.e2eBase64,
|
||||
bytes: ref.bytes, mime: ref.mime, w: ref.width, h: ref.height,
|
||||
// Audio always; a story carries one when it is a video.
|
||||
dur: ref.duration > 0 ? ref.duration : nil)
|
||||
dur: ref.duration > 0 ? ref.duration : nil,
|
||||
clen: ref.clen.map { .bytes($0) })
|
||||
// A file envelope ALWAYS carries a caption, even when the sender wrote
|
||||
// none.
|
||||
let effectiveCaption: String? = {
|
||||
@@ -489,7 +576,7 @@ enum E2EAttachment {
|
||||
let att = Envelope.Att(
|
||||
id: ref.id, key: ref.key.e2eBase64, digest: ref.digest.e2eBase64,
|
||||
bytes: ref.bytes, mime: ref.mime, w: ref.width, h: ref.height,
|
||||
dur: nil)
|
||||
dur: nil, clen: ref.clen.map { .bytes($0) })
|
||||
return try assemble(
|
||||
kind: "avatar", caption: nil, exp: nil, gid: nil,
|
||||
att: att, mid: nil, re: nil)
|
||||
@@ -793,13 +880,56 @@ enum E2EAttachment {
|
||||
return men.filter { seen.insert($0).inserted }.prefix(maximumMentions).map { $0 }
|
||||
}
|
||||
|
||||
/// §24.4: the longest line an offer may carry, in UTF-8 bytes. Here and
|
||||
/// not in `Obfs4Settings` because this file is also compiled, alone, into
|
||||
/// flamenet-e2e's interop driver, which has no networking code.
|
||||
static let maximumBridgeLineBytes = 1024
|
||||
|
||||
/// §24.4 and §24.5: a line worth keeping as an offer.
|
||||
static func isOfferable(_ line: String) -> Bool {
|
||||
!line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& line.utf8.count <= maximumBridgeLineBytes
|
||||
}
|
||||
|
||||
/// Kinds that never become a bubble, whatever else an envelope carries.
|
||||
private static let controlKinds: Set<String> = [
|
||||
"react", "edit", "pin", "shot", "ttl", "otr", "bridge", "avatar", "delivery_key",
|
||||
"kt_head", "poll", "pshare", "psum",
|
||||
]
|
||||
|
||||
/// §6.6: a plaintext that starts with the marker and parses as JSON with an
|
||||
/// integer `fnc` is an envelope, even when a key this build knows has the
|
||||
/// wrong type and the full decode fails. It used to fall back to text,
|
||||
/// which put the raw JSON in a bubble. Control traffic is consumed
|
||||
/// silently; anything else shows its caption if it has one, or the
|
||||
/// placeholder an unknown kind gets.
|
||||
private static func undecodable(_ plaintext: String, data: Data) -> Content {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let fnc = object["fnc"] as? NSNumber,
|
||||
CFGetTypeID(fnc) != CFBooleanGetTypeID(),
|
||||
fnc.doubleValue == 1
|
||||
else { return Content(text: plaintext, attachment: nil, gid: nil) }
|
||||
let kind = object["kind"] as? String ?? ""
|
||||
let ctl = object["ctl"].map { ($0 as? NSNumber)?.boolValue ?? false } ?? false
|
||||
if ctl || controlKinds.contains(kind) || CallSignal.Action(rawValue: kind) != nil
|
||||
|| MultiDeviceEnvelope.kinds.contains(kind)
|
||||
{
|
||||
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
|
||||
}
|
||||
return Content(
|
||||
text: object["caption"] as? String ?? "[unsupported attachment]",
|
||||
attachment: nil, gid: object["gid"] as? String)
|
||||
}
|
||||
|
||||
/// Interpret ratchet plaintext.
|
||||
static func parse(plaintext: String) -> Content {
|
||||
guard plaintext.hasPrefix(marker),
|
||||
let data = plaintext.data(using: .utf8),
|
||||
let env = try? JSONDecoder().decode(Envelope.self, from: data),
|
||||
env.fnc == 1
|
||||
else { return Content(text: plaintext, attachment: nil, gid: nil) }
|
||||
guard plaintext.hasPrefix(marker), let data = plaintext.data(using: .utf8) else {
|
||||
return Content(text: plaintext, attachment: nil, gid: nil)
|
||||
}
|
||||
guard let env = try? JSONDecoder().decode(Envelope.self, from: data) else {
|
||||
return undecodable(plaintext, data: data)
|
||||
}
|
||||
guard env.fnc == 1 else { return Content(text: plaintext, attachment: nil, gid: nil) }
|
||||
|
||||
// A reaction is not a message and must never become a bubble.
|
||||
if env.kind == "react", let re = env.re {
|
||||
@@ -842,9 +972,10 @@ enum E2EAttachment {
|
||||
let key = Data(e2eBase64: att.key), key.count == 32,
|
||||
let digest = Data(e2eBase64: att.digest), digest.count == 32
|
||||
else { return Content(text: "", attachment: nil, gid: nil, isAvatar: true) }
|
||||
let ref = E2EAttachmentRef(
|
||||
var ref = E2EAttachmentRef(
|
||||
kind: .image, id: att.id, key: key, digest: digest, bytes: att.bytes,
|
||||
mime: att.mime, width: att.w, height: att.h, duration: 0, expires: 0)
|
||||
ref.clen = att.clen?.value
|
||||
return Content(text: "", attachment: ref, gid: nil, isAvatar: true)
|
||||
}
|
||||
|
||||
@@ -931,12 +1062,14 @@ enum E2EAttachment {
|
||||
offTheRecordIsEcho: env.echo?.on == true)
|
||||
}
|
||||
|
||||
// §6.19, a contact offering a bridge.
|
||||
if env.kind == "bridge", let line = env.line,
|
||||
!line.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
).isEmpty
|
||||
{
|
||||
// §24.5, a contact offering a bridge. The kind alone makes it one, so
|
||||
// an offer §24.5 drops is consumed silently, never shown: no `gid`
|
||||
// (offers go to one person), a line that is not blank, and no more
|
||||
// than 1024 bytes of it.
|
||||
if env.kind == "bridge" {
|
||||
guard env.gid == nil, let line = env.line, isOfferable(line) else {
|
||||
return Content(text: "", attachment: nil, gid: nil, isUnknownControl: true)
|
||||
}
|
||||
return Content(text: "", attachment: nil, gid: nil, bridgeLine: line)
|
||||
}
|
||||
|
||||
@@ -993,7 +1126,7 @@ enum E2EAttachment {
|
||||
let digest = Data(e2eBase64: att.digest), digest.count == 32
|
||||
else {
|
||||
return Content(
|
||||
text: env.caption ?? "[unsupported attachment]",
|
||||
text: env.caption ?? String(localized: "[unsupported attachment]"),
|
||||
attachment: nil, gid: env.gid, mid: env.mid, re: env.re)
|
||||
}
|
||||
|
||||
@@ -1005,7 +1138,8 @@ enum E2EAttachment {
|
||||
// A filename from a PEER.
|
||||
filename: env.fn ?? "",
|
||||
classification: env.cls ?? 0,
|
||||
compartments: env.cmp ?? []
|
||||
compartments: env.cmp ?? [],
|
||||
clen: att.clen?.value
|
||||
)
|
||||
return Content(
|
||||
text: env.caption ?? "", attachment: ref, gid: env.gid,
|
||||
|
||||
@@ -171,6 +171,31 @@ enum E2EBackupPayload {
|
||||
root["polls"] = out
|
||||
}
|
||||
|
||||
// Section 16.2.1's account-state keys (stage 2-B, and section 17's
|
||||
// account delivery key and issued batches). Each written only when
|
||||
// there is something to say.
|
||||
if let acc = snapshot.acc, !acc.isEmpty { root["acc"] = acc.sorted() }
|
||||
if let blocked = snapshot.blocked, !blocked.isEmpty { root["blocked"] = blocked.sorted() }
|
||||
if let flags = snapshot.convFlags {
|
||||
var out: [String: [String: Bool]] = [:]
|
||||
for (key, f) in flags {
|
||||
var one: [String: Bool] = [:]
|
||||
if f.starred { one["starred"] = true }
|
||||
if f.archived { one["archived"] = true }
|
||||
if !one.isEmpty { out[key] = one }
|
||||
}
|
||||
if !out.isEmpty { root["convFlags"] = out }
|
||||
}
|
||||
if let dk = snapshot.dk, Data(base64Encoded: dk)?.count == 32 { root["dk"] = dk }
|
||||
if let issued = snapshot.deliveryTokensIssued {
|
||||
let out = Dictionary(
|
||||
uniqueKeysWithValues: issued.filter { !$0.value.isEmpty }.map { ("\($0.key)", $0.value) })
|
||||
if !out.isEmpty { root["deliveryTokensIssued"] = out }
|
||||
}
|
||||
if let read = snapshot.read, !read.isEmpty {
|
||||
root["read"] = read.mapValues { NSNumber(value: $0) }
|
||||
}
|
||||
|
||||
// Sorted keys so the same history seals to the same bytes twice, which
|
||||
// is what makes a vector pinnable at all.
|
||||
return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])
|
||||
@@ -625,6 +650,46 @@ enum E2EBackupPayload {
|
||||
if !out.isEmpty { snapshot.polls = out }
|
||||
}
|
||||
|
||||
// Section 16.2.1, the account-state keys. An entry that does not parse
|
||||
// is dropped, never half adopted.
|
||||
let ids: (Any?) -> [Int]? = { raw in
|
||||
guard let a = raw as? [Any] else { return nil }
|
||||
let out = a.compactMap { JSONValue(any: $0)?.intValue }.filter { $0 > 0 }.map(Int.init)
|
||||
return out.isEmpty ? nil : out.sorted()
|
||||
}
|
||||
snapshot.acc = ids(root["acc"])
|
||||
snapshot.blocked = ids(root["blocked"])
|
||||
if let flags = root["convFlags"] as? [String: Any] {
|
||||
var out: [String: E2EInbox.ConversationFlags] = [:]
|
||||
for (key, v) in flags {
|
||||
guard let o = v as? [String: Any] else { continue }
|
||||
let f = E2EInbox.ConversationFlags(
|
||||
starred: (o["starred"] as? Bool) == true,
|
||||
archived: (o["archived"] as? Bool) == true)
|
||||
if f.starred || f.archived { out[key] = f }
|
||||
}
|
||||
if !out.isEmpty { snapshot.convFlags = out }
|
||||
}
|
||||
if let dk = root["dk"] as? String, Data(base64Encoded: dk)?.count == 32 {
|
||||
snapshot.dk = dk
|
||||
}
|
||||
if let issued = root["deliveryTokensIssued"] as? [String: Any] {
|
||||
var out: [Int: [String]] = [:]
|
||||
for (k, v) in issued {
|
||||
guard let id = Int(k), id > 0, let tokens = v as? [String] else { continue }
|
||||
let good = tokens.filter { Data(base64Encoded: $0)?.count == 32 }
|
||||
if !good.isEmpty { out[id] = good }
|
||||
}
|
||||
if !out.isEmpty { snapshot.deliveryTokensIssued = out }
|
||||
}
|
||||
if let read = root["read"] as? [String: Any] {
|
||||
var out: [String: Int64] = [:]
|
||||
for (k, v) in read {
|
||||
if let ms = JSONValue(any: v)?.intValue, ms >= 0 { out[k] = ms }
|
||||
}
|
||||
if !out.isEmpty { snapshot.read = out }
|
||||
}
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ final class E2EClient {
|
||||
private(set) var protocolClasses: [AnyClass]?
|
||||
private let restPrefix: String
|
||||
private let auth: (username: String, appPassword: String)
|
||||
private let session: URLSession
|
||||
/// Follows the bridge setting (`BridgedSession`).
|
||||
private let sessions: BridgedSession
|
||||
private var session: URLSession { sessions.session }
|
||||
|
||||
/// - Parameter protocolClasses: a test seam, and nothing else.
|
||||
init(
|
||||
@@ -52,8 +54,8 @@ final class E2EClient {
|
||||
cfg.httpAdditionalHeaders = [
|
||||
"User-Agent": "FlamenetMessenger-E2E/\(Self.appVersion) (iOS)"
|
||||
]
|
||||
Obfs4Proxy.shared.apply(to: cfg)
|
||||
self.session = URLSession(configuration: cfg)
|
||||
// A copy per build: `BridgedSession` sets the proxy on what it is given.
|
||||
self.sessions = BridgedSession { cfg.copy() as! URLSessionConfiguration }
|
||||
}
|
||||
|
||||
/// Adopt token auth for subsequent requests.
|
||||
@@ -162,6 +164,45 @@ final class E2EClient {
|
||||
path: "/e2e/log/consistency")
|
||||
}
|
||||
|
||||
/// Section 8.13: the separate client for reads that name nobody. Its own
|
||||
/// session, so nothing here ever shares a connection with it.
|
||||
private(set) lazy var anonymous = AnonymousRelayClient(
|
||||
base: endpoint(""), protocolClasses: protocolClasses)
|
||||
|
||||
/// Section 8.13: this account's own devices, with their registration and
|
||||
/// last-poll times, from the route that answers about the caller only.
|
||||
func ownDevicesWithTimes() async throws -> E2EDevicesResponse {
|
||||
try await getJSON(endpoint("/e2e/devices/me"), path: "/e2e/devices/me")
|
||||
}
|
||||
|
||||
/// Section 14.5, stage C2: this account's own handle certificate.
|
||||
func handleCertificate() async throws -> HandleCertificate {
|
||||
var req = URLRequest(url: endpoint("/e2e/handle-cert"))
|
||||
req.httpMethod = "GET"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
await authorize(&req, path: "/e2e/handle-cert")
|
||||
let raw: RawJSON = try await performAuthed(req, path: "/e2e/handle-cert")
|
||||
guard let cert = HandleCertificate(json: raw.object) else {
|
||||
throw MessengerError.decoding("handle certificate")
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
/// Section 8.14, stage B2: add and revoke reusable contact links, by their
|
||||
/// SHA-256 only. Answers how many the account now holds.
|
||||
@discardableResult
|
||||
func setContactLinks(add: [String], revoke: [String]) async throws -> Int {
|
||||
struct Body: Encodable {
|
||||
let add: [String]
|
||||
let revoke: [String]
|
||||
}
|
||||
struct Answer: Decodable { let links: Int }
|
||||
let answer: Answer = try await postJSON(
|
||||
endpoint("/e2e/contact-links"), body: Body(add: add, revoke: revoke),
|
||||
path: "/e2e/contact-links")
|
||||
return answer.links
|
||||
}
|
||||
|
||||
func listDevices(of userID: Int) async throws -> E2EDevicesResponse {
|
||||
let url = endpoint("/e2e/devices/\(userID)")
|
||||
return try await getJSON(url, path: "/e2e/devices/\(userID)")
|
||||
@@ -328,7 +369,7 @@ final class E2EClient {
|
||||
DeviceClock.observe(header: http.value(forHTTPHeaderField: "Date"))
|
||||
if let maxBytes, data.count > maxBytes {
|
||||
throw MessengerError.transport(
|
||||
"Attachment is larger than it claimed to be, refusing it.")
|
||||
String(localized: "Attachment is larger than it claimed to be, refusing it."))
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
if http.statusCode == 429 {
|
||||
@@ -468,6 +509,18 @@ final class E2EClient {
|
||||
|
||||
private struct EmptyAck: Decodable {}
|
||||
|
||||
/// Any JSON object, for answers read field by field.
|
||||
private struct RawJSON: Decodable {
|
||||
let object: [String: Any]
|
||||
init(from decoder: Decoder) throws {
|
||||
let value = try decoder.singleValueContainer().decode(JSONValue.self)
|
||||
guard case .object = value, let o = value.any as? [String: Any] else {
|
||||
throw MessengerError.decoding("expected an object")
|
||||
}
|
||||
object = o
|
||||
}
|
||||
}
|
||||
|
||||
/// The relay's error shape.
|
||||
private struct WireErrorBody: Decodable {
|
||||
let code: String?
|
||||
@@ -539,7 +592,13 @@ final class E2EClient {
|
||||
|
||||
// MARK: - §8.9c, the contact list as tokens
|
||||
|
||||
private struct PublishRosterTagsBody: Encodable { let digests: [String] }
|
||||
private struct PublishRosterTagsBody: Encodable {
|
||||
let digests: [String]
|
||||
/// Section 17.6: on a relay that keys roster tags by device, the set
|
||||
/// this device publishes replaces its own and nobody else's. Omitted
|
||||
/// otherwise, which is the account-wide set older relays keep.
|
||||
let device_id: String?
|
||||
}
|
||||
private struct OKBody: Decodable { let ok: Bool? }
|
||||
private struct PresenceBody: Encodable { let tokens: [String] }
|
||||
private struct PresenceAnswer: Decodable {
|
||||
@@ -560,10 +619,10 @@ final class E2EClient {
|
||||
/// refusal is swallowed and presence simply never leaves the roster path.
|
||||
/// That is how this shipped and how it was found: the relay log had the
|
||||
/// refusals, the app had no idea.
|
||||
func publishRosterTags(digests: [String]) async throws {
|
||||
func publishRosterTags(digests: [String], deviceID: String? = nil) async throws {
|
||||
let _: OKBody = try await postJSONBasic(
|
||||
endpoint("messenger/roster/tags"),
|
||||
body: PublishRosterTagsBody(digests: digests))
|
||||
body: PublishRosterTagsBody(digests: digests, device_id: deviceID))
|
||||
}
|
||||
|
||||
/// A POST to the mobile surface, which authenticates with Basic only.
|
||||
|
||||
@@ -22,6 +22,10 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
case deliveryKey
|
||||
/// A contact gossiping their view of the key log (§19.4).
|
||||
case keyLogHead
|
||||
/// Sections 17.2 to 17.6: link offers, sync ops and snapshots, sent
|
||||
/// copies, history, and rekey requests. One branch, one handler, which
|
||||
/// checks who may send each.
|
||||
case multiDevice
|
||||
/// The other side says they screenshotted something timed (§6.27).
|
||||
case screenshotNotice
|
||||
/// Off the record, on or off, or the echo answering it (§6.28).
|
||||
@@ -30,6 +34,8 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
case disappearTimer
|
||||
/// A poll, a piece of somebody's vote, or a published sum (§6.29).
|
||||
case poll
|
||||
/// A contact's bridge offer that §24.5 keeps, stored for review.
|
||||
case bridgeOffer
|
||||
/// Somebody's answer to an event (§6.32). Control traffic, folded onto
|
||||
/// the event's card and never notified.
|
||||
case rsvp
|
||||
@@ -89,10 +95,12 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
.remote,
|
||||
.deliveryKey,
|
||||
.keyLogHead,
|
||||
.multiDevice,
|
||||
.screenshotNotice,
|
||||
.offTheRecord,
|
||||
.disappearTimer,
|
||||
.poll,
|
||||
.bridgeOffer,
|
||||
.rsvp,
|
||||
.unpost,
|
||||
.unknownControl,
|
||||
@@ -159,6 +167,8 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
// The handler needs both fields, so an envelope missing either is
|
||||
// not this branch's and must fall through rather than vanish.
|
||||
return e.controlKind == "kt_head" && Self.hasKeyLogHead(e.text)
|
||||
case .multiDevice:
|
||||
return e.controlKind.map(MultiDeviceEnvelope.kinds.contains) ?? false
|
||||
case .screenshotNotice:
|
||||
return content.screenshotAt != nil
|
||||
case .offTheRecord:
|
||||
@@ -167,6 +177,8 @@ enum E2EEnvelopeRoute: String, CaseIterable, Sendable {
|
||||
return content.disappearSeconds != nil
|
||||
case .poll:
|
||||
return content.poll != nil
|
||||
case .bridgeOffer:
|
||||
return content.bridgeLine != nil
|
||||
case .rsvp:
|
||||
return content.rsvp != nil
|
||||
case .unpost:
|
||||
|
||||
@@ -33,12 +33,14 @@ enum E2EHistoryExport {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAnExport:
|
||||
return "That file is not a Flamenet history export."
|
||||
return String(localized: "That file is not a Flamenet history export.")
|
||||
case .wrongPlatform(let p):
|
||||
return
|
||||
"This backup was made on \(p == "android" ? "Android" : p), import it on a device of the same kind."
|
||||
let platform = p == "android" ? "Android" : p
|
||||
return String(
|
||||
localized:
|
||||
"This backup was made on \(platform), import it on a device of the same kind.")
|
||||
case .wrongPassphrase:
|
||||
return "That passphrase doesn't open this backup."
|
||||
return String(localized: "That passphrase doesn't open this backup.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,24 @@ struct E2EHistoryStore {
|
||||
/// buys nothing: a phone that is closed between the arrival and the
|
||||
/// acceptance is exactly the case this exists for.
|
||||
var heldEnvelopes: HeldEnvelopeBook? = nil
|
||||
|
||||
/// Section 17.6: this account's state about other people as sync
|
||||
/// registers (acceptance, blocks, starred and archived conversations,
|
||||
/// read marks, and from stage A3 everything a linked device mirrors).
|
||||
/// The source of truth for the four that left the relay at stage 2-B.
|
||||
var syncRegisters: SyncState? = nil
|
||||
|
||||
/// Section 16.2.1's `acc`, `blocked`, `convFlags`, `dk`,
|
||||
/// `deliveryTokensIssued` and `read`, filled only when a snapshot is
|
||||
/// built for an export or read from one. The sealed store keeps the
|
||||
/// first four as `syncRegisters` and the last two in the keychain, so
|
||||
/// these stay nil on disk.
|
||||
var acc: [Int]? = nil
|
||||
var blocked: [Int]? = nil
|
||||
var convFlags: [String: E2EInbox.ConversationFlags]? = nil
|
||||
var dk: String? = nil
|
||||
var deliveryTokensIssued: [Int: [String]]? = nil
|
||||
var read: [String: Int64]? = nil
|
||||
}
|
||||
|
||||
/// One contact, as the sealed store keeps it.
|
||||
@@ -116,6 +134,10 @@ struct E2EHistoryStore {
|
||||
}
|
||||
|
||||
private let userID: Int
|
||||
/// Which file this is, `"\(userID)"` for every real account. A test that
|
||||
/// stands up two devices of one account in one process gives each its own
|
||||
/// slot, so the two do not share a file. See `KeychainE2EStore.slot`.
|
||||
private let fileTag: String
|
||||
private let keyProvider: () -> SymmetricKey?
|
||||
|
||||
/// Why the last save did not happen, or nil if it did (ios#46).
|
||||
@@ -148,8 +170,9 @@ struct E2EHistoryStore {
|
||||
nonmutating set { status.onChange = newValue }
|
||||
}
|
||||
|
||||
init(userID: Int, keyProvider: @escaping () -> SymmetricKey?) {
|
||||
init(userID: Int, slot: String? = nil, keyProvider: @escaping () -> SymmetricKey?) {
|
||||
self.userID = userID
|
||||
self.fileTag = slot.map { "\(userID)-\($0)" } ?? "\(userID)"
|
||||
self.keyProvider = keyProvider
|
||||
}
|
||||
|
||||
@@ -206,7 +229,7 @@ struct E2EHistoryStore {
|
||||
at: dir, withIntermediateDirectories: true,
|
||||
attributes: [.protectionKey: Self.protection])
|
||||
}
|
||||
let url = dir.appendingPathComponent("history-\(userID).bin")
|
||||
let url = dir.appendingPathComponent("history-\(fileTag).bin")
|
||||
Self.relaxProtectionIfNeeded(dir: dir, file: url)
|
||||
return url
|
||||
}
|
||||
@@ -220,15 +243,15 @@ struct E2EHistoryStore {
|
||||
/// save path has no way to tell "this account has no history yet" from
|
||||
/// "its history did not open a moment ago", and those want opposite
|
||||
/// behaviour.
|
||||
private static var unreadable = Set<Int>()
|
||||
private static var unreadable = Set<String>()
|
||||
|
||||
var historyIsUnreadable: Bool { Self.unreadable.contains(userID) }
|
||||
var historyIsUnreadable: Bool { Self.unreadable.contains(fileTag) }
|
||||
|
||||
func load() -> Snapshot? {
|
||||
guard let url = fileURL else {
|
||||
// No path at all, so nothing can be said about what is on disk.
|
||||
// Refusing to write is the only safe reading of that.
|
||||
Self.unreadable.insert(userID)
|
||||
Self.unreadable.insert(fileTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -247,7 +270,7 @@ struct E2EHistoryStore {
|
||||
guard exists else {
|
||||
// Genuinely nothing here. An empty history is the truth, and
|
||||
// writing it is safe.
|
||||
Self.unreadable.remove(userID)
|
||||
Self.unreadable.remove(fileTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -264,10 +287,10 @@ struct E2EHistoryStore {
|
||||
// phone is locked -- and this cannot tell those apart, which is
|
||||
// exactly why it must assume the file is worth more than what is
|
||||
// in memory. Writing is refused until a load succeeds.
|
||||
Self.unreadable.insert(userID)
|
||||
Self.unreadable.insert(fileTag)
|
||||
return nil
|
||||
}
|
||||
Self.unreadable.remove(userID)
|
||||
Self.unreadable.remove(fileTag)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@@ -292,7 +315,7 @@ struct E2EHistoryStore {
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
guard (try? FileManager.default.removeItem(at: url)) != nil else { return false }
|
||||
}
|
||||
Self.unreadable.remove(userID)
|
||||
Self.unreadable.remove(fileTag)
|
||||
record(nil)
|
||||
return true
|
||||
}
|
||||
@@ -321,7 +344,8 @@ struct E2EHistoryStore {
|
||||
addrThreads: [String: [E2EMessage]] = [:],
|
||||
seenStoryIDs: Set<String> = [],
|
||||
unreadPeers: [Int: Int] = [:],
|
||||
heldEnvelopes: HeldEnvelopeBook = HeldEnvelopeBook()
|
||||
heldEnvelopes: HeldEnvelopeBook = HeldEnvelopeBook(),
|
||||
syncRegisters: SyncState = SyncState()
|
||||
) {
|
||||
guard let url = fileURL, let key = keyProvider() else { return }
|
||||
// The history on disk did not open. Writing now would seal whatever is
|
||||
@@ -351,7 +375,8 @@ struct E2EHistoryStore {
|
||||
addrThreads: addrThreads.isEmpty ? nil : addrThreads,
|
||||
seenStoryIDs: seenStoryIDs.isEmpty ? nil : seenStoryIDs.sorted(),
|
||||
unreadPeers: unreadPeers.isEmpty ? nil : unreadPeers,
|
||||
heldEnvelopes: heldEnvelopes.isEmpty ? nil : heldEnvelopes)
|
||||
heldEnvelopes: heldEnvelopes.isEmpty ? nil : heldEnvelopes,
|
||||
syncRegisters: syncRegisters.registers.isEmpty ? nil : syncRegisters)
|
||||
guard let plain = try? JSONEncoder().encode(snapshot),
|
||||
let box = try? AES.GCM.seal(plain, using: key),
|
||||
let combined = box.combined
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Section 8.9d (pairs stage 2-B) and 17.6: what this account has decided about
|
||||
/// other people, held on the device rather than on the relay.
|
||||
///
|
||||
/// Who was accepted, who is blocked, which conversations are starred or
|
||||
/// archived, and how far each has been read are sync registers
|
||||
/// (`syncState`). On a device with no linked devices they are simply local
|
||||
/// state; with linked ones every write is also an op the others merge
|
||||
/// (stage A3). The relay used to keep each of these as a pair of account ids,
|
||||
/// and a dump of those tables was a partial contact graph.
|
||||
extension E2EInbox {
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// Contacts whose first message was accepted, or who this account wrote
|
||||
/// to or added (writing to somebody accepts them, section 14.5).
|
||||
var acceptedIDs: Set<Int> { Set(syncState.members(of: "acc").compactMap(Int.init)) }
|
||||
|
||||
/// Contacts this account has blocked.
|
||||
var blockedIDs: Set<Int> { Set(syncState.members(of: "blocked").compactMap(Int.init)) }
|
||||
|
||||
func isAccepted(_ id: Int) -> Bool { syncState.contains("acc/\(id)") }
|
||||
func isBlocked(_ id: Int) -> Bool { syncState.contains("blocked/\(id)") }
|
||||
|
||||
struct ConversationFlags: Equatable, Codable {
|
||||
var starred = false
|
||||
var archived = false
|
||||
|
||||
/// As `conv/…` and `convFlags` carry it: each flag only when true.
|
||||
var json: JSONValue? {
|
||||
var o: [String: JSONValue] = [:]
|
||||
if starred { o["starred"] = .bool(true) }
|
||||
if archived { o["archived"] = .bool(true) }
|
||||
return o.isEmpty ? nil : .object(o)
|
||||
}
|
||||
|
||||
init(starred: Bool = false, archived: Bool = false) {
|
||||
self.starred = starred
|
||||
self.archived = archived
|
||||
}
|
||||
|
||||
init(_ value: JSONValue?) {
|
||||
let o = value?.objectValue ?? [:]
|
||||
starred = o["starred"]?.boolValue == true
|
||||
archived = o["archived"]?.boolValue == true
|
||||
}
|
||||
}
|
||||
|
||||
func flags(forThread key: String) -> ConversationFlags {
|
||||
guard syncState.contains("conv/\(key)") else { return ConversationFlags() }
|
||||
return ConversationFlags(syncState["conv/\(key)"]?.val)
|
||||
}
|
||||
|
||||
/// Epoch milliseconds of the newest message read in a thread, if marked.
|
||||
func readMark(forThread key: String) -> Int64? { syncState["read/\(key)"]?.val?.intValue }
|
||||
|
||||
/// Section 14.5, decision DC2: people who wrote first and have not been
|
||||
/// accepted. Their conversation is in a Requests folder, not the
|
||||
/// conversation list, and nothing in it notifies more loudly than a
|
||||
/// quiet badge. Newest first. Blocked people are not here: their
|
||||
/// messages are dropped on arrival.
|
||||
var requestIDs: [Int] {
|
||||
threads
|
||||
.filter { id, msgs in
|
||||
!msgs.isEmpty && id != myUserID && !isAccepted(id) && !isBlocked(id)
|
||||
}
|
||||
.sorted { ($0.value.last?.date ?? .distantPast) > ($1.value.last?.date ?? .distantPast) }
|
||||
.map(\.key)
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// Write one register of this account's state and, from stage A3, tell
|
||||
/// the linked devices.
|
||||
///
|
||||
/// `at` is this device's clock, moved on past anything the register
|
||||
/// already holds so a write made here always takes: a linked device whose
|
||||
/// clock is a minute fast would otherwise win every register it touched
|
||||
/// for that minute, and the person's own tap would do nothing. Section
|
||||
/// 17.6 forbids writing one key twice in a millisecond, which this also
|
||||
/// covers.
|
||||
@discardableResult
|
||||
func writeSync(_ op: String, key: String, val: JSONValue? = nil, now: Date = Date())
|
||||
-> SyncOp?
|
||||
{
|
||||
let nowMs = SyncMerge.nowMs(now)
|
||||
let at = max(nowMs, (syncState[key]?.at ?? -1) + 1)
|
||||
let o = SyncOp(op: op, key: key, val: SyncMerge.isRemoval(op) ? nil : val, at: at,
|
||||
dev: store.deviceID)
|
||||
guard o.isValid else { return nil }
|
||||
var state = syncState
|
||||
guard state.merge(o, now: max(nowMs, at)) else { return nil }
|
||||
syncState = state
|
||||
flushHistory()
|
||||
syncWritten(o)
|
||||
return o
|
||||
}
|
||||
|
||||
/// Accept somebody: they leave Requests and become a conversation.
|
||||
func accept(_ id: Int) {
|
||||
guard id > 0, id != myUserID, !isAccepted(id) else { return }
|
||||
writeSync("add", key: "acc/\(id)")
|
||||
}
|
||||
|
||||
/// Take somebody's acceptance back, as removing a contact does: a later
|
||||
/// message from them is a request again.
|
||||
func unaccept(_ id: Int) {
|
||||
guard isAccepted(id) else { return }
|
||||
writeSync("rm", key: "acc/\(id)")
|
||||
}
|
||||
|
||||
/// The local half of a block (section 8.9d). The relay is told separately
|
||||
/// and still enforces it on unsealed messages until stage 2-E; tokens are
|
||||
/// revoked and the delivery key rotated by `AppEnvironment.setBlocked`.
|
||||
func setBlockedLocally(_ blocked: Bool, userID: Int) {
|
||||
guard userID > 0, userID != myUserID, isBlocked(userID) != blocked else { return }
|
||||
writeSync(blocked ? "add" : "rm", key: "blocked/\(userID)")
|
||||
}
|
||||
|
||||
func setStarred(_ on: Bool, thread key: String) {
|
||||
var f = flags(forThread: key)
|
||||
guard f.starred != on else { return }
|
||||
f.starred = on
|
||||
writeConversationFlags(f, thread: key)
|
||||
}
|
||||
|
||||
func setArchived(_ on: Bool, thread key: String) {
|
||||
var f = flags(forThread: key)
|
||||
guard f.archived != on else { return }
|
||||
f.archived = on
|
||||
writeConversationFlags(f, thread: key)
|
||||
}
|
||||
|
||||
private func writeConversationFlags(_ f: ConversationFlags, thread key: String) {
|
||||
if let val = f.json {
|
||||
writeSync("set", key: "conv/\(key)", val: val)
|
||||
} else {
|
||||
writeSync("del", key: "conv/\(key)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that a thread has been read through `ms`.
|
||||
///
|
||||
/// Coalesced (decision DA6): at most one op per thread per 30 seconds, and
|
||||
/// the latest once the window passes, because reading a long thread is
|
||||
/// otherwise an op per message. The register only moves forward.
|
||||
func noteRead(thread key: String, through ms: Int64) {
|
||||
guard ms > (readMark(forThread: key) ?? -1) else { return }
|
||||
let last = readWrittenAt[key] ?? .distantPast
|
||||
let wait = Double(SyncMerge.readCoalesceMs) / 1000 - Date().timeIntervalSince(last)
|
||||
if wait <= 0 {
|
||||
readWrittenAt[key] = Date()
|
||||
writeSync("max", key: "read/\(key)", val: .int(ms))
|
||||
return
|
||||
}
|
||||
readPending[key] = max(readPending[key] ?? 0, ms)
|
||||
guard readFlushTasks[key] == nil else { return }
|
||||
readFlushTasks[key] = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
|
||||
guard let self else { return }
|
||||
self.readFlushTasks[key] = nil
|
||||
guard let pending = self.readPending.removeValue(forKey: key),
|
||||
pending > (self.readMark(forThread: key) ?? -1)
|
||||
else { return }
|
||||
self.readWrittenAt[key] = Date()
|
||||
self.writeSync("max", key: "read/\(key)", val: .int(pending))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Seeding (stage 2-B)
|
||||
|
||||
/// Seed acceptance from what this device already knows, once: everybody
|
||||
/// on the contact list, and everybody this device has written to. Without
|
||||
/// it every existing conversation would land in Requests the day this
|
||||
/// build arrives.
|
||||
func seedAccountStateFromDeviceIfNeeded() {
|
||||
guard !store.accountStateSeededFromDevice else { return }
|
||||
var ids = Set(roster.map(\.id))
|
||||
for (id, msgs) in threads where msgs.contains(where: \.isMine) { ids.insert(id) }
|
||||
for id in ids.sorted() where id > 0 && id != myUserID && !isAccepted(id) {
|
||||
writeSync("add", key: "acc/\(id)")
|
||||
}
|
||||
store.accountStateSeededFromDevice = true
|
||||
}
|
||||
|
||||
/// Seed from the relay's answer, once (section 8.9d: "a client seeds it
|
||||
/// once from the relay's answer, then stops asking"): contacts it says
|
||||
/// were accepted, and everybody it says this account blocked. Only a
|
||||
/// complete answer seeds; a failed fetch is retried by the caller.
|
||||
func seedAccountStateFromRelayIfNeeded(acceptedBuddies: [Int], blocked: [Int]) {
|
||||
guard !store.accountStateSeededFromRelay else { return }
|
||||
for id in acceptedBuddies.sorted() where id > 0 && id != myUserID && !isAccepted(id) {
|
||||
writeSync("add", key: "acc/\(id)")
|
||||
}
|
||||
for id in blocked.sorted() where id > 0 && id != myUserID && !isBlocked(id) {
|
||||
writeSync("add", key: "blocked/\(id)")
|
||||
}
|
||||
store.accountStateSeededFromRelay = true
|
||||
}
|
||||
|
||||
// MARK: - Backup keys (section 16.2.1)
|
||||
|
||||
/// `acc`, `blocked`, `convFlags` and `read` as a backup carries them.
|
||||
func accountStateForBackup() -> (
|
||||
acc: [Int], blocked: [Int], convFlags: [String: ConversationFlags], read: [String: Int64]
|
||||
) {
|
||||
var flags: [String: ConversationFlags] = [:]
|
||||
for key in syncState.members(of: "conv") {
|
||||
let f = self.flags(forThread: key)
|
||||
if f.starred || f.archived { flags[key] = f }
|
||||
}
|
||||
var read: [String: Int64] = [:]
|
||||
for key in syncState.members(of: "read") {
|
||||
if let ms = readMark(forThread: key) { read[key] = ms }
|
||||
}
|
||||
return (acceptedIDs.sorted(), blockedIDs.sorted(), flags, read)
|
||||
}
|
||||
|
||||
/// Merge a backup's account state into this device's: a union for `acc`,
|
||||
/// `blocked` and the flags, a maximum for read marks. A restore never
|
||||
/// unblocks anybody and never clears a flag (the same fall-narrow rule as
|
||||
/// the story audience).
|
||||
func mergeAccountStateFromBackup(
|
||||
acc: [Int], blocked: [Int], convFlags: [String: ConversationFlags], read: [String: Int64]
|
||||
) {
|
||||
for id in acc where id > 0 && id != myUserID { accept(id) }
|
||||
for id in blocked where id > 0 && id != myUserID { setBlockedLocally(true, userID: id) }
|
||||
for (key, f) in convFlags {
|
||||
if f.starred { setStarred(true, thread: key) }
|
||||
if f.archived { setArchived(true, thread: key) }
|
||||
}
|
||||
for (key, ms) in read where ms > (readMark(forThread: key) ?? -1) {
|
||||
writeSync("max", key: "read/\(key)", val: .int(ms))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Sections 14.5, 8.11 and 8.12, stages C2, C3, D2 and B3: first contact that
|
||||
/// names nobody, reads that name nobody, and contact links.
|
||||
///
|
||||
/// Every part of this waits for the relay to say it can: the descriptor's
|
||||
/// `introductions`, `anonymousReads` and `contactLinks` switches. A relay that
|
||||
/// does not advertise them gets exactly the 0.31 behaviour, which is what
|
||||
/// every relay deployed today is.
|
||||
extension E2EInbox {
|
||||
|
||||
private static let firstContactLog = OSLog(
|
||||
subsystem: "io.flamenet.messenger", category: "first-contact")
|
||||
|
||||
/// How the first message to somebody is authorised when this account holds
|
||||
/// none of their capabilities.
|
||||
enum FirstContact {
|
||||
/// A one-time contact link's delivery token (section 8.14).
|
||||
case token(Data)
|
||||
/// An introduction from `POST /e2e/intro` (section 14.5).
|
||||
case intro(E2EIntroAnswer)
|
||||
}
|
||||
|
||||
// MARK: - D2, reads that name nobody
|
||||
|
||||
/// Somebody's devices: anonymously, with the presence token they issued
|
||||
/// this account as the meter, when the relay offers it; this account's own
|
||||
/// from the route that answers about the caller only.
|
||||
func directory(of userID: Int, features: E2ERelayDescriptor.Features)
|
||||
async throws -> [E2EDevicesResponse.Device]
|
||||
{
|
||||
guard features.anonymousReads == true else {
|
||||
return try await client.listDevices(of: userID).devices
|
||||
}
|
||||
if userID == myUserID { return try await client.ownDevicesWithTimes().devices }
|
||||
return try await client.anonymous.devices(
|
||||
of: userID, cap: presenceTokensHeld[userID]
|
||||
).devices
|
||||
}
|
||||
|
||||
func bundles(of userID: Int, features: E2ERelayDescriptor.Features)
|
||||
async throws -> E2EKeysResponse
|
||||
{
|
||||
guard features.anonymousReads == true, userID != myUserID else {
|
||||
return try await client.fetchKeys(for: userID)
|
||||
}
|
||||
return try await client.anonymous.bundles(of: userID, cap: presenceTokensHeld[userID])
|
||||
}
|
||||
|
||||
/// Presence by token in random batches of at most eight, each after a
|
||||
/// random wait of up to `maxDelay` seconds (decision DD3), so one request
|
||||
/// is not this account's whole contact list.
|
||||
func anonymousPresence(
|
||||
_ tokens: [String], maxDelay: Double = 20
|
||||
) async -> [String: String] {
|
||||
var out: [String: String] = [:]
|
||||
var shuffled = tokens.shuffled()
|
||||
while !shuffled.isEmpty {
|
||||
let batch = Array(shuffled.prefix(AnonymousRelayClient.presenceBatch))
|
||||
shuffled.removeFirst(batch.count)
|
||||
if maxDelay > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(Double.random(in: 0...maxDelay) * 1e9))
|
||||
}
|
||||
if let answer = try? await client.anonymous.presence(tokens: batch) {
|
||||
out.merge(answer) { a, _ in a }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - C2, handle certificates
|
||||
|
||||
/// The name a verified certificate gave for `id`.
|
||||
func certifiedHandle(for id: Int) -> String? { store.certifiedHandle(for: id)?.h }
|
||||
|
||||
/// This account's own certificate, for the prelude of a first message.
|
||||
/// Fetched when a relay offers them, kept while it has an hour to run.
|
||||
func ownCertificateForPrelude() async -> HandleCertificate? {
|
||||
guard await relayFeatures().introductions == true else { return nil }
|
||||
let soon = Int(Date().timeIntervalSince1970) + 3600
|
||||
if let held = ownHandleCert, held.exp > soon { return held }
|
||||
do {
|
||||
ownHandleCert = try await client.handleCertificate()
|
||||
} catch {
|
||||
os_log(
|
||||
"own handle certificate not fetched: %{public}@", log: Self.firstContactLog,
|
||||
type: .error, String(describing: error))
|
||||
}
|
||||
return ownHandleCert
|
||||
}
|
||||
|
||||
/// Verify a certificate against the relay's pinned key, for this relay,
|
||||
/// for the account it must be about, before it expires. No pinned key, or
|
||||
/// a relay whose key changed and was not accepted, verifies nothing.
|
||||
func verifies(_ cert: HandleCertificate, about userID: Int) async -> Bool {
|
||||
_ = await relayFeatures()
|
||||
guard !store.relayIdentityChanged, let audience = relayAudience else { return false }
|
||||
return cert.verifies(issuerKey: store.relayIdentityPin, audience: audience, userID: userID)
|
||||
}
|
||||
|
||||
/// A prelude carried `hc`: keep it if it verifies, about the sender the
|
||||
/// ratchet proved.
|
||||
func adoptHandleCertificate(_ json: [String: Any], from userID: Int) async {
|
||||
guard let cert = HandleCertificate(json: json) else {
|
||||
_ = Self.dropped("prelude carried a malformed handle certificate", from: "")
|
||||
return
|
||||
}
|
||||
guard await verifies(cert, about: userID) else {
|
||||
_ = Self.dropped("prelude carried a handle certificate that does not verify", from: "")
|
||||
return
|
||||
}
|
||||
store.setCertifiedHandle(cert, for: userID)
|
||||
updateBuddyNames([userID: cert.h])
|
||||
knownHandles[userID] = cert.h
|
||||
}
|
||||
|
||||
// MARK: - C3, introductions
|
||||
|
||||
/// The usernames this device knows, for introductions.
|
||||
func updateKnownHandles(_ handles: [Int: String]) {
|
||||
knownHandles.merge(handles.filter { !$0.value.isEmpty }) { _, new in new }
|
||||
}
|
||||
|
||||
/// One fresh introduction to `userID`, from the reusable link the
|
||||
/// conversation started from, or its handle (listed accounts only, with
|
||||
/// the proof of work). Nil when there is neither, or the relay knows
|
||||
/// nothing by them; the answer must be about `userID`.
|
||||
func introduce(_ userID: Int) async -> E2EIntroAnswer? {
|
||||
guard await relayFeatures().introductions == true else { return nil }
|
||||
if let link = store.startedFromLink(userID),
|
||||
let answer = try? await introduce(link: link, expecting: userID)
|
||||
{
|
||||
return answer
|
||||
}
|
||||
let handle = knownHandles[userID] ?? roster.first { $0.id == userID }?.handle
|
||||
?? certifiedHandle(for: userID)
|
||||
guard let handle, !handle.isEmpty else { return nil }
|
||||
return try? await introduce(handle: handle, expecting: userID)
|
||||
}
|
||||
|
||||
/// An introduction by handle: a challenge, the work, the intro. The
|
||||
/// certificate must name the account the intro answered for; a mismatch
|
||||
/// is refused rather than shown.
|
||||
func introduce(handle: String, expecting userID: Int? = nil) async throws -> E2EIntroAnswer? {
|
||||
let challenge = try await client.anonymous.introChallenge()
|
||||
let nonce = await Task.detached(priority: .userInitiated) {
|
||||
IntroProofOfWork.solve(challenge: challenge.challenge, bits: challenge.bits)
|
||||
}.value
|
||||
guard let nonce else { return nil }
|
||||
guard
|
||||
let answer = try await client.anonymous.intro(
|
||||
handle: handle, challenge: challenge.challenge, nonce: nonce)
|
||||
else { return nil }
|
||||
return try await accepted(answer, expecting: userID)
|
||||
}
|
||||
|
||||
func introduce(link: Data, expecting userID: Int? = nil) async throws -> E2EIntroAnswer? {
|
||||
guard let answer = try await client.anonymous.intro(link: link) else { return nil }
|
||||
return try await accepted(answer, expecting: userID)
|
||||
}
|
||||
|
||||
/// Keep an intro only if it is about the account expected and its
|
||||
/// certificate, when it carries one, verifies for that account.
|
||||
private func accepted(_ answer: E2EIntroAnswer, expecting userID: Int?) async throws
|
||||
-> E2EIntroAnswer
|
||||
{
|
||||
if let userID, answer.userID != userID { throw FirstContactRefusal.wrongAccount }
|
||||
if let cert = answer.handleCert {
|
||||
guard await verifies(cert, about: answer.userID) else {
|
||||
throw FirstContactRefusal.certificateDoesNotVerify
|
||||
}
|
||||
store.setCertifiedHandle(cert, for: answer.userID)
|
||||
updateBuddyNames([answer.userID: cert.h])
|
||||
knownHandles[answer.userID] = cert.h
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
enum FirstContactRefusal: LocalizedError, Equatable {
|
||||
case wrongAccount
|
||||
case certificateDoesNotVerify
|
||||
case notSealable(String)
|
||||
case linkUnusable
|
||||
/// A link made on a relay this account is not on.
|
||||
case otherServer(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .wrongAccount:
|
||||
return String(
|
||||
localized:
|
||||
"The link doesn't belong to the account the server answered for, so nothing was sent.")
|
||||
case .certificateDoesNotVerify:
|
||||
return String(
|
||||
localized: "The server's answer about this person didn't check out, so nothing was sent.")
|
||||
case .notSealable(let name):
|
||||
return String(localized: "Could not send. \(name) needs to message you or send you a new link.")
|
||||
case .linkUnusable:
|
||||
return String(localized: "That link has already been used or was turned off. Ask for a new one.")
|
||||
case .otherServer(let relay):
|
||||
return String(localized: "That link is for another server (\(relay)).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Safety numbers (SPEC §7)
|
||||
|
||||
/// Before showing a safety number this device could not cover, read the
|
||||
/// contact's bundles once and pin their post-quantum identities (§20.5),
|
||||
/// so the digits match the ones their device shows. Only for devices whose
|
||||
/// `IK_sig` is the one already pinned, and only when this device has a
|
||||
/// post-quantum identity of its own. True when a pin was added. It spends
|
||||
/// one prekey per device, once.
|
||||
@discardableResult
|
||||
func pinPeerPQIdentityIfNeeded(_ userID: Int) async -> Bool {
|
||||
guard store.pqIdentitySeed != nil, !safetyNumberIsPostQuantum(for: userID) else {
|
||||
return false
|
||||
}
|
||||
let trusted = Set(store.pinnedIdentities(forUser: userID).map { $0.e2eBase64 })
|
||||
guard !trusted.isEmpty,
|
||||
let served = try? await bundles(of: userID, features: await relayFeatures())
|
||||
else { return false }
|
||||
var pinned = false
|
||||
for bundle in served.devices where trusted.contains(bundle.ik_sig) {
|
||||
guard store.pqPin(forDevice: bundle.device_id) == nil else { continue }
|
||||
if (try? enforcePQIdentity(bundle: bundle)) != nil,
|
||||
store.pqPin(forDevice: bundle.device_id) != nil
|
||||
{
|
||||
pinned = true
|
||||
}
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
// MARK: - B3, contact links
|
||||
|
||||
/// The name a refusal uses: as the person called themselves, without the
|
||||
/// "not verified yet" the conversation title carries.
|
||||
func nameForRefusal(_ id: Int) -> String {
|
||||
let shown = displayName(for: id)
|
||||
let marked = Self.unverifiedName("")
|
||||
return shown.hasSuffix(marked) ? String(shown.dropLast(marked.count)) : shown
|
||||
}
|
||||
|
||||
/// A name from a link's `h`, which nothing has vouched for yet.
|
||||
nonisolated static func unverifiedName(_ handle: String) -> String {
|
||||
String(localized: "\(handle) (not verified yet)")
|
||||
}
|
||||
|
||||
/// A one-time link (section 8.14): a delivery token this account mints
|
||||
/// for it and uploads among its others, so the relay holds nothing that
|
||||
/// marks it as a link. Whoever scans it can send one sealed first
|
||||
/// message. Works on any relay that takes delivery tokens.
|
||||
func makeOneTimeLink(handle: String?) async throws -> ContactLink {
|
||||
let token = ContactLink.freshCapability()
|
||||
try await client.setDeliveryTokens(
|
||||
add: [Data(SHA256.hash(data: token)).e2eBase64], revoke: [])
|
||||
store.oneTimeLinkTokens = store.oneTimeLinkTokens + [token.e2eBase64]
|
||||
return ContactLink(
|
||||
relay: client.relayHost, userID: myUserID, token: token, handle: handle,
|
||||
fnid: myAddress.flatMap { $0.split(separator: "@").first.map(String.init) })
|
||||
}
|
||||
|
||||
/// Turn off every one-time link not yet used. One revoke for all of them.
|
||||
func revokeOneTimeLinks() async throws {
|
||||
let held = store.oneTimeLinkTokens.compactMap { Data(e2eBase64: $0) }
|
||||
guard !held.isEmpty else { return }
|
||||
try await client.setDeliveryTokens(
|
||||
add: [], revoke: held.map { Data(SHA256.hash(data: $0)).e2eBase64 })
|
||||
store.oneTimeLinkTokens = []
|
||||
}
|
||||
|
||||
/// A reusable link (section 8.14, stage B2): good until revoked, at most
|
||||
/// five. The relay stores only its SHA-256.
|
||||
func makeReusableLink(handle: String?) async throws -> ContactLink {
|
||||
let capability = ContactLink.freshCapability()
|
||||
try await client.setContactLinks(add: [ContactLink.digest(capability)], revoke: [])
|
||||
store.reusableLinks = store.reusableLinks + [capability.base64EncodedString()]
|
||||
return reusableLink(capability, handle: handle)
|
||||
}
|
||||
|
||||
func reusableLink(_ capability: Data, handle: String?) -> ContactLink {
|
||||
ContactLink(
|
||||
relay: client.relayHost, userID: myUserID, link: capability, handle: handle,
|
||||
fnid: myAddress.flatMap { $0.split(separator: "@").first.map(String.init) })
|
||||
}
|
||||
|
||||
func revokeReusableLink(_ capability: Data) async throws {
|
||||
try await client.setContactLinks(add: [], revoke: [ContactLink.digest(capability)])
|
||||
store.reusableLinks = store.reusableLinks.filter {
|
||||
Data(base64Encoded: $0) != capability
|
||||
}
|
||||
}
|
||||
|
||||
var heldReusableLinks: [Data] { store.reusableLinks.compactMap { Data(base64Encoded: $0) } }
|
||||
var heldOneTimeLinkCount: Int { store.oneTimeLinkTokens.count }
|
||||
|
||||
/// Open somebody's link and send them a first message: sealed, under the
|
||||
/// link's token or an introduction by it, landing in their Requests.
|
||||
/// Returns the account written to.
|
||||
@discardableResult
|
||||
func sendFirstMessage(_ text: String, via link: ContactLink) async throws -> Int {
|
||||
guard link.relay == client.relayHost.lowercased() else {
|
||||
throw FirstContactRefusal.otherServer(link.relay)
|
||||
}
|
||||
let first: FirstContact
|
||||
if let token = link.token {
|
||||
first = .token(token)
|
||||
} else if let capability = link.link {
|
||||
guard await relayFeatures().introductions == true,
|
||||
let answer = try await introduce(link: capability, expecting: link.userID)
|
||||
else { throw FirstContactRefusal.linkUnusable }
|
||||
store.setStartedFromLink(capability, for: link.userID)
|
||||
first = .intro(answer)
|
||||
} else {
|
||||
throw FirstContactRefusal.linkUnusable
|
||||
}
|
||||
if let handle = link.handle, !handle.isEmpty, certifiedHandle(for: link.userID) == nil {
|
||||
// Shown as unverified until their first reply carries a
|
||||
// certificate (section 8.14), which `adoptHandleCertificate`
|
||||
// writes over this. The same words as Android's chat title.
|
||||
updateBuddyNames([link.userID: Self.unverifiedName(handle)])
|
||||
}
|
||||
let wire = try E2EAttachment.encodeText(text, mid: UUID().uuidString)
|
||||
do {
|
||||
try await transmit(wire, to: link.userID, firstContact: first)
|
||||
} catch MessengerError.refused(.capabilityRevoked, _) {
|
||||
throw FirstContactRefusal.linkUnusable
|
||||
}
|
||||
appendToThread(
|
||||
link.userID,
|
||||
message: E2EMessage(
|
||||
id: nextLocalID(), isMine: true, text: text, date: Date(),
|
||||
mid: E2EAttachment.parse(plaintext: wire).mid, delivery: .delivered))
|
||||
return link.userID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Sections 17.2 to 17.6: the private channel between one person's linked
|
||||
/// devices, and how a device joins it.
|
||||
///
|
||||
/// A device signed in with the password is a device of the account and
|
||||
/// nothing more. It joins the linked set only when a person points an already
|
||||
/// linked device at it (QR, or comparing a safety number on both screens), and
|
||||
/// only linked devices receive the account delivery key, the state snapshot,
|
||||
/// history, sent copies and sync ops. The relay brokers none of it.
|
||||
extension E2EInbox {
|
||||
|
||||
private static let log = OSLog(subsystem: "io.flamenet.messenger", category: "link")
|
||||
|
||||
/// What a person is told about a change to the linked set (section 17.3:
|
||||
/// a client MUST say which it was).
|
||||
struct LinkNotice: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let deviceID: String
|
||||
let text: String
|
||||
}
|
||||
|
||||
/// The other devices this one is linked with: device id to IK_sig.
|
||||
var otherLinkedDevices: [String: String] {
|
||||
store.linkedDevices.filter { $0.key != store.deviceID }
|
||||
}
|
||||
|
||||
var hasLinkedDevices: Bool { !otherLinkedDevices.isEmpty }
|
||||
|
||||
// MARK: - The new device: the code it shows (section 17.3, step 1)
|
||||
|
||||
/// A fresh code, replacing any shown before. Good for ten minutes and one
|
||||
/// link; the secret leaves this device only as light.
|
||||
@discardableResult
|
||||
func freshLinkCode(now: Date = Date()) -> DeviceLinkCode {
|
||||
let code = DeviceLinkCode(
|
||||
deviceID: store.deviceID, ikSig: identity.ikSigPub,
|
||||
secret: DeviceLinkCode.freshSecret())
|
||||
pendingLinkCode = (code, now.addingTimeInterval(DeviceLinkCode.lifetime))
|
||||
return code
|
||||
}
|
||||
|
||||
// MARK: - The existing device: checking a code (step 2)
|
||||
|
||||
enum LinkRefusal: LocalizedError, Equatable {
|
||||
case notALinkedDevice
|
||||
case isThisDevice
|
||||
case notOnThisAccount
|
||||
case keyDiffers
|
||||
case retired
|
||||
case directoryUnreadable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notALinkedDevice:
|
||||
return String(
|
||||
localized:
|
||||
"Only a device that's already linked can link another. This one isn't linked yet: show its code to one that is.")
|
||||
case .isThisDevice:
|
||||
return String(localized: "That's this device's own code.")
|
||||
case .notOnThisAccount:
|
||||
return String(
|
||||
localized: "That device isn't signed in to your account. Sign in on it first, then show its code again.")
|
||||
case .keyDiffers:
|
||||
return String(
|
||||
localized: """
|
||||
The server lists that device with a different key than its code \
|
||||
shows. That's what a substituted device would look like, so it \
|
||||
wasn't linked.
|
||||
""")
|
||||
case .retired:
|
||||
return String(localized: "The server's key log says that device was removed, so it can't be linked.")
|
||||
case .directoryUnreadable:
|
||||
return String(localized: "Couldn't check that device with the server. Nothing was linked. Try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this device may link others: it is linked itself, or it is
|
||||
/// the account's first device and nobody has linked anything yet.
|
||||
var mayLinkOthers: Bool {
|
||||
hasLinkedDevices || store.linkedDevices.isEmpty
|
||||
}
|
||||
|
||||
/// Check a scanned code against the relay before anything is sent: the
|
||||
/// device is this account's in the relay's own list, under exactly the key
|
||||
/// the code shows, and the key log holds no `revoke` or `expire` for it.
|
||||
func verifyLinkCode(_ code: DeviceLinkCode) async throws -> E2EDevicesResponse.Device {
|
||||
guard code.deviceID != store.deviceID else { throw LinkRefusal.isThisDevice }
|
||||
guard mayLinkOthers else { throw LinkRefusal.notALinkedDevice }
|
||||
return try await verifyOwnDevice(code.deviceID, key: code.ikSig)
|
||||
}
|
||||
|
||||
func verifyOwnDevice(_ deviceID: String, key: Data) async throws -> E2EDevicesResponse.Device {
|
||||
let listed: [E2EDevicesResponse.Device]
|
||||
do {
|
||||
listed = try await directory(of: myUserID, features: await relayFeatures())
|
||||
} catch {
|
||||
throw LinkRefusal.directoryUnreadable
|
||||
}
|
||||
guard let device = listed.first(where: { $0.device_id == deviceID }) else {
|
||||
throw LinkRefusal.notOnThisAccount
|
||||
}
|
||||
guard Data(e2eBase64: device.ik_sig) == key else { throw LinkRefusal.keyDiffers }
|
||||
await refreshRevocations(for: [deviceID])
|
||||
guard !store.revokedDeviceIDs.contains(deviceID) else { throw LinkRefusal.retired }
|
||||
return device
|
||||
}
|
||||
|
||||
/// The safety number of this device and another of this account's
|
||||
/// devices (section 7, which works unchanged for two devices of one
|
||||
/// account). The numeric fallback compares it on both screens.
|
||||
func linkSafetyNumber(withKey key: Data) -> String {
|
||||
SafetyNumber.compute(identity.ikSigPub, key)
|
||||
}
|
||||
|
||||
// MARK: - The existing device: linking (step 3)
|
||||
|
||||
/// Link `device` and hand it the account: a `link_offer` with the QR's
|
||||
/// secret (nil in the numeric fallback), the linked set and the account
|
||||
/// delivery key; then a snapshot of every register; then, if the person
|
||||
/// wants it, history (section 17.4).
|
||||
///
|
||||
/// The device is linked HERE first, which is what lets the offer reach it:
|
||||
/// own-device traffic goes to linked devices only.
|
||||
func linkDevice(
|
||||
_ device: E2EDevicesResponse.Device, secret: Data?, withHistory: Bool,
|
||||
now: Date = Date()
|
||||
) async throws {
|
||||
guard mayLinkOthers else { throw LinkRefusal.notALinkedDevice }
|
||||
guard let key = Data(e2eBase64: device.ik_sig) else { throw LinkRefusal.keyDiffers }
|
||||
if store.deliveryKey == nil { await ensureDeliveryKey() }
|
||||
guard let dk = store.deliveryKey else { throw E2EError.crypto("no account delivery key") }
|
||||
|
||||
// This device is in its own set, and so is every device it links.
|
||||
store.link(deviceID: store.deviceID, ikSig: identity.ikSigPub.e2eBase64)
|
||||
store.link(deviceID: device.device_id, ikSig: key.e2eBase64)
|
||||
seedMirrorRegistersIfNeeded()
|
||||
writeSync("add", key: "link/\(store.deviceID)", val: .string(identity.ikSigPub.e2eBase64))
|
||||
writeSync("add", key: "link/\(device.device_id)", val: .string(key.e2eBase64))
|
||||
|
||||
let set = store.linkedDevices.sorted { $0.key < $1.key }.map {
|
||||
MultiDeviceEnvelope.Member(deviceID: $0.key, ikSig: $0.value)
|
||||
}
|
||||
let offer = MultiDeviceEnvelope.linkOffer(
|
||||
secret: secret, set: set, dk: dk, at: SyncMerge.nowMs(now))
|
||||
try await transmit(offer.json, to: myUserID, onlyDevices: [device.device_id])
|
||||
await sendSnapshot(to: device.device_id, want: false)
|
||||
if withHistory { await sendHistory(to: device.device_id) }
|
||||
os_log(
|
||||
"linked device %{public}@ (%{public}@)", log: Self.log, type: .info,
|
||||
String(device.device_id.prefix(8)), secret == nil ? "numeric" : "code")
|
||||
}
|
||||
|
||||
// MARK: - The new device: an offer arrives (step 4)
|
||||
|
||||
private func receiveLinkOffer(
|
||||
_ offer: MultiDeviceEnvelope, fromDevice: String, senderKey: Data?
|
||||
) async {
|
||||
guard case .linkOffer(let secret, let set, _, _) = offer, let senderKey else { return }
|
||||
// The offer must name its sender, under the key the session proved.
|
||||
guard set.contains(where: {
|
||||
$0.deviceID == fromDevice && Data(base64Encoded: $0.ikSig) == senderKey
|
||||
}) else {
|
||||
_ = Self.dropped("link offer whose set does not hold its sender", from: fromDevice)
|
||||
return
|
||||
}
|
||||
if let secret {
|
||||
// A wrong or stale secret is dropped without a word.
|
||||
guard let pending = pendingLinkCode, Date() < pending.expires,
|
||||
DeviceLinkCode.sameSecret(secret, pending.code.secret)
|
||||
else {
|
||||
_ = Self.dropped("link offer with a wrong or stale secret", from: fromDevice)
|
||||
return
|
||||
}
|
||||
pendingLinkCode = nil
|
||||
await adoptLinkOffer(offer)
|
||||
return
|
||||
}
|
||||
// The numeric fallback: held until the person confirms the same number
|
||||
// on this device too. A confirmation on one side links nothing.
|
||||
pendingNumericOffer = PendingLinkOffer(
|
||||
offer: offer, fromDevice: fromDevice, senderKey: senderKey,
|
||||
safetyNumber: linkSafetyNumber(withKey: senderKey))
|
||||
}
|
||||
|
||||
/// The person compared the number and it matched.
|
||||
func confirmPendingNumericOffer() async {
|
||||
guard let pending = pendingNumericOffer else { return }
|
||||
pendingNumericOffer = nil
|
||||
guard await adoptLinkOffer(pending.offer) else { return }
|
||||
// The device that offered sent its snapshot right behind the offer,
|
||||
// while this one was still waiting for the person, and it was dropped
|
||||
// as traffic from a device not yet linked. Ask for it again (section
|
||||
// 17.6): the answer is a snapshot like the one at link time.
|
||||
await sendSnapshot(to: pending.fromDevice, want: true)
|
||||
}
|
||||
|
||||
/// The number did not match, or the person did not start this.
|
||||
func rejectPendingNumericOffer() {
|
||||
pendingNumericOffer = nil
|
||||
}
|
||||
|
||||
/// Adopt an offer: each member of the set only if the relay lists it on
|
||||
/// this account under the key the offer names, and the account delivery
|
||||
/// key.
|
||||
@discardableResult
|
||||
private func adoptLinkOffer(_ offer: MultiDeviceEnvelope) async -> Bool {
|
||||
guard case .linkOffer(_, let set, let dk, _) = offer else { return false }
|
||||
guard let listed = try? await directory(of: myUserID, features: await relayFeatures()) else {
|
||||
linkNotices.append(
|
||||
LinkNotice(
|
||||
deviceID: "",
|
||||
text: String(
|
||||
localized:
|
||||
"A link was offered but the server's device list couldn't be read, so nothing was linked. Show the code again.")))
|
||||
return false
|
||||
}
|
||||
await refreshRevocations(for: set.map(\.deviceID))
|
||||
var adopted = 0
|
||||
for member in set where member.deviceID != store.deviceID {
|
||||
guard
|
||||
listed.contains(where: {
|
||||
$0.device_id == member.deviceID
|
||||
&& Data(e2eBase64: $0.ik_sig) == Data(base64Encoded: member.ikSig)
|
||||
}),
|
||||
!store.revokedDeviceIDs.contains(member.deviceID)
|
||||
else { continue }
|
||||
store.link(deviceID: member.deviceID, ikSig: member.ikSig)
|
||||
adopted += 1
|
||||
}
|
||||
guard adopted > 0 else {
|
||||
_ = Self.dropped("link offer named no device the relay lists", from: "")
|
||||
return false
|
||||
}
|
||||
store.link(deviceID: store.deviceID, ikSig: identity.ikSigPub.e2eBase64)
|
||||
// The account's delivery key, so a contact holding it reaches this
|
||||
// device sealed too (section 17.3, decision DA4).
|
||||
if store.deliveryKey != dk {
|
||||
store.deliveryKey = dk
|
||||
store.deliveryKeyPublished = false
|
||||
await ensureDeliveryKey()
|
||||
}
|
||||
for (id, key) in store.linkedDevices {
|
||||
writeSync("add", key: "link/\(id)", val: .string(key))
|
||||
}
|
||||
linkNotices.append(
|
||||
LinkNotice(deviceID: "", text: String(localized: "This device is now linked to your account.")))
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Unlinking, and the set following the key log (section 17.3)
|
||||
|
||||
/// Take a device out of the linked set. It stays signed in; it stops
|
||||
/// receiving copies, ops and handoffs.
|
||||
func unlinkDevice(_ deviceID: String, notice: String? = nil) {
|
||||
guard deviceID != store.deviceID, store.linkedDevices[deviceID] != nil else { return }
|
||||
store.unlink(deviceID: deviceID)
|
||||
writeSync("rm", key: "link/\(deviceID)")
|
||||
if let notice { linkNotices.append(LinkNotice(deviceID: deviceID, text: notice)) }
|
||||
}
|
||||
|
||||
/// Remove one of this account's devices (section 8.3a), saying so.
|
||||
func removeOwnDevice(_ deviceID: String) async throws {
|
||||
try await revokeOwnDevice(id: deviceID)
|
||||
let short = String(deviceID.prefix(8))
|
||||
unlinkDevice(deviceID, notice: String(localized: "You removed device \(short)."))
|
||||
}
|
||||
|
||||
/// Drop linked devices the relay has retired or stopped listing, and say
|
||||
/// which it was: the key log's `revoke` or `expire`, or neither.
|
||||
func reconcileLinkedDevices() async {
|
||||
let linked = otherLinkedDevices
|
||||
guard !linked.isEmpty,
|
||||
let listed = try? await directory(of: myUserID, features: await relayFeatures())
|
||||
else { return }
|
||||
await refreshRevocations(for: Array(linked.keys))
|
||||
let listedIDs = Set(listed.map(\.device_id))
|
||||
for (id, key) in linked {
|
||||
let short = String(id.prefix(8))
|
||||
if store.revokedDeviceIDs.contains(id) {
|
||||
unlinkDevice(
|
||||
id,
|
||||
notice: String(
|
||||
localized: """
|
||||
The server retired device \(short): it was removed from the \
|
||||
account, or signed out for inactivity or to make room for a new \
|
||||
sign-in. It no longer gets your messages.
|
||||
"""))
|
||||
} else if !listedIDs.contains(id) {
|
||||
unlinkDevice(
|
||||
id,
|
||||
notice: String(
|
||||
localized:
|
||||
"The server stopped listing device \(short) without saying why in its key log, so it was unlinked."))
|
||||
} else if let row = listed.first(where: { $0.device_id == id }), row.ik_sig != key {
|
||||
unlinkDevice(
|
||||
id,
|
||||
notice: String(
|
||||
localized: """
|
||||
The server now lists device \(short) under a different key, so \
|
||||
it was unlinked. If you didn't reinstall it, someone may be \
|
||||
posing as it.
|
||||
"""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync ops out (section 17.6, stage A3)
|
||||
|
||||
/// A register this device just wrote goes to the linked devices, batched
|
||||
/// for half a second: one op as a `sync`, several as one `sync_state`.
|
||||
func syncWritten(_ o: SyncOp) {
|
||||
guard !applyingSync, !suppressSyncBroadcast, hasLinkedDevices else { return }
|
||||
syncOutbox.append(o)
|
||||
guard syncFlushTask == nil else { return }
|
||||
syncFlushTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
await self?.flushSyncOutbox()
|
||||
}
|
||||
}
|
||||
|
||||
func flushSyncOutbox() async {
|
||||
syncFlushTask = nil
|
||||
let ops = syncOutbox
|
||||
syncOutbox = []
|
||||
guard !ops.isEmpty, hasLinkedDevices else { return }
|
||||
let envelopes: [String]
|
||||
if ops.count == 1 {
|
||||
envelopes = [MultiDeviceEnvelope.sync(ops[0]).json]
|
||||
} else {
|
||||
envelopes = Self.snapshotEnvelopes(ops, want: false)
|
||||
}
|
||||
for body in envelopes {
|
||||
do {
|
||||
try await transmit(body, to: myUserID)
|
||||
} catch {
|
||||
// Not lost for good: the next snapshot carries the register.
|
||||
os_log(
|
||||
"sync ops not sent to linked devices: %{public}@", log: Self.log,
|
||||
type: .error, String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers as `sync_state` chunks of at most 16 KiB of registers each.
|
||||
static func snapshotEnvelopes(_ registers: [SyncOp], want: Bool) -> [String] {
|
||||
let sorted = registers.sorted { $0.key < $1.key }
|
||||
let items = sorted.map { r -> [String: Any] in [r.key: r.registerJSON] }
|
||||
let chunks = SyncMerge.chunk(items)
|
||||
let byKey = Dictionary(sorted.map { ($0.key, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
return chunks.enumerated().map { i, chunk in
|
||||
let regs = chunk.compactMap { $0.keys.first.flatMap { byKey[$0] } }
|
||||
return MultiDeviceEnvelope.syncState(
|
||||
seq: i + 1, of: chunks.count, want: want && i == 0, registers: regs
|
||||
).json
|
||||
}
|
||||
}
|
||||
|
||||
/// Every register, to one linked device or (nil) to all of them.
|
||||
func sendSnapshot(to device: String?, want: Bool) async {
|
||||
seedMirrorRegistersIfNeeded()
|
||||
let registers = Array(syncState.registers.values)
|
||||
guard !registers.isEmpty else { return }
|
||||
for body in Self.snapshotEnvelopes(registers, want: want) {
|
||||
do {
|
||||
try await transmit(body, to: myUserID, onlyDevices: device.map { [$0] })
|
||||
} catch {
|
||||
os_log(
|
||||
"snapshot chunk not sent: %{public}@", log: Self.log, type: .error,
|
||||
String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 17.6: a device that has not polled for a week asks its linked
|
||||
/// devices for their snapshots (and sends its own); one away 90 days or
|
||||
/// more first forgets its own registers, keeping only `link/…`, because
|
||||
/// a removal it missed may already be forgotten elsewhere.
|
||||
func catchUpAfterBeingAway(lastPoll: Date?, now: Date = Date()) async {
|
||||
guard let lastPoll, hasLinkedDevices else { return }
|
||||
let away = SyncMerge.nowMs(now) - SyncMerge.nowMs(lastPoll)
|
||||
guard away >= SyncMerge.snapshotAfterMs else { return }
|
||||
if away >= SyncMerge.tombstoneMs {
|
||||
var state = syncState
|
||||
state.keepOnlyLinks()
|
||||
syncState = state
|
||||
flushHistory()
|
||||
// Offer only the links until the others' snapshots arrive.
|
||||
let links = Array(syncState.registers.values)
|
||||
for body in Self.snapshotEnvelopes(links, want: true) {
|
||||
try? await transmit(body, to: myUserID)
|
||||
}
|
||||
return
|
||||
}
|
||||
await sendSnapshot(to: nil, want: true)
|
||||
}
|
||||
|
||||
// MARK: - Sync ops in
|
||||
|
||||
/// Merge ops or snapshot registers from a linked device, and apply every
|
||||
/// register that changed.
|
||||
func mergeIncoming(_ ops: [SyncOp], now: Date = Date()) async {
|
||||
let nowMs = SyncMerge.nowMs(now)
|
||||
var state = syncState
|
||||
var changed: [String] = []
|
||||
for o in ops where state.merge(o, now: nowMs) { changed.append(o.key) }
|
||||
guard !changed.isEmpty else { return }
|
||||
syncState = state
|
||||
state.collectTombstones(now: nowMs)
|
||||
syncState = state
|
||||
applyingSync = true
|
||||
defer { applyingSync = false }
|
||||
for key in changed { await applyRegister(key) }
|
||||
flushHistory()
|
||||
}
|
||||
|
||||
/// Bring this device's state in line with one register.
|
||||
private func applyRegister(_ key: String) async {
|
||||
let family = SyncMerge.family(of: key)
|
||||
let rest = String(key.dropFirst(family.count + 1))
|
||||
let reg = syncState[key]
|
||||
let live = reg.map { !SyncMerge.isRemoval($0.op) } ?? false
|
||||
let val = live ? reg?.val : nil
|
||||
switch family {
|
||||
case "acc":
|
||||
return // read straight from the registers
|
||||
case "blocked":
|
||||
if live, let id = Int(rest) { discardHeldEnvelopes(for: id) }
|
||||
case "conv":
|
||||
return
|
||||
case "read":
|
||||
if let ms = val?.intValue { applyReadMark(thread: rest, through: ms) }
|
||||
case "link":
|
||||
guard rest != store.deviceID else { return }
|
||||
if live, let k = val?.stringValue, let keyData = Data(base64Encoded: k) {
|
||||
// A device another linked device vouched for is linked here
|
||||
// only if the relay lists it on this account under that key.
|
||||
guard (try? await verifyOwnDevice(rest, key: keyData)) != nil else { return }
|
||||
store.link(deviceID: rest, ikSig: k)
|
||||
} else if !live {
|
||||
store.unlink(deviceID: rest)
|
||||
}
|
||||
case "roster":
|
||||
guard let id = Int(rest) else { return }
|
||||
if live, let o = val?.objectValue {
|
||||
rosterAdd(
|
||||
id: id, handle: o["h"]?.stringValue ?? "",
|
||||
displayName: o["n"]?.stringValue ?? o["h"]?.stringValue ?? "")
|
||||
} else if !live {
|
||||
rosterRemove(id: id)
|
||||
}
|
||||
case "muted":
|
||||
if let id = Int(rest) { setStoryMuted(live, authorID: id) }
|
||||
case "gname":
|
||||
if let name = val?.stringValue, !name.isEmpty {
|
||||
store.setGroupName(name, for: rest)
|
||||
renameGroupsFromStore()
|
||||
}
|
||||
case "gsecret":
|
||||
guard let o = val?.any as? [String: Any],
|
||||
let data = try? JSONSerialization.data(withJSONObject: o),
|
||||
let secrets = try? JSONDecoder().decode(
|
||||
KeychainE2EStore.GroupSecrets.self, from: data)
|
||||
else { return }
|
||||
if let held = store.groupSecrets(rest), held.version >= secrets.version { return }
|
||||
store.setGroupSecrets(secrets, for: rest)
|
||||
await refreshSealedGroups()
|
||||
case "pin":
|
||||
if live, let o = val?.objectValue, let mid = o["mid"]?.stringValue,
|
||||
let at = o["at"]?.intValue, let by = o["by"]?.intValue
|
||||
{
|
||||
applyPin(mid, key: rest, by: Int(by), at: Date(timeIntervalSince1970: Double(at) / 1000))
|
||||
} else if !live, let current = pins[rest] {
|
||||
applyPin("", key: rest, by: current.by, at: Date())
|
||||
}
|
||||
case "audience":
|
||||
guard let o = val?.objectValue, let mode = o["mode"]?.stringValue,
|
||||
let parsed = E2EStoryAudience.Mode(rawValue: mode)
|
||||
else { return }
|
||||
let ids = (o["ids"].flatMap { v -> [Int]? in
|
||||
guard case .array(let a) = v else { return nil }
|
||||
return a.compactMap { $0.intValue.map(Int.init) }
|
||||
}) ?? []
|
||||
storyAudience = E2EStoryAudience(mode: parsed, userIDs: Set(ids))
|
||||
case "pdk":
|
||||
guard let id = Int(rest) else { return }
|
||||
if let b64 = val?.stringValue, let key = Data(e2eBase64: b64), key.count == 32 {
|
||||
store.setPeerDeliveryKey(key, for: id)
|
||||
} else if !live {
|
||||
store.clearPeerDeliveryKey(for: id)
|
||||
}
|
||||
case "tok":
|
||||
guard let id = Int(rest) else { return }
|
||||
if case .array(let a)? = val {
|
||||
store.setIssuedTokens(a.compactMap(\.stringValue), for: id)
|
||||
} else if !live {
|
||||
store.setIssuedTokens([], for: id)
|
||||
}
|
||||
case "ptok":
|
||||
// `ptok/i/<id>` (issued) and `ptok/h/<id>` (held).
|
||||
let parts = rest.split(separator: "/")
|
||||
guard parts.count == 2, let id = Int(parts[1]) else { return }
|
||||
let token = val?.stringValue.flatMap { Data(e2eBase64: $0)?.count == 32 ? $0 : nil }
|
||||
if parts[0] == "i" {
|
||||
presenceTokensIssued[id] = token
|
||||
await publishPresenceTags()
|
||||
} else if parts[0] == "h" {
|
||||
presenceTokensHeld[id] = token
|
||||
}
|
||||
case "dk":
|
||||
guard let b64 = val?.stringValue, let key = Data(e2eBase64: b64), key.count == 32,
|
||||
key != store.deliveryKey
|
||||
else { return }
|
||||
store.deliveryKey = key
|
||||
store.deliveryKeyPublished = false
|
||||
await ensureDeliveryKey()
|
||||
default:
|
||||
return // kept and passed on, never acted on
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 17.3: at link time every register goes in the snapshot, so the
|
||||
/// state that predates the registers is written as registers once.
|
||||
func seedMirrorRegistersIfNeeded() {
|
||||
guard !store.mirrorRegistersSeeded else { return }
|
||||
suppressSyncBroadcast = true
|
||||
defer { suppressSyncBroadcast = false }
|
||||
for entry in roster where !syncState.contains("roster/\(entry.id)") {
|
||||
writeSync(
|
||||
"add", key: "roster/\(entry.id)",
|
||||
val: .object(["h": .string(entry.handle), "n": .string(entry.displayName)]))
|
||||
}
|
||||
for id in mutedStoryAuthors where !syncState.contains("muted/\(id)") {
|
||||
writeSync("add", key: "muted/\(id)")
|
||||
}
|
||||
for (gid, name) in store.allGroupNames() where syncState["gname/\(gid)"] == nil {
|
||||
writeSync("set", key: "gname/\(gid)", val: .string(name))
|
||||
}
|
||||
for gid in store.allGroupSecrets().keys where syncState["gsecret/\(gid)"] == nil {
|
||||
syncGroupSecrets(gid)
|
||||
}
|
||||
for (key, pin) in persistableKeyed(pins) where syncState["pin/\(key)"] == nil {
|
||||
syncPin(key: key, pin: pin)
|
||||
}
|
||||
if syncState["audience"] == nil { syncAudience() }
|
||||
for (id, tokens) in store.allIssuedTokens() where syncState["tok/\(id)"] == nil {
|
||||
writeSync("set", key: "tok/\(id)", val: .array(tokens.map { .string($0) }))
|
||||
}
|
||||
for (id, token) in presenceTokensIssued where syncState["ptok/i/\(id)"] == nil {
|
||||
writeSync("set", key: "ptok/i/\(id)", val: .string(token))
|
||||
}
|
||||
for (id, token) in presenceTokensHeld where syncState["ptok/h/\(id)"] == nil {
|
||||
writeSync("set", key: "ptok/h/\(id)", val: .string(token))
|
||||
}
|
||||
for id in store.deliveryKeySentIDs + roster.map(\.id) {
|
||||
if syncState["pdk/\(id)"] == nil, let key = store.peerDeliveryKey(for: id) {
|
||||
writeSync("set", key: "pdk/\(id)", val: .string(key.e2eBase64))
|
||||
}
|
||||
}
|
||||
if syncState["dk"] == nil, let dk = store.deliveryKey {
|
||||
writeSync("set", key: "dk", val: .string(dk.e2eBase64))
|
||||
}
|
||||
store.mirrorRegistersSeeded = true
|
||||
}
|
||||
|
||||
// MARK: - Mirrored writes (stage A3): one call at each place state changes
|
||||
|
||||
func syncGroupSecrets(_ gid: String) {
|
||||
guard let secrets = store.groupSecrets(gid),
|
||||
let data = try? JSONEncoder().encode(secrets),
|
||||
let any = try? JSONSerialization.jsonObject(with: data),
|
||||
let val = JSONValue(any: any)
|
||||
else { return }
|
||||
writeSync("set", key: "gsecret/\(gid)", val: val)
|
||||
}
|
||||
|
||||
func syncGroupName(_ gid: String) {
|
||||
guard let name = store.groupName(gid) else { return }
|
||||
writeSync("set", key: "gname/\(gid)", val: .string(name))
|
||||
}
|
||||
|
||||
func syncPin(key: String, pin: E2EPin?) {
|
||||
guard !applyingSync else { return }
|
||||
if let pin {
|
||||
writeSync(
|
||||
"set", key: "pin/\(key)",
|
||||
val: .object([
|
||||
"mid": .string(pin.mid), "at": .int(SyncMerge.nowMs(pin.at)),
|
||||
"by": .int(Int64(pin.by)),
|
||||
]))
|
||||
} else {
|
||||
writeSync("del", key: "pin/\(key)")
|
||||
}
|
||||
}
|
||||
|
||||
func syncAudience() {
|
||||
guard !applyingSync else { return }
|
||||
writeSync(
|
||||
"set", key: "audience",
|
||||
val: .object([
|
||||
"mode": .string(storyAudience.mode.rawValue),
|
||||
"ids": .array(storyAudience.userIDs.sorted().map { .int(Int64($0)) }),
|
||||
]))
|
||||
}
|
||||
|
||||
/// A mirrored family's write, skipped while a linked device's op is
|
||||
/// being applied (that op is already everywhere it needs to be).
|
||||
func syncMirror(_ op: String, key: String, val: JSONValue? = nil) {
|
||||
guard !applyingSync else { return }
|
||||
writeSync(op, key: key, val: val)
|
||||
}
|
||||
|
||||
// MARK: - Sent copies (section 17.2)
|
||||
|
||||
/// Whether a plaintext sent to somebody is copied to this account's other
|
||||
/// linked devices. Every kind a person sends is, except: control preludes
|
||||
/// and key-log gossip, this section's own traffic, call signalling (the
|
||||
/// handoff of section 6.30 covers calls), off-the-record switches, avatars,
|
||||
/// stories (section 6.22.1 mirrors those itself), screenshot notices, and
|
||||
/// poll traffic, whose pieces are secret to each member. A group message
|
||||
/// is copied once, by the fan-out, not once per member.
|
||||
static func isCopiedToOwnDevices(_ body: String, forGroup: Bool = false) -> Bool {
|
||||
guard !body.isEmpty else { return false }
|
||||
guard body.hasPrefix(E2EEnvelopeRoute.controlPrefix) else { return !forGroup }
|
||||
if let kind = E2EEnvelopeRoute.controlKind(of: body),
|
||||
kind == "delivery_key" || kind == "kt_head" || MultiDeviceEnvelope.kinds.contains(kind)
|
||||
{
|
||||
return false
|
||||
}
|
||||
let c = E2EAttachment.parse(plaintext: body)
|
||||
if c.isUnknownControl || c.call != nil || c.offTheRecord != nil || c.isAvatar
|
||||
|| c.textStoryExpires != nil || c.attachment?.kind == .story
|
||||
|| c.screenshotAt != nil || c.poll != nil
|
||||
{
|
||||
return false
|
||||
}
|
||||
return forGroup == (c.gid != nil)
|
||||
}
|
||||
|
||||
/// Copy something just sent to `to` (or to group `gid`) to the linked
|
||||
/// devices. Never for an off-the-record conversation: it exists on the
|
||||
/// two devices that held it and nowhere else (decision DA5).
|
||||
func copyToLinkedDevices(_ body: String, to: Int? = nil, gid: String? = nil) {
|
||||
guard hasLinkedDevices, (to == nil) != (gid == nil) else { return }
|
||||
if let to {
|
||||
guard to != myUserID, !offTheRecord(for: to),
|
||||
Self.isCopiedToOwnDevices(body)
|
||||
else { return }
|
||||
} else {
|
||||
guard Self.isCopiedToOwnDevices(body, forGroup: true) else { return }
|
||||
}
|
||||
let mid = E2EAttachment.parse(plaintext: body).mid
|
||||
let copy = MultiDeviceEnvelope.sent(
|
||||
to: to, gid: gid, body: body, at: SyncMerge.nowMs(), mid: mid)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.transmit(copy.json, to: self.myUserID)
|
||||
} catch {
|
||||
os_log(
|
||||
"sent copy not delivered to linked devices: %{public}@", log: Self.log,
|
||||
type: .error, String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a copy as an outgoing message, exactly as the sending device
|
||||
/// did, and never notify on it.
|
||||
private func applySentCopy(to: Int?, gid: String?, body: String, at: Int64, mid: String?) {
|
||||
let content = E2EAttachment.parse(plaintext: body)
|
||||
let when = Date(timeIntervalSince1970: Double(at) / 1000)
|
||||
if let to {
|
||||
if let emoji = content.reaction, let re = content.re {
|
||||
applyReaction(emoji, mid: re.mid, from: myUserID, in: to,
|
||||
private: content.reactionIsPrivate)
|
||||
return
|
||||
}
|
||||
if let target = content.editOf {
|
||||
applyEdit(content.text, mid: target, byBuddy: false, at: when, in: to)
|
||||
return
|
||||
}
|
||||
if let pinned = content.pins {
|
||||
applyPin(pinned, key: Self.pinKey(buddyID: to), by: myUserID, at: when)
|
||||
return
|
||||
}
|
||||
if let seconds = content.disappearSeconds {
|
||||
store.setDisappearSeconds(seconds, for: to)
|
||||
return
|
||||
}
|
||||
let id = mid ?? content.mid
|
||||
if let id, threads[to]?.contains(where: { $0.mid == id }) == true { return }
|
||||
appendToThread(
|
||||
to,
|
||||
message: E2EMessage(
|
||||
id: nextLocalID(), isMine: true, text: content.text, date: when,
|
||||
attachment: content.attachment, mid: id, replyTo: content.re,
|
||||
replyToStory: content.repliesToStory ? true : nil,
|
||||
sticker: content.sticker, link: content.link, delivery: .delivered,
|
||||
isForwarded: content.isForwarded ? true : nil,
|
||||
lifetimeSeconds: content.lifetimeSeconds))
|
||||
} else if let gid {
|
||||
if let emoji = content.reaction, let re = content.re {
|
||||
applyGroupReaction(emoji, mid: re.mid, from: myUserID, in: gid,
|
||||
private: content.reactionIsPrivate)
|
||||
return
|
||||
}
|
||||
let id = mid ?? content.mid
|
||||
if let id, groupThreads[gid]?.contains(where: { $0.mid == id }) == true { return }
|
||||
appendToGroupThread(
|
||||
gid,
|
||||
message: E2EGroupMessage(
|
||||
id: nextLocalID(), authorID: myUserID, isMine: true, text: content.text,
|
||||
date: when, attachment: content.attachment, sticker: content.sticker,
|
||||
delivery: .delivered, mid: id, replyTo: content.re))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History backfill (section 17.4, stage A4)
|
||||
|
||||
/// Everything this device holds, newest first, at most 10,000 messages,
|
||||
/// in chunks of at most 16 KiB of items. Attachments go by reference.
|
||||
/// Off-the-record conversations are not sent.
|
||||
func historyItems() -> [MultiDeviceEnvelope.HistoryItem] {
|
||||
var items: [MultiDeviceEnvelope.HistoryItem] = []
|
||||
for (peer, msgs) in persistable(threads) {
|
||||
for m in msgs {
|
||||
items.append(
|
||||
.init(
|
||||
thread: Self.pinKey(buddyID: peer), mid: m.mid,
|
||||
at: SyncMerge.nowMs(m.date), outgoing: m.isMine,
|
||||
from: m.isMine ? nil : peer, body: Self.historyBody(m)))
|
||||
}
|
||||
}
|
||||
for (gid, msgs) in groupThreads {
|
||||
for m in msgs {
|
||||
items.append(
|
||||
.init(
|
||||
thread: Self.pinKey(gid: gid), mid: m.mid,
|
||||
at: SyncMerge.nowMs(m.date), outgoing: m.isMine,
|
||||
from: m.isMine ? nil : m.authorID,
|
||||
body: Self.historyBody(text: m.text, attachment: m.attachment,
|
||||
sticker: m.sticker, mid: m.mid, gid: gid)))
|
||||
}
|
||||
}
|
||||
return Array(
|
||||
items.sorted { $0.at > $1.at }.prefix(SyncMerge.historyMaxItems))
|
||||
}
|
||||
|
||||
static func historyBody(_ m: E2EMessage) -> String {
|
||||
historyBody(text: m.text, attachment: m.attachment, sticker: m.sticker, mid: m.mid, gid: nil)
|
||||
}
|
||||
|
||||
/// The plaintext a message was, as near as this device can rebuild it: a
|
||||
/// content envelope when it carried anything but words.
|
||||
static func historyBody(
|
||||
text: String, attachment: E2EAttachmentRef?, sticker: E2EAttachment.StickerRef?,
|
||||
mid: String?, gid: String?
|
||||
) -> String {
|
||||
if let sticker {
|
||||
return (try? E2EAttachment.encodeSticker(
|
||||
pack: sticker.pack, id: sticker.id, gid: gid, mid: mid)) ?? text
|
||||
}
|
||||
if let attachment {
|
||||
return (try? E2EAttachment.encodeMessage(
|
||||
ref: attachment, caption: text, gid: gid, mid: mid)) ?? text
|
||||
}
|
||||
if let gid {
|
||||
return (try? E2EAttachment.encodeGroupText(text, gid: gid, mid: mid)) ?? text
|
||||
}
|
||||
if mid == nil { return text }
|
||||
return (try? E2EAttachment.encodeText(text, mid: mid)) ?? text
|
||||
}
|
||||
|
||||
func sendHistory(to device: String) async {
|
||||
let items = historyItems()
|
||||
guard !items.isEmpty else { return }
|
||||
let rendered = items.map { item -> [String: Any] in
|
||||
var o: [String: Any] = ["t": item.thread, "at": item.at, "body": item.body]
|
||||
if let mid = item.mid { o["mid"] = mid }
|
||||
return o
|
||||
}
|
||||
let chunks = SyncMerge.chunk(rendered)
|
||||
var start = 0
|
||||
for (i, chunk) in chunks.enumerated() {
|
||||
let slice = Array(items[start..<(start + chunk.count)])
|
||||
start += chunk.count
|
||||
let env = MultiDeviceEnvelope.history(seq: i + 1, of: chunks.count, items: slice)
|
||||
do {
|
||||
try await transmit(env.json, to: myUserID, onlyDevices: [device])
|
||||
} catch {
|
||||
// Best effort: a partial backfill is tolerated by design.
|
||||
os_log(
|
||||
"history chunk %{public}d of %{public}d not sent: %{public}@", log: Self.log,
|
||||
type: .error, i + 1, chunks.count, String(describing: error))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge a chunk of history: each item once (by `mid`), in time order.
|
||||
private func applyHistory(_ items: [MultiDeviceEnvelope.HistoryItem]) {
|
||||
for item in items {
|
||||
let when = Date(timeIntervalSince1970: Double(item.at) / 1000)
|
||||
let content = E2EAttachment.parse(plaintext: item.body)
|
||||
if item.thread.hasPrefix("u:"), let peer = Int(item.thread.dropFirst(2)) {
|
||||
if let mid = item.mid, threads[peer]?.contains(where: { $0.mid == mid }) == true {
|
||||
continue
|
||||
}
|
||||
insertHistory(
|
||||
E2EMessage(
|
||||
id: nextLocalID(), isMine: item.outgoing, text: content.text, date: when,
|
||||
attachment: content.attachment, mid: item.mid, sticker: content.sticker,
|
||||
delivery: item.outgoing ? .delivered : nil),
|
||||
peer: peer)
|
||||
} else if item.thread.hasPrefix("g:") {
|
||||
let gid = String(item.thread.dropFirst(2))
|
||||
if let mid = item.mid, groupThreads[gid]?.contains(where: { $0.mid == mid }) == true {
|
||||
continue
|
||||
}
|
||||
insertGroupHistory(
|
||||
E2EGroupMessage(
|
||||
id: nextLocalID(), authorID: item.outgoing ? myUserID : (item.from ?? 0),
|
||||
isMine: item.outgoing, text: content.text, date: when,
|
||||
attachment: content.attachment, sticker: content.sticker,
|
||||
delivery: item.outgoing ? .delivered : nil, mid: item.mid),
|
||||
gid: gid)
|
||||
}
|
||||
}
|
||||
flushHistory()
|
||||
}
|
||||
|
||||
// MARK: - Dispatch
|
||||
|
||||
/// Every multi-device kind arrives here (`E2EEnvelopeRoute.multiDevice`).
|
||||
/// A `link_offer` is taken from any own device (it proves itself with the
|
||||
/// secret or the number); everything else only from a linked one, under
|
||||
/// the key it was linked with (section 17.6). `rekey_request` comes from
|
||||
/// contacts and is stage A5.
|
||||
func handleMultiDevice(
|
||||
_ text: String, fromUser: Int, fromDevice: String,
|
||||
senderProven: Bool, senderKey: Data?, sentAt: Date
|
||||
) async {
|
||||
guard let envelope = MultiDeviceEnvelope(text: text) else {
|
||||
_ = Self.dropped("multi-device envelope that does not parse", from: fromDevice)
|
||||
return
|
||||
}
|
||||
if case .rekeyRequest = envelope {
|
||||
_ = Self.dropped("rekey request: not acted on before stage A5", from: fromDevice)
|
||||
return
|
||||
}
|
||||
guard senderProven, fromUser == myUserID else {
|
||||
_ = Self.dropped("multi-device envelope from another account", from: fromDevice)
|
||||
return
|
||||
}
|
||||
if case .linkOffer = envelope {
|
||||
await receiveLinkOffer(envelope, fromDevice: fromDevice, senderKey: senderKey)
|
||||
return
|
||||
}
|
||||
guard isFromLinkedOwnDevice(fromUser: fromUser, fromDevice: fromDevice, senderKey: senderKey)
|
||||
else {
|
||||
_ = Self.dropped("multi-device envelope from an own device that is not linked", from: fromDevice)
|
||||
return
|
||||
}
|
||||
switch envelope {
|
||||
case .sync(let o):
|
||||
await mergeIncoming([o])
|
||||
case .syncState(_, _, let want, let registers):
|
||||
await mergeIncoming(registers)
|
||||
if want {
|
||||
// Answered with this device's own, without asking back.
|
||||
Task { [weak self] in await self?.sendSnapshot(to: fromDevice, want: false) }
|
||||
}
|
||||
case .sent(let to, let gid, let body, let at, let mid):
|
||||
applySentCopy(to: to, gid: gid, body: body, at: at, mid: mid)
|
||||
case .history(_, _, let items):
|
||||
applyHistory(items)
|
||||
case .linkOffer, .rekeyRequest:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// A numeric-fallback link offer waiting for the person to compare the number
|
||||
/// on this device too.
|
||||
struct PendingLinkOffer: Equatable {
|
||||
let offer: MultiDeviceEnvelope
|
||||
let fromDevice: String
|
||||
let senderKey: Data
|
||||
let safetyNumber: String
|
||||
}
|
||||
|
||||
extension E2EInbox {
|
||||
/// Whether a device of this account is in this device's linked set.
|
||||
func isLinkedHere(_ deviceID: String) -> Bool { store.linkedDevices[deviceID] != nil }
|
||||
|
||||
/// The key the relay lists for one of this account's devices, for the
|
||||
/// numeric fallback's number.
|
||||
func ownDeviceKey(_ device: E2EDevicesResponse.Device) -> Data? {
|
||||
Data(e2eBase64: device.ik_sig)
|
||||
}
|
||||
}
|
||||
+978
-149
File diff suppressed because it is too large
Load Diff
@@ -126,6 +126,9 @@ struct E2EMessagesSendRequest: Encodable {
|
||||
var delivery_key: String?
|
||||
/// §21.4, a single-use capability, spent on acceptance.
|
||||
var delivery_token: String?
|
||||
/// Section 14.5: an introduction from `POST /e2e/intro`, good for one
|
||||
/// sealed submission to one account within ten minutes.
|
||||
var intro: String? = nil
|
||||
/// Time-critical: ask the relay to ring rather than merely wake (§6.14).
|
||||
var urgent: Bool?
|
||||
/// Deliver without announcing: protocol traffic, not a message.
|
||||
@@ -181,6 +184,9 @@ struct E2EMessagesSendResponse: Decodable {
|
||||
struct E2EMessagesPollResponse: Decodable {
|
||||
let messages: [Incoming]
|
||||
let cursor: Int
|
||||
/// Section 8.5: the relay stopped early at its byte cap and has more
|
||||
/// waiting. Present only then, never `false`. Absent from older relays.
|
||||
var more: Bool? = nil
|
||||
|
||||
/// For a sealed row the relay does not know the sender: `from_user` is 0
|
||||
/// and `from_device` is empty.
|
||||
@@ -302,7 +308,16 @@ struct E2EGroup: Decodable, Identifiable, Hashable {
|
||||
func memberName(_ userID: Int) -> String {
|
||||
let member = members.first { $0.user_id == userID }
|
||||
if let name = member?.name, !name.isEmpty { return name }
|
||||
return "Member #\(userID)"
|
||||
return String(localized: "Member #\(String(userID))")
|
||||
}
|
||||
|
||||
/// What a screen calls one member: "You" for this account, a buddy's
|
||||
/// name when there is one, otherwise the roster's placeholder. Nobody is
|
||||
/// their own buddy and the relay's roster carries no names, so without
|
||||
/// the first rule this account was "Member #N" in every group it was in.
|
||||
func displayName(of userID: Int, me: Int, known: String?) -> String {
|
||||
if userID == me { return "You" }
|
||||
return known ?? memberName(userID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +427,37 @@ struct E2ELogDeviceEntries: Decodable {
|
||||
struct E2ERelayDescriptor: Decodable {
|
||||
let audience: String
|
||||
let issuerPublicKey: String
|
||||
/// Switches a client reads before using something older clients cannot
|
||||
/// handle. Absent on relays that predate them, which means every switch
|
||||
/// is off.
|
||||
var features: Features? = nil
|
||||
|
||||
struct Features: Decodable, Equatable {
|
||||
/// Section 23.9: senders may pad attachments, because every receiver
|
||||
/// still in use reads `clen`. Absent or false means off.
|
||||
var padAttachments: Bool? = nil
|
||||
/// Section 17.6: the relay keys delivery-key digests and roster tags
|
||||
/// by account and device rather than by account alone.
|
||||
var perDeviceDigests: Bool? = nil
|
||||
/// Section 14.5, stage C1: `POST /e2e/intro`, its proof-of-work
|
||||
/// challenge, and `GET /e2e/handle-cert`. Turns on handle
|
||||
/// certificates (C2) and sealed first contact with no named fallback
|
||||
/// (C3).
|
||||
var introductions: Bool? = nil
|
||||
/// Section 8.13, stage D1: `/e2e/anon/presence|devices|bundles` and
|
||||
/// `GET /e2e/devices/me`. Turns on reads that name nobody (D2).
|
||||
var anonymousReads: Bool? = nil
|
||||
/// Section 8.14, stage B2: `POST /e2e/contact-links`, reusable links.
|
||||
var contactLinks: Bool? = nil
|
||||
/// Section 8.14, stage B1: the Listed / Unlisted setting on
|
||||
/// `/messenger/me` (`discoverable`).
|
||||
var unlisted: Bool? = nil
|
||||
/// `POST /api/flamenet/v1/search`: web search the relay asks of its
|
||||
/// own index on a member's behalf and never keeps.
|
||||
var webSearch: Bool? = nil
|
||||
|
||||
static let off = Features()
|
||||
}
|
||||
}
|
||||
|
||||
struct E2ELogInclusion: Decodable {
|
||||
@@ -442,14 +488,14 @@ struct E2EWipeOrderResponse: Decodable {
|
||||
struct KeyNotLoggedError: LocalizedError {
|
||||
let deviceID: String
|
||||
var errorDescription: String? {
|
||||
"The keys offered for this contact are not in the server's public key log."
|
||||
String(localized: "The keys offered for this contact are not in the server's public key log.")
|
||||
}
|
||||
}
|
||||
|
||||
/// §19, the log no longer extends the one we were shown before.
|
||||
struct KeyLogForkError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
"The server's key log no longer matches what it published before."
|
||||
String(localized: "The server's key log no longer matches what it published before.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,8 +505,9 @@ struct KeyLogForkError: LocalizedError {
|
||||
struct PQDowngradeError: LocalizedError {
|
||||
let deviceID: String
|
||||
var errorDescription: String? {
|
||||
"This contact's device stopped offering a post-quantum identity. "
|
||||
+ "This can mean the connection is being tampered with."
|
||||
String(
|
||||
localized:
|
||||
"This contact's device stopped offering a post-quantum identity. This can mean the connection is being tampered with.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,8 +515,9 @@ struct PQDowngradeError: LocalizedError {
|
||||
struct PQVerificationError: LocalizedError {
|
||||
let deviceID: String
|
||||
var errorDescription: String? {
|
||||
"This contact's post-quantum signature did not verify. "
|
||||
+ "The keys offered for them cannot be trusted."
|
||||
String(
|
||||
localized:
|
||||
"This contact's post-quantum signature did not verify. The keys offered for them cannot be trusted.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,8 +525,8 @@ struct PQVerificationError: LocalizedError {
|
||||
struct PQIdentityChangedError: LocalizedError {
|
||||
let deviceID: String
|
||||
var errorDescription: String? {
|
||||
"This contact's post-quantum identity key changed. "
|
||||
+ "Compare safety numbers before continuing."
|
||||
String(
|
||||
localized: "This contact's post-quantum identity key changed. Compare safety numbers before continuing.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,3 +605,30 @@ struct PushTokenRequest: Encodable {
|
||||
struct PushTokenResponse: Decodable {
|
||||
let ok: Bool
|
||||
}
|
||||
|
||||
/// Section 14.5, what `POST /e2e/intro` answers: the account, its handle
|
||||
/// certificate, its bundles (one prekey popped per device), and one
|
||||
/// capability good for one sealed submission before `exp`.
|
||||
struct E2EIntroAnswer {
|
||||
let userID: Int
|
||||
let fnid: String?
|
||||
let intro: Data
|
||||
let exp: Int
|
||||
let handleCert: HandleCertificate?
|
||||
let devices: [E2EKeysResponse.Bundle]
|
||||
|
||||
/// The bundles as the directory would list them, for the trust check.
|
||||
var asDirectory: [E2EDevicesResponse.Device] {
|
||||
devices.map {
|
||||
E2EDevicesResponse.Device(
|
||||
device_id: $0.device_id, ik_dh: $0.ik_dh, ik_sig: $0.ik_sig,
|
||||
proto: $0.proto, caps_sig: $0.caps_sig, created_at: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 8.13, the challenge an introduction by handle has to answer.
|
||||
struct E2EIntroChallenge: Decodable {
|
||||
let challenge: String
|
||||
let bits: Int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Section 14.5, a relay's signature over an account's handle:
|
||||
/// `fnhandle-v1|<audience>|<user id>|<handle>|<exp>`, Ed25519 under the key the
|
||||
/// relay signs its log heads with, which this app already pins.
|
||||
///
|
||||
/// It replaces asking the relay "who is user 12?", a question the relay
|
||||
/// answers to anyone and can log. A name is shown from one only after it
|
||||
/// verifies against the pinned key, for this relay, for the account it is
|
||||
/// about, before it expires.
|
||||
struct HandleCertificate: Codable, Equatable {
|
||||
let v: Int
|
||||
let aud: String
|
||||
let uid: Int
|
||||
let h: String
|
||||
let exp: Int
|
||||
let sig: String
|
||||
|
||||
/// The exact bytes the relay signs, built by concatenation so every
|
||||
/// verifier reproduces them without agreeing on a JSON encoder.
|
||||
static func canonical(audience: String, userID: Int, handle: String, exp: Int) -> Data {
|
||||
Data("fnhandle-v1|\(audience)|\(userID)|\(handle)|\(exp)".utf8)
|
||||
}
|
||||
|
||||
/// Whether the fields can be signed at all: no field holds the separator,
|
||||
/// and the id and expiry are positive. Checked before any signature.
|
||||
var isWellFormed: Bool {
|
||||
guard v == 1, !aud.isEmpty, !aud.contains("|") else { return false }
|
||||
guard !h.isEmpty, h.utf16.count <= 64, !h.contains("|") else { return false }
|
||||
return uid > 0 && exp > 0
|
||||
}
|
||||
|
||||
/// Verify against the relay's pinned key.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - issuerKey: the relay's pinned Ed25519 public key, 32 bytes. None
|
||||
/// pinned means nothing can be verified.
|
||||
/// - audience: the relay this app is talking to.
|
||||
/// - userID: the account the certificate must name: for a sender, the
|
||||
/// one the ratchet proved; for an intro, the one the intro answered.
|
||||
func verifies(
|
||||
issuerKey: Data?, audience: String, userID: Int, now: Date = Date()
|
||||
) -> Bool {
|
||||
guard isWellFormed, let issuerKey, issuerKey.count == 32 else { return false }
|
||||
guard aud == audience, uid == userID else { return false }
|
||||
guard Double(exp) > now.timeIntervalSince1970.rounded(.down) else { return false }
|
||||
guard let signature = Data(base64Encoded: sig), signature.count == 64,
|
||||
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: issuerKey)
|
||||
else { return false }
|
||||
return key.isValidSignature(
|
||||
signature,
|
||||
for: Self.canonical(audience: aud, userID: uid, handle: h, exp: exp))
|
||||
}
|
||||
|
||||
/// From a JSON object as a prelude or an intro carries it. `uid` and
|
||||
/// `exp` must be integers; anything else is not a certificate.
|
||||
init?(json: [String: Any]) {
|
||||
guard let v = JSONValue(any: json["v"])?.intValue,
|
||||
let aud = json["aud"] as? String,
|
||||
let uid = JSONValue(any: json["uid"])?.intValue,
|
||||
let h = json["h"] as? String,
|
||||
let exp = JSONValue(any: json["exp"])?.intValue,
|
||||
let sig = json["sig"] as? String,
|
||||
uid <= SyncMerge.maxSafeInteger, exp <= SyncMerge.maxSafeInteger
|
||||
else { return nil }
|
||||
self.v = Int(v)
|
||||
self.aud = aud
|
||||
self.uid = Int(uid)
|
||||
self.h = h
|
||||
self.exp = Int(exp)
|
||||
self.sig = sig
|
||||
}
|
||||
|
||||
init(v: Int = 1, aud: String, uid: Int, h: String, exp: Int, sig: String) {
|
||||
self.v = v
|
||||
self.aud = aud
|
||||
self.uid = uid
|
||||
self.h = h
|
||||
self.exp = exp
|
||||
self.sig = sig
|
||||
}
|
||||
|
||||
var json: [String: Any] { ["v": v, "aud": aud, "uid": uid, "h": h, "exp": exp, "sig": sig] }
|
||||
|
||||
/// The certificate as a prelude carries it: `v, aud, uid, h, exp, sig`,
|
||||
/// and `/` in the signature left alone.
|
||||
var orderedJSON: String {
|
||||
OrderedJSON.object([
|
||||
("v", String(v)), ("aud", OrderedJSON.string(aud)), ("uid", String(uid)),
|
||||
("h", OrderedJSON.string(h)), ("exp", String(exp)), ("sig", OrderedJSON.string(sig)),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,19 @@ final class KeychainE2EStore {
|
||||
/// and the history key rides the backup next to the file it unlocks.
|
||||
static let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
init(userId: Int) {
|
||||
/// Nil for every real install. A test that needs two devices of ONE
|
||||
/// account in one process (linking, sent copies, sync) gives each a slot,
|
||||
/// which keeps their keys, suite and history file apart; without it the
|
||||
/// second device would read the first one's identity and be the same
|
||||
/// device.
|
||||
let slot: String?
|
||||
|
||||
init(userId: Int, slot: String? = nil) {
|
||||
self.userID = userId
|
||||
self.slot = slot
|
||||
let suffix = slot.map { ".s\($0)" } ?? ""
|
||||
self.suite =
|
||||
UserDefaults(suiteName: "io.flamenet.messenger.e2e.u\(userId)")
|
||||
UserDefaults(suiteName: "io.flamenet.messenger.e2e.u\(userId)\(suffix)")
|
||||
?? .standard
|
||||
migrateAccessibilityIfNeeded()
|
||||
}
|
||||
@@ -102,6 +111,11 @@ final class KeychainE2EStore {
|
||||
// device on an account that no longer has it registered, which is the
|
||||
// mismatch this store exists to keep straight.
|
||||
"device_id",
|
||||
// Section 17.3. Which of this account's other devices a person linked
|
||||
// to this one; everything this device sends to its own account goes
|
||||
// to these and no others, so a set left behind would be trusted by
|
||||
// whoever signs in next.
|
||||
"linked_devices",
|
||||
]
|
||||
|
||||
/// Re-write the long-term items once, so items created before the switch to
|
||||
@@ -441,6 +455,64 @@ final class KeychainE2EStore {
|
||||
set { suite.set(newValue, forKey: "delivery_key_published") }
|
||||
}
|
||||
|
||||
/// Relays this device has republished its delivery-key digest to since
|
||||
/// they began keying digests by device (section 17.6). Once per relay:
|
||||
/// a device whose digest was overwritten under the old behaviour has no
|
||||
/// other reason ever to publish it again.
|
||||
func hasRepublishedDeliveryKey(forRelay relay: String) -> Bool {
|
||||
(suite.stringArray(forKey: "per_device_digest_relays") ?? []).contains(relay)
|
||||
}
|
||||
|
||||
func markDeliveryKeyRepublished(forRelay relay: String) {
|
||||
var relays = suite.stringArray(forKey: "per_device_digest_relays") ?? []
|
||||
guard !relays.contains(relay) else { return }
|
||||
relays.append(relay)
|
||||
suite.set(relays, forKey: "per_device_digest_relays")
|
||||
}
|
||||
|
||||
// MARK: - Linked devices (section 17.3)
|
||||
|
||||
/// This account's other devices that a person has linked to this one: the
|
||||
/// device id, and the base64 `IK_sig` it was linked under.
|
||||
///
|
||||
/// Starts empty, and nothing in this release adds to it: linking itself
|
||||
/// arrives later (stage A2). Until then a device that merely signed in
|
||||
/// with the password is a device of the account and nothing more, and
|
||||
/// gets none of what this device sends to its own account, except the
|
||||
/// signal that stops a ring. In the Keychain rather than the defaults
|
||||
/// suite, because it decides who receives copies of everything.
|
||||
var linkedDevices: [String: String] {
|
||||
get {
|
||||
guard let blob = readKeychain("linked_devices"),
|
||||
let obj = try? JSONSerialization.jsonObject(with: blob) as? [String: String]
|
||||
else { return [:] }
|
||||
return obj
|
||||
}
|
||||
set {
|
||||
guard let blob = try? JSONSerialization.data(withJSONObject: newValue) else { return }
|
||||
writeKeychain("linked_devices", data: blob)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a device is linked under exactly this key. A device id the
|
||||
/// relay now lists under a different key is a substitution, not the
|
||||
/// device that was linked.
|
||||
func isLinked(deviceID: String, ikSig: String) -> Bool {
|
||||
linkedDevices[deviceID] == ikSig
|
||||
}
|
||||
|
||||
func link(deviceID: String, ikSig: String) {
|
||||
var set = linkedDevices
|
||||
set[deviceID] = ikSig
|
||||
linkedDevices = set
|
||||
}
|
||||
|
||||
func unlink(deviceID: String) {
|
||||
var set = linkedDevices
|
||||
set[deviceID] = nil
|
||||
linkedDevices = set
|
||||
}
|
||||
|
||||
/// Delivery keys contacts have sent us.
|
||||
private func loadPeerDeliveryKeys() -> [String: String] {
|
||||
guard let blob = readKeychain("peer_delivery_keys"),
|
||||
@@ -605,6 +677,92 @@ final class KeychainE2EStore {
|
||||
// MARK: - §21.4 single-use delivery capabilities
|
||||
|
||||
/// Tokens we ISSUED, by contact.
|
||||
/// Every batch this account issued, keyed by contact (section 16.2.1's
|
||||
/// `deliveryTokensIssued`).
|
||||
func allIssuedTokens() -> [Int: [String]] {
|
||||
let all = (suite.dictionary(forKey: "issued_tokens") as? [String: [String]]) ?? [:]
|
||||
var out: [Int: [String]] = [:]
|
||||
for (k, v) in all { if let id = Int(k), !v.isEmpty { out[id] = v } }
|
||||
return out
|
||||
}
|
||||
|
||||
/// Stage 2-B seeding, once from this device's own history and once from
|
||||
/// the relay. In the suite, so a wipe or sign-out starts it again.
|
||||
var accountStateSeededFromDevice: Bool {
|
||||
get { suite.bool(forKey: "account_state_seeded_device") }
|
||||
set { suite.set(newValue, forKey: "account_state_seeded_device") }
|
||||
}
|
||||
// MARK: - First contact (section 14.5, 8.12)
|
||||
|
||||
/// Handle certificates that verified, as JSON, keyed by account id. The
|
||||
/// name a Requests row shows when the relay vouched for it.
|
||||
func certifiedHandle(for userID: Int) -> HandleCertificate? {
|
||||
guard let all = suite.dictionary(forKey: "handle_certs") as? [String: [String: Any]],
|
||||
let o = all["\(userID)"]
|
||||
else { return nil }
|
||||
return HandleCertificate(json: o)
|
||||
}
|
||||
|
||||
func setCertifiedHandle(_ cert: HandleCertificate, for userID: Int) {
|
||||
var all = (suite.dictionary(forKey: "handle_certs") as? [String: [String: Any]]) ?? [:]
|
||||
all["\(userID)"] = cert.json
|
||||
suite.set(all, forKey: "handle_certs")
|
||||
}
|
||||
|
||||
/// One-time contact links this account made and has not revoked: the
|
||||
/// delivery tokens they carry (section 8.14), base64.
|
||||
var oneTimeLinkTokens: [String] {
|
||||
get { suite.stringArray(forKey: "one_time_links") ?? [] }
|
||||
set { suite.set(newValue, forKey: "one_time_links") }
|
||||
}
|
||||
|
||||
/// Reusable contact links this account made: the 32-byte capabilities,
|
||||
/// base64. The relay holds only their SHA-256.
|
||||
var reusableLinks: [String] {
|
||||
get { suite.stringArray(forKey: "reusable_links") ?? [] }
|
||||
set { suite.set(newValue, forKey: "reusable_links") }
|
||||
}
|
||||
|
||||
/// The reusable link a conversation was started from, keyed by account,
|
||||
/// so a refused send can ask for one fresh introduction (section 14.5).
|
||||
func startedFromLink(_ userID: Int) -> Data? {
|
||||
(suite.dictionary(forKey: "started_from_link") as? [String: String])?["\(userID)"]
|
||||
.flatMap { Data(base64Encoded: $0) }
|
||||
}
|
||||
|
||||
func setStartedFromLink(_ link: Data, for userID: Int) {
|
||||
var all = (suite.dictionary(forKey: "started_from_link") as? [String: String]) ?? [:]
|
||||
all["\(userID)"] = link.base64EncodedString()
|
||||
suite.set(all, forKey: "started_from_link")
|
||||
}
|
||||
|
||||
/// When a poll last reached the relay (section 17.6: a device away a
|
||||
/// week or more asks its linked devices for snapshots).
|
||||
var lastPollSucceededAt: Date? {
|
||||
get {
|
||||
let t = suite.double(forKey: "last_poll_succeeded_at")
|
||||
return t > 0 ? Date(timeIntervalSince1970: t) : nil
|
||||
}
|
||||
set { suite.set(newValue?.timeIntervalSince1970 ?? 0, forKey: "last_poll_succeeded_at") }
|
||||
}
|
||||
|
||||
/// Section 17.3: state that predates the registers has been written as
|
||||
/// registers once, so a snapshot carries all of it.
|
||||
var mirrorRegistersSeeded: Bool {
|
||||
get { suite.bool(forKey: "mirror_registers_seeded") }
|
||||
set { suite.set(newValue, forKey: "mirror_registers_seeded") }
|
||||
}
|
||||
var accountStateSeededFromRelay: Bool {
|
||||
get { suite.bool(forKey: "account_state_seeded_relay") }
|
||||
set { suite.set(newValue, forKey: "account_state_seeded_relay") }
|
||||
}
|
||||
|
||||
/// Contacts this account has handed its delivery key, so a rotation can
|
||||
/// push the new one to each (section 14.4, decision DC6).
|
||||
var deliveryKeySentIDs: [Int] {
|
||||
suite.array(forKey: "delivery_key_sent_ids") as? [Int] ?? []
|
||||
}
|
||||
|
||||
func issuedTokens(for userID: Int) -> [String] {
|
||||
(suite.dictionary(forKey: "issued_tokens") as? [String: [String]])?["\(userID)"] ?? []
|
||||
}
|
||||
@@ -1192,7 +1350,7 @@ final class KeychainE2EStore {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: "\(account).u\(userID)",
|
||||
kSecAttrAccount as String: "\(account).u\(userID)\(slot.map { ".s\($0)" } ?? "")",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Base64url without padding, the spelling every link and code in sections
|
||||
/// 8.12 and 17.3 uses.
|
||||
enum Base64URL {
|
||||
static func encode(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
/// Nil for anything outside `[A-Za-z0-9_-]`, padded input included, or a
|
||||
/// length no byte string has.
|
||||
static func decode(_ s: String) -> Data? {
|
||||
guard s.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") })
|
||||
else { return nil }
|
||||
let remainder = s.count % 4
|
||||
guard remainder != 1 else { return nil }
|
||||
let padded =
|
||||
s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
+ String(repeating: "=", count: remainder == 0 ? 0 : 4 - remainder)
|
||||
return Data(base64Encoded: padded)
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 17.3, the code a NEW device shows so an existing linked device can
|
||||
/// link it:
|
||||
/// `flamenet:link?d=<device id>&k=<IK_sig, base64url>&s=<secret, base64url>`.
|
||||
struct DeviceLinkCode: Equatable {
|
||||
let deviceID: String
|
||||
let ikSig: Data
|
||||
let secret: Data
|
||||
|
||||
/// How long a shown code is good for.
|
||||
static let lifetime: TimeInterval = 10 * 60
|
||||
|
||||
var text: String {
|
||||
"flamenet:link?d=\(deviceID)&k=\(Base64URL.encode(ikSig))&s=\(Base64URL.encode(secret))"
|
||||
}
|
||||
|
||||
/// A fresh secret: 16 random bytes, replaced each time the code is shown.
|
||||
static func freshSecret() -> Data {
|
||||
var bytes = Data(count: 16)
|
||||
_ = bytes.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Parse a scanned code, or nil. The key must be 32 bytes and the secret
|
||||
/// 16 to 32; anything else is not a link code and is refused whole.
|
||||
init?(text raw: String) {
|
||||
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard s.lowercased().hasPrefix("flamenet:link?") else { return nil }
|
||||
let query = String(s.dropFirst("flamenet:link?".count))
|
||||
var q: [String: String] = [:]
|
||||
for pair in query.split(separator: "&", omittingEmptySubsequences: false) {
|
||||
guard let eq = pair.firstIndex(of: "="), eq > pair.startIndex else { return nil }
|
||||
let k = String(pair[..<eq])
|
||||
guard q[k] == nil else { return nil }
|
||||
q[k] = String(pair[pair.index(after: eq)...])
|
||||
}
|
||||
let device = (q["d"] ?? "").lowercased()
|
||||
guard Self.isDeviceID(device),
|
||||
let key = q["k"].flatMap(Base64URL.decode), key.count == 32,
|
||||
let secret = q["s"].flatMap(Base64URL.decode), (16...32).contains(secret.count)
|
||||
else { return nil }
|
||||
self.deviceID = device
|
||||
self.ikSig = key
|
||||
self.secret = secret
|
||||
}
|
||||
|
||||
init(deviceID: String, ikSig: Data, secret: Data) {
|
||||
self.deviceID = deviceID
|
||||
self.ikSig = ikSig
|
||||
self.secret = secret
|
||||
}
|
||||
|
||||
static func isDeviceID(_ s: String) -> Bool {
|
||||
let parts = s.split(separator: "-", omittingEmptySubsequences: false)
|
||||
guard parts.map(\.count) == [8, 4, 4, 4, 12] else { return false }
|
||||
return s.allSatisfy { $0 == "-" || ("0"..."9").contains($0) || ("a"..."f").contains($0) }
|
||||
}
|
||||
|
||||
/// Constant-time comparison of two secrets of equal length.
|
||||
static func sameSecret(_ a: Data, _ b: Data) -> Bool {
|
||||
guard a.count == b.count, !a.isEmpty else { return false }
|
||||
var diff: UInt8 = 0
|
||||
for (x, y) in zip(a, b) { diff |= x ^ y }
|
||||
return diff == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 8.14, a contact link: how somebody reaches an account without a
|
||||
/// username search. One-time links carry a delivery token (`t`), reusable
|
||||
/// ones a link capability (`l`); exactly one of the two.
|
||||
struct ContactLink: Equatable {
|
||||
let relay: String
|
||||
let userID: Int
|
||||
/// A one-time link's delivery token (section 21.4), 32 bytes.
|
||||
let token: Data?
|
||||
/// A reusable link's capability, 32 bytes.
|
||||
let link: Data?
|
||||
/// A hint, unverified until a handle certificate vouches for it.
|
||||
let handle: String?
|
||||
let fnid: String?
|
||||
|
||||
var isOneTime: Bool { token != nil }
|
||||
|
||||
/// Both forms, or nil when a field cannot be written (a relay that is not
|
||||
/// a hostname, a capability of the wrong size).
|
||||
func encoded() -> (https: String, flamenet: String)? {
|
||||
let host = relay.lowercased()
|
||||
guard Self.isHost(host), userID > 0, (token == nil) != (link == nil) else { return nil }
|
||||
let cap = token ?? link ?? Data()
|
||||
guard cap.count == 32 else { return nil }
|
||||
var parts = ["u=\(userID)"]
|
||||
parts.append(token != nil ? "t=\(Base64URL.encode(cap))" : "l=\(Base64URL.encode(cap))")
|
||||
if let handle, !handle.isEmpty { parts.append("h=\(Self.encodeComponent(handle))") }
|
||||
let q = parts.joined(separator: "&")
|
||||
return ("https://\(host)/add#\(q)", "flamenet:\(fnid ?? "")@\(host)?\(q)")
|
||||
}
|
||||
|
||||
init(relay: String, userID: Int, token: Data? = nil, link: Data? = nil,
|
||||
handle: String? = nil, fnid: String? = nil) {
|
||||
self.relay = relay.lowercased()
|
||||
self.userID = userID
|
||||
self.token = token
|
||||
self.link = link
|
||||
self.handle = handle
|
||||
self.fnid = fnid
|
||||
}
|
||||
|
||||
/// Parse either form, or nil. Unknown keys are skipped; both `t` and `l`,
|
||||
/// neither, a capability that is not 32 bytes, a relay that is not a
|
||||
/// hostname, or a repeated key refuse the whole link.
|
||||
init?(text raw: String) {
|
||||
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var relay: String
|
||||
var fnid: String?
|
||||
var query: String
|
||||
let lower = s.lowercased()
|
||||
if lower.hasPrefix("https://"), let hash = s.firstIndex(of: "#") {
|
||||
let authorityAndPath = s[s.index(s.startIndex, offsetBy: 8)..<hash]
|
||||
guard let slash = authorityAndPath.firstIndex(of: "/"),
|
||||
authorityAndPath[slash...].lowercased() == "/add"
|
||||
else { return nil }
|
||||
relay = String(authorityAndPath[..<slash])
|
||||
guard !relay.contains("?"), !relay.contains("#") else { return nil }
|
||||
query = String(s[s.index(after: hash)...])
|
||||
} else if lower.hasPrefix("flamenet:"), let at = s.firstIndex(of: "@"),
|
||||
let qmark = s[at...].firstIndex(of: "?")
|
||||
{
|
||||
let id = s[s.index(s.startIndex, offsetBy: 9)..<at]
|
||||
guard !id.contains("?"), !id.contains("#") else { return nil }
|
||||
fnid = id.isEmpty ? nil : String(id)
|
||||
relay = String(s[s.index(after: at)..<qmark])
|
||||
guard !relay.isEmpty, !relay.contains("#") else { return nil }
|
||||
query = String(s[s.index(after: qmark)...])
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
relay = relay.lowercased()
|
||||
guard Self.isHost(relay) else { return nil }
|
||||
var q: [String: String] = [:]
|
||||
for pair in query.split(separator: "&", omittingEmptySubsequences: true) {
|
||||
guard let eq = pair.firstIndex(of: "="), eq > pair.startIndex else { return nil }
|
||||
let k = String(pair[..<eq])
|
||||
guard q[k] == nil else { return nil }
|
||||
q[k] = String(pair[pair.index(after: eq)...])
|
||||
}
|
||||
guard let u = q["u"], Self.isUserID(u), let userID = Int(u) else { return nil }
|
||||
guard (q["t"] == nil) != (q["l"] == nil) else { return nil }
|
||||
guard let cap = (q["t"] ?? q["l"]).flatMap(Base64URL.decode), cap.count == 32 else {
|
||||
return nil
|
||||
}
|
||||
var handle: String?
|
||||
if let h = q["h"] {
|
||||
guard let decoded = h.removingPercentEncoding else { return nil }
|
||||
handle = decoded
|
||||
}
|
||||
self.relay = relay
|
||||
self.userID = userID
|
||||
self.fnid = fnid
|
||||
self.handle = handle
|
||||
self.token = q["t"] != nil ? cap : nil
|
||||
self.link = q["l"] != nil ? cap : nil
|
||||
}
|
||||
|
||||
static func isUserID(_ s: String) -> Bool {
|
||||
guard let first = s.first, ("1"..."9").contains(first), s.count <= 16 else { return false }
|
||||
return s.allSatisfy { ("0"..."9").contains($0) }
|
||||
}
|
||||
|
||||
/// A hostname with at least one dot, lower case, no port.
|
||||
static func isHost(_ s: String) -> Bool {
|
||||
let labels = s.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard labels.count >= 2 else { return false }
|
||||
for label in labels {
|
||||
guard let first = label.first, let last = label.last else { return false }
|
||||
let ok: (Character) -> Bool = { c in
|
||||
("a"..."z").contains(c) || ("0"..."9").contains(c)
|
||||
}
|
||||
guard ok(first), ok(last), label.allSatisfy({ ok($0) || $0 == "-" }) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// `encodeURIComponent`: everything but `A-Z a-z 0-9 - _ . ! ~ * ' ( )`.
|
||||
static func encodeComponent(_ s: String) -> String {
|
||||
var allowed = CharacterSet()
|
||||
allowed.insert(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()")
|
||||
return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s
|
||||
}
|
||||
|
||||
/// A fresh 32-byte capability.
|
||||
static func freshCapability() -> Data {
|
||||
var bytes = Data(count: 32)
|
||||
_ = bytes.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// What the relay stores for a reusable link: SHA-256, base64.
|
||||
static func digest(_ capability: Data) -> String {
|
||||
Data(SHA256.hash(data: capability)).base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 8.13, the proof of work on an introduction by handle: the smallest
|
||||
/// decimal nonce for which SHA-256(`<challenge>.<nonce>`) starts with at least
|
||||
/// `bits` zero bits.
|
||||
enum IntroProofOfWork {
|
||||
static func leadingZeroBits(_ bytes: [UInt8]) -> Int {
|
||||
var n = 0
|
||||
for b in bytes {
|
||||
if b == 0 {
|
||||
n += 8
|
||||
continue
|
||||
}
|
||||
return n + b.leadingZeroBitCount
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/// Nil when nothing under `ceiling` works, or the task was cancelled.
|
||||
static func solve(challenge: String, bits: Int, ceiling: Int = 50_000_000) -> String? {
|
||||
guard bits > 0 else { return "0" }
|
||||
for n in 0..<ceiling {
|
||||
if n % 65_536 == 0, Task.isCancelled { return nil }
|
||||
let digest = SHA256.hash(data: Data("\(challenge).\(n)".utf8))
|
||||
if leadingZeroBits(Array(digest)) >= bits { return String(n) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension LinkPreviewFetcher {
|
||||
/// A preview, unless a bridge is saved: a bridge cannot reach the linked
|
||||
/// site, and going around it is the one thing a bridge user must not have
|
||||
/// happen (SPEC §24.6).
|
||||
static func fetchUnlessBridged(_ url: URL) async -> LinkPreview? {
|
||||
guard !AroundTheBridge.active else { return nil }
|
||||
return await fetch(url)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,21 @@ enum LinkPreviewFetcher {
|
||||
}
|
||||
|
||||
/// Fetch and parse Open Graph tags.
|
||||
///
|
||||
/// Connects to the linked site directly. The app calls it only through
|
||||
/// `fetchUnlessBridged`, which refuses while a bridge is saved (§24.6).
|
||||
/// This file is also compiled into flamenet-e2e's interop driver, which
|
||||
/// has no bridge code, so the check lives beside it, not in it.
|
||||
/// Stand-in protocol handlers, for a test that watches whether this fetch
|
||||
/// happens at all.
|
||||
///
|
||||
/// The session below is `.ephemeral` on purpose, so a preview fetch
|
||||
/// carries no cookies, cache or credentials to the linked site. A session
|
||||
/// built from a configuration does not see `URLProtocol.registerClass`,
|
||||
/// which registers only for the shared session, so without a seam here a
|
||||
/// test cannot observe the fetch either way. Nil in the app.
|
||||
nonisolated(unsafe) static var protocolClasses: [AnyClass]?
|
||||
|
||||
static func fetch(_ url: URL, timeout: TimeInterval = 5) async -> LinkPreview? {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = timeout
|
||||
@@ -49,7 +64,9 @@ enum LinkPreviewFetcher {
|
||||
// site that wants to refuse these should be able to.
|
||||
request.setValue("FlamenetLinkPreview/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
guard let (data, response) = try? await URLSession.shared.data(for: request),
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
if let protocolClasses { configuration.protocolClasses = protocolClasses }
|
||||
guard let (data, response) = try? await URLSession(configuration: configuration).data(for: request),
|
||||
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode)
|
||||
else { return nil }
|
||||
|
||||
|
||||
@@ -27,17 +27,19 @@ struct MailboxHoming {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAHostname:
|
||||
return "That does not look like a relay's address. It is a hostname, "
|
||||
+ "like relay.example.com, no https:// and no path."
|
||||
return String(
|
||||
localized:
|
||||
"That does not look like a relay's address. It is a hostname, like relay.example.com, no https:// and no path.")
|
||||
case .alreadyAHome:
|
||||
return "That relay already holds a mailbox for you."
|
||||
return String(localized: "That relay already holds a mailbox for you.")
|
||||
case .noAddress:
|
||||
return "This device has not worked out your address yet, so there is "
|
||||
+ "nothing to publish. Signing in again should fix it."
|
||||
return String(
|
||||
localized:
|
||||
"This device has not worked out your address yet, so there is nothing to publish. Signing in again should fix it.")
|
||||
case .signIn(let why):
|
||||
return why
|
||||
case .rejected(let relay, let reason):
|
||||
return "\(relay) refused the mailbox: \(reason)"
|
||||
return String(localized: "\(relay) refused the mailbox: \(reason)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,12 +153,13 @@ struct MailboxHoming {
|
||||
guard relay != primary.relay else {
|
||||
throw Failure.rejected(
|
||||
relay: relay,
|
||||
reason: "this is the relay you are signed in to, so it cannot be removed "
|
||||
+ "while you are using it")
|
||||
reason: String(
|
||||
localized:
|
||||
"this is the relay you are signed in to, so it cannot be removed while you are using it"))
|
||||
}
|
||||
let remaining = remainingHomes.filter { $0 != relay }
|
||||
guard !remaining.isEmpty else {
|
||||
throw Failure.rejected(relay: relay, reason: "an account needs at least one mailbox")
|
||||
throw Failure.rejected(relay: relay, reason: String(localized: "an account needs at least one mailbox"))
|
||||
}
|
||||
|
||||
var byRelay: [String: E2EClient] = [primary.relay: primary.client]
|
||||
@@ -242,20 +245,22 @@ struct MailboxHoming {
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw Failure.signIn("That relay did not answer.")
|
||||
throw Failure.signIn(String(localized: "That relay did not answer."))
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
// Says which of the two things went wrong.
|
||||
throw Failure.signIn(
|
||||
http.statusCode == 401 || http.statusCode == 403
|
||||
? "That relay did not accept those details. Sign up there first, "
|
||||
+ "with the same passphrase, that is what gives you the same address."
|
||||
: "That relay refused the sign-in (\(http.statusCode)).")
|
||||
? String(
|
||||
localized:
|
||||
"That relay did not accept those details. Sign up there first, with the same passphrase, that is what gives you the same address."
|
||||
)
|
||||
: String(localized: "That relay refused the sign-in (\(String(http.statusCode)))."))
|
||||
}
|
||||
guard let decoded = try? JSONDecoder().decode(Response.self, from: data) else {
|
||||
throw Failure.signIn(
|
||||
"That relay answered with something this app "
|
||||
+ "could not read. It may not be a Flamenet relay.")
|
||||
String(
|
||||
localized: "That relay answered with something this app could not read. It may not be a Flamenet relay."))
|
||||
}
|
||||
return AccountAtRelay(
|
||||
userID: decoded.user_id, username: decoded.username,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import Foundation
|
||||
|
||||
/// JSON written in a fixed key order, for envelopes a vector pins to the byte.
|
||||
///
|
||||
/// `JSONSerialization` neither keeps key order nor, by default, leaves `/`
|
||||
/// alone, and base64 is full of `/`. Strings and nested values go through it
|
||||
/// one at a time with `.withoutEscapingSlashes`; the objects around them are
|
||||
/// joined here, in the order the caller gives.
|
||||
enum OrderedJSON {
|
||||
static func string(_ s: String) -> String {
|
||||
guard
|
||||
let data = try? JSONSerialization.data(
|
||||
withJSONObject: s, options: [.fragmentsAllowed, .withoutEscapingSlashes])
|
||||
else { return "\"\"" }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
/// A value whose own keys have no order to keep (a register's `val`):
|
||||
/// sorted, so one value is one string.
|
||||
static func value(_ v: JSONValue) -> String {
|
||||
switch v {
|
||||
case .null: return "null"
|
||||
case .bool(let b): return b ? "true" : "false"
|
||||
case .int(let i): return String(i)
|
||||
case .double(let d):
|
||||
let data = try? JSONSerialization.data(withJSONObject: d, options: [.fragmentsAllowed])
|
||||
return data.map { String(decoding: $0, as: UTF8.self) } ?? "0"
|
||||
case .string(let s): return string(s)
|
||||
case .array(let a): return "[" + a.map(value).joined(separator: ",") + "]"
|
||||
case .object(let o):
|
||||
return "{"
|
||||
+ o.keys.sorted().map { string($0) + ":" + value(o[$0]!) }.joined(separator: ",")
|
||||
+ "}"
|
||||
}
|
||||
}
|
||||
|
||||
/// An object from already-rendered values, in this order.
|
||||
static func object(_ pairs: [(String, String)]) -> String {
|
||||
"{" + pairs.map { string($0.0) + ":" + $0.1 }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sections 17.2 to 17.6: the envelopes that travel between one person's own
|
||||
/// linked devices, and `rekey_request`, which goes to contacts.
|
||||
///
|
||||
/// Every one is control traffic (`ctl: 1`), so a build that does not know the
|
||||
/// kind consumes it rather than drawing a bubble (section 6.10). The writers
|
||||
/// keep the key order `content-v1.json`'s `pending` cases pin.
|
||||
enum MultiDeviceEnvelope: Equatable {
|
||||
/// One device of the linked set, as a `link_offer` lists it.
|
||||
struct Member: Equatable {
|
||||
let deviceID: String
|
||||
/// Standard base64 of the device's IK_sig.
|
||||
let ikSig: String
|
||||
}
|
||||
|
||||
case linkOffer(secret: Data?, set: [Member], dk: Data, at: Int64)
|
||||
case sync(SyncOp)
|
||||
case syncState(seq: Int, of: Int, want: Bool, registers: [SyncOp])
|
||||
/// A copy of something this account sent: to a person (`to`) or a group
|
||||
/// (`gid`), never both. `body` is the exact plaintext they received.
|
||||
case sent(to: Int?, gid: String?, body: String, at: Int64, mid: String?)
|
||||
case history(seq: Int, of: Int, items: [HistoryItem])
|
||||
case rekeyRequest(device: String, at: Int64)
|
||||
|
||||
/// One message of a backfill (section 17.4).
|
||||
struct HistoryItem: Equatable {
|
||||
/// The thread key, as section 16.2.1 spells it (`u:12`, `g:<gid>`).
|
||||
let thread: String
|
||||
let mid: String?
|
||||
let at: Int64
|
||||
/// True for this account's own message; otherwise `from` is the author.
|
||||
let outgoing: Bool
|
||||
let from: Int?
|
||||
let body: String
|
||||
}
|
||||
|
||||
static let kinds: Set<String> = [
|
||||
"link_offer", "sync", "sync_state", "sent", "history", "rekey_request",
|
||||
]
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
var json: String {
|
||||
let head: [(String, String)] = [("fnc", "1")]
|
||||
switch self {
|
||||
case .linkOffer(let secret, let set, let dk, let at):
|
||||
var pairs = head + [("kind", OrderedJSON.string("link_offer")), ("ctl", "1"), ("v", "1")]
|
||||
if let secret { pairs.append(("s", OrderedJSON.string(Base64URL.encode(secret)))) }
|
||||
let members = set.map {
|
||||
OrderedJSON.object([
|
||||
("d", OrderedJSON.string($0.deviceID)), ("k", OrderedJSON.string($0.ikSig)),
|
||||
])
|
||||
}
|
||||
pairs.append(("set", "[" + members.joined(separator: ",") + "]"))
|
||||
pairs.append(("dk", OrderedJSON.string(dk.base64EncodedString())))
|
||||
pairs.append(("at", String(at)))
|
||||
return OrderedJSON.object(pairs)
|
||||
case .sync(let o):
|
||||
var pairs = head + [
|
||||
("kind", OrderedJSON.string("sync")), ("ctl", "1"),
|
||||
("op", OrderedJSON.string(o.op)), ("key", OrderedJSON.string(o.key)),
|
||||
]
|
||||
if let val = o.val { pairs.append(("val", OrderedJSON.value(val))) }
|
||||
pairs.append(("at", String(o.at)))
|
||||
pairs.append(("dev", OrderedJSON.string(o.dev)))
|
||||
return OrderedJSON.object(pairs)
|
||||
case .syncState(let seq, let of, let want, let registers):
|
||||
var pairs = head + [
|
||||
("kind", OrderedJSON.string("sync_state")), ("ctl", "1"),
|
||||
("seq", String(seq)), ("of", String(of)),
|
||||
]
|
||||
if want { pairs.append(("want", "1")) }
|
||||
let regs = registers.map { r -> (String, String) in
|
||||
var fields: [(String, String)] = [("op", OrderedJSON.string(r.op))]
|
||||
if let val = r.val { fields.append(("val", OrderedJSON.value(val))) }
|
||||
fields.append(("at", String(r.at)))
|
||||
fields.append(("dev", OrderedJSON.string(r.dev)))
|
||||
return (r.key, OrderedJSON.object(fields))
|
||||
}
|
||||
pairs.append(("regs", OrderedJSON.object(regs)))
|
||||
return OrderedJSON.object(pairs)
|
||||
case .sent(let to, let gid, let body, let at, let mid):
|
||||
var pairs = head + [("kind", OrderedJSON.string("sent")), ("ctl", "1")]
|
||||
if let to { pairs.append(("to", String(to))) }
|
||||
if let gid { pairs.append(("gid", OrderedJSON.string(gid))) }
|
||||
pairs.append(("body", OrderedJSON.string(body)))
|
||||
pairs.append(("at", String(at)))
|
||||
if let mid { pairs.append(("mid", OrderedJSON.string(mid))) }
|
||||
return OrderedJSON.object(pairs)
|
||||
case .history(let seq, let of, let items):
|
||||
let rendered = items.map { item -> String in
|
||||
var f: [(String, String)] = [("t", OrderedJSON.string(item.thread))]
|
||||
if let mid = item.mid { f.append(("mid", OrderedJSON.string(mid))) }
|
||||
f.append(("at", String(item.at)))
|
||||
if item.outgoing {
|
||||
f.append(("out", "true"))
|
||||
} else if let from = item.from {
|
||||
f.append(("from", String(from)))
|
||||
}
|
||||
f.append(("body", OrderedJSON.string(item.body)))
|
||||
return OrderedJSON.object(f)
|
||||
}
|
||||
return OrderedJSON.object(
|
||||
head + [
|
||||
("kind", OrderedJSON.string("history")), ("ctl", "1"),
|
||||
("seq", String(seq)), ("of", String(of)),
|
||||
("items", "[" + rendered.joined(separator: ",") + "]"),
|
||||
])
|
||||
case .rekeyRequest(let device, let at):
|
||||
return OrderedJSON.object(
|
||||
head + [
|
||||
("kind", OrderedJSON.string("rekey_request")), ("ctl", "1"),
|
||||
("dev", OrderedJSON.string(device)), ("at", String(at)),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// Parse one of these kinds, or nil. A field of the wrong shape makes the
|
||||
/// whole envelope nil: it is consumed and ignored, never half applied.
|
||||
init?(text: String) {
|
||||
guard text.hasPrefix(E2EEnvelopeRoute.controlPrefix),
|
||||
let data = text.data(using: .utf8),
|
||||
let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let kind = o["kind"] as? String, Self.kinds.contains(kind)
|
||||
else { return nil }
|
||||
let int: (String) -> Int64? = { JSONValue(any: o[$0])?.intValue }
|
||||
switch kind {
|
||||
case "link_offer":
|
||||
guard int("v") == 1, let at = int("at"),
|
||||
let dkB64 = o["dk"] as? String, let dk = Data(base64Encoded: dkB64), dk.count == 32,
|
||||
let rawSet = o["set"] as? [[String: Any]]
|
||||
else { return nil }
|
||||
var secret: Data?
|
||||
if let s = o["s"] {
|
||||
guard let str = s as? String, let bytes = Base64URL.decode(str),
|
||||
(16...32).contains(bytes.count)
|
||||
else { return nil }
|
||||
secret = bytes
|
||||
}
|
||||
var members: [Member] = []
|
||||
for m in rawSet {
|
||||
guard let d = (m["d"] as? String)?.lowercased(), DeviceLinkCode.isDeviceID(d),
|
||||
let k = m["k"] as? String, Data(base64Encoded: k)?.count == 32
|
||||
else { return nil }
|
||||
members.append(Member(deviceID: d, ikSig: k))
|
||||
}
|
||||
guard !members.isEmpty else { return nil }
|
||||
self = .linkOffer(secret: secret, set: members, dk: dk, at: at)
|
||||
case "sync":
|
||||
guard let op = SyncOp(json: o) else { return nil }
|
||||
self = .sync(op)
|
||||
case "sync_state":
|
||||
guard let seq = int("seq"), let of = int("of"), seq >= 1, of >= seq,
|
||||
let regs = o["regs"] as? [String: Any]
|
||||
else { return nil }
|
||||
self = .syncState(
|
||||
seq: Int(seq), of: Int(of), want: int("want") == 1,
|
||||
registers: SyncState.ops(fromRegisters: regs).sorted { $0.key < $1.key })
|
||||
case "sent":
|
||||
let to = int("to").map(Int.init)
|
||||
let gid = o["gid"] as? String
|
||||
guard (to == nil) != (gid == nil), let body = o["body"] as? String,
|
||||
let at = int("at")
|
||||
else { return nil }
|
||||
if let to, to <= 0 { return nil }
|
||||
self = .sent(to: to, gid: gid, body: body, at: at, mid: o["mid"] as? String)
|
||||
case "history":
|
||||
guard let seq = int("seq"), let of = int("of"), seq >= 1,
|
||||
let raw = o["items"] as? [[String: Any]]
|
||||
else { return nil }
|
||||
var items: [HistoryItem] = []
|
||||
for i in raw {
|
||||
guard let t = i["t"] as? String, let body = i["body"] as? String,
|
||||
let at = JSONValue(any: i["at"])?.intValue
|
||||
else { continue } // one bad item costs that item, not the chunk
|
||||
let out = (i["out"] as? Bool) == true
|
||||
let from = JSONValue(any: i["from"])?.intValue.map(Int.init)
|
||||
guard out || from != nil else { continue }
|
||||
items.append(
|
||||
HistoryItem(
|
||||
thread: t, mid: i["mid"] as? String, at: at, outgoing: out,
|
||||
from: out ? nil : from, body: body))
|
||||
}
|
||||
self = .history(seq: Int(seq), of: Int(of), items: items)
|
||||
case "rekey_request":
|
||||
guard let dev = o["dev"] as? String, !dev.isEmpty, let at = int("at") else {
|
||||
return nil
|
||||
}
|
||||
self = .rekeyRequest(device: dev, at: at)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Length hiding, spec section 23.
|
||||
///
|
||||
/// Every payload this app hands the relay is made exactly one of two lengths,
|
||||
/// every attachment blob one of nine, and every sealed group roster a multiple
|
||||
/// of 8192 bytes. The arithmetic has to come out exact: a payload one byte off
|
||||
/// a ladder step is the very signal padding exists to remove, and no receiver
|
||||
/// would notice, because receivers ignore the pad. So the length is checked on
|
||||
/// the bytes, after the fact, and a payload that misses is never sent.
|
||||
enum Padding {
|
||||
|
||||
// MARK: - Constants (section 23.3, 23.9, 23.10)
|
||||
|
||||
/// The first message step, in characters of the `payload` string. Named
|
||||
/// because it may move to 24576 if the relay's histogram says so; every
|
||||
/// other message number here is derived from the ladder, never written out.
|
||||
static let messageStep1 = 16384
|
||||
/// The second and top message step. Above it a message is refused.
|
||||
static let messageStep2 = 65536
|
||||
static let messageLadder = [messageStep1, messageStep2]
|
||||
|
||||
/// Attachment blob lengths in bytes: 64 KiB doubling to 8 MiB, then the
|
||||
/// relay's 10 MiB cap.
|
||||
static let attachmentLadder: [Int] = (0..<8).map { 65536 << $0 } + [10 * 1024 * 1024]
|
||||
|
||||
/// A sealed roster's plaintext is padded to this, or the next multiple.
|
||||
static let rosterTarget = 8192
|
||||
|
||||
/// The only characters a pad may contain. None of them is escaped by any
|
||||
/// JSON writer, so a pad's serialized length is its character count.
|
||||
static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||
|
||||
/// What `,"pad":""` costs before its value.
|
||||
static let padKeyOverhead = 9
|
||||
/// `{"s":1,"eph":"` + 44 + `","ct":"` + `"}`, the canonical outer form.
|
||||
static let sealedOuterOverhead = 68
|
||||
/// AES-GCM nonce and tag around an attachment.
|
||||
static let attachmentOverhead = 12 + 16
|
||||
|
||||
// MARK: - What a person can type (section 23.7)
|
||||
|
||||
/// The longest text the composer lets through, counted as `composerCost`
|
||||
/// counts it. About 20,000 characters of plain text, as the spec puts it,
|
||||
/// and chosen so that it fits the top step with the largest header there
|
||||
/// is: a first message carrying every post-quantum block, sealed, with a
|
||||
/// return address and a reply quote (`PaddingPropertyTests`).
|
||||
static let composerLimit = 20_000
|
||||
|
||||
/// What a text costs inside a content envelope: its length as a JSON
|
||||
/// string, so a quote mark or a control character counts for what it
|
||||
/// will take on the wire rather than for one.
|
||||
static func composerCost(_ text: String) -> Int {
|
||||
guard let json = try? encoder().encode(text) else { return text.utf8.count }
|
||||
return max(0, json.count - 2)
|
||||
}
|
||||
|
||||
/// A plaintext up to this many bytes fits the top step whatever header it
|
||||
/// travels with, so a send that size needs no dry run before it spends
|
||||
/// anything (`PaddingPropertyTests` holds this to the largest header).
|
||||
static let alwaysFitsPlaintextBytes = 16384
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum Failure: LocalizedError, Equatable {
|
||||
/// Section 23.7, the unpadded object does not fit the top step.
|
||||
case tooLong
|
||||
/// The bytes did not come out the length the arithmetic aimed at.
|
||||
/// A bug in this sender; the payload is never sent.
|
||||
case wrongLength(want: Int, got: Int)
|
||||
/// Section 23.9, a sealed attachment over the 10 MiB cap.
|
||||
case attachmentTooLarge(Int)
|
||||
/// Only a JSON object with at least one key can take a pad.
|
||||
case notAnObject
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .tooLong:
|
||||
return String(localized: "Too long to send as a message. Send it as a file.")
|
||||
case .wrongLength:
|
||||
return String(localized: "This message could not be made the right size, so it was not sent.")
|
||||
case .attachmentTooLarge:
|
||||
return String(localized: "This file is too large to send. The limit is 10 MB.")
|
||||
case .notAnObject:
|
||||
return String(localized: "This message could not be made the right size, so it was not sent.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The arithmetic (section 23.5, 23.6, 23.7)
|
||||
|
||||
enum Kind {
|
||||
/// The seal's inner JSON (section 23.5).
|
||||
case sealed
|
||||
/// An unsealed ratchet envelope's JSON (section 23.6).
|
||||
case envelope
|
||||
}
|
||||
|
||||
struct Targets: Equatable {
|
||||
/// Characters of the `payload` string.
|
||||
let payload: Int
|
||||
/// UTF-8 length an unsealed envelope's JSON must have, and the length
|
||||
/// of a sealed payload's canonical outer form.
|
||||
let envelopeJSON: Int
|
||||
/// UTF-8 length a sealed payload's inner JSON must have.
|
||||
let sealedInner: Int
|
||||
}
|
||||
|
||||
/// The lengths that make a `payload` exactly `step` characters.
|
||||
static func targets(step: Int) -> Targets {
|
||||
// A step that is not a multiple of 16 leaves `=` padding in one of the
|
||||
// base64 layers and the arithmetic stops being exact.
|
||||
precondition(step > 0 && step % 16 == 0, "a ladder step must be a multiple of 16")
|
||||
let envelopeJSON = step / 4 * 3
|
||||
let ctChars = envelopeJSON - sealedOuterOverhead
|
||||
return Targets(
|
||||
payload: step, envelopeJSON: envelopeJSON, sealedInner: ctChars / 4 * 3 - 16)
|
||||
}
|
||||
|
||||
struct Plan: Equatable {
|
||||
let step: Int
|
||||
let target: Int
|
||||
/// Characters of pad.
|
||||
let n: Int
|
||||
}
|
||||
|
||||
/// The step and pad length for an object whose unpadded serialization is
|
||||
/// `len0` UTF-8 bytes. Throws `.tooLong` above the top step.
|
||||
static func plan(len0: Int, kind: Kind, ladder: [Int] = messageLadder) throws -> Plan {
|
||||
for step in ladder {
|
||||
let t = targets(step: step)
|
||||
let target = kind == .sealed ? t.sealedInner : t.envelopeJSON
|
||||
let n = target - len0 - padKeyOverhead
|
||||
if n >= 0 { return Plan(step: step, target: target, n: n) }
|
||||
}
|
||||
throw Failure.tooLong
|
||||
}
|
||||
|
||||
// MARK: - The pad (section 23.4)
|
||||
|
||||
/// `n` characters drawn uniformly from `alphabet` with the system's
|
||||
/// cryptographic random source. Rejection sampling: 62 does not divide
|
||||
/// 256, and `byte % 62` alone would favour the first eight characters.
|
||||
static func randomPad(_ n: Int) -> String {
|
||||
var out: [Character] = []
|
||||
out.reserveCapacity(n)
|
||||
while out.count < n {
|
||||
let want = min(65536, n - out.count + 16)
|
||||
var bytes = [UInt8](repeating: 0, count: want)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, want, &bytes)
|
||||
precondition(status == errSecSuccess, "the system random source failed")
|
||||
for b in bytes where b < 248 && out.count < n {
|
||||
out.append(alphabet[Int(b) % 62])
|
||||
}
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
|
||||
/// Whether a string is a pad this sender may write.
|
||||
static func isValidPad(_ pad: String) -> Bool {
|
||||
pad.unicodeScalars.allSatisfy { $0.isASCII && alphabet.contains(Character($0)) }
|
||||
}
|
||||
|
||||
// MARK: - Serializing with a pad
|
||||
|
||||
/// The writer every padded object goes through. `/` is not escaped, so the
|
||||
/// base64 inside an envelope costs what it looks like it costs.
|
||||
static func encoder() -> JSONEncoder {
|
||||
let e = JSONEncoder()
|
||||
e.outputFormatting = [.withoutEscapingSlashes]
|
||||
return e
|
||||
}
|
||||
|
||||
/// `json` with `,"pad":"<pad>"` written as its last key.
|
||||
///
|
||||
/// Equivalent to serializing the object again with the key added last, as
|
||||
/// section 23.7 step 4 says, and exact by construction: the bytes before
|
||||
/// the closing brace are the ones that were measured.
|
||||
static func appending(pad: String, to json: Data) throws -> Data {
|
||||
guard json.count > 2, json.first == UInt8(ascii: "{"), json.last == UInt8(ascii: "}")
|
||||
else { throw Failure.notAnObject }
|
||||
var out = json.dropLast()
|
||||
out.append(contentsOf: Array(",\"pad\":\"".utf8))
|
||||
out.append(contentsOf: Array(pad.utf8))
|
||||
out.append(contentsOf: Array("\"}".utf8))
|
||||
return Data(out)
|
||||
}
|
||||
|
||||
/// Section 23.7 steps 1 to 4 and the length check on the result: `json` is
|
||||
/// the object serialized without a pad, by the writer that writes it.
|
||||
static func padded(
|
||||
_ json: Data, kind: Kind,
|
||||
ladder: [Int] = messageLadder,
|
||||
padSource: (Int) -> String = randomPad
|
||||
) throws -> (json: Data, plan: Plan) {
|
||||
let plan = try plan(len0: json.count, kind: kind, ladder: ladder)
|
||||
let pad = padSource(plan.n)
|
||||
guard pad.utf8.count == plan.n, isValidPad(pad) else {
|
||||
throw Failure.wrongLength(want: plan.n, got: pad.utf8.count)
|
||||
}
|
||||
let out = try appending(pad: pad, to: json)
|
||||
guard out.count == plan.target else {
|
||||
throw Failure.wrongLength(want: plan.target, got: out.count)
|
||||
}
|
||||
return (out, plan)
|
||||
}
|
||||
|
||||
/// An unsealed ratchet envelope as the `payload` field, padded to a ladder
|
||||
/// step and checked to be exactly that long (section 23.6).
|
||||
static func envelopePayload(
|
||||
_ env: E2EEnvelopeJSON,
|
||||
ladder: [Int] = messageLadder,
|
||||
padSource: (Int) -> String = randomPad
|
||||
) throws -> String {
|
||||
let bare = try env.wireJSON()
|
||||
let (json, plan) = try padded(bare, kind: .envelope, ladder: ladder, padSource: padSource)
|
||||
let payload = json.base64EncodedString()
|
||||
guard payload.count == plan.step else {
|
||||
throw Failure.wrongLength(want: plan.step, got: payload.count)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Section 23.7 for a fan-out: throws `.tooLong` when a plaintext this
|
||||
/// long would miss the top step under the largest header an envelope can
|
||||
/// carry (a first message with X3DH, the PQX3DH ciphertext and a waiting
|
||||
/// post-quantum ratchet step), sealed.
|
||||
///
|
||||
/// A group message goes to each member on its own session, and whether it
|
||||
/// fits depends on that session's header. Checked once against the worst
|
||||
/// header before the first member is sent anything, a message is refused
|
||||
/// whole or sent whole, never delivered to some members and refused for
|
||||
/// the rest. Below `alwaysFitsPlaintextBytes` nothing is too long.
|
||||
static func refuseUnlessItFitsEveryHeader(
|
||||
plaintextBytes: Int, fromUser: Int, fromDevice: String
|
||||
) throws {
|
||||
guard plaintextBytes > alwaysFitsPlaintextBytes else { return }
|
||||
let inner = try SealedSender.innerJSON(
|
||||
envelope: worstCaseEnvelope(plaintextBytes: plaintextBytes),
|
||||
fromUser: fromUser, fromDevice: fromDevice)
|
||||
_ = try plan(len0: inner.count, kind: .sealed)
|
||||
}
|
||||
|
||||
/// An envelope as long as one around `plaintextBytes` can be: every
|
||||
/// optional block present, every number at its widest. The bytes are
|
||||
/// stand-ins; only their lengths matter.
|
||||
static func worstCaseEnvelope(plaintextBytes: Int) -> E2EEnvelopeJSON {
|
||||
func b64(_ n: Int) -> String { Data(count: n).base64EncodedString() }
|
||||
var header = E2EEnvelopeJSON.Header(
|
||||
dh: b64(32), pn: Int(Int32.max), n: Int(Int32.max))
|
||||
header.pqdh = b64(PQKEM.publicKeyBytes)
|
||||
header.pqct = b64(PQKEM.ciphertextBytes)
|
||||
return E2EEnvelopeJSON(
|
||||
v: 3, type: "prekey", header: header,
|
||||
x3dh: .init(
|
||||
ik_dh: b64(32), ik_sig: b64(32), ek: b64(32),
|
||||
spk_id: UInt32.max, opk_id: Int(Int32.max)),
|
||||
pq: .init(kind: "opk", id: UInt32.max, ct: b64(PQKEM.ciphertextBytes)),
|
||||
ct: b64(plaintextBytes + 16))
|
||||
}
|
||||
|
||||
/// Whether a `payload` string is a ladder length. For checks and tests;
|
||||
/// receivers never look (section 23.8).
|
||||
static func isLadderLength(_ payload: String) -> Bool {
|
||||
messageLadder.contains(payload.utf8.count)
|
||||
}
|
||||
|
||||
// MARK: - Attachments (section 23.9)
|
||||
|
||||
/// The step an attachment of `clen` sealed bytes is padded to.
|
||||
static func attachmentStep(clen: Int, ladder: [Int] = attachmentLadder) throws -> Int {
|
||||
guard let step = ladder.first(where: { $0 >= clen }) else {
|
||||
throw Failure.attachmentTooLarge(clen)
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
/// `n` bytes from the cryptographic random source. Zeros would show
|
||||
/// anybody holding the blob where the ciphertext ends.
|
||||
static func randomTail(_ n: Int) -> Data {
|
||||
var out = Data(count: n)
|
||||
guard n > 0 else { return out }
|
||||
let status = out.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, n, $0.baseAddress!)
|
||||
}
|
||||
precondition(status == errSecSuccess, "the system random source failed")
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Sealed rosters (section 23.10)
|
||||
|
||||
/// A roster's plaintext JSON padded to 8192 bytes, or to the next multiple
|
||||
/// of 8192 when a long name does not fit. Never refused.
|
||||
static func rosterPlaintext(
|
||||
_ json: Data, padSource: (Int) -> String = randomPad
|
||||
) throws -> Data {
|
||||
var target = rosterTarget
|
||||
while target - json.count - padKeyOverhead < 0 { target += rosterTarget }
|
||||
let n = target - json.count - padKeyOverhead
|
||||
let pad = padSource(n)
|
||||
guard pad.utf8.count == n, isValidPad(pad) else {
|
||||
throw Failure.wrongLength(want: n, got: pad.utf8.count)
|
||||
}
|
||||
let out = try appending(pad: pad, to: json)
|
||||
guard out.count == target else { throw Failure.wrongLength(want: target, got: out.count) }
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One writer for the envelope
|
||||
|
||||
extension E2EEnvelopeJSON {
|
||||
|
||||
/// This envelope's JSON, written in one fixed key order.
|
||||
///
|
||||
/// `JSONEncoder` writes a struct's keys in whatever order its dictionary
|
||||
/// comes out, which differs from run to run. The length is the same either
|
||||
/// way, so padding would still be right, but the bytes could never be
|
||||
/// compared with the published vector, and a padded payload that cannot be
|
||||
/// reproduced cannot be checked. The order is the declaration order, which
|
||||
/// is also the reference engine's. Receivers decode by key and do not care.
|
||||
func wireJSON() throws -> Data {
|
||||
func str(_ s: String) throws -> String {
|
||||
String(decoding: try Padding.encoder().encode(s), as: UTF8.self)
|
||||
}
|
||||
var json = "{\"v\":\(v),\"type\":\(try str(type))"
|
||||
json += ",\"header\":" + String(decoding: canonicalHeaderBytes, as: UTF8.self)
|
||||
if let x = x3dh {
|
||||
json += ",\"x3dh\":{\"ik_dh\":\(try str(x.ik_dh)),\"ik_sig\":\(try str(x.ik_sig))"
|
||||
json += ",\"ek\":\(try str(x.ek)),\"spk_id\":\(x.spk_id),\"opk_id\":\(x.opk_id)}"
|
||||
}
|
||||
if let p = pq {
|
||||
json += ",\"pq\":{\"kind\":\(try str(p.kind)),\"id\":\(p.id),\"ct\":\(try str(p.ct))}"
|
||||
}
|
||||
json += ",\"ct\":\(try str(ct))}"
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,14 @@ public enum RelayIdentityPin {
|
||||
|
||||
/// What to tell somebody when it changed.
|
||||
public static func warning(host: String) -> String {
|
||||
"The server at \(host) is presenting a different identity than the one "
|
||||
+ "this app recorded the first time it connected. That happens when the "
|
||||
+ "server's keys are rotated on purpose, and it also happens when "
|
||||
+ "something else is answering in its place. Nothing here can tell those "
|
||||
+ "apart. If you were not expecting a change, stop and check with "
|
||||
+ "whoever runs it before signing in again."
|
||||
String(
|
||||
localized: """
|
||||
The server at \(host) is presenting a different identity than the one \
|
||||
this app recorded the first time it connected. That happens when the \
|
||||
server's keys are rotated on purpose, and it also happens when \
|
||||
something else is answering in its place. Nothing here can tell those \
|
||||
apart. If you were not expecting a change, stop and check with \
|
||||
whoever runs it before signing in again.
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,12 +113,16 @@ struct RelayRepair {
|
||||
// doing everything in turn.
|
||||
guard credentialOK else {
|
||||
return .needsPerson(
|
||||
"This device is signed in but the server is not accepting it. "
|
||||
+ "Signing out and back in will fix it.")
|
||||
String(
|
||||
localized:
|
||||
"This device is signed in but the server is not accepting it. Signing out and back in will fix it."
|
||||
))
|
||||
}
|
||||
return .needsPerson(
|
||||
"This device could not register with the server. Check the "
|
||||
+ "connection and try again in a moment.")
|
||||
String(
|
||||
localized:
|
||||
"This device could not register with the server. Check the connection and try again in a moment."
|
||||
))
|
||||
}
|
||||
|
||||
return .repaired
|
||||
|
||||
@@ -26,7 +26,8 @@ enum RevocationLatch {
|
||||
}
|
||||
|
||||
/// What a client may say when the log cannot be reached.
|
||||
static let logUnavailableNote =
|
||||
"The key log could not be reached, so new device changes cannot be checked "
|
||||
+ "right now. Devices already known to be revoked stay revoked."
|
||||
static let logUnavailableNote = String(
|
||||
localized:
|
||||
"The key log could not be reached, so new device changes cannot be checked right now. Devices already known to be revoked stay revoked."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,17 @@ enum SealedRoster {
|
||||
}
|
||||
|
||||
/// Seal a roster for storage on the relay.
|
||||
static func seal(_ contents: Contents, key: Data) throws -> String {
|
||||
let plain = try JSONEncoder().encode(contents)
|
||||
///
|
||||
/// The plaintext is padded to 8192 bytes, or the next multiple of 8192
|
||||
/// for a name too long to fit (section 23.10), so the blob is the same
|
||||
/// length for every group within the 32-member cap. Readers ignore `pad`:
|
||||
/// `Contents` has no such field. `padSource` exists for tests.
|
||||
static func seal(
|
||||
_ contents: Contents, key: Data,
|
||||
padSource: (Int) -> String = Padding.randomPad
|
||||
) throws -> String {
|
||||
let plain = try Padding.rosterPlaintext(
|
||||
try JSONEncoder().encode(contents), padSource: padSource)
|
||||
guard let box = try AES.GCM.seal(plain, using: SymmetricKey(data: key)).combined else {
|
||||
throw E2EError.crypto("roster seal produced no combined box")
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ enum SealedSender {
|
||||
return (SymmetricKey(data: raw.prefix(32)), nonce)
|
||||
}
|
||||
|
||||
/// Wrap an envelope so the relay cannot see who sent it.
|
||||
/// Wrap an envelope so the relay cannot see who sent it, WITHOUT padding.
|
||||
///
|
||||
/// What a sender before section 23 wrote. Every send path in the app uses
|
||||
/// `sealedPayload` instead; this stays for tests that stand in for an
|
||||
/// older sender, which receivers must go on reading.
|
||||
/// - Parameter fromAddress: the sender's `fnid@relay`, for a message that
|
||||
/// crosses a relay boundary.
|
||||
static func seal(
|
||||
@@ -65,6 +69,85 @@ enum SealedSender {
|
||||
ct: (box.ciphertext + box.tag).e2eBase64)
|
||||
}
|
||||
|
||||
/// Seal an envelope and return the `payload` string, padded to a ladder
|
||||
/// step and checked to be exactly that long (section 23.5, 23.7).
|
||||
///
|
||||
/// The inner JSON carries `pad` as its last key, under the seal's
|
||||
/// encryption. The outer blob is written by template in its canonical form,
|
||||
/// never by a JSON library: `JSONEncoder` writes every `/` in the base64 as
|
||||
/// `\/`, about one character in 32, and the length misses the step.
|
||||
///
|
||||
/// Throws `Padding.Failure.tooLong` above the top step, and `.wrongLength`
|
||||
/// if the bytes and the arithmetic disagree. Either way nothing comes back,
|
||||
/// so nothing can be sent. `ephemeral` and `padSource` exist for the
|
||||
/// known-answer vector; production callers pass neither.
|
||||
static func sealedPayload(
|
||||
envelope: E2EEnvelopeJSON,
|
||||
fromUser: Int,
|
||||
fromDevice: String,
|
||||
fromAddress: String? = nil,
|
||||
toDevice: String,
|
||||
recipientIKdh: Data,
|
||||
ladder: [Int] = Padding.messageLadder,
|
||||
ephemeral: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||
padSource: (Int) -> String = Padding.randomPad
|
||||
) throws -> String {
|
||||
let bare = try innerJSON(
|
||||
envelope: envelope, fromUser: fromUser, fromDevice: fromDevice,
|
||||
fromAddress: fromAddress)
|
||||
let (inner, plan) = try Padding.padded(
|
||||
bare, kind: .sealed, ladder: ladder, padSource: padSource)
|
||||
let blob = try seal(
|
||||
inner: inner, toDevice: toDevice, recipientIKdh: recipientIKdh,
|
||||
ephemeral: ephemeral)
|
||||
let payload = Data(canonicalOuterJSON(blob).utf8).base64EncodedString()
|
||||
guard payload.count == plan.step else {
|
||||
throw Padding.Failure.wrongLength(want: plan.step, got: payload.count)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/// The inner JSON without a pad, as the padded seal writes it. Its length
|
||||
/// is what decides the step (section 23.7 step 1).
|
||||
///
|
||||
/// Written in a fixed key order, `from_user`, `from_device`, `from_addr`
|
||||
/// when present, `envelope`, for the reason on `E2EEnvelopeJSON.wireJSON`.
|
||||
static func innerJSON(
|
||||
envelope: E2EEnvelopeJSON, fromUser: Int, fromDevice: String,
|
||||
fromAddress: String? = nil
|
||||
) throws -> Data {
|
||||
func str(_ s: String) throws -> String {
|
||||
String(decoding: try Padding.encoder().encode(s), as: UTF8.self)
|
||||
}
|
||||
var json = "{\"from_user\":\(fromUser),\"from_device\":\(try str(fromDevice))"
|
||||
if let fromAddress { json += ",\"from_addr\":\(try str(fromAddress))" }
|
||||
json += ",\"envelope\":"
|
||||
return Data(json.utf8) + (try envelope.wireJSON()) + Data("}".utf8)
|
||||
}
|
||||
|
||||
/// The canonical outer form of section 23.5, byte for byte: keys in this
|
||||
/// order, no spaces, `/` never escaped.
|
||||
static func canonicalOuterJSON(_ blob: Blob) -> String {
|
||||
"{\"s\":1,\"eph\":\"\(blob.eph)\",\"ct\":\"\(blob.ct)\"}"
|
||||
}
|
||||
|
||||
/// Encrypt already-serialized inner JSON.
|
||||
private static func seal(
|
||||
inner: Data, toDevice: String, recipientIKdh: Data,
|
||||
ephemeral: Curve25519.KeyAgreement.PrivateKey?
|
||||
) throws -> Blob {
|
||||
let eph = ephemeral ?? Curve25519.KeyAgreement.PrivateKey()
|
||||
let peer = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: recipientIKdh)
|
||||
let shared = try eph.sharedSecretFromKeyAgreement(with: peer)
|
||||
guard let (key, nonce) = derive(shared: shared) else {
|
||||
throw E2EError.crypto("sealed sender: key derivation failed")
|
||||
}
|
||||
let ephPub = eph.publicKey.rawRepresentation
|
||||
let aad = Data(toDevice.utf8) + ephPub
|
||||
let box = try AES.GCM.seal(inner, using: key, nonce: nonce, authenticating: aad)
|
||||
return Blob(s: 1, eph: ephPub.e2eBase64, ct: (box.ciphertext + box.tag).e2eBase64)
|
||||
}
|
||||
|
||||
/// Open a sealed blob with this device's `IK_dh`.
|
||||
static func open(
|
||||
blob: Blob,
|
||||
@@ -89,6 +172,8 @@ enum SealedSender {
|
||||
tag: ctTag.suffix(16)
|
||||
)
|
||||
let plain = try AES.GCM.open(box, using: key, authenticating: aad)
|
||||
// `pad`, when present, is ignored (section 23.8): `Inner` has no such
|
||||
// field, and synthesized decoding skips keys it does not know.
|
||||
let inner = try JSONDecoder().decode(Inner.self, from: plain)
|
||||
return Opened(
|
||||
claimedFromUser: inner.from_user,
|
||||
@@ -138,9 +223,18 @@ struct DeliveryKey {
|
||||
|
||||
/// The control message handing a contact our key (§14.4) and, since §21.4,
|
||||
/// a batch of single-use capabilities alongside it.
|
||||
func contentEnvelopeJSON(tokens: [String], presenceToken: String? = nil) -> String {
|
||||
contentEnvelopeJSON(
|
||||
func contentEnvelopeJSON(
|
||||
tokens: [String], presenceToken: String? = nil,
|
||||
handleCert: HandleCertificate? = nil
|
||||
) -> String {
|
||||
let base = contentEnvelopeJSON(
|
||||
tokens: tokens, gid: nil, groupName: nil, presenceToken: presenceToken)
|
||||
// Section 14.5: on a first message, the sender's own handle
|
||||
// certificate, so the recipient can show a name the relay vouched for
|
||||
// without asking the relay who this is. Last, after the tokens, as
|
||||
// `content-v1.json`'s `prelude-first-contact` pins it.
|
||||
guard let handleCert else { return base }
|
||||
return String(base.dropLast()) + ",\"hc\":" + handleCert.orderedJSON + "}"
|
||||
}
|
||||
|
||||
/// The same envelope, optionally also telling this contact what a group is
|
||||
|
||||
@@ -50,15 +50,20 @@ struct SealingRecord {
|
||||
/// The sentence shown to the user.
|
||||
var summary: String {
|
||||
guard let share = unsealedShare else {
|
||||
return "Nothing sent from this device yet."
|
||||
return String(localized: "Nothing sent from this device yet.")
|
||||
}
|
||||
if unsealed == 0 {
|
||||
return "All \(sealed) messages sent from this device were sealed, "
|
||||
+ "the server was never told who sent them."
|
||||
return String(
|
||||
localized:
|
||||
"All \(sealed) messages sent from this device were sealed, the server was never told who sent them."
|
||||
)
|
||||
}
|
||||
let percent = Int((share * 100).rounded())
|
||||
return "\(unsealed) of \(total) messages sent from this device (\(percent)%) "
|
||||
+ "went unsealed. Those were still end-to-end encrypted, the server "
|
||||
+ "could see who sent them, not what they said."
|
||||
return String(
|
||||
localized: """
|
||||
\(unsealed) of \(total) messages sent from this device (\(percent)%) \
|
||||
went unsealed. Those were still end-to-end encrypted, the server \
|
||||
could see who sent them, not what they said.
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import Foundation
|
||||
|
||||
/// A JSON value, kept exactly as it arrived so a register this build does not
|
||||
/// understand can be stored and passed on unchanged (section 17.6).
|
||||
///
|
||||
/// Integers are kept apart from other numbers: `at` and read marks are epoch
|
||||
/// milliseconds, and a value that went through a `Double` and came back as
|
||||
/// `1.7999999999e12` would compare wrong and travel wrong.
|
||||
indirect enum JSONValue: Equatable, Hashable, Codable {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case int(Int64)
|
||||
case double(Double)
|
||||
case string(String)
|
||||
case array([JSONValue])
|
||||
case object([String: JSONValue])
|
||||
|
||||
/// From whatever `JSONSerialization` produced. Nil for something that is
|
||||
/// not JSON at all.
|
||||
init?(any value: Any?) {
|
||||
guard let value else {
|
||||
self = .null
|
||||
return
|
||||
}
|
||||
switch value {
|
||||
case is NSNull: self = .null
|
||||
case let n as NSNumber:
|
||||
if CFGetTypeID(n) == CFBooleanGetTypeID() {
|
||||
self = .bool(n.boolValue)
|
||||
} else if CFNumberIsFloatType(n) {
|
||||
let d = n.doubleValue
|
||||
if d.rounded() == d, abs(d) < 9_007_199_254_740_992 {
|
||||
self = .int(Int64(d))
|
||||
} else {
|
||||
self = .double(d)
|
||||
}
|
||||
} else {
|
||||
self = .int(n.int64Value)
|
||||
}
|
||||
case let s as String: self = .string(s)
|
||||
case let a as [Any]:
|
||||
var out: [JSONValue] = []
|
||||
for item in a {
|
||||
guard let v = JSONValue(any: item) else { return nil }
|
||||
out.append(v)
|
||||
}
|
||||
self = .array(out)
|
||||
case let o as [String: Any]:
|
||||
var out: [String: JSONValue] = [:]
|
||||
for (k, item) in o {
|
||||
guard let v = JSONValue(any: item) else { return nil }
|
||||
out[k] = v
|
||||
}
|
||||
self = .object(out)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// What `JSONSerialization` writes back.
|
||||
var any: Any {
|
||||
switch self {
|
||||
case .null: return NSNull()
|
||||
case .bool(let b): return b
|
||||
case .int(let i): return NSNumber(value: i)
|
||||
case .double(let d): return d
|
||||
case .string(let s): return s
|
||||
case .array(let a): return a.map(\.any)
|
||||
case .object(let o): return o.mapValues(\.any)
|
||||
}
|
||||
}
|
||||
|
||||
var intValue: Int64? {
|
||||
if case .int(let i) = self { return i }
|
||||
return nil
|
||||
}
|
||||
var stringValue: String? {
|
||||
if case .string(let s) = self { return s }
|
||||
return nil
|
||||
}
|
||||
var objectValue: [String: JSONValue]? {
|
||||
if case .object(let o) = self { return o }
|
||||
return nil
|
||||
}
|
||||
var boolValue: Bool? {
|
||||
if case .bool(let b) = self { return b }
|
||||
return nil
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if c.decodeNil() {
|
||||
self = .null
|
||||
} else if let b = try? c.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let i = try? c.decode(Int64.self) {
|
||||
self = .int(i)
|
||||
} else if let d = try? c.decode(Double.self) {
|
||||
self = .double(d)
|
||||
} else if let s = try? c.decode(String.self) {
|
||||
self = .string(s)
|
||||
} else if let a = try? c.decode([JSONValue].self) {
|
||||
self = .array(a)
|
||||
} else {
|
||||
self = .object(try c.decode([String: JSONValue].self))
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .null: try c.encodeNil()
|
||||
case .bool(let b): try c.encode(b)
|
||||
case .int(let i): try c.encode(i)
|
||||
case .double(let d): try c.encode(d)
|
||||
case .string(let s): try c.encode(s)
|
||||
case .array(let a): try c.encode(a)
|
||||
case .object(let o): try c.encode(o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Section 17.6, the register merge that every device of one account runs.
|
||||
///
|
||||
/// A function of the registers alone: two devices that have seen the same ops,
|
||||
/// in any order and any number of times, hold the same registers. Ported from
|
||||
/// the reference engine's `sync.js` and checked against
|
||||
/// `test/vectors/sync-merge-v1.json`, every case in every order of its ops,
|
||||
/// because a tie two engines break differently is a conversation archived on
|
||||
/// one phone and not the other, for ever, with nothing to say why.
|
||||
enum SyncMerge {
|
||||
|
||||
static let dayMs: Int64 = 24 * 60 * 60 * 1000
|
||||
/// How long a removal is remembered.
|
||||
static let tombstoneMs: Int64 = 90 * dayMs
|
||||
/// How far ahead of the receiver's clock an op may claim to be.
|
||||
static let futureSkewMs: Int64 = dayMs
|
||||
/// Offline this long or longer and a device asks for snapshots.
|
||||
static let snapshotAfterMs: Int64 = 7 * dayMs
|
||||
/// Read marks: at most one op per thread per window.
|
||||
static let readCoalesceMs: Int64 = 30 * 1000
|
||||
/// What one `history` or `sync_state` chunk may carry, in bytes of JSON.
|
||||
static let chunkBytes = 16 * 1024
|
||||
/// The most messages a backfill carries (section 17.4).
|
||||
static let historyMaxItems = 10_000
|
||||
|
||||
enum Rule: String { case register, setElement = "set-element", max }
|
||||
|
||||
/// Which families take which rule. Anything else is an ordinary register,
|
||||
/// kept and passed on by a device that does not understand it.
|
||||
static let families: [String: Rule] = [
|
||||
"link": .setElement, "roster": .setElement, "blocked": .setElement,
|
||||
"acc": .setElement, "muted": .setElement, "read": .max,
|
||||
]
|
||||
|
||||
static func family(of key: String) -> String {
|
||||
guard let slash = key.firstIndex(of: "/") else { return key }
|
||||
return String(key[..<slash])
|
||||
}
|
||||
|
||||
static func rule(for key: String) -> Rule { families[family(of: key)] ?? .register }
|
||||
|
||||
static func ops(for rule: Rule) -> Set<String> {
|
||||
switch rule {
|
||||
case .register: return ["set", "del"]
|
||||
case .setElement: return ["add", "rm"]
|
||||
case .max: return ["max"]
|
||||
}
|
||||
}
|
||||
|
||||
static func isRemoval(_ op: String) -> Bool { op == "del" || op == "rm" }
|
||||
|
||||
/// The largest integer every engine holds exactly.
|
||||
static let maxSafeInteger: Int64 = 9_007_199_254_740_991
|
||||
}
|
||||
|
||||
/// One op, or one register (a register is the last op a device accepted for
|
||||
/// its key).
|
||||
struct SyncOp: Equatable, Codable {
|
||||
var op: String
|
||||
var key: String
|
||||
var val: JSONValue?
|
||||
var at: Int64
|
||||
var dev: String
|
||||
|
||||
/// Whether the op is well formed for its key. A malformed op is ignored,
|
||||
/// never half applied.
|
||||
var isValid: Bool {
|
||||
guard !key.isEmpty, key.utf16.count <= 512, !dev.isEmpty else { return false }
|
||||
guard at >= 0, at <= SyncMerge.maxSafeInteger else { return false }
|
||||
let rule = SyncMerge.rule(for: key)
|
||||
guard SyncMerge.ops(for: rule).contains(op) else { return false }
|
||||
if op == "max" {
|
||||
guard let v = val?.intValue, v >= 0, v <= SyncMerge.maxSafeInteger else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// From one JSON object, as `sync` carries it, or nil. `at` that is not an
|
||||
/// integer is not an op.
|
||||
init?(json: [String: Any], key overriding: String? = nil) {
|
||||
guard let op = json["op"] as? String,
|
||||
let key = overriding ?? (json["key"] as? String),
|
||||
let dev = json["dev"] as? String,
|
||||
let atValue = JSONValue(any: json["at"]), case .int(let at) = atValue
|
||||
else { return nil }
|
||||
self.op = op
|
||||
self.key = key
|
||||
self.dev = dev
|
||||
self.at = at
|
||||
if json.keys.contains("val") {
|
||||
guard let v = JSONValue(any: json["val"]) else { return nil }
|
||||
self.val = v
|
||||
} else {
|
||||
self.val = nil
|
||||
}
|
||||
}
|
||||
|
||||
init(op: String, key: String, val: JSONValue? = nil, at: Int64, dev: String) {
|
||||
self.op = op
|
||||
self.key = key
|
||||
self.val = val
|
||||
self.at = at
|
||||
self.dev = dev
|
||||
}
|
||||
|
||||
/// The op as `sync` writes it.
|
||||
var json: [String: Any] {
|
||||
var out: [String: Any] = ["op": op, "key": key, "at": NSNumber(value: at), "dev": dev]
|
||||
if let val { out["val"] = val.any }
|
||||
return out
|
||||
}
|
||||
|
||||
/// The register as `sync_state` writes it, without the key.
|
||||
var registerJSON: [String: Any] {
|
||||
var out: [String: Any] = ["op": op, "at": NSNumber(value: at), "dev": dev]
|
||||
if let val { out["val"] = val.any }
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/// Every register one device holds, keyed by register key.
|
||||
struct SyncState: Equatable, Codable {
|
||||
private(set) var registers: [String: SyncOp] = [:]
|
||||
|
||||
init(registers: [String: SyncOp] = [:]) { self.registers = registers }
|
||||
|
||||
subscript(key: String) -> SyncOp? { registers[key] }
|
||||
|
||||
/// Whether `incoming` replaces `current` (section 17.6). Later `at` wins;
|
||||
/// on equal `at` a block beats an unblock; otherwise the greater device id,
|
||||
/// compared as text. Equal `at` and device is the same op seen twice.
|
||||
private static func replaces(_ incoming: SyncOp, _ current: SyncOp?) -> Bool {
|
||||
guard let current else { return true }
|
||||
if incoming.at != current.at { return incoming.at > current.at }
|
||||
if SyncMerge.family(of: incoming.key) == "blocked", incoming.op != current.op {
|
||||
return incoming.op == "add"
|
||||
}
|
||||
if incoming.dev != current.dev { return Self.textGreater(incoming.dev, current.dev) }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Text comparison by UTF-16 code unit, which is what the reference
|
||||
/// engine's `>` on strings does; Swift's `String` `>` compares by
|
||||
/// Unicode scalar and can disagree outside the Basic Multilingual Plane.
|
||||
static func textGreater(_ a: String, _ b: String) -> Bool {
|
||||
let x = Array(a.utf16)
|
||||
let y = Array(b.utf16)
|
||||
for i in 0..<min(x.count, y.count) where x[i] != y[i] { return x[i] > y[i] }
|
||||
return x.count > y.count
|
||||
}
|
||||
|
||||
/// Merge one op. Returns whether anything changed. `now` is the
|
||||
/// receiver's clock in milliseconds.
|
||||
@discardableResult
|
||||
mutating func merge(_ o: SyncOp, now: Int64) -> Bool {
|
||||
guard o.isValid else { return false }
|
||||
// A clock a year fast would otherwise win every register it touched
|
||||
// until the year came round.
|
||||
guard o.at <= now + SyncMerge.futureSkewMs else { return false }
|
||||
var reg = o
|
||||
if SyncMerge.isRemoval(o.op) { reg.val = nil }
|
||||
let current = registers[o.key]
|
||||
if SyncMerge.rule(for: o.key) == .max {
|
||||
// A read mark only moves forward, whatever the clocks say.
|
||||
let incoming = reg.val?.intValue ?? 0
|
||||
if let current, let held = current.val?.intValue {
|
||||
if held > incoming { return false }
|
||||
if held == incoming, !Self.replaces(reg, current) { return false }
|
||||
}
|
||||
registers[o.key] = reg
|
||||
return true
|
||||
}
|
||||
guard Self.replaces(reg, current) else { return false }
|
||||
registers[o.key] = reg
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func merge(_ ops: [SyncOp], now: Int64) -> Bool {
|
||||
var changed = false
|
||||
for o in ops where merge(o, now: now) { changed = true }
|
||||
return changed
|
||||
}
|
||||
|
||||
/// A snapshot's `regs` object as ops. A register that is not an op is
|
||||
/// skipped, exactly as a malformed op would be.
|
||||
static func ops(fromRegisters regs: [String: Any]) -> [SyncOp] {
|
||||
regs.compactMap { key, value in
|
||||
guard let o = value as? [String: Any] else { return nil }
|
||||
return SyncOp(json: o, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget removals older than the tombstone window.
|
||||
mutating func collectTombstones(now: Int64) {
|
||||
registers = registers.filter { _, r in
|
||||
!(SyncMerge.isRemoval(r.op) && r.at < now - SyncMerge.tombstoneMs)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the registers say: each live key and its value (`true` for a set
|
||||
/// element that carries none). Removed keys are absent.
|
||||
var view: [String: JSONValue] {
|
||||
var out: [String: JSONValue] = [:]
|
||||
for (key, r) in registers where !SyncMerge.isRemoval(r.op) {
|
||||
out[key] = r.val ?? .bool(true)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Whether a set element is present (`add` beats nothing).
|
||||
func contains(_ key: String) -> Bool {
|
||||
guard let r = registers[key] else { return false }
|
||||
return !SyncMerge.isRemoval(r.op)
|
||||
}
|
||||
|
||||
/// The live keys of one family, with the family prefix stripped.
|
||||
func members(of family: String) -> [String] {
|
||||
let prefix = family + "/"
|
||||
return registers.compactMap { key, r in
|
||||
guard key.hasPrefix(prefix), !SyncMerge.isRemoval(r.op) else { return nil }
|
||||
return String(key.dropFirst(prefix.count))
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
/// Keep only `link/…` registers (section 17.6, a device away 90 days or
|
||||
/// more starts again from its linked devices' snapshots).
|
||||
mutating func keepOnlyLinks() {
|
||||
registers = registers.filter { key, _ in SyncMerge.family(of: key) == "link" }
|
||||
}
|
||||
|
||||
/// The registers as a `sync_state` `regs` object.
|
||||
var registersJSON: [String: Any] { registers.mapValues(\.registerJSON) }
|
||||
}
|
||||
|
||||
extension SyncMerge {
|
||||
/// Split JSON-able items into chunks of at most `limit` bytes of item
|
||||
/// JSON, keeping order. An item larger than the limit travels alone.
|
||||
static func chunk(_ items: [[String: Any]], limit: Int = chunkBytes) -> [[[String: Any]]] {
|
||||
var chunks: [[[String: Any]]] = []
|
||||
var current: [[String: Any]] = []
|
||||
var size = 0
|
||||
for item in items {
|
||||
let n =
|
||||
((try? JSONSerialization.data(withJSONObject: item, options: [.sortedKeys]))?
|
||||
.count ?? 0) + 1
|
||||
if !current.isEmpty, size + n > limit {
|
||||
chunks.append(current)
|
||||
current = []
|
||||
size = 0
|
||||
}
|
||||
current.append(item)
|
||||
size += n
|
||||
}
|
||||
if !current.isEmpty { chunks.append(current) }
|
||||
return chunks
|
||||
}
|
||||
|
||||
/// Milliseconds since the epoch, as every op carries it.
|
||||
static func nowMs(_ date: Date = Date()) -> Int64 {
|
||||
Int64((date.timeIntervalSince1970 * 1000).rounded())
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ struct VerificationRecord: Codable, Equatable {
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .inPerson: return "in person"
|
||||
case .qrCode: return "by scanning their code"
|
||||
case .call: return "over a call"
|
||||
case .other: return "some other way"
|
||||
case .inPerson: return String(localized: "in person", comment: "How a safety number was compared")
|
||||
case .qrCode: return String(localized: "by scanning their code")
|
||||
case .call: return String(localized: "over a call")
|
||||
case .other: return String(localized: "some other way")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,50 @@ struct VerificationRecord: Codable, Equatable {
|
||||
func summary(now: Date = Date()) -> String {
|
||||
let when = verifiedAt.formatted(date: .abbreviated, time: .omitted)
|
||||
guard let brokenAt else {
|
||||
return "You verified them \(method.label) on \(when), on this device."
|
||||
// One whole sentence per method, so a translation never has to
|
||||
// splice a phrase into the middle of another one.
|
||||
switch method {
|
||||
case .inPerson:
|
||||
return String(localized: "You verified them in person on \(when), on this device.")
|
||||
case .qrCode:
|
||||
return String(
|
||||
localized: "You verified them by scanning their code on \(when), on this device.")
|
||||
case .call:
|
||||
return String(localized: "You verified them over a call on \(when), on this device.")
|
||||
case .other:
|
||||
return String(localized: "You verified them some other way on \(when), on this device.")
|
||||
}
|
||||
}
|
||||
let broke = brokenAt.formatted(date: .abbreviated, time: .omitted)
|
||||
return "You verified them \(method.label) on \(when). Their keys changed "
|
||||
+ "on \(broke), so that check no longer applies, it was made against "
|
||||
+ "the keys they had then."
|
||||
switch method {
|
||||
case .inPerson:
|
||||
return String(
|
||||
localized: """
|
||||
You verified them in person on \(when). Their keys changed \
|
||||
on \(broke), so that check no longer applies, it was made against \
|
||||
the keys they had then.
|
||||
""")
|
||||
case .qrCode:
|
||||
return String(
|
||||
localized: """
|
||||
You verified them by scanning their code on \(when). Their keys changed \
|
||||
on \(broke), so that check no longer applies, it was made against \
|
||||
the keys they had then.
|
||||
""")
|
||||
case .call:
|
||||
return String(
|
||||
localized: """
|
||||
You verified them over a call on \(when). Their keys changed \
|
||||
on \(broke), so that check no longer applies, it was made against \
|
||||
the keys they had then.
|
||||
""")
|
||||
case .other:
|
||||
return String(
|
||||
localized: """
|
||||
You verified them some other way on \(when). Their keys changed \
|
||||
on \(broke), so that check no longer applies, it was made against \
|
||||
the keys they had then.
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Adding somebody to the contact list, in one place, as `ContactRemoval` is
|
||||
/// for taking them off.
|
||||
///
|
||||
/// Section 8.9d: with the contact list off the relay, a contact is added by
|
||||
/// the lookup that found them and onto the sealed list, never through the
|
||||
/// relay's add-a-buddy route, which would write the very pair the setting
|
||||
/// exists to avoid. Four surfaces add (the Home search, the add sheet, the
|
||||
/// Messages add sheet, `/add`), and three of them went to the relay whatever
|
||||
/// the setting said. Adding somebody also accepts them (section 14.5).
|
||||
enum ContactAdd {
|
||||
|
||||
/// Add `person`, and return what went wrong, or nil.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
static func perform(
|
||||
_ person: BuddyProfile, inbox: E2EInbox?, api: MessengerAPI?, pagePath: String
|
||||
) async -> String? {
|
||||
if inbox?.rosterOffServer == true {
|
||||
inbox?.accept(person.id)
|
||||
inbox?.rosterAdd(id: person.id, handle: person.slug, displayName: person.name)
|
||||
return nil
|
||||
}
|
||||
guard let api else { return String(localized: "Not connected to a site.") }
|
||||
do {
|
||||
try await api.addBuddy(username: person.slug, fromPagePath: pagePath)
|
||||
inbox?.accept(person.id)
|
||||
return nil
|
||||
} catch {
|
||||
return (error as? MessengerError)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Add by username alone, as `/add` does: looked up first, so the
|
||||
/// off-relay path has an id to put on the sealed list.
|
||||
@MainActor
|
||||
static func perform(
|
||||
username: String, inbox: E2EInbox?, api: MessengerAPI?, pagePath: String
|
||||
) async -> String? {
|
||||
guard let api else { return String(localized: "Not connected to a site.") }
|
||||
do {
|
||||
guard let person = try await api.lookupMember(username: username) else {
|
||||
return String(localized: "Nobody here is called \(username).")
|
||||
}
|
||||
return await perform(person, inbox: inbox, api: api, pagePath: pagePath)
|
||||
} catch {
|
||||
return (error as? MessengerError)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ enum ContactRemoval {
|
||||
@discardableResult
|
||||
static func perform(id: Int, inbox: E2EInbox?, api: MessengerAPI?) async -> String? {
|
||||
inbox?.rosterRemove(id: id)
|
||||
// A removed contact is no longer accepted: a later message from them
|
||||
// is a request again rather than a conversation that reopens itself.
|
||||
inbox?.unaccept(id)
|
||||
// And the presence pair, which the roster does not carry. Taking
|
||||
// somebody off the list left them holding a token that still answered
|
||||
// for this account: the relay goes on saying "online" to whoever
|
||||
@@ -30,7 +33,7 @@ enum ContactRemoval {
|
||||
// Off the server there is no relay row to delete, and asking for one
|
||||
// is an error the person did not cause.
|
||||
if inbox?.rosterOffServer == true { return nil }
|
||||
guard let api else { return "Not connected to a site." }
|
||||
guard let api else { return String(localized: "Not connected to a site.") }
|
||||
do {
|
||||
try await api.removeBuddy(buddyID: id)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Section 8.14, Listed and Unlisted (decision DB1), and the choice offered at
|
||||
/// sign-up (DB2).
|
||||
///
|
||||
/// The words matter and the spec pins them: Unlisted means people cannot
|
||||
/// find or ADD you by your username. It never means the username is secret,
|
||||
/// because anyone trying to sign up with it is told it is taken.
|
||||
enum ListingChoice {
|
||||
static let pendingKey = "fnmsg.signupChoseUnlisted"
|
||||
|
||||
static let explanation =
|
||||
String(localized: "If you turn this off, nobody can find or add you by your username. People reach you through a link or QR code you give them. Your username isn't secret, though: anyone who tries to sign up with it is told it's taken.")
|
||||
|
||||
/// A choice made at sign-up and not yet applied, kept until the relay
|
||||
/// can take it.
|
||||
static var pendingUnlisted: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: pendingKey) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: pendingKey) }
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,10 @@ enum Presence: String, Codable, CaseIterable {
|
||||
/// What the picker says.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .online: return "Online"
|
||||
case .away: return "Away"
|
||||
case .busy: return "Busy"
|
||||
case .invisible: return "Appear offline"
|
||||
case .online: return String(localized: "Online")
|
||||
case .away: return String(localized: "Away")
|
||||
case .busy: return String(localized: "Busy")
|
||||
case .invisible: return String(localized: "Appear offline")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,23 +46,23 @@ enum Presence: String, Codable, CaseIterable {
|
||||
var explanation: String {
|
||||
switch self {
|
||||
case .online:
|
||||
return "Contacts see you as online while the app has been used recently."
|
||||
return String(localized: "Contacts see you as online while the app has been used recently.")
|
||||
case .away:
|
||||
return "Contacts see you as away. Messages still arrive normally."
|
||||
return String(localized: "Contacts see you as away. Messages still arrive normally.")
|
||||
case .busy:
|
||||
return "Contacts see you as busy. Nothing is blocked, it is a hint, not a filter."
|
||||
return String(localized: "Contacts see you as busy. Nothing is blocked, it is a hint, not a filter.")
|
||||
case .invisible:
|
||||
return "Contacts see you as offline. You still receive messages."
|
||||
return String(localized: "Contacts see you as offline. You still receive messages.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .online: return "Online"
|
||||
case .away: return "Away"
|
||||
case .busy: return "Busy"
|
||||
case .offline: return "Offline"
|
||||
case .online: return String(localized: "Online")
|
||||
case .away: return String(localized: "Away")
|
||||
case .busy: return String(localized: "Busy")
|
||||
case .offline: return String(localized: "Offline")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
/// Features that connect to a host other than the Flamenet server, and so
|
||||
/// cannot go through a bridge.
|
||||
///
|
||||
/// A bridge forwards to the edge in front of the Flamenet server and nowhere
|
||||
/// else. A link preview fetches the linked site, a call connects to the other
|
||||
/// person or a TURN server, the wallet asks a public chain node, a live stream
|
||||
/// plays from wherever it is hosted. Each of those would be a direct
|
||||
/// connection, and SPEC §24.6 says a client with bridges turned on must not
|
||||
/// make one. So each is off while a line is saved, and says so in words.
|
||||
enum AroundTheBridge {
|
||||
/// A line is saved, so these features are off.
|
||||
static var active: Bool { Obfs4Settings.isEnabled }
|
||||
|
||||
static let linkPreviewsOff =
|
||||
String(localized: "Link previews are off while you use a bridge. Making one would connect to the linked website directly, around the bridge.")
|
||||
|
||||
static let callsOff =
|
||||
String(localized: "Calls are off while you use a bridge. A call connects around the bridge, so the network you're on could see it.")
|
||||
|
||||
static let walletOff =
|
||||
String(localized: "The wallet is off while you use a bridge. It talks to a public chain node directly, and a bridge only reaches your Flamenet server.")
|
||||
|
||||
static func streamOff(host: String) -> String {
|
||||
String(localized: "Live streams are off while you use a bridge. Playing this one would connect to \(host) directly, around the bridge.")
|
||||
}
|
||||
|
||||
/// Thrown where a connection would have been made.
|
||||
struct Refused: LocalizedError, Equatable {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// For a fetch from a site that is not the Flamenet server, once the
|
||||
/// caller has checked `active`. Ephemeral, not the shared session, so
|
||||
/// nothing it holds is reused by anything else.
|
||||
static let thirdParty = URLSession(configuration: .ephemeral)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ final class MessengerAPI {
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw Self.restError(status: http.statusCode, body: data)
|
||||
}
|
||||
return (try? decoder.decode(MessageOnlyResponse.self, from: data).message) ?? "Done."
|
||||
return (try? decoder.decode(MessageOnlyResponse.self, from: data).message) ?? String(localized: "Done.")
|
||||
}
|
||||
|
||||
// MARK: - Buddies
|
||||
@@ -243,6 +243,23 @@ final class MessengerAPI {
|
||||
|
||||
// MARK: - Display name
|
||||
|
||||
/// Section 8.14, stage B1: whether people can find this account by its
|
||||
/// username. Nil from a relay that does not say.
|
||||
func discoverable() async throws -> Bool? {
|
||||
let url = client.restURL.appendingPathComponent("flamenet/v1/messenger/me")
|
||||
let (data, http) = try await client.getJSON(at: url)
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw Self.restError(status: http.statusCode, body: data)
|
||||
}
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return object?["discoverable"] as? Bool
|
||||
}
|
||||
|
||||
/// Listed (true) or Unlisted (false).
|
||||
func setDiscoverable(_ listed: Bool) async throws {
|
||||
try await putMe(["discoverable": listed])
|
||||
}
|
||||
|
||||
func updateDisplayName(_ name: String) async throws {
|
||||
try await putMe(["name": name])
|
||||
}
|
||||
@@ -340,6 +357,36 @@ final class MessengerAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Web search
|
||||
|
||||
/// `POST /api/flamenet/v1/search`: the relay asks its own index on this
|
||||
/// member's behalf and keeps nothing. The query goes in the body, never
|
||||
/// the URL, so it is in no access log; it is never logged here either.
|
||||
func webSearch(_ query: String, page: Int = 1) async throws -> WebSearchPage {
|
||||
var request = URLRequest(
|
||||
url: client.restURL.appendingPathComponent("flamenet/v1/search"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let h = client.basicAuthHeader {
|
||||
request.setValue(h, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["q": query, "page": page])
|
||||
let (data, http) = try await client.sendUnauthenticated(request: request)
|
||||
if http.statusCode == 429 {
|
||||
throw MessengerError.rateLimited(
|
||||
retryAfterSeconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw Self.restError(status: http.statusCode, body: data)
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(WebSearchPage.self, from: data)
|
||||
} catch {
|
||||
throw MessengerError.decoding(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Member lookup
|
||||
|
||||
/// Find one member by their exact username.
|
||||
@@ -445,7 +492,7 @@ private struct BuddyListResponse: Decodable {
|
||||
if !display.isEmpty { return display }
|
||||
let handle = (screen_name ?? "").trimmingCharacters(in: .whitespaces)
|
||||
if !handle.isEmpty { return handle }
|
||||
return "User #\(id)"
|
||||
return String(localized: "User #\(String(id))")
|
||||
}
|
||||
|
||||
func toBuddy() -> Buddy {
|
||||
@@ -465,8 +512,30 @@ struct BlockedUser: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let slug: String
|
||||
|
||||
init(id: Int, name: String, slug: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlockedEnvelope: Decodable {
|
||||
let blocked: [BlockedUser]
|
||||
}
|
||||
|
||||
/// What `POST /api/flamenet/v1/search` answers. `unavailable` means the index
|
||||
/// is down or slow, which is not the same as finding nothing.
|
||||
struct WebSearchPage: Decodable, Equatable {
|
||||
struct Result: Decodable, Equatable, Identifiable {
|
||||
let title: String
|
||||
let url: String
|
||||
let host: String
|
||||
let snippet: String
|
||||
let source: String
|
||||
var id: String { url }
|
||||
}
|
||||
let results: [Result]
|
||||
let sources: [String]
|
||||
let unavailable: Bool
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ final class MessengerClient {
|
||||
|
||||
let baseURL: URL
|
||||
let restPrefix: String
|
||||
private let session: URLSession
|
||||
/// Follows the bridge setting: a client made before a bridge was set
|
||||
/// must not go on connecting directly.
|
||||
private let sessions: BridgedSession
|
||||
private var session: URLSession { sessions.session }
|
||||
/// The session requests go out on now, for tests.
|
||||
var transportSession: URLSession { session }
|
||||
let cookieStorage: HTTPCookieStorage
|
||||
private(set) var basicAuthHeader: String?
|
||||
private(set) var basicAuthUsername: String?
|
||||
@@ -20,26 +25,25 @@ final class MessengerClient {
|
||||
self.restPrefix = restPrefix
|
||||
|
||||
let storage = HTTPCookieStorage()
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = storage
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
// The policy above only skips the cache on READ.
|
||||
config.urlCache = nil
|
||||
config.timeoutIntervalForRequest = 20
|
||||
config.httpAdditionalHeaders = [
|
||||
"User-Agent": "FlamenetMessenger/1.0 (iOS)",
|
||||
"Accept": "application/json, text/html;q=0.9, */*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
]
|
||||
if let protocolClasses { config.protocolClasses = protocolClasses }
|
||||
|
||||
self.cookieStorage = storage
|
||||
// Unconditional by design: a no-op unless a bridge is configured, which
|
||||
// it is not by default.
|
||||
Obfs4Proxy.shared.apply(to: config)
|
||||
self.session = URLSession(configuration: config)
|
||||
self.sessions = BridgedSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = storage
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
// The policy above only skips the cache on READ.
|
||||
config.urlCache = nil
|
||||
config.timeoutIntervalForRequest = 20
|
||||
config.httpAdditionalHeaders = [
|
||||
"User-Agent": "FlamenetMessenger/1.0 (iOS)",
|
||||
"Accept": "application/json, text/html;q=0.9, */*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
]
|
||||
if let protocolClasses { config.protocolClasses = protocolClasses }
|
||||
// `BridgedSession` applies the bridges, a no-op unless one is set.
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Endpoints
|
||||
@@ -98,7 +102,7 @@ final class MessengerClient {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw MessengerError.transport("Non-HTTP response")
|
||||
throw MessengerError.transport(String(localized: "Non-HTTP response"))
|
||||
}
|
||||
return (data, http)
|
||||
} catch let error as MessengerError {
|
||||
@@ -107,9 +111,12 @@ final class MessengerClient {
|
||||
// The common cause is not a bad certificate: it is `https://` typed
|
||||
// (or defaulted) against a relay serving plain HTTP, which fails in
|
||||
// the handshake and reports itself as a security problem.
|
||||
let host = request.url?.host ?? String(localized: "the server")
|
||||
throw MessengerError.transport(
|
||||
"Couldn't establish a secure connection to \(request.url?.host ?? "the server"). "
|
||||
+ "If that address serves plain HTTP, enter it with an http:// prefix.")
|
||||
String(
|
||||
localized:
|
||||
"Couldn't establish a secure connection to \(host). If that address serves plain HTTP, enter it with an http:// prefix."
|
||||
))
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
// A cancelled request is not a failed one. It is what happens when
|
||||
// a screen goes away mid-fetch, a newer load supersedes this one,
|
||||
|
||||
@@ -79,41 +79,44 @@ enum MessengerError: LocalizedError, Equatable {
|
||||
// the CAPTCHA.
|
||||
let raw: String
|
||||
switch self {
|
||||
case .notConfigured: raw = "No site has been configured yet."
|
||||
case .invalidURL: raw = "The site address is not a valid URL."
|
||||
case .notAuthenticated: raw = "You need to sign in again."
|
||||
case .spentProof: raw = "That request had already been sent; signing it again."
|
||||
case .nonceUnavailable: raw = "Couldn't obtain a security token from the site."
|
||||
case .transport(let m): raw = "Network error: \(m)"
|
||||
case .notConfigured: raw = String(localized: "No site has been configured yet.")
|
||||
case .invalidURL: raw = String(localized: "The site address is not a valid URL.")
|
||||
case .notAuthenticated: raw = String(localized: "You need to sign in again.")
|
||||
case .spentProof: raw = String(localized: "That request had already been sent; signing it again.")
|
||||
case .nonceUnavailable: raw = String(localized: "Couldn't obtain a security token from the site.")
|
||||
case .transport(let m): raw = String(localized: "Network error: \(m)")
|
||||
// 451 is the geo-gate, and it needs a sentence rather than a number.
|
||||
case .httpStatus(451):
|
||||
raw =
|
||||
"Flamenet isn't available in your country yet. "
|
||||
+ "This isn't a problem with your account or your connection."
|
||||
case .httpStatus(let code): raw = "Server returned HTTP \(code)."
|
||||
raw = String(
|
||||
localized:
|
||||
"Flamenet isn't available in your country yet. This isn't a problem with your account or your connection."
|
||||
)
|
||||
case .httpStatus(let code): raw = String(localized: "Server returned HTTP \(String(code)).")
|
||||
case .unauthorized:
|
||||
// Deliberately not the relay's wording.
|
||||
raw = "This device needs to reconnect to the server. Trying again…"
|
||||
raw = String(localized: "This device needs to reconnect to the server. Trying again…")
|
||||
case .refused(let refusal, let reason):
|
||||
// Each of these says what is actually wrong and whether waiting
|
||||
// helps, because "Trying again…" was said for an hour to somebody
|
||||
// whose sends could never have succeeded by being tried again.
|
||||
switch refusal {
|
||||
case .deliveryKeyRejected, .capabilityRevoked, .tokenExpired:
|
||||
raw = "Reconnecting to the server…"
|
||||
raw = String(localized: "Reconnecting to the server…")
|
||||
case .credentialRejected:
|
||||
raw =
|
||||
"This device is signed in but the server is not accepting "
|
||||
+ "it. Sign out and back in to fix it."
|
||||
raw = String(
|
||||
localized:
|
||||
"This device is signed in but the server is not accepting it. Sign out and back in to fix it."
|
||||
)
|
||||
case .deviceLimitReached:
|
||||
// The relay's own sentence, because it knows the numbers and
|
||||
// it names the one screen that fixes it. Anything vaguer here
|
||||
// leaves somebody with an app that looks signed in and does
|
||||
// nothing.
|
||||
raw = reason.isEmpty
|
||||
? "This account has too many devices registered. Remove one "
|
||||
+ "from the Devices screen on a signed-in device, then sign "
|
||||
+ "in again here."
|
||||
? String(
|
||||
localized:
|
||||
"This account has too many devices registered. Remove one from the Devices screen on a signed-in device, then sign in again here."
|
||||
)
|
||||
: reason
|
||||
}
|
||||
case .rateLimited(let seconds):
|
||||
@@ -121,9 +124,9 @@ enum MessengerError: LocalizedError, Equatable {
|
||||
let mins = max(1, (seconds + 59) / 60)
|
||||
raw = "Too many attempts. Try again in about \(mins) minute\(mins == 1 ? "" : "s")."
|
||||
} else {
|
||||
raw = "Too many attempts. Wait a few minutes."
|
||||
raw = String(localized: "Too many attempts. Wait a few minutes.")
|
||||
}
|
||||
case .decoding(let m): raw = "Couldn't read the server's response (\(m))."
|
||||
case .decoding(let m): raw = String(localized: "Couldn't read the server's response (\(m)).")
|
||||
case .ajaxFailure(let m): raw = mapAjaxMessage(m)
|
||||
}
|
||||
return Self.cleanupHTML(raw)
|
||||
@@ -165,52 +168,57 @@ enum MessengerError: LocalizedError, Equatable {
|
||||
|
||||
private func mapAjaxMessage(_ raw: String) -> String {
|
||||
switch raw {
|
||||
case "not_logged_in": return "Your session expired, please sign in again."
|
||||
case "invalid": return "That message couldn't be sent (invalid recipient or empty body)."
|
||||
case "save_failed": return "The server failed to save your message."
|
||||
case "bad_credentials": return "Incorrect username or password."
|
||||
case "not_logged_in": return String(localized: "Your session expired, please sign in again.")
|
||||
case "invalid": return String(localized: "That message couldn't be sent (invalid recipient or empty body).")
|
||||
case "save_failed": return String(localized: "The server failed to save your message.")
|
||||
case "bad_credentials": return String(localized: "Incorrect username or password.")
|
||||
case "no_rest_api":
|
||||
return
|
||||
"This server doesn't answer at /api/. Check the address, and that it is a Flamenet relay."
|
||||
case "login_failed": return "Sign in didn't complete. The server didn't return a session."
|
||||
case "add_notfound": return "No Flamenet member has that username."
|
||||
case "add_self": return "You can't add yourself."
|
||||
case "add_duplicate": return "They're already on your buddy list."
|
||||
case "add_nonce": return "Security check failed. Pull to refresh and try again."
|
||||
case "add_unknown": return "Couldn't add that buddy. The server didn't say why."
|
||||
case "signup_username_taken": return "That username is already taken, try another."
|
||||
case "signup_email_taken": return "An account with that email already exists."
|
||||
String(localized: "This server doesn't answer at /api/. Check the address, and that it is a Flamenet relay.")
|
||||
case "login_failed": return String(localized: "Sign in didn't complete. The server didn't return a session.")
|
||||
case "add_notfound": return String(localized: "No Flamenet member has that username.")
|
||||
case "add_self": return String(localized: "You can't add yourself.")
|
||||
case "add_duplicate": return String(localized: "They're already on your buddy list.")
|
||||
case "add_nonce": return String(localized: "Security check failed. Pull to refresh and try again.")
|
||||
case "add_unknown": return String(localized: "Couldn't add that buddy. The server didn't say why.")
|
||||
case "signup_username_taken": return String(localized: "That username is already taken, try another.")
|
||||
case "signup_email_taken": return String(localized: "An account with that email already exists.")
|
||||
// Not "letters, numbers, and . _ - @": the server rejects punctuation,
|
||||
// because OSCAR cannot carry `.`, `_` or `-` in a screen name and
|
||||
// reports it as "service unreachable" at first sign-on.
|
||||
case "signup_bad_username":
|
||||
return "Usernames can only contain lowercase letters and numbers."
|
||||
case "signup_bad_email": return "That doesn't look like a valid email address."
|
||||
case "signup_weak_password": return "Your password needs to be at least 8 characters."
|
||||
return String(localized: "Usernames can only contain lowercase letters and numbers.")
|
||||
case "signup_bad_email": return String(localized: "That doesn't look like a valid email address.")
|
||||
case "signup_weak_password": return String(localized: "Your password needs to be at least 8 characters.")
|
||||
// Says what to DO.
|
||||
case "signup_username_confusable":
|
||||
return "That username looks too much like one that already exists. "
|
||||
+ "Changing a digit won't help, try a different word."
|
||||
return String(
|
||||
localized:
|
||||
"That username looks too much like one that already exists. Changing a digit won't help, try a different word."
|
||||
)
|
||||
// Explains the reason rather than sounding like a judgement about their
|
||||
// email provider.
|
||||
case "signup_disposable_email":
|
||||
return "That email provider isn't accepted. Use an address you'll still "
|
||||
+ "be able to read later, it's how you get back in."
|
||||
return String(
|
||||
localized:
|
||||
"That email provider isn't accepted. Use an address you'll still be able to read later, it's how you get back in."
|
||||
)
|
||||
|
||||
// Says where to look and what it is for.
|
||||
case "email_unverified":
|
||||
return "Check your email for a code, then enter it to finish setting up "
|
||||
+ "your account."
|
||||
case "signup_closed": return "New accounts are not being accepted right now."
|
||||
case "no_app_passwords": return "This server isn't set up to issue session tokens."
|
||||
return String(
|
||||
localized: "Check your email for a code, then enter it to finish setting up your account."
|
||||
)
|
||||
case "signup_closed": return String(localized: "New accounts are not being accepted right now.")
|
||||
case "no_app_passwords": return String(localized: "This server isn't set up to issue session tokens.")
|
||||
// Neither of these blames the person, because neither is their doing.
|
||||
case "signup_challenge_required":
|
||||
return
|
||||
"This server asks new accounts to do a quick check first, and this version of the app can't do it yet. Update the app and try again."
|
||||
String(localized: "This server asks new accounts to do a quick check first, and this version of the app can't do it yet. Update the app and try again.")
|
||||
case "signup_challenge_failed":
|
||||
return
|
||||
"That check took too long. Tap Create account again and it will start a fresh one."
|
||||
default: return "Request failed: \(raw)"
|
||||
String(localized: "That check took too long. Tap Create account again and it will start a fresh one.")
|
||||
default: return String(localized: "Request failed: \(raw)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,14 @@ enum Obfs4Keychain {
|
||||
/// and they stay in every backup taken from here on unless something
|
||||
/// removes them. This runs once per key and removes the old copy whether
|
||||
/// or not the new one was written, because leaving it is the bug.
|
||||
///
|
||||
/// Only when the old key is there. Removing a key that is not there still
|
||||
/// posts `UserDefaults.didChangeNotification`, and this runs on every read
|
||||
/// of the bridge settings, which SwiftUI views make in `body`: every
|
||||
/// `@AppStorage` view re-rendered, read again, and posted again, and the
|
||||
/// wallet's send sheet hung its main thread (WalletUITests).
|
||||
static func migrate(from defaultsKey: String, to account: String) {
|
||||
guard UserDefaults.standard.object(forKey: defaultsKey) != nil else { return }
|
||||
defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }
|
||||
guard read(account) == nil else { return }
|
||||
if let data = UserDefaults.standard.data(forKey: defaultsKey) {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import FilesClient
|
||||
import Foundation
|
||||
|
||||
/// The one transport instance the app shares.
|
||||
public final class Obfs4Proxy {
|
||||
public static let shared = Obfs4Proxy()
|
||||
private var transport: Obfs4Transport?
|
||||
private let lock = NSLock()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Point the Files package's sessions at the bridges.
|
||||
public func routeFilesClient() {
|
||||
FilesTransport.configure = { Obfs4Proxy.shared.apply(to: $0) }
|
||||
}
|
||||
|
||||
/// What is actually happening, as opposed to what is stored (§24.6).
|
||||
public enum State: Equatable {
|
||||
/// No line stored: the app connects directly.
|
||||
case off
|
||||
/// Lines are stored and nothing has gone through any of them yet.
|
||||
case waiting(lines: Int)
|
||||
/// The last connection went through this bridge.
|
||||
case active(host: String, port: UInt16)
|
||||
/// The last connection tried every line and none answered. The app
|
||||
/// keeps trying, and does not connect directly.
|
||||
case noneReachable(lines: Int)
|
||||
/// The local listener could not be opened. Traffic is blocked, not
|
||||
/// sent directly.
|
||||
case couldNotStart
|
||||
}
|
||||
|
||||
/// Bumped when bridges turn on or off, or the transport is rebuilt, so a
|
||||
/// session made before knows to make a new one (`BridgedSession`).
|
||||
public var generation: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return bumped
|
||||
}
|
||||
private var bumped = 0
|
||||
|
||||
private var failedToStart = false
|
||||
private var carried: Obfs4Transport.Bridge?
|
||||
private var everyLineFailed = false
|
||||
/// A test seam: the next start fails as if the port could not be opened.
|
||||
var failNextStartForTesting = false
|
||||
|
||||
public var state: State {
|
||||
let lines = Obfs4Settings.attemptOrder()
|
||||
let stored = Obfs4Settings.lines.count
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard stored > 0 else { return .off }
|
||||
if failedToStart { return .couldNotStart }
|
||||
if let carried, lines.contains(where: { $0.bridge == carried }) {
|
||||
return .active(host: carried.host, port: carried.port)
|
||||
}
|
||||
if everyLineFailed { return .noneReachable(lines: stored) }
|
||||
return .waiting(lines: stored)
|
||||
}
|
||||
|
||||
/// Drops the running transport, so the next session builds one from the
|
||||
/// current settings.
|
||||
public func reset() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
transport?.stop()
|
||||
transport = nil
|
||||
failedToStart = false
|
||||
carried = nil
|
||||
everyLineFailed = false
|
||||
bumped += 1
|
||||
}
|
||||
|
||||
/// The stored list changed. Every open tunnel closes, so nothing keeps
|
||||
/// travelling through a line that was removed (§24.7). Turning bridges on
|
||||
/// or off also makes every bridged session rebuild.
|
||||
func linesChanged(wasOn: Bool, isOn: Bool) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
transport?.closeTunnels()
|
||||
carried = nil
|
||||
everyLineFailed = false
|
||||
if !isOn {
|
||||
transport?.stop()
|
||||
transport = nil
|
||||
failedToStart = false
|
||||
}
|
||||
if wasOn != isOn { bumped += 1 }
|
||||
}
|
||||
|
||||
private func worked(_ bridge: Obfs4Transport.Bridge) {
|
||||
lock.lock()
|
||||
carried = bridge
|
||||
everyLineFailed = false
|
||||
lock.unlock()
|
||||
if let line = Obfs4Settings.attemptOrder().first(where: { $0.bridge == bridge })?.line {
|
||||
Obfs4Settings.markWorked(line)
|
||||
}
|
||||
}
|
||||
|
||||
private func failedEveryLine() {
|
||||
lock.lock()
|
||||
carried = nil
|
||||
everyLineFailed = true
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Where a session goes when bridges are on and the local listener could
|
||||
/// not be opened: a port nothing listens on, so requests fail rather than
|
||||
/// go direct (§24.6).
|
||||
static let nowhere: [AnyHashable: Any] = [
|
||||
"SOCKSEnable": 1, "SOCKSProxy": "127.0.0.1", "SOCKSPort": 1, "SOCKSVersion": 5,
|
||||
]
|
||||
|
||||
/// Points a session configuration at the bridges, starting the local proxy
|
||||
/// on first use. Returns false, and leaves the configuration alone, only
|
||||
/// when no line is stored.
|
||||
@discardableResult
|
||||
public func apply(to configuration: URLSessionConfiguration) -> Bool {
|
||||
guard Obfs4Settings.isEnabled else { return false }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if transport == nil && !failedToStart {
|
||||
if failNextStartForTesting {
|
||||
failNextStartForTesting = false
|
||||
failedToStart = true
|
||||
} else {
|
||||
let started = Obfs4Transport(
|
||||
bridges: { Obfs4Settings.attemptOrder().map(\.bridge) },
|
||||
attemptTimeout: 15,
|
||||
onWorked: { [weak self] in self?.worked($0) },
|
||||
onAllFailed: { [weak self] in self?.failedEveryLine() })
|
||||
do {
|
||||
try started.start()
|
||||
transport = started
|
||||
} catch {
|
||||
failedToStart = true
|
||||
}
|
||||
}
|
||||
}
|
||||
configuration.connectionProxyDictionary = transport?.proxyConfiguration ?? Self.nowhere
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension Obfs4Proxy {
|
||||
|
||||
/// What a reachability check found.
|
||||
public enum CheckResult: Equatable {
|
||||
case working
|
||||
/// The local proxy could not be opened.
|
||||
case couldNotStartLocally
|
||||
/// Deliberately does not say which.
|
||||
case noAnswer
|
||||
/// The bridge answered and the relay behind it did not, so the bridge
|
||||
/// itself is fine.
|
||||
case bridgeWorkedRelayDidNot(String)
|
||||
|
||||
public var message: String {
|
||||
switch self {
|
||||
case .working:
|
||||
return String(localized: "Connected through the bridge.")
|
||||
case .couldNotStartLocally:
|
||||
return String(localized: "This phone wouldn't let the app set up the connection. Restarting the app usually clears it.")
|
||||
case .noAnswer:
|
||||
// Says both possibilities because it cannot tell, and guessing
|
||||
// would send somebody off to fix the wrong thing.
|
||||
return String(localized: "No answer from the bridge. That means either the line isn't quite right, or nothing is running at that address, a bridge stays quiet in both cases, so there's no way to tell them apart from here. Worth checking the line with whoever gave it to you.")
|
||||
case .bridgeWorkedRelayDidNot(let detail):
|
||||
return String(localized: "The bridge answered, so the line is good. What's behind it didn't reply: \(detail)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a real connection through the bridges to `target`.
|
||||
public func check(target: URL) async -> CheckResult {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 60
|
||||
guard apply(to: config) else { return .couldNotStartLocally }
|
||||
if state == .couldNotStart { return .couldNotStartLocally }
|
||||
|
||||
var request = URLRequest(url: target)
|
||||
request.httpMethod = "HEAD"
|
||||
let session = URLSession(configuration: config)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
// Any HTTP status at all means the whole path worked, the handshake
|
||||
// completed, the bridge proxied, something answered.
|
||||
_ = response
|
||||
return .working
|
||||
} catch let error as URLError {
|
||||
// The default is to blame nothing.
|
||||
switch error.code {
|
||||
case .secureConnectionFailed, .serverCertificateUntrusted,
|
||||
.serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
|
||||
.serverCertificateNotYetValid, .badServerResponse, .cannotParseResponse:
|
||||
return .bridgeWorkedRelayDidNot(error.localizedDescription)
|
||||
default:
|
||||
return .noAnswer
|
||||
}
|
||||
} catch {
|
||||
return .noAnswer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A URLSession that follows the bridge setting.
|
||||
///
|
||||
/// A configuration cannot change once a session is made from it, so a client
|
||||
/// that made its session before a bridge was set would go on connecting
|
||||
/// directly. This rebuilds the session whenever `Obfs4Proxy.generation` moves.
|
||||
/// The old session is dropped, not invalidated: a task already holding it
|
||||
/// finishes, and making a task on an invalidated session raises.
|
||||
final class BridgedSession {
|
||||
private let make: () -> URLSessionConfiguration
|
||||
private var built: URLSession?
|
||||
private var builtFor = -1
|
||||
private let lock = NSLock()
|
||||
|
||||
init(_ make: @escaping () -> URLSessionConfiguration) { self.make = make }
|
||||
|
||||
var session: URLSession {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let generation = Obfs4Proxy.shared.generation
|
||||
if let built, builtFor == generation { return built }
|
||||
let configuration = make()
|
||||
Obfs4Proxy.shared.apply(to: configuration)
|
||||
let fresh = URLSession(configuration: configuration)
|
||||
built = fresh
|
||||
builtFor = Obfs4Proxy.shared.generation
|
||||
return fresh
|
||||
}
|
||||
|
||||
/// For everything before sign-in: relay discovery, the sign-up proof of
|
||||
/// work and the account service.
|
||||
static let preSignIn = BridgedSession {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.urlCache = nil
|
||||
configuration.timeoutIntervalForRequest = 30
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
/// Whether relay traffic goes through an obfs4 bridge, and which one.
|
||||
/// Whether relay traffic goes through obfs4 bridges, and which ones.
|
||||
///
|
||||
/// SPEC §24. Up to eight lines, in the order they were added (§24.6). A line
|
||||
/// the person pasted and a line accepted from a contact's offer are stored
|
||||
/// alike; who offered it is kept only to show beside it.
|
||||
public enum Obfs4Settings {
|
||||
|
||||
private static let bridgeLineKey = "net.flamenet.obfs4.bridgeLine"
|
||||
/// Keychain accounts. The legacy keys above are now only read once, by
|
||||
/// Keychain accounts. The legacy keys are now only read once, by
|
||||
/// `Obfs4Keychain.migrate`, and removed.
|
||||
private static let bridgeLineAccount = "bridgeLine"
|
||||
private static let linesAccount = "bridgeLines"
|
||||
|
||||
/// §24.6: a client MAY hold up to 8 lines.
|
||||
public static let maximumLines = 8
|
||||
/// §24.4: the longest line an offer may carry, in UTF-8 bytes.
|
||||
public static let maximumLineBytes = E2EAttachment.maximumBridgeLineBytes
|
||||
|
||||
/// What is wrong with a bridge line, specifically enough to say so.
|
||||
public enum Problem: Error, Equatable {
|
||||
@@ -23,69 +33,196 @@ public enum Obfs4Settings {
|
||||
public var message: String {
|
||||
switch self {
|
||||
case .empty:
|
||||
return "Paste the line your bridge provider gave you."
|
||||
return String(localized: "Paste the line your bridge provider gave you.")
|
||||
case .noAddress:
|
||||
return "This doesn't have an address in it. A bridge line has one near the "
|
||||
+ "start, like 198.51.100.7:4444."
|
||||
return String(localized: "This doesn't have an address in it. A bridge line has one near the start, like 198.51.100.7:4444.")
|
||||
case .badPort(let port):
|
||||
return "\"\(port)\" isn't a port number. It should be digits, after the "
|
||||
+ "colon in the address."
|
||||
return String(localized: "\"\(String(port))\" isn't a port number. It should be digits, after the colon in the address.")
|
||||
case .noCert:
|
||||
return "The part starting cert= is missing. That's the piece that proves "
|
||||
+ "you're talking to the right bridge, so it's worth going back for "
|
||||
+ "the whole line."
|
||||
return String(localized: "The part starting cert= is missing. That's the piece that proves you're talking to the right bridge, so it's worth going back for the whole line.")
|
||||
case .certNotBase64:
|
||||
return "The part after cert= has something in it that doesn't belong, "
|
||||
+ "often a stray space or a line break picked up while copying. Try "
|
||||
+ "copying it again."
|
||||
return String(localized: "The part after cert= has something in it that doesn't belong, often a stray space or a line break picked up while copying. Try copying it again.")
|
||||
case .certWrongLength(let got):
|
||||
// The likeliest real failure by a distance, and the one worth
|
||||
// spending words on.
|
||||
return got < 52
|
||||
? "This line looks cut short. They're long enough that most apps wrap "
|
||||
+ "them onto a second line, and it's easy to copy only the first, "
|
||||
+ "check you've got all of it."
|
||||
: "There's more after cert= than belongs there. Something extra was "
|
||||
+ "probably picked up while copying."
|
||||
? String(localized: "This line looks cut short. They're long enough that most apps wrap them onto a second line, and it's easy to copy only the first, check you've got all of it.")
|
||||
: String(localized: "There's more after cert= than belongs there. Something extra was probably picked up while copying.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured bridge line, in the standard form: obfs4 <host>:<port>
|
||||
/// <FINGERPRINT> cert=<base64> iat-mode=0 Stored as the operator's own line
|
||||
/// rather than parsed fields, so what the user pasted can be shown back to
|
||||
/// them exactly.
|
||||
public static var bridgeLine: String? {
|
||||
get {
|
||||
Obfs4Keychain.migrate(from: bridgeLineKey, to: bridgeLineAccount)
|
||||
return Obfs4Keychain.read(bridgeLineAccount).map {
|
||||
String(decoding: $0, as: UTF8.self)
|
||||
/// Why a line was not added to the list.
|
||||
public enum Refusal: Error, Equatable {
|
||||
case unreadable(Problem)
|
||||
case tooMany
|
||||
case alreadySaved
|
||||
|
||||
public var message: String {
|
||||
switch self {
|
||||
case .unreadable(let problem):
|
||||
return problem.message
|
||||
case .tooMany:
|
||||
return String(localized: "You can keep up to 8 bridges. Remove one before adding another.")
|
||||
case .alreadySaved:
|
||||
return String(localized: "This bridge is already in your list.")
|
||||
}
|
||||
}
|
||||
set {
|
||||
Obfs4Keychain.migrate(from: bridgeLineKey, to: bridgeLineAccount)
|
||||
let trimmed = newValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
Obfs4Keychain.write(Data(trimmed.utf8), to: bridgeLineAccount)
|
||||
}
|
||||
|
||||
/// One stored line.
|
||||
public struct StoredLine: Codable, Equatable, Identifiable {
|
||||
/// The line as it was pasted or received, trimmed.
|
||||
public let line: String
|
||||
public let addedAt: Double
|
||||
/// Who offered it, for display. Nil for a line the person pasted.
|
||||
public let offeredBy: String?
|
||||
/// When it last carried a connection that reached the relay.
|
||||
public var lastWorked: Double?
|
||||
public var id: String { line }
|
||||
}
|
||||
|
||||
/// Serialises read-modify-write of the stored list.
|
||||
private static let lock = NSLock()
|
||||
|
||||
/// The stored lines, in the order they were added.
|
||||
public static var lines: [StoredLine] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return readLines()
|
||||
}
|
||||
|
||||
private static func readLines() -> [StoredLine] {
|
||||
if let data = Obfs4Keychain.read(linesAccount),
|
||||
let decoded = try? JSONDecoder().decode([StoredLine].self, from: data)
|
||||
{
|
||||
return decoded
|
||||
}
|
||||
// A one-line build stored the line on its own. Take it, and remove the
|
||||
// old copy whether or not the new one was written.
|
||||
Obfs4Keychain.migrate(from: bridgeLineKey, to: bridgeLineAccount)
|
||||
guard let old = Obfs4Keychain.read(bridgeLineAccount) else { return [] }
|
||||
defer { Obfs4Keychain.delete(bridgeLineAccount) }
|
||||
let text = String(decoding: old, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return [] }
|
||||
let migrated = [StoredLine(line: text, addedAt: 0, offeredBy: nil, lastWorked: nil)]
|
||||
writeLines(migrated)
|
||||
return migrated
|
||||
}
|
||||
|
||||
private static func writeLines(_ all: [StoredLine]) {
|
||||
if all.isEmpty {
|
||||
Obfs4Keychain.delete(linesAccount)
|
||||
} else if let data = try? JSONEncoder().encode(all) {
|
||||
Obfs4Keychain.write(data, to: linesAccount)
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the list and tell the transport, which closes what a removed line
|
||||
/// was carrying (§24.7).
|
||||
private static func mutate(_ change: (inout [StoredLine]) -> Void) {
|
||||
lock.lock()
|
||||
var all = readLines()
|
||||
let before = all
|
||||
change(&all)
|
||||
if all != before { writeLines(all) }
|
||||
lock.unlock()
|
||||
if all.map(\.line) != before.map(\.line) {
|
||||
Obfs4Proxy.shared.linesChanged(wasOn: !before.isEmpty, isOn: !all.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges are on when any line is stored. Off is the person's choice, and
|
||||
/// the one case where the app connects directly (§24.6).
|
||||
public static var isEnabled: Bool { !lines.isEmpty }
|
||||
|
||||
/// Add a line at the end of the list.
|
||||
@discardableResult
|
||||
public static func add(
|
||||
_ line: String, offeredBy: String? = nil,
|
||||
at now: Double = Date().timeIntervalSince1970
|
||||
) -> Result<Obfs4Transport.Bridge, Refusal> {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bridge: Obfs4Transport.Bridge
|
||||
switch validate(trimmed) {
|
||||
case .failure(let problem): return .failure(.unreadable(problem))
|
||||
case .success(let parsed): bridge = parsed
|
||||
}
|
||||
var refusal: Refusal?
|
||||
mutate { all in
|
||||
if all.contains(where: { parse($0.line).map { sameBridge($0, bridge) } ?? false }) {
|
||||
refusal = .alreadySaved
|
||||
} else if all.count >= maximumLines {
|
||||
refusal = .tooMany
|
||||
} else {
|
||||
Obfs4Keychain.delete(bridgeLineAccount)
|
||||
all.append(
|
||||
StoredLine(line: trimmed, addedAt: now, offeredBy: offeredBy, lastWorked: nil))
|
||||
}
|
||||
cached = nil
|
||||
Obfs4Proxy.shared.reset()
|
||||
}
|
||||
if let refusal { return .failure(refusal) }
|
||||
return .success(bridge)
|
||||
}
|
||||
|
||||
/// Two lines name one bridge when they point at the same place.
|
||||
private static func sameBridge(_ a: Obfs4Transport.Bridge, _ b: Obfs4Transport.Bridge) -> Bool {
|
||||
a.host.lowercased() == b.host.lowercased() && a.port == b.port
|
||||
}
|
||||
|
||||
/// Remove a line. Connections it carried close at once (§24.7).
|
||||
public static func remove(_ line: String) {
|
||||
mutate { all in all.removeAll { $0.line == line } }
|
||||
}
|
||||
|
||||
/// Record that a line carried a connection that reached the relay.
|
||||
public static func markWorked(_ line: String, at now: Double = Date().timeIntervalSince1970) {
|
||||
mutate { all in
|
||||
guard let i = all.firstIndex(where: { $0.line == line }) else { return }
|
||||
all[i].lastWorked = now
|
||||
}
|
||||
}
|
||||
|
||||
public static var isEnabled: Bool { bridge != nil }
|
||||
/// The order to try lines in (§24.6): the one that last completed a
|
||||
/// connection first, then the rest in stored order.
|
||||
public static func attemptOrder() -> [(line: String, bridge: Obfs4Transport.Bridge)] {
|
||||
let all = lines
|
||||
let readable = all.compactMap { stored in parse(stored.line).map { (stored.line, $0) } }
|
||||
guard
|
||||
let latest = all.filter({ $0.lastWorked != nil })
|
||||
.max(by: { $0.lastWorked! < $1.lastWorked! })
|
||||
else { return readable }
|
||||
return readable.filter { $0.0 == latest.line } + readable.filter { $0.0 != latest.line }
|
||||
}
|
||||
|
||||
private static var cached: Obfs4Transport.Bridge??
|
||||
/// The first stored line. Setting it replaces the whole list with that one
|
||||
/// line, and setting nil removes every line. Kept for the callers and
|
||||
/// tests written when there was only one.
|
||||
public static var bridgeLine: String? {
|
||||
get { lines.first?.line }
|
||||
set {
|
||||
let trimmed = newValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
mutate { all in
|
||||
all =
|
||||
trimmed.isEmpty
|
||||
? []
|
||||
: [StoredLine(
|
||||
line: trimmed, addedAt: Date().timeIntervalSince1970, offeredBy: nil,
|
||||
lastWorked: nil)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed bridge, or `nil` when none is configured or the line is
|
||||
/// malformed.
|
||||
public static var bridge: Obfs4Transport.Bridge? {
|
||||
if let cached { return cached }
|
||||
let parsed = bridgeLine.flatMap(parse)
|
||||
cached = parsed
|
||||
return parsed
|
||||
/// The first stored line, parsed, or nil.
|
||||
public static var bridge: Obfs4Transport.Bridge? { bridgeLine.flatMap(parse) }
|
||||
|
||||
/// A cert decodes with or without its padding, and only in the standard
|
||||
/// alphabet (§24.2).
|
||||
static func decodeCert(_ cert: String) -> Data? {
|
||||
var bare = Substring(cert)
|
||||
while bare.hasSuffix("=") { bare = bare.dropLast() }
|
||||
if bare.count % 4 == 1 { return nil }
|
||||
let padded = bare + String(repeating: "=", count: (4 - bare.count % 4) % 4)
|
||||
return Data(base64Encoded: String(padded))
|
||||
}
|
||||
|
||||
/// Parses a bridge line, saying what is wrong when it cannot.
|
||||
@@ -102,7 +239,9 @@ public enum Obfs4Settings {
|
||||
let host = String(address[address.startIndex..<colon])
|
||||
let portText = String(address[address.index(after: colon)...])
|
||||
guard !host.isEmpty else { return .failure(.noAddress) }
|
||||
guard let port = UInt16(portText), port > 0 else { return .failure(.badPort(portText)) }
|
||||
guard !portText.isEmpty, portText.allSatisfy({ $0.isASCII && $0.isNumber }),
|
||||
let port = UInt16(portText), port > 0
|
||||
else { return .failure(.badPort(portText)) }
|
||||
|
||||
guard let certField = fields.first(where: { $0.hasPrefix("cert=") })
|
||||
else { return .failure(.noCert) }
|
||||
@@ -110,18 +249,19 @@ public enum Obfs4Settings {
|
||||
|
||||
// The two ways a cert fails are told apart here rather than in
|
||||
// `Bridge.init?`, which can only say "no".
|
||||
guard let raw = Data(base64Encoded: cert + "==") else { return .failure(.certNotBase64) }
|
||||
guard let raw = decodeCert(cert) else { return .failure(.certNotBase64) }
|
||||
guard raw.count == 52 else { return .failure(.certWrongLength(raw.count)) }
|
||||
|
||||
guard let bridge = Obfs4Transport.Bridge(host: host, port: port, cert: cert)
|
||||
else { return .failure(.certNotBase64) }
|
||||
return .success(bridge)
|
||||
return .success(
|
||||
Obfs4Transport.Bridge(
|
||||
host: host, port: port,
|
||||
nodeID: Array(raw.prefix(20)), identityPublic: Array(raw.suffix(32))))
|
||||
}
|
||||
|
||||
private static let offersKey = "net.flamenet.obfs4.offers"
|
||||
private static let offersAccount = "offers"
|
||||
|
||||
/// A bridge line a contact offered, and who offered it (§6.19).
|
||||
/// A bridge line a contact offered, and who offered it (§24.5).
|
||||
public struct Offer: Codable, Equatable, Identifiable {
|
||||
public let fromUser: Int
|
||||
public let fromName: String
|
||||
@@ -157,13 +297,16 @@ public enum Obfs4Settings {
|
||||
saveOffers(offers.filter { $0.fromUser != fromUser })
|
||||
}
|
||||
|
||||
/// Accept an offer, which is the ONLY path from one to a route.
|
||||
/// Accept an offer, which is the ONLY path from one to a route. The line
|
||||
/// joins the list (§24.6) and the offer goes.
|
||||
@discardableResult
|
||||
public static func accept(_ offer: Offer) -> Result<Obfs4Transport.Bridge, Problem> {
|
||||
let result = validate(offer.line)
|
||||
if case .success = result {
|
||||
bridgeLine = offer.line
|
||||
public static func accept(_ offer: Offer) -> Result<Obfs4Transport.Bridge, Refusal> {
|
||||
let result = add(offer.line, offeredBy: offer.fromName)
|
||||
switch result {
|
||||
case .success, .failure(.alreadySaved):
|
||||
dismissOffer(fromUser: offer.fromUser)
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -175,10 +318,7 @@ public enum Obfs4Settings {
|
||||
let mode = fields.first(where: { $0.hasPrefix("iat-mode=") })?
|
||||
.dropFirst("iat-mode=".count), mode != "0"
|
||||
else { return nil }
|
||||
return "This bridge asks for extra timing camouflage (iat-mode \(mode)) that this "
|
||||
+ "app doesn't do yet. It will still connect, and the traffic still doesn't "
|
||||
+ "look like anything in particular, the gaps between messages just aren't "
|
||||
+ "disguised as well."
|
||||
return String(localized: "This bridge asks for extra timing camouflage (iat-mode \(String(mode))) that this app doesn't do yet. It will still connect, and the traffic still doesn't look like anything in particular, the gaps between messages just aren't disguised as well.")
|
||||
}
|
||||
|
||||
/// The non-reporting form, for call sites that only need the bridge.
|
||||
@@ -186,137 +326,3 @@ public enum Obfs4Settings {
|
||||
try? validate(line).get()
|
||||
}
|
||||
}
|
||||
|
||||
/// The one transport instance the app shares.
|
||||
public final class Obfs4Proxy {
|
||||
public static let shared = Obfs4Proxy()
|
||||
private var transport: Obfs4Transport?
|
||||
private let lock = NSLock()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// What is actually happening, as opposed to what is stored.
|
||||
public enum State: Equatable {
|
||||
/// No bridge configured.
|
||||
case off
|
||||
/// A bridge is stored but nothing has needed the network yet, so
|
||||
/// whether it works is not known.
|
||||
case notStartedYet(host: String, port: UInt16)
|
||||
case active(host: String, port: UInt16)
|
||||
/// The local listener could not be opened, so traffic is going direct.
|
||||
case couldNotStart
|
||||
}
|
||||
|
||||
public var state: State {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let bridge = Obfs4Settings.bridge else { return .off }
|
||||
if failedToStart { return .couldNotStart }
|
||||
guard transport != nil else { return .notStartedYet(host: bridge.host, port: bridge.port) }
|
||||
return .active(host: bridge.host, port: bridge.port)
|
||||
}
|
||||
|
||||
private var failedToStart = false
|
||||
|
||||
/// Drops the running transport so the next call builds one from the current
|
||||
/// settings.
|
||||
public func reset() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
transport?.stop()
|
||||
transport = nil
|
||||
failedToStart = false
|
||||
}
|
||||
|
||||
/// Applies the configured bridge to a session configuration, starting the
|
||||
/// local proxy on first use.
|
||||
@discardableResult
|
||||
public func apply(to configuration: URLSessionConfiguration) -> Bool {
|
||||
guard let bridge = Obfs4Settings.bridge else { return false }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if transport == nil {
|
||||
let started = Obfs4Transport(bridge: bridge)
|
||||
do {
|
||||
try started.start()
|
||||
transport = started
|
||||
failedToStart = false
|
||||
} catch {
|
||||
// Falling back to a direct connection is deliberate: a failure
|
||||
// to open a loopback port must not make the app unusable.
|
||||
failedToStart = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
guard let transport else { return false }
|
||||
configuration.connectionProxyDictionary = transport.proxyConfiguration
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension Obfs4Proxy {
|
||||
|
||||
/// What a reachability check found.
|
||||
public enum CheckResult: Equatable {
|
||||
case working
|
||||
/// The local proxy could not be opened.
|
||||
case couldNotStartLocally
|
||||
/// Deliberately does not say which.
|
||||
case noAnswer
|
||||
/// The bridge answered and the relay behind it did not, so the bridge
|
||||
/// itself is fine.
|
||||
case bridgeWorkedRelayDidNot(String)
|
||||
|
||||
public var message: String {
|
||||
switch self {
|
||||
case .working:
|
||||
return "Connected through the bridge."
|
||||
case .couldNotStartLocally:
|
||||
return "This phone wouldn't let the app set up the connection. Restarting "
|
||||
+ "the app usually clears it."
|
||||
case .noAnswer:
|
||||
// Says both possibilities because it cannot tell, and guessing
|
||||
// would send somebody off to fix the wrong thing.
|
||||
return "No answer from the bridge. That means either the line isn't quite "
|
||||
+ "right, or nothing is running at that address, a bridge stays quiet "
|
||||
+ "in both cases, so there's no way to tell them apart from here. "
|
||||
+ "Worth checking the line with whoever gave it to you."
|
||||
case .bridgeWorkedRelayDidNot(let detail):
|
||||
return "The bridge answered, so the line is good. What's behind it didn't "
|
||||
+ "reply: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a real connection through the bridge to `target`.
|
||||
public func check(target: URL) async -> CheckResult {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 25
|
||||
guard apply(to: config) else { return .couldNotStartLocally }
|
||||
|
||||
var request = URLRequest(url: target)
|
||||
request.httpMethod = "HEAD"
|
||||
let session = URLSession(configuration: config)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
// Any HTTP status at all means the whole path worked, the handshake
|
||||
// completed, the bridge proxied, something answered.
|
||||
_ = response
|
||||
return .working
|
||||
} catch let error as URLError {
|
||||
// The default is to blame nothing.
|
||||
switch error.code {
|
||||
case .secureConnectionFailed, .serverCertificateUntrusted,
|
||||
.serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
|
||||
.serverCertificateNotYetValid, .badServerResponse, .cannotParseResponse:
|
||||
return .bridgeWorkedRelayDidNot(error.localizedDescription)
|
||||
default:
|
||||
return .noAnswer
|
||||
}
|
||||
} catch {
|
||||
return .noAnswer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import Obfs4
|
||||
|
||||
/// Carries relay traffic inside obfs4, for networks that block or fingerprint
|
||||
/// TLS to the relay.
|
||||
///
|
||||
/// A local SOCKS5 listener that URLSession is pointed at. Each connection it
|
||||
/// accepts is carried through one bridge, chosen by trying the stored lines one
|
||||
/// at a time (SPEC §24.6). The SOCKS target is ignored: a bridge forwards to the
|
||||
/// relay's edge, which routes by the TLS name inside the tunnel.
|
||||
public final class Obfs4Transport {
|
||||
|
||||
public struct Bridge {
|
||||
public struct Bridge: Equatable {
|
||||
public let host: String
|
||||
public let port: UInt16
|
||||
public let nodeID: [UInt8]
|
||||
@@ -25,21 +30,59 @@ public final class Obfs4Transport {
|
||||
}
|
||||
|
||||
/// `cert=` as it appears in a bridge line: base64 of `nodeID ‖
|
||||
/// identityPublic` with its trailing `==` stripped.
|
||||
/// identityPublic`, with or without its trailing `==`.
|
||||
public init?(host: String, port: UInt16, cert: String) {
|
||||
guard let raw = Data(base64Encoded: cert + "=="), raw.count == 52 else { return nil }
|
||||
guard let raw = Obfs4Settings.decodeCert(cert), raw.count == 52 else { return nil }
|
||||
self.init(
|
||||
host: host, port: port,
|
||||
nodeID: Array(raw.prefix(20)), identityPublic: Array(raw.suffix(32)))
|
||||
}
|
||||
|
||||
/// The host as the resolver wants it: an IPv6 literal without its
|
||||
/// brackets.
|
||||
var dialHost: String {
|
||||
host.hasPrefix("[") && host.hasSuffix("]")
|
||||
? String(host.dropFirst().dropLast()) : host
|
||||
}
|
||||
}
|
||||
|
||||
private let bridge: Bridge
|
||||
private let bridges: () -> [Bridge]
|
||||
private let attemptTimeout: TimeInterval
|
||||
private let onWorked: (Bridge) -> Void
|
||||
private let onAllFailed: () -> Void
|
||||
|
||||
private var listener: Int32 = -1
|
||||
private let queue = DispatchQueue(label: "net.flamenet.obfs4", attributes: .concurrent)
|
||||
public private(set) var localPort: UInt16 = 0
|
||||
|
||||
public init(bridge: Bridge) { self.bridge = bridge }
|
||||
/// Sockets of tunnels that are open now, so a settings change can close
|
||||
/// them at once (§24.7).
|
||||
private var open: Set<Int32> = []
|
||||
private let openLock = NSLock()
|
||||
|
||||
/// - Parameters:
|
||||
/// - bridges: the lines to try, in order, read afresh for every
|
||||
/// connection so a change in settings applies to the next one.
|
||||
/// - attemptTimeout: how long one line gets to complete a handshake, and
|
||||
/// then to answer through it (§24.6: 15 seconds).
|
||||
/// - onWorked: a line carried a connection that something answered.
|
||||
/// - onAllFailed: every line was tried for one connection and none
|
||||
/// answered.
|
||||
public init(
|
||||
bridges: @escaping () -> [Bridge], attemptTimeout: TimeInterval = 15,
|
||||
onWorked: @escaping (Bridge) -> Void = { _ in },
|
||||
onAllFailed: @escaping () -> Void = {}
|
||||
) {
|
||||
self.bridges = bridges
|
||||
self.attemptTimeout = attemptTimeout
|
||||
self.onWorked = onWorked
|
||||
self.onAllFailed = onAllFailed
|
||||
}
|
||||
|
||||
/// One fixed bridge.
|
||||
public convenience init(bridge: Bridge) {
|
||||
self.init(bridges: { [bridge] })
|
||||
}
|
||||
|
||||
public enum Failure: Error {
|
||||
case cannotListen
|
||||
@@ -65,7 +108,7 @@ public final class Obfs4Transport {
|
||||
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard bound == 0, Darwin.listen(fd, 8) == 0 else {
|
||||
guard bound == 0, Darwin.listen(fd, 16) == 0 else {
|
||||
close(fd)
|
||||
throw Failure.cannotListen
|
||||
}
|
||||
@@ -84,6 +127,8 @@ public final class Obfs4Transport {
|
||||
var plen = socklen_t(MemoryLayout<sockaddr>.size)
|
||||
let conn = accept(self.listener, &peer, &plen)
|
||||
if conn < 0 { return }
|
||||
var one: Int32 = 1
|
||||
setsockopt(conn, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout<Int32>.size))
|
||||
self.queue.async { self.serve(conn) }
|
||||
}
|
||||
}
|
||||
@@ -95,6 +140,28 @@ public final class Obfs4Transport {
|
||||
close(listener)
|
||||
listener = -1
|
||||
}
|
||||
closeTunnels()
|
||||
}
|
||||
|
||||
/// Close every tunnel that is open now. The next connection is carried by
|
||||
/// whatever the settings say then.
|
||||
public func closeTunnels() {
|
||||
openLock.lock()
|
||||
let all = open
|
||||
openLock.unlock()
|
||||
for fd in all { shutdown(fd, SHUT_RDWR) }
|
||||
}
|
||||
|
||||
private func track(_ fd: Int32) {
|
||||
openLock.lock()
|
||||
open.insert(fd)
|
||||
openLock.unlock()
|
||||
}
|
||||
|
||||
private func untrack(_ fd: Int32) {
|
||||
openLock.lock()
|
||||
open.remove(fd)
|
||||
openLock.unlock()
|
||||
}
|
||||
|
||||
/// The proxy dictionary to hand a `URLSessionConfiguration`.
|
||||
@@ -108,39 +175,62 @@ public final class Obfs4Transport {
|
||||
// MARK: - One connection
|
||||
|
||||
private func serve(_ client: Int32) {
|
||||
defer { close(client) }
|
||||
guard let target = readSocksRequest(client) else { return }
|
||||
guard let upstream = try? dialBridge() else {
|
||||
// SOCKS reply 0x04 = host unreachable.
|
||||
sendSocksReply(client, status: 0x04)
|
||||
return
|
||||
track(client)
|
||||
defer {
|
||||
untrack(client)
|
||||
close(client)
|
||||
}
|
||||
sendSocksReply(client, status: 0x00)
|
||||
pump(client: client, upstream: upstream, target: target)
|
||||
guard readSocksRequest(client) else { return }
|
||||
|
||||
// Bytes the client sent before anything answered, sent again through
|
||||
// the next line if this one turns out to reach nothing.
|
||||
var unanswered: [UInt8] = []
|
||||
var replied = false
|
||||
for bridge in bridges() {
|
||||
guard let upstream = dial(bridge) else { continue }
|
||||
track(upstream)
|
||||
defer {
|
||||
untrack(upstream)
|
||||
close(upstream)
|
||||
}
|
||||
guard let session = handshake(on: upstream, with: bridge) else { continue }
|
||||
if !replied {
|
||||
sendSocksReply(client, status: 0x00)
|
||||
replied = true
|
||||
}
|
||||
switch pump(client: client, upstream: upstream, session: session, unanswered: &unanswered) {
|
||||
case .answered:
|
||||
onWorked(bridge)
|
||||
return
|
||||
case .clientLeft:
|
||||
return
|
||||
case .reachedNothing:
|
||||
continue
|
||||
}
|
||||
}
|
||||
// §24.6: no direct fallback. Host unreachable, or a closed tunnel.
|
||||
if !replied { sendSocksReply(client, status: 0x04) }
|
||||
onAllFailed()
|
||||
}
|
||||
|
||||
/// A deliberately minimal SOCKS5 server: no authentication, CONNECT only.
|
||||
private func readSocksRequest(_ fd: Int32) -> (host: String, port: UInt16)? {
|
||||
/// Reads a SOCKS5 greeting and CONNECT. The target is read and ignored.
|
||||
private func readSocksRequest(_ fd: Int32) -> Bool {
|
||||
var tv = timeval(tv_sec: 10, tv_usec: 0)
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
var buf = [UInt8](repeating: 0, count: 512)
|
||||
var n = recv(fd, &buf, buf.count, 0)
|
||||
guard n >= 3, buf[0] == 0x05 else { return nil }
|
||||
guard n >= 3, buf[0] == 0x05 else { return false }
|
||||
// Offer "no authentication".
|
||||
var greeting: [UInt8] = [0x05, 0x00]
|
||||
_ = send(fd, &greeting, greeting.count, 0)
|
||||
|
||||
n = recv(fd, &buf, buf.count, 0)
|
||||
guard n >= 10, buf[0] == 0x05, buf[1] == 0x01 else { return nil }
|
||||
guard n >= 10, buf[0] == 0x05, buf[1] == 0x01 else { return false }
|
||||
switch buf[3] {
|
||||
case 0x01: // IPv4
|
||||
let host = "\(buf[4]).\(buf[5]).\(buf[6]).\(buf[7])"
|
||||
return (host, UInt16(buf[8]) << 8 | UInt16(buf[9]))
|
||||
case 0x03: // domain name
|
||||
let length = Int(buf[4])
|
||||
guard n >= 5 + length + 2 else { return nil }
|
||||
let host = String(decoding: buf[5..<(5 + length)], as: UTF8.self)
|
||||
return (host, UInt16(buf[5 + length]) << 8 | UInt16(buf[6 + length]))
|
||||
default:
|
||||
return nil
|
||||
case 0x01: return n >= 10 // IPv4
|
||||
case 0x03: return n >= 5 + Int(buf[4]) + 2 // a name
|
||||
case 0x04: return n >= 22 // IPv6
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,69 +239,154 @@ public final class Obfs4Transport {
|
||||
_ = send(fd, &reply, reply.count, 0)
|
||||
}
|
||||
|
||||
private func dialBridge() throws -> Int32 {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw Failure.cannotListen }
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = bridge.port.bigEndian
|
||||
addr.sin_addr.s_addr = inet_addr(bridge.host)
|
||||
let ok = withUnsafePointer(to: &addr) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
/// Connects to a bridge at an IPv4 address, an IPv6 address or a name,
|
||||
/// trying each address the name has, within the attempt's time limit.
|
||||
private func dial(_ bridge: Bridge) -> Int32? {
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_UNSPEC
|
||||
hints.ai_socktype = SOCK_STREAM
|
||||
hints.ai_protocol = IPPROTO_TCP
|
||||
var result: UnsafeMutablePointer<addrinfo>?
|
||||
guard getaddrinfo(bridge.dialHost, String(bridge.port), &hints, &result) == 0,
|
||||
let first = result
|
||||
else { return nil }
|
||||
defer { freeaddrinfo(result) }
|
||||
|
||||
let deadline = Date().addingTimeInterval(attemptTimeout)
|
||||
var cursor: UnsafeMutablePointer<addrinfo>? = first
|
||||
while let ai = cursor {
|
||||
cursor = ai.pointee.ai_next
|
||||
let fd = socket(ai.pointee.ai_family, ai.pointee.ai_socktype, ai.pointee.ai_protocol)
|
||||
if fd < 0 { continue }
|
||||
var one: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout<Int32>.size))
|
||||
let flags = fcntl(fd, F_GETFL)
|
||||
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
|
||||
let rc = Darwin.connect(fd, ai.pointee.ai_addr, ai.pointee.ai_addrlen)
|
||||
if rc != 0 && errno != EINPROGRESS {
|
||||
close(fd)
|
||||
continue
|
||||
}
|
||||
if rc != 0 {
|
||||
let left = Int32(max(0, deadline.timeIntervalSinceNow) * 1000)
|
||||
var p = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0)
|
||||
var err: Int32 = 0
|
||||
var len = socklen_t(MemoryLayout<Int32>.size)
|
||||
guard poll(&p, 1, left) == 1,
|
||||
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == 0, err == 0
|
||||
else {
|
||||
close(fd)
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ = fcntl(fd, F_SETFL, flags)
|
||||
return fd
|
||||
}
|
||||
guard ok == 0 else {
|
||||
close(fd)
|
||||
throw Failure.bridgeDidNotAnswer
|
||||
}
|
||||
// Without this a rejected handshake hangs forever, because the bridge's
|
||||
// correct behaviour is to say nothing.
|
||||
var tv = timeval(tv_sec: 20, tv_usec: 0)
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
return fd
|
||||
return nil
|
||||
}
|
||||
|
||||
private func pump(client: Int32, upstream: Int32, target: (host: String, port: UInt16)) {
|
||||
defer { close(upstream) }
|
||||
guard let session = handshake(on: upstream) else { return }
|
||||
private enum Outcome { case answered, reachedNothing, clientLeft }
|
||||
|
||||
/// Moves bytes both ways until one side closes.
|
||||
///
|
||||
/// "Answered" means plaintext came back through the bridge: something
|
||||
/// behind it, the relay's TLS, replied. A bridge that closes, or stays
|
||||
/// quiet past the time limit, with the client's bytes unanswered reached
|
||||
/// nothing (§24.6), and those bytes are handed back for the next line.
|
||||
private func pump(
|
||||
client: Int32, upstream: Int32,
|
||||
session: (plane: Obfs4DataPlane, leftover: [UInt8]),
|
||||
unanswered: inout [UInt8]
|
||||
) -> Outcome {
|
||||
var plane = session.plane
|
||||
var answered = false
|
||||
var clientOpen = true
|
||||
var buf = [UInt8](repeating: 0, count: 16384)
|
||||
|
||||
// Bridge → client.
|
||||
let reader = DispatchWorkItem { [leftover = session.leftover] in
|
||||
var carried = leftover
|
||||
var buf = [UInt8](repeating: 0, count: 16384)
|
||||
while true {
|
||||
if !carried.isEmpty {
|
||||
_ = carried.withUnsafeBufferPointer {
|
||||
send(client, $0.baseAddress, $0.count, 0)
|
||||
}
|
||||
carried = []
|
||||
}
|
||||
if !session.leftover.isEmpty {
|
||||
answered = true
|
||||
_ = session.leftover.withUnsafeBufferPointer { send(client, $0.baseAddress, $0.count, 0) }
|
||||
}
|
||||
if !unanswered.isEmpty {
|
||||
guard let framed = try? plane.send(unanswered), sendAll(upstream, framed) else {
|
||||
return .reachedNothing
|
||||
}
|
||||
}
|
||||
var waitingSince = unanswered.isEmpty ? nil : Date()
|
||||
|
||||
while true {
|
||||
var fds = [
|
||||
pollfd(fd: upstream, events: Int16(POLLIN), revents: 0),
|
||||
pollfd(fd: client, events: clientOpen ? Int16(POLLIN) : 0, revents: 0),
|
||||
]
|
||||
var timeout: Int32 = -1
|
||||
if !answered, let since = waitingSince {
|
||||
let left = attemptTimeout - Date().timeIntervalSince(since)
|
||||
if left <= 0 { return .reachedNothing }
|
||||
timeout = Int32(left * 1000) + 1
|
||||
}
|
||||
let ready = poll(&fds, 2, timeout)
|
||||
if ready < 0 {
|
||||
if errno == EINTR { continue }
|
||||
return answered ? .answered : .reachedNothing
|
||||
}
|
||||
if ready == 0 { continue }
|
||||
|
||||
if fds[0].revents != 0 {
|
||||
let n = recv(upstream, &buf, buf.count, 0)
|
||||
if n <= 0 { break }
|
||||
guard let out = try? plane.receive(Array(buf[0..<n]), isClient: true) else { break }
|
||||
if n <= 0 {
|
||||
if answered {
|
||||
shutdown(client, SHUT_RDWR)
|
||||
return .answered
|
||||
}
|
||||
return clientOpen ? .reachedNothing : .clientLeft
|
||||
}
|
||||
guard let out = try? plane.receive(Array(buf[0..<n]), isClient: true) else {
|
||||
return answered ? .answered : .reachedNothing
|
||||
}
|
||||
if !out.isEmpty {
|
||||
_ = out.withUnsafeBufferPointer { send(client, $0.baseAddress, $0.count, 0) }
|
||||
answered = true
|
||||
unanswered = []
|
||||
if !sendAll(client, out) { return .answered }
|
||||
}
|
||||
}
|
||||
if clientOpen, fds[1].revents != 0 {
|
||||
let n = recv(client, &buf, buf.count, 0)
|
||||
if n <= 0 {
|
||||
clientOpen = false
|
||||
shutdown(upstream, SHUT_WR)
|
||||
if !answered { return .clientLeft }
|
||||
continue
|
||||
}
|
||||
let chunk = Array(buf[0..<n])
|
||||
if !answered {
|
||||
unanswered += chunk
|
||||
if waitingSince == nil { waitingSince = Date() }
|
||||
}
|
||||
guard let framed = try? plane.send(chunk), sendAll(upstream, framed) else {
|
||||
return answered ? .answered : .reachedNothing
|
||||
}
|
||||
}
|
||||
shutdown(client, SHUT_RDWR)
|
||||
}
|
||||
queue.async(execute: reader)
|
||||
|
||||
// Client → bridge.
|
||||
var buf = [UInt8](repeating: 0, count: 16384)
|
||||
while true {
|
||||
let n = recv(client, &buf, buf.count, 0)
|
||||
if n <= 0 { break }
|
||||
guard let framed = try? plane.send(Array(buf[0..<n])) else { break }
|
||||
_ = framed.withUnsafeBufferPointer { send(upstream, $0.baseAddress, $0.count, 0) }
|
||||
}
|
||||
shutdown(upstream, SHUT_RDWR)
|
||||
reader.wait()
|
||||
}
|
||||
|
||||
private func handshake(on fd: Int32) -> (plane: Obfs4DataPlane, leftover: [UInt8])? {
|
||||
private func sendAll(_ fd: Int32, _ bytes: [UInt8]) -> Bool {
|
||||
var offset = 0
|
||||
while offset < bytes.count {
|
||||
let n = bytes[offset...].withUnsafeBufferPointer {
|
||||
send(fd, $0.baseAddress, $0.count, 0)
|
||||
}
|
||||
if n <= 0 { return false }
|
||||
offset += n
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// The obfs4 handshake, within the attempt's time limit. A bridge that
|
||||
/// cannot verify the handshake says nothing, so silence is the failure.
|
||||
private func handshake(on fd: Int32, with bridge: Bridge)
|
||||
-> (plane: Obfs4DataPlane, leftover: [UInt8])?
|
||||
{
|
||||
var priv = [UInt8]()
|
||||
var pub = [UInt8]()
|
||||
var repr = [UInt8]()
|
||||
@@ -232,11 +407,14 @@ public final class Obfs4Transport {
|
||||
|
||||
let padLength = Int.random(in: Obfs4Handshake.clientMinPadLength...1200)
|
||||
let padding = (0..<padLength).map { _ in UInt8.random(in: 0...255) }
|
||||
let opening = handshake.opening(padding: padding)
|
||||
_ = opening.withUnsafeBufferPointer { send(fd, $0.baseAddress, $0.count, 0) }
|
||||
guard sendAll(fd, handshake.opening(padding: padding)) else { return nil }
|
||||
|
||||
let deadline = Date().addingTimeInterval(attemptTimeout)
|
||||
var buf = [UInt8](repeating: 0, count: 16384)
|
||||
for _ in 0..<16 {
|
||||
let left = Int32(max(0, deadline.timeIntervalSinceNow) * 1000)
|
||||
var p = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
guard left > 0, poll(&p, 1, left) == 1 else { return nil }
|
||||
let n = recv(fd, &buf, buf.count, 0)
|
||||
if n <= 0 { return nil }
|
||||
|
||||
|
||||
@@ -80,15 +80,9 @@ enum DeviceIntegrity {
|
||||
|
||||
// MARK: - What to say about it
|
||||
|
||||
static let title = "This iPhone appears to be jailbroken"
|
||||
static let title = String(localized: "This iPhone appears to be jailbroken")
|
||||
|
||||
/// Says what actually changes, and does not overstate it.
|
||||
static let explanation =
|
||||
"Flamenet relies on iOS keeping other apps out of its data, that is what "
|
||||
+ "protects your keys and your history on this phone. On a jailbroken "
|
||||
+ "iPhone that separation is no longer guaranteed, so software running here "
|
||||
+ "may be able to read them.\n\n"
|
||||
+ "Messages are still encrypted in transit and the people you talk to are "
|
||||
+ "unaffected. Nothing is being blocked, and this check can be wrong, "
|
||||
+ "developer devices and some security tools look the same from here."
|
||||
String(localized: "Flamenet relies on iOS keeping other apps out of its data, that is what protects your keys and your history on this phone. On a jailbroken iPhone that separation is no longer guaranteed, so software running here may be able to read them.\n\nMessages are still encrypted in transit and the people you talk to are unaffected. Nothing is being blocked, and this check can be wrong, developer devices and some security tools look the same from here.")
|
||||
}
|
||||
|
||||
@@ -393,8 +393,10 @@ final class TodayStore: ObservableObject {
|
||||
/// other traces. The file is public; the picks say where somebody cares
|
||||
/// about, and the next person to use this phone has no business with
|
||||
/// either.
|
||||
static func forgetOnThisPhone(_ store: TodayStore = .shared, defaults: UserDefaults = .standard) {
|
||||
store.forgetEverything()
|
||||
/// `store` is optional because a default of `.shared` is evaluated
|
||||
/// outside the main actor, which Swift 6 refuses.
|
||||
static func forgetOnThisPhone(_ store: TodayStore? = nil, defaults: UserDefaults = .standard) {
|
||||
(store ?? .shared).forgetEverything()
|
||||
defaults.removeObject(forKey: TodayPlaces.key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,20 +74,25 @@ final class BuddyListViewModel: ObservableObject {
|
||||
|
||||
/// Add without touching the relay, the off-server path (P1).
|
||||
func addLocalOnly(id: Int, handle: String, displayName: String) {
|
||||
inbox?.accept(id)
|
||||
inbox?.rosterAdd(id: id, handle: handle, displayName: displayName)
|
||||
buddies = inbox?.rosterBuddies() ?? buddies
|
||||
}
|
||||
|
||||
/// Add a buddy by account username.
|
||||
func addBuddy(username: String) async -> String? {
|
||||
guard let api else { return "Not connected to a site." }
|
||||
do {
|
||||
try await api.addBuddy(username: username, fromPagePath: pagePath)
|
||||
await load()
|
||||
return nil
|
||||
} catch {
|
||||
return (error as? MessengerError)?.errorDescription ?? error.localizedDescription
|
||||
/// Add somebody the lookup found: onto the sealed list when the relay no
|
||||
/// longer keeps one, otherwise the relay's (`ContactAdd`).
|
||||
func addBuddy(_ person: BuddyProfile) async -> String? {
|
||||
if let problem = await ContactAdd.perform(
|
||||
person, inbox: inbox, api: api, pagePath: pagePath)
|
||||
{
|
||||
return problem
|
||||
}
|
||||
if rosterIsOffServer {
|
||||
buddies = inbox?.rosterBuddies() ?? buddies
|
||||
} else {
|
||||
await load()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var rosterIsOffServer: Bool { inbox?.rosterOffServer ?? false }
|
||||
|
||||
@@ -41,16 +41,20 @@ final class ChatViewModel: ObservableObject {
|
||||
message: (command == .back || command == .here) ? "" : (query.isEmpty ? nil : query)
|
||||
)
|
||||
if command == .away {
|
||||
return query.isEmpty ? "You are away." : "You are away: \(query)"
|
||||
return query.isEmpty ? String(localized: "You are away.") : String(localized: "You are away: \(query)")
|
||||
}
|
||||
if command == .invisible { return "You are invisible." }
|
||||
return "You are back online."
|
||||
if command == .invisible { return String(localized: "You are invisible.") }
|
||||
return String(localized: "You are back online.")
|
||||
case .status:
|
||||
_ = try await api.setStatus(nil, message: query)
|
||||
return query.isEmpty ? "Status message cleared." : "Status set: \(query)"
|
||||
return query.isEmpty ? String(localized: "Status message cleared.") : String(localized: "Status set: \(query)")
|
||||
case .add:
|
||||
try await api.addBuddy(username: query, fromPagePath: "")
|
||||
return "\(query) added to your buddy list."
|
||||
if let problem = await ContactAdd.perform(
|
||||
username: query, inbox: inbox, api: api, pagePath: "")
|
||||
{
|
||||
throw MessengerError.ajaxFailure(problem)
|
||||
}
|
||||
return String(localized: "\(query) added to your buddy list.")
|
||||
case .remove:
|
||||
// Off the server the sealed roster IS the list, so asking the relay
|
||||
// who is on it answers for an account that no longer keeps one, and
|
||||
@@ -62,22 +66,22 @@ final class ChatViewModel: ObservableObject {
|
||||
$0.displayName.lowercased() == wanted || $0.handle.lowercased() == wanted
|
||||
})
|
||||
else {
|
||||
throw MessengerError.ajaxFailure("\(query) is not on your buddy list.")
|
||||
throw MessengerError.ajaxFailure(String(localized: "\(query) is not on your buddy list."))
|
||||
}
|
||||
await ContactRemoval.perform(id: entry.id, inbox: inbox, api: api)
|
||||
return "\(entry.displayName) removed from your buddy list."
|
||||
return String(localized: "\(entry.displayName) removed from your buddy list.")
|
||||
}
|
||||
let match = try await api.fetchBuddies(fromPagePath: "")
|
||||
.first { $0.displayName.lowercased() == query.lowercased() }
|
||||
guard let match else {
|
||||
throw MessengerError.ajaxFailure("\(query) is not on your buddy list.")
|
||||
throw MessengerError.ajaxFailure(String(localized: "\(query) is not on your buddy list."))
|
||||
}
|
||||
if let failure = await ContactRemoval.perform(
|
||||
id: match.id, inbox: inbox, api: api)
|
||||
{
|
||||
throw MessengerError.ajaxFailure(failure)
|
||||
}
|
||||
return "\(match.displayName) removed from your buddy list."
|
||||
return String(localized: "\(match.displayName) removed from your buddy list.")
|
||||
case .block, .unblock:
|
||||
let target: (id: Int, name: String)
|
||||
if query.isEmpty {
|
||||
@@ -86,7 +90,7 @@ final class ChatViewModel: ObservableObject {
|
||||
let match = try await api.fetchBuddies(fromPagePath: "")
|
||||
.first { $0.displayName.lowercased() == query.lowercased() }
|
||||
guard let match else {
|
||||
throw MessengerError.ajaxFailure("\(query) is not on your buddy list.")
|
||||
throw MessengerError.ajaxFailure(String(localized: "\(query) is not on your buddy list."))
|
||||
}
|
||||
target = (match.id, match.displayName)
|
||||
}
|
||||
@@ -95,13 +99,26 @@ final class ChatViewModel: ObservableObject {
|
||||
if command == .block {
|
||||
_ = try? await inbox?.revokeDeliveryTokens(for: target.id)
|
||||
}
|
||||
return "\(target.name) \(command == .block ? "blocked." : "unblocked.")"
|
||||
return command == .block
|
||||
? String(localized: "\(target.name) blocked.")
|
||||
: String(localized: "\(target.name) unblocked.")
|
||||
case .whois:
|
||||
return query.isEmpty
|
||||
? "\(buddy.displayName), user #\(buddy.id)"
|
||||
: "\(query), look them up in the member directory."
|
||||
? String(localized: "\(buddy.displayName), user #\(String(buddy.id))")
|
||||
: String(localized: "\(query), look them up in the member directory.")
|
||||
case .star, .archive:
|
||||
return try await api.conversationAction(command.rawValue, buddy: buddy.id)
|
||||
// Section 8.9d: kept on this device (and its linked devices), never
|
||||
// on the relay, which stored both and read neither. The command
|
||||
// toggles.
|
||||
guard let inbox else { throw MessengerError.notAuthenticated }
|
||||
let key = E2EInbox.pinKey(buddyID: buddy.id)
|
||||
let flags = inbox.flags(forThread: key)
|
||||
if command == .star {
|
||||
inbox.setStarred(!flags.starred, thread: key)
|
||||
return flags.starred ? String(localized: "Unstarred.") : String(localized: "Starred.")
|
||||
}
|
||||
inbox.setArchived(!flags.archived, thread: key)
|
||||
return flags.archived ? String(localized: "Moved back to your chats.") : String(localized: "Archived.")
|
||||
case .buddies:
|
||||
let count = try await api.fetchBuddies(fromPagePath: "").count
|
||||
return count == 1 ? "You have 1 buddy." : "You have \(count) buddies."
|
||||
@@ -121,7 +138,7 @@ final class ChatViewModel: ObservableObject {
|
||||
guard commandsEnabled else { return false }
|
||||
guard let (command, query) = BuddyCommand.parse(draft) else { return false }
|
||||
guard let api = commandAPI else {
|
||||
errorMessage = "Commands aren't available right now."
|
||||
errorMessage = String(localized: "Commands aren't available right now.")
|
||||
return true
|
||||
}
|
||||
// Answered on the device; nothing is sent.
|
||||
@@ -143,7 +160,7 @@ final class ChatViewModel: ObservableObject {
|
||||
// /me is a real message, just written in the third person.
|
||||
if command.kind == .emote {
|
||||
guard !query.isEmpty else {
|
||||
errorMessage = "Usage: /me does something"
|
||||
errorMessage = String(localized: "Usage: /me does something")
|
||||
return true
|
||||
}
|
||||
draft = "/me " + query
|
||||
@@ -152,7 +169,7 @@ final class ChatViewModel: ObservableObject {
|
||||
|
||||
if command.kind == .action {
|
||||
if command.requiresArgument && query.isEmpty {
|
||||
errorMessage = "Usage: /\(command.rawValue) \(command.argumentLabel ?? "")"
|
||||
errorMessage = String(localized: "Usage: /\(command.rawValue) \(command.argumentLabel ?? "")")
|
||||
return true
|
||||
}
|
||||
runningCommand = command
|
||||
@@ -228,7 +245,7 @@ final class ChatViewModel: ObservableObject {
|
||||
static func present(_ m: E2EMessage, name: String) -> Message {
|
||||
Message(
|
||||
id: m.id,
|
||||
who: m.isMine ? "Me" : name,
|
||||
who: m.isMine ? String(localized: "Me") : name,
|
||||
body: m.text,
|
||||
timeText: timeFormatter.string(from: m.date),
|
||||
isMine: m.isMine,
|
||||
@@ -271,8 +288,13 @@ final class ChatViewModel: ObservableObject {
|
||||
|
||||
var canSend: Bool {
|
||||
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending
|
||||
&& !draftTooLong
|
||||
}
|
||||
|
||||
/// Section 23.7, longer than the largest message step holds. The send
|
||||
/// button stays, disabled, and the composer says why.
|
||||
var draftTooLong: Bool { DraftLengthNotice.isTooLong(draft) }
|
||||
|
||||
/// Always true in practice now that the plaintext transport is gone; kept
|
||||
/// as the single place the UI asks, rather than reading `inbox` directly.
|
||||
var supportsAttachments: Bool { inbox != nil }
|
||||
@@ -518,10 +540,10 @@ final class ChatViewModel: ObservableObject {
|
||||
|
||||
/// The choices offered.
|
||||
static let messageLifetimes: [(label: String, seconds: Int)] = [
|
||||
("1 minute", 60),
|
||||
("5 minutes", 300),
|
||||
("1 hour", 3600),
|
||||
("1 day", 86_400),
|
||||
(String(localized: "1 minute"), 60),
|
||||
(String(localized: "5 minutes"), 300),
|
||||
(String(localized: "1 hour"), 3600),
|
||||
(String(localized: "1 day"), 86_400),
|
||||
]
|
||||
|
||||
/// Only E2E messages can be replied to with a resolvable target, but a
|
||||
@@ -532,7 +554,10 @@ final class ChatViewModel: ObservableObject {
|
||||
// A card is quoted by its front, never by the words inside.
|
||||
let preview =
|
||||
target.kindSummary
|
||||
?? target.attachment.map { $0.kind == .audio ? "🎤 Voice note" : "📷 Photo" }
|
||||
?? target.attachment.map {
|
||||
$0.kind == .audio
|
||||
? String(localized: "🎤 Voice note") : String(localized: "📷 Photo")
|
||||
}
|
||||
?? String(target.body.prefix(140))
|
||||
return E2EAttachment.Reference(
|
||||
mid: target.mid ?? "",
|
||||
@@ -649,10 +674,10 @@ final class ChatViewModel: ObservableObject {
|
||||
do {
|
||||
// No plaintext path, by design.
|
||||
guard let inbox else {
|
||||
errorMessage =
|
||||
"This conversation isn't encrypted yet. "
|
||||
+ "Reopen the app to finish setting up encryption, "
|
||||
+ "nothing is sent unencrypted."
|
||||
errorMessage = String(
|
||||
localized:
|
||||
"This conversation isn't encrypted yet. Reopen the app to finish setting up encryption, nothing is sent unencrypted."
|
||||
)
|
||||
return
|
||||
}
|
||||
try await inbox.send(
|
||||
|
||||
@@ -20,9 +20,9 @@ struct AboutView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
aboutRow("Encryption", "Signal-style X3DH + Double Ratchet")
|
||||
aboutRow(String(localized: "Encryption"), String(localized: "Signal-style X3DH + Double Ratchet"))
|
||||
Divider().padding(.leading, 16)
|
||||
aboutRow("Wire AEAD", "AES-256-GCM")
|
||||
aboutRow(String(localized: "Wire AEAD"), "AES-256-GCM")
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
.background(
|
||||
@@ -45,7 +45,7 @@ struct AboutView: View {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let v = info?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
let b = info?["CFBundleVersion"] as? String ?? "1"
|
||||
return "Version \(v) (\(b))"
|
||||
return String(localized: "Version \(v) (\(b))")
|
||||
}
|
||||
|
||||
private func aboutRow(_ title: String, _ value: String) -> some View {
|
||||
@@ -60,7 +60,7 @@ struct AboutView: View {
|
||||
|
||||
private var links: some View {
|
||||
VStack(spacing: 8) {
|
||||
link("Open flamenet.io", url: URL(string: "https://flamenet.io")!)
|
||||
link(String(localized: "Open flamenet.io"), url: URL(string: "https://flamenet.io")!)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ struct BuddyListView: View {
|
||||
/// On a phone it is pushed onto this stack. On an iPad it belongs in the
|
||||
/// right-hand column, and pushing it here would bury the list it was
|
||||
/// chosen from inside the left one.
|
||||
/// Who a deep link opens a conversation with: the buddy, or, for
|
||||
/// somebody reached through a contact link (section 8.14), who is not on
|
||||
/// the relay's contact list, the person by the name the inbox holds.
|
||||
/// Writing to them accepted them, so the conversation is theirs either way.
|
||||
static func deepLinkTarget(_ id: Int, buddies: [Buddy], name: String?) -> Buddy? {
|
||||
if let buddy = buddies.first(where: { $0.id == id }) { return buddy }
|
||||
guard let name, !name.isEmpty else { return nil }
|
||||
return Buddy(id: id, displayName: name, accepted: true)
|
||||
}
|
||||
|
||||
private func openConversation(_ buddy: Buddy) {
|
||||
if pane.isWide {
|
||||
pane.buddy = buddy
|
||||
@@ -46,13 +56,13 @@ struct BuddyListView: View {
|
||||
/// of the screen and the things that act on it share a row.
|
||||
private var header: some View {
|
||||
PortalHeader(
|
||||
title: "Home",
|
||||
title: String(localized: "Home"),
|
||||
subtitle: environment.headerSubtitle,
|
||||
actions: {
|
||||
// Small, and first: it is the least-used of the three, so it
|
||||
// sits furthest from the thumb rather than displacing the add
|
||||
// button people reach for.
|
||||
MiniWallet()
|
||||
if !WalletAvailability.hidden { MiniWallet() }
|
||||
// The explicit way in for people who don't think to type into
|
||||
// a SEARCH box to add somebody.
|
||||
Button {
|
||||
@@ -74,7 +84,7 @@ struct BuddyListView: View {
|
||||
},
|
||||
accessory: {
|
||||
PortalSearchField(
|
||||
prompt: "Search or add by username", text: $searchText,
|
||||
prompt: String(localized: "Search or add by username"), text: $searchText,
|
||||
onSubmit: { Task { await runLookup() } })
|
||||
})
|
||||
}
|
||||
@@ -105,7 +115,9 @@ struct BuddyListView: View {
|
||||
}
|
||||
// A scan fills the search box and stops.
|
||||
.sheet(isPresented: $scanning) {
|
||||
CardScanner { card in searchText = card.address }
|
||||
CardScanner(
|
||||
onFound: { card in searchText = card.address },
|
||||
onLink: { environment.pendingContactLink = $0 })
|
||||
}
|
||||
// Another tab handed Home an address (Messages' scanner does), same
|
||||
// landing as a scan here: the search box, then the address path
|
||||
@@ -128,18 +140,9 @@ struct BuddyListView: View {
|
||||
// Off-server roster (P1): the add is this device's to
|
||||
// make, the relay only resolved the handle, and that
|
||||
// lookup was transient.
|
||||
if model.rosterIsOffServer {
|
||||
model.addLocalOnly(
|
||||
id: person.id, handle: person.slug,
|
||||
displayName: person.name)
|
||||
environment.knownBuddies = model.buddies
|
||||
environment.knownBuddyIDs = model.buddies.map(\.id)
|
||||
return nil
|
||||
}
|
||||
if let problem = await model.addBuddy(username: person.slug) {
|
||||
if let problem = await model.addBuddy(person) {
|
||||
return problem
|
||||
}
|
||||
await model.load()
|
||||
environment.knownBuddies = model.buddies
|
||||
environment.knownBuddyIDs = model.buddies.map(\.id)
|
||||
return nil
|
||||
@@ -154,11 +157,11 @@ struct BuddyListView: View {
|
||||
environment.e2eInbox?.loadAvatars(for: model.buddies.map(\.id))
|
||||
pushNamesToInbox()
|
||||
}
|
||||
// A stranger's thread appeared, resolve who they are and add them,
|
||||
// so the row upgrades from "Buddy <id>" to a real contact.
|
||||
// A stranger's thread appeared (a request), resolve who they are
|
||||
// so the row says a name rather than "Buddy <id>". Nobody is added.
|
||||
.task(id: unlistedCorrespondents.map(\.id)) {
|
||||
guard !unlistedCorrespondents.isEmpty else { return }
|
||||
guard await environment.adoptStrangers() else { return }
|
||||
guard await environment.nameStrangers() else { return }
|
||||
await model.load()
|
||||
environment.knownBuddyIDs = model.buddies.map(\.id)
|
||||
environment.knownBuddies = model.buddies
|
||||
@@ -184,7 +187,9 @@ struct BuddyListView: View {
|
||||
}
|
||||
.onChange(of: environment.deepLinkBuddyID) { _, newValue in
|
||||
guard let id = newValue,
|
||||
let buddy = model.buddies.first(where: { $0.id == id })
|
||||
let buddy = Self.deepLinkTarget(
|
||||
id, buddies: model.buddies,
|
||||
name: environment.e2eInbox?.displayName(for: id))
|
||||
else { return }
|
||||
openConversation(buddy)
|
||||
environment.deepLinkBuddyID = nil
|
||||
@@ -275,8 +280,7 @@ struct BuddyListView: View {
|
||||
Text("That's your own address.")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(
|
||||
"Show this code to somebody else so they can add you, "
|
||||
+ "your Address screen has it."
|
||||
"Show this code to somebody else so they can add you, your Address screen has it."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -295,10 +299,12 @@ struct BuddyListView: View {
|
||||
Text("They're on your relay.")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(
|
||||
"This address is on \(card.relay), the same server you are on. "
|
||||
+ "People here are added by username, search for theirs above, "
|
||||
+ "and you get a normal conversation rather than one that has to "
|
||||
+ "cross between servers."
|
||||
"""
|
||||
This address is on \(card.relay), the same server you are on. \
|
||||
People here are added by username, search for theirs above, \
|
||||
and you get a normal conversation rather than one that has to \
|
||||
cross between servers.
|
||||
"""
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -324,8 +330,7 @@ struct BuddyListView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
// Says where it goes.
|
||||
Text(
|
||||
"Messages to this address travel from your relay to theirs. "
|
||||
+ "Compare safety numbers with them before you trust it."
|
||||
"Messages to this address travel from your relay to theirs. Compare safety numbers with them before you trust it."
|
||||
)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -396,8 +401,7 @@ struct BuddyListView: View {
|
||||
// relay once it has signed in to Messenger at least
|
||||
// once.
|
||||
Text(
|
||||
"Usernames have to match exactly. Someone with a FlameNet "
|
||||
+ "account shows up here once they have used Messenger."
|
||||
"Usernames have to match exactly. Someone with a FlameNet account shows up here once they have used Messenger."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -480,11 +484,10 @@ struct BuddyListView: View {
|
||||
addingID = person.id
|
||||
defer { addingID = nil }
|
||||
// `addBuddy` returns a message on failure and nil on success.
|
||||
if let problem = await model.addBuddy(username: person.slug) {
|
||||
if let problem = await model.addBuddy(person) {
|
||||
lookupState = .failed(problem)
|
||||
return
|
||||
}
|
||||
await model.load()
|
||||
// Clearing the field is what makes the add visible: the list comes back
|
||||
// unfiltered with the new buddy in it.
|
||||
searchText = ""
|
||||
@@ -659,9 +662,9 @@ struct BuddyRow: View {
|
||||
let body =
|
||||
last.card?.summary ?? last.event?.event.summary ?? last.game?.summary
|
||||
?? (last.text.isEmpty
|
||||
? (last.attachment != nil ? "Photo" : "")
|
||||
? (last.attachment != nil ? String(localized: "Photo") : "")
|
||||
: last.text)
|
||||
if !body.isEmpty { return last.isMine ? "You: \(body)" : body }
|
||||
if !body.isEmpty { return last.isMine ? String(localized: "You: \(body)") : body }
|
||||
}
|
||||
if let sn = buddy.screenName, !sn.isEmpty { return sn }
|
||||
return buddy.presence.label
|
||||
@@ -728,7 +731,7 @@ struct AddContactSheet: View {
|
||||
|
||||
var body: some View {
|
||||
if ParentalControls.contactsGated && !supervisionUnlocked {
|
||||
ParentalPINPrompt(title: "Add contact") { supervisionUnlocked = true }
|
||||
ParentalPINPrompt(title: String(localized: "Add contact")) { supervisionUnlocked = true }
|
||||
} else {
|
||||
addForm
|
||||
}
|
||||
@@ -748,8 +751,7 @@ struct AddContactSheet: View {
|
||||
.accessibilityIdentifier("add.lookup")
|
||||
} footer: {
|
||||
Text(
|
||||
"The username they signed up with, or a flamenet address "
|
||||
+ "from their Address screen.")
|
||||
"The username they signed up with, or a flamenet address from their Address screen.")
|
||||
}
|
||||
|
||||
if let found {
|
||||
@@ -775,8 +777,7 @@ struct AddContactSheet: View {
|
||||
.accessibilityIdentifier("add.confirm")
|
||||
} footer: {
|
||||
Text(
|
||||
"Check their safety number once you start talking, a "
|
||||
+ "lookup only says the name exists on this server.")
|
||||
"Check their safety number once you start talking, a lookup only says the name exists on this server.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,7 +817,7 @@ struct AddContactSheet: View {
|
||||
if let person = try await api.lookupMember(username: trimmed) {
|
||||
found = person
|
||||
} else {
|
||||
message = "No account called \"\(trimmed)\" here."
|
||||
message = String(localized: "No account called \"\(trimmed)\" here.")
|
||||
}
|
||||
} catch {
|
||||
message =
|
||||
|
||||
@@ -12,6 +12,8 @@ struct BuddyProfileView: View {
|
||||
var onCall: (() -> Void)? = nil
|
||||
|
||||
@State private var isPinned = false
|
||||
/// Bumped when a post-quantum pin lands, so the number is read again.
|
||||
@State private var numberChecked = 0
|
||||
@State private var isMuted = false
|
||||
@State private var isBlocked = false
|
||||
|
||||
@@ -31,15 +33,15 @@ struct BuddyProfileView: View {
|
||||
// The kit's contact detail: a cover, a name plate, and a rail
|
||||
// of actions floating at the seam between them.
|
||||
coverAndRail
|
||||
section("Account") {
|
||||
row("Username", buddy.screenName ?? ", ")
|
||||
row("User ID", "\(buddy.id)")
|
||||
row("Status", buddy.presence.label)
|
||||
section(String(localized: "Account")) {
|
||||
row(String(localized: "Username"), buddy.screenName ?? ", ")
|
||||
row(String(localized: "User ID"), "\(buddy.id)")
|
||||
row(String(localized: "Status"), buddy.presence.label)
|
||||
}
|
||||
encryptionSection
|
||||
// The way back.
|
||||
if let inbox = environment.e2eInbox {
|
||||
section("Stories") {
|
||||
section(String(localized: "Stories")) {
|
||||
Toggle(
|
||||
"Show \(buddy.displayName)'s stories",
|
||||
isOn: Binding(
|
||||
@@ -50,9 +52,7 @@ struct BuddyProfileView: View {
|
||||
.accessibilityIdentifier("profile.stories.toggle")
|
||||
// Says what muting is NOT.
|
||||
Text(
|
||||
"Turning this off hides their stories on this "
|
||||
+ "phone. They aren't told, and their messages "
|
||||
+ "still arrive as usual."
|
||||
"Turning this off hides their stories on this phone. They aren't told, and their messages still arrive as usual."
|
||||
)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
@@ -92,9 +92,7 @@ struct BuddyProfileView: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"Both of you should be reading the same number. Comparing it in "
|
||||
+ "person or by scanning is stronger than reading it over a call, "
|
||||
+ "which could itself be intercepted.")
|
||||
"Both of you should be reading the same number. Comparing it in person or by scanning is stronger than reading it over a call, which could itself be intercepted.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Block \(buddy.displayName)?",
|
||||
@@ -148,6 +146,12 @@ struct BuddyProfileView: View {
|
||||
}
|
||||
|
||||
private func refreshBlocked() async {
|
||||
// This device's list first (section 8.9d), then the relay's, which
|
||||
// may still hold a block made on a device that predates 0.32.
|
||||
if environment.e2eInbox?.isBlocked(buddy.id) == true {
|
||||
isBlocked = true
|
||||
return
|
||||
}
|
||||
guard let api = environment.api else { return }
|
||||
if let list = try? await api.fetchBlocked() {
|
||||
isBlocked = list.contains { $0.id == buddy.id }
|
||||
@@ -163,9 +167,8 @@ struct BuddyProfileView: View {
|
||||
isBlocked = true
|
||||
} catch {
|
||||
// Do NOT flip the toggle.
|
||||
blockFailure =
|
||||
"Couldn't block \(buddy.displayName). "
|
||||
+ "They have NOT been blocked, try again."
|
||||
blockFailure = String(
|
||||
localized: "Couldn't block \(buddy.displayName). They have NOT been blocked, try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,9 +181,8 @@ struct BuddyProfileView: View {
|
||||
blockFailure = nil
|
||||
isBlocked = false
|
||||
} catch {
|
||||
blockFailure =
|
||||
"Couldn't unblock \(buddy.displayName). "
|
||||
+ "They are still blocked, try again."
|
||||
blockFailure = String(
|
||||
localized: "Couldn't unblock \(buddy.displayName). They are still blocked, try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,17 +247,17 @@ struct BuddyProfileView: View {
|
||||
/// 60x240 rail at radius 30.
|
||||
private var actionRail: some View {
|
||||
VStack(spacing: 0) {
|
||||
railButton("bubble.left.fill", "Message", id: "profile.message") {
|
||||
railButton("bubble.left.fill", String(localized: "Message"), id: "profile.message") {
|
||||
startChat()
|
||||
}
|
||||
if let onCall {
|
||||
railDivider
|
||||
railButton("phone.fill", "Call", id: "profile.call", action: onCall)
|
||||
railButton("phone.fill", String(localized: "Call"), id: "profile.call", action: onCall)
|
||||
}
|
||||
railDivider
|
||||
railButton(
|
||||
isMuted ? "bell.slash.fill" : "bell.fill",
|
||||
isMuted ? "Unmute" : "Mute", id: "profile.mute"
|
||||
isMuted ? String(localized: "Unmute") : String(localized: "Mute"), id: "profile.mute"
|
||||
) {
|
||||
isMuted.toggle()
|
||||
NotificationManager.shared.setMuted(isMuted, buddyID: buddy.id)
|
||||
@@ -355,7 +357,7 @@ struct BuddyProfileView: View {
|
||||
private var quickActions: some View {
|
||||
HStack(spacing: 12) {
|
||||
actionButton(
|
||||
title: isPinned ? "Pinned" : "Pin",
|
||||
title: isPinned ? String(localized: "Pinned") : String(localized: "Pin"),
|
||||
systemImage: isPinned ? "pin.fill" : "pin",
|
||||
active: isPinned
|
||||
) {
|
||||
@@ -363,7 +365,7 @@ struct BuddyProfileView: View {
|
||||
BuddyPrefs.setPinned(isPinned, buddyID: buddy.id)
|
||||
}
|
||||
actionButton(
|
||||
title: isMuted ? "Muted" : "Mute",
|
||||
title: isMuted ? String(localized: "Muted") : String(localized: "Mute"),
|
||||
systemImage: isMuted ? "bell.slash.fill" : "bell",
|
||||
active: isMuted
|
||||
) {
|
||||
@@ -398,7 +400,7 @@ struct BuddyProfileView: View {
|
||||
private var encryptionSection: some View {
|
||||
if let inbox = environment.e2eInbox {
|
||||
let state = inbox.trustState(for: buddy.id)
|
||||
section("Encryption") {
|
||||
section(String(localized: "Encryption")) {
|
||||
switch state {
|
||||
case .unknown:
|
||||
Text("No encrypted session yet. Send a message and their keys will be pinned.")
|
||||
@@ -434,11 +436,13 @@ struct BuddyProfileView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
Text(
|
||||
"Their existing keys are unchanged, so this is what buying a "
|
||||
+ "second phone looks like. It is also what somebody adding a "
|
||||
+ "device to their account looks like, nothing breaks either "
|
||||
+ "way. Ask them whether they did, and compare the number "
|
||||
+ "below if you want to be sure."
|
||||
"""
|
||||
Their existing keys are unchanged, so this is what buying a \
|
||||
second phone looks like. It is also what somebody adding a \
|
||||
device to their account looks like, nothing breaks either \
|
||||
way. Ask them whether they did, and compare the number \
|
||||
below if you want to be sure.
|
||||
"""
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -449,10 +453,12 @@ struct BuddyProfileView: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(
|
||||
"One of their phones is no longer on this account. The keys "
|
||||
+ "that are left are the same ones you already had, so "
|
||||
+ "nothing about your conversation changes and there is "
|
||||
+ "nothing for you to accept."
|
||||
"""
|
||||
One of their phones is no longer on this account. The keys \
|
||||
that are left are the same ones you already had, so \
|
||||
nothing about your conversation changes and there is \
|
||||
nothing for you to accept.
|
||||
"""
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -465,7 +471,7 @@ struct BuddyProfileView: View {
|
||||
.foregroundStyle(state == .verified ? .green : .secondary)
|
||||
}
|
||||
|
||||
if let number = inbox.safetyNumber(for: buddy.id) {
|
||||
if let number = inbox.safetyNumber(for: buddy.id), numberChecked >= 0 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(SafetyNumber.displayRows(number), id: \.self) { line in
|
||||
Text(line)
|
||||
@@ -474,6 +480,12 @@ struct BuddyProfileView: View {
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.accessibilityLabel("Safety number \(number)")
|
||||
// SPEC §7: read their bundle once if this device cannot
|
||||
// cover their post-quantum identity yet, so the digits
|
||||
// match the ones on their screen.
|
||||
.task(id: buddy.id) {
|
||||
if await inbox.pinPeerPQIdentityIfNeeded(buddy.id) { numberChecked += 1 }
|
||||
}
|
||||
|
||||
Text(
|
||||
"Read this aloud with \(buddy.displayName). Both devices must show the same 60 digits."
|
||||
|
||||
@@ -47,19 +47,23 @@ struct CallIdentityReview: View {
|
||||
// far the likelier, and the other one anyway, because it is
|
||||
// the one that matters.
|
||||
Text(
|
||||
"\(peerName)'s keys are not the ones you accepted before, so the "
|
||||
+ "call was not set up. That happens after a reinstall or a new "
|
||||
+ "phone, but it is also what someone reading your messages "
|
||||
+ "would look like."
|
||||
"""
|
||||
\(peerName)'s keys are not the ones you accepted before, so the \
|
||||
call was not set up. That happens after a reinstall or a new \
|
||||
phone, but it is also what someone reading your messages \
|
||||
would look like.
|
||||
"""
|
||||
)
|
||||
.font(.callout)
|
||||
|
||||
safetyNumber
|
||||
|
||||
Text(
|
||||
"Accepting means this call, and everything you send after it, goes "
|
||||
+ "to whoever holds the new keys. Read the number to \(peerName) "
|
||||
+ "on a line you already trust, and accept only if it matches."
|
||||
"""
|
||||
Accepting means this call, and everything you send after it, goes \
|
||||
to whoever holds the new keys. Read the number to \(peerName) \
|
||||
on a line you already trust, and accept only if it matches.
|
||||
"""
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -125,11 +125,11 @@ struct CallScreen: View {
|
||||
case .ringing:
|
||||
return [
|
||||
.init(
|
||||
id: "decline", symbol: "phone.down.fill", label: "Decline",
|
||||
id: "decline", symbol: "phone.down.fill", label: String(localized: "Decline"),
|
||||
tint: Theme.danger, iconColor: .white
|
||||
) { manager.declineIncomingCall() },
|
||||
.init(
|
||||
id: "accept", symbol: "phone.fill", label: "Accept",
|
||||
id: "accept", symbol: "phone.fill", label: String(localized: "Accept"),
|
||||
isPrimary: true, tint: Theme.success, iconColor: .white
|
||||
) {
|
||||
manager.acceptIncomingCall()
|
||||
@@ -138,7 +138,7 @@ struct CallScreen: View {
|
||||
case .dialing, .connecting:
|
||||
return [
|
||||
.init(
|
||||
id: "cancel", symbol: "phone.down.fill", label: "Cancel",
|
||||
id: "cancel", symbol: "phone.down.fill", label: String(localized: "Cancel"),
|
||||
isPrimary: true, tint: Theme.danger, iconColor: .white
|
||||
) { manager.hangUp() }
|
||||
]
|
||||
@@ -146,19 +146,19 @@ struct CallScreen: View {
|
||||
return [
|
||||
.init(
|
||||
id: "mute", symbol: manager.isMuted ? "mic.slash.fill" : "mic.fill",
|
||||
label: "Mute",
|
||||
label: String(localized: "Mute"),
|
||||
tint: manager.isMuted ? Theme.primary : Theme.pillFill,
|
||||
iconColor: manager.isMuted ? .white : Theme.ink
|
||||
) { manager.toggleMute() },
|
||||
.init(
|
||||
id: "speaker", symbol: "speaker.wave.2.fill", label: "Speaker",
|
||||
id: "speaker", symbol: "speaker.wave.2.fill", label: String(localized: "Speaker"),
|
||||
tint: manager.isSpeakerOn ? Theme.primary : Theme.pillFill,
|
||||
iconColor: manager.isSpeakerOn ? .white : Theme.ink
|
||||
) {
|
||||
manager.toggleSpeaker()
|
||||
},
|
||||
.init(
|
||||
id: "end", symbol: "phone.down.fill", label: "End",
|
||||
id: "end", symbol: "phone.down.fill", label: String(localized: "End", comment: "Call button: hang up"),
|
||||
isPrimary: true, tint: Theme.danger, iconColor: .white
|
||||
) { manager.hangUp() },
|
||||
]
|
||||
@@ -169,7 +169,7 @@ struct CallScreen: View {
|
||||
? [
|
||||
CallScreenBody.Action(
|
||||
id: "handoff", symbol: "ipad.and.iphone",
|
||||
label: "Move",
|
||||
label: String(localized: "Move", comment: "Call button: move the call to another of your devices"),
|
||||
tint: Theme.pillFill, iconColor: Theme.ink
|
||||
) { manager.offerHandoff() }
|
||||
] : [])
|
||||
@@ -179,7 +179,7 @@ struct CallScreen: View {
|
||||
return [
|
||||
.init(
|
||||
id: "review", symbol: "exclamationmark.shield.fill",
|
||||
label: "Review keys",
|
||||
label: String(localized: "Review keys"),
|
||||
isPrimary: true, tint: Theme.primary, iconColor: .white
|
||||
) {
|
||||
// Stay on the call screen. Sending somebody to the
|
||||
@@ -188,7 +188,7 @@ struct CallScreen: View {
|
||||
reviewingKeys = true
|
||||
},
|
||||
.init(
|
||||
id: "close", symbol: "xmark", label: "Close",
|
||||
id: "close", symbol: "xmark", label: String(localized: "Close"),
|
||||
tint: Theme.pillFill, iconColor: Theme.ink
|
||||
) {
|
||||
manager.acknowledgeEnd()
|
||||
@@ -197,7 +197,7 @@ struct CallScreen: View {
|
||||
}
|
||||
return [
|
||||
.init(
|
||||
id: "close", symbol: "xmark", label: "Close",
|
||||
id: "close", symbol: "xmark", label: String(localized: "Close"),
|
||||
isPrimary: true, tint: Theme.pillFill, iconColor: Theme.ink
|
||||
) {
|
||||
manager.acknowledgeEnd()
|
||||
@@ -216,9 +216,9 @@ struct CallScreen: View {
|
||||
private var statusLine: String {
|
||||
switch manager.state {
|
||||
case .idle: return ""
|
||||
case .dialing: return "Calling…"
|
||||
case .ringing: return "Incoming call"
|
||||
case .connecting: return "Connecting…"
|
||||
case .dialing: return String(localized: "Calling…")
|
||||
case .ringing: return String(localized: "Incoming call")
|
||||
case .connecting: return String(localized: "Connecting…")
|
||||
case .active(let since): return elapsed(since: since)
|
||||
case .ended(let reason): return reason
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ struct CallsView: View {
|
||||
// read as a coloured band with a button floating in it.
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
PortalHeader("Calls") {
|
||||
PortalHeader(String(localized: "Calls")) {
|
||||
// The way IN.
|
||||
Button {
|
||||
showingNewCall = true
|
||||
@@ -110,13 +110,13 @@ struct CallsView: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { place(buddy, video: false) }
|
||||
callGlyph(
|
||||
"phone", label: "Call \(buddy.displayName)",
|
||||
"phone", label: String(localized: "Call \(buddy.displayName)"),
|
||||
id: "calls.new.\(buddy.id)"
|
||||
) {
|
||||
place(buddy, video: false)
|
||||
}
|
||||
callGlyph(
|
||||
"video", label: "Video call \(buddy.displayName)",
|
||||
"video", label: String(localized: "Video call \(buddy.displayName)"),
|
||||
id: "calls.new.video.\(buddy.id)"
|
||||
) {
|
||||
place(buddy, video: true)
|
||||
@@ -155,9 +155,11 @@ struct CallsView: View {
|
||||
Text("No calls yet")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text(
|
||||
"Calls you place and receive appear here. Start one from a "
|
||||
+ "conversation, they go directly between the two phones, so "
|
||||
+ "this list is the only record of them and it is on this device."
|
||||
"""
|
||||
Calls you place and receive appear here. Start one from a \
|
||||
conversation, they go directly between the two phones, so \
|
||||
this list is the only record of them and it is on this device.
|
||||
"""
|
||||
)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
@@ -177,7 +179,7 @@ struct CallsView: View {
|
||||
let name =
|
||||
buddy?.displayName
|
||||
?? inbox.knownName(for: entry.peerID)
|
||||
?? "user \(entry.peerID)"
|
||||
?? String(localized: "user \(String(entry.peerID))")
|
||||
return HStack(spacing: Theme.Kit.gutter) {
|
||||
AvatarView(name: name, picture: inbox.avatars[entry.peerID], inbox: inbox)
|
||||
|
||||
@@ -241,8 +243,12 @@ struct CallsView: View {
|
||||
}
|
||||
|
||||
private func description(_ entry: E2ECallEntry) -> String {
|
||||
var parts = [entry.incoming ? "Incoming" : "Outgoing"]
|
||||
if !entry.connected { parts.append("missed") }
|
||||
var parts = [
|
||||
entry.incoming
|
||||
? String(localized: "Incoming", comment: "Call log: a call they placed to you")
|
||||
: String(localized: "Outgoing", comment: "Call log: a call you placed")
|
||||
]
|
||||
if !entry.connected { parts.append(String(localized: "missed", comment: "Call log: the call was not answered")) }
|
||||
parts.append(RowStamp.text(entry.at))
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
@@ -257,7 +263,7 @@ enum RowStamp {
|
||||
if cal.isDateInToday(at) {
|
||||
f.dateFormat = "hh:mm a"
|
||||
} else if cal.isDateInYesterday(at) {
|
||||
return "Yesterday"
|
||||
return String(localized: "Yesterday")
|
||||
} else if let d = cal.dateComponents([.day], from: at, to: Date()).day, d < 7 {
|
||||
f.dateFormat = "EEE"
|
||||
} else {
|
||||
|
||||
@@ -60,11 +60,11 @@ struct ChangePasswordSheet: View {
|
||||
private func save() {
|
||||
guard let api = environment.api, !isWorking else { return }
|
||||
if newPassword != confirm {
|
||||
localError = "Passwords don't match."
|
||||
localError = String(localized: "Passwords don't match.")
|
||||
return
|
||||
}
|
||||
if newPassword.count < 8 {
|
||||
localError = "Password must be at least 8 characters."
|
||||
localError = String(localized: "Password must be at least 8 characters.")
|
||||
return
|
||||
}
|
||||
isWorking = true
|
||||
|
||||
+113
-58
@@ -64,9 +64,12 @@ struct ChatScreen: View {
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(
|
||||
"Flamenet won't send messages unencrypted, so this conversation "
|
||||
+ "stays closed until encryption is set up. Nothing has been sent "
|
||||
+ "in the clear."
|
||||
String(
|
||||
localized: """
|
||||
Flamenet won't send messages unencrypted, so this conversation \
|
||||
stays closed until encryption is set up. Nothing has been sent \
|
||||
in the clear.
|
||||
""")
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -139,7 +142,8 @@ struct ChatView: View {
|
||||
/// only thing that used it. Six outcomes use it now, so reporting a
|
||||
/// message and blocking somebody both announced themselves as "Files".
|
||||
private struct Notice {
|
||||
let title: String
|
||||
/// A key, so every title literal below is picked up for translation.
|
||||
let title: LocalizedStringKey
|
||||
let body: String
|
||||
}
|
||||
|
||||
@@ -421,7 +425,11 @@ struct ChatView: View {
|
||||
showingSearch.toggle()
|
||||
if !showingSearch { searchQuery = "" }
|
||||
}
|
||||
Button(isMuted ? "Unmute notifications" : "Mute notifications") {
|
||||
Button(
|
||||
isMuted
|
||||
? String(localized: "Unmute notifications")
|
||||
: String(localized: "Mute notifications")
|
||||
) {
|
||||
toggleMute()
|
||||
}
|
||||
// Reporting lived only behind a press and hold on a
|
||||
@@ -445,8 +453,8 @@ struct ChatView: View {
|
||||
}
|
||||
Button(
|
||||
model.offTheRecord
|
||||
? "Stop holding this off the record"
|
||||
: "Hold this off the record"
|
||||
? String(localized: "Stop holding this off the record")
|
||||
: String(localized: "Hold this off the record")
|
||||
) {
|
||||
Task { await model.setOffTheRecord(!model.offTheRecord) }
|
||||
}
|
||||
@@ -484,21 +492,25 @@ struct ChatView: View {
|
||||
reason: reason,
|
||||
includeContent: includeContent,
|
||||
note: note)
|
||||
notice = Notice(title: "Reported", body: "Reported to whoever runs this server.")
|
||||
notice = Notice(
|
||||
title: "Reported",
|
||||
body: String(localized: "Reported to whoever runs this server."))
|
||||
} catch {
|
||||
// A relay may decline reports entirely
|
||||
// (`reports_not_accepted`, 501).
|
||||
notice = Notice(
|
||||
title: "Report not sent",
|
||||
body: (error as? MessengerError)?.errorDescription
|
||||
?? "That report could not be sent.")
|
||||
?? String(localized: "That report could not be sent."))
|
||||
}
|
||||
},
|
||||
onBlock: {
|
||||
// Same call the stories path uses.
|
||||
Task {
|
||||
try? await environment.setBlocked(true, userID: model.buddy.id)
|
||||
notice = Notice(title: "Blocked", body: "\(model.buddy.displayName) blocked.")
|
||||
notice = Notice(
|
||||
title: "Blocked",
|
||||
body: String(localized: "\(model.buddy.displayName) blocked."))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -574,19 +586,25 @@ struct ChatView: View {
|
||||
/// "Not in your contacts", with the two things worth offering.
|
||||
@ViewBuilder
|
||||
private var strangerBanner: some View {
|
||||
if !model.buddy.accepted, !acceptedStranger, !blockedStranger {
|
||||
// Section 14.5: acceptance is held on the device now, not on the relay.
|
||||
if !(environment.e2eInbox?.isAccepted(model.buddy.id) ?? model.buddy.accepted),
|
||||
!acceptedStranger, !blockedStranger
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("\(model.buddy.displayName) isn't in your contacts")
|
||||
.font(.subheadline.weight(.medium))
|
||||
// Says what happened rather than warning about it.
|
||||
Text(
|
||||
"They messaged you, so they were added automatically. "
|
||||
+ "You can keep them or stop them reaching you."
|
||||
String(
|
||||
localized: """
|
||||
This is a message request. Accept to add them to your contacts, \
|
||||
or block them so they can't reach you.
|
||||
""")
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button("Keep as contact") { acceptBuddy() }
|
||||
Button("Accept") { acceptBuddy() }
|
||||
.buttonStyle(.portalProminent)
|
||||
.accessibilityIdentifier("accept-stranger")
|
||||
Button("Block", role: .destructive) { blockBuddy() }
|
||||
@@ -698,8 +716,9 @@ struct ChatView: View {
|
||||
if visibleMessages.isEmpty {
|
||||
Text(
|
||||
showingSearch && !searchQuery.isEmpty
|
||||
? "No messages match \"\(searchQuery)\"."
|
||||
: "No messages yet. Say hello to \(model.buddy.displayName)!"
|
||||
? String(localized: "No messages match \"\(searchQuery)\".")
|
||||
: String(
|
||||
localized: "No messages yet. Say hello to \(model.buddy.displayName)!")
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -790,7 +809,9 @@ struct ChatView: View {
|
||||
Task { await model.setPin(isPinned ? "" : mid) }
|
||||
} label: {
|
||||
Label(
|
||||
isPinned ? "Unpin" : "Pin",
|
||||
isPinned
|
||||
? String(localized: "Unpin")
|
||||
: String(localized: "Pin", comment: "Verb"),
|
||||
systemImage: isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
}
|
||||
@@ -873,18 +894,18 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func acceptBuddy() {
|
||||
guard let api = environment.api else { return }
|
||||
// Optimistic, then confirmed.
|
||||
// Accepted on the device at once (section 14.5); adding them as a
|
||||
// contact may still fail, and is said so if it does.
|
||||
acceptedStranger = true
|
||||
Task {
|
||||
do {
|
||||
try await api.acceptConversation(buddyID: model.buddy.id)
|
||||
try await environment.acceptRequest(model.buddy.id)
|
||||
} catch {
|
||||
// Put it back rather than pretending.
|
||||
acceptedStranger = false
|
||||
model.errorMessage =
|
||||
"Couldn't save that. \(model.buddy.displayName) "
|
||||
+ "has NOT been added to your contacts, try again."
|
||||
model.errorMessage = String(
|
||||
localized: """
|
||||
Accepted, but \(model.buddy.displayName) couldn't be added to \
|
||||
your contacts just now. Try adding them again later.
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,17 +919,18 @@ struct ChatView: View {
|
||||
// offering to block somebody who had just been blocked, with
|
||||
// nothing on screen having changed, which is indistinguishable
|
||||
// from the button not working and is what it was taken for.
|
||||
blockedNotice =
|
||||
"\(model.buddy.displayName) is blocked. They cannot send you "
|
||||
+ "messages. You can undo this on their profile."
|
||||
blockedNotice = String(
|
||||
localized: """
|
||||
\(model.buddy.displayName) is blocked. They cannot send you \
|
||||
messages. You can undo this on their profile.
|
||||
""")
|
||||
blockedStranger = true
|
||||
} catch {
|
||||
// Blocking is a safety action, so a silent no-op is the worst
|
||||
// possible failure: the person believes they are protected and
|
||||
// the server was never told.
|
||||
model.errorMessage =
|
||||
"Couldn't block \(model.buddy.displayName). "
|
||||
+ "They have NOT been blocked, try again."
|
||||
model.errorMessage = String(
|
||||
localized: "Couldn't block \(model.buddy.displayName). They have NOT been blocked, try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1018,13 +1040,16 @@ struct ChatView: View {
|
||||
.fill(Theme.primary)
|
||||
.frame(width: 3, height: 32)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(target.isMine ? "Replying to yourself" : "Replying to \(target.who)")
|
||||
Text(
|
||||
target.isMine
|
||||
? String(localized: "Replying to yourself")
|
||||
: String(localized: "Replying to \(target.who)"))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Theme.primary)
|
||||
Text(
|
||||
target.kindSummary
|
||||
?? (target.attachment != nil && target.body.isEmpty
|
||||
? "Attachment" : target.body)
|
||||
? String(localized: "Attachment") : target.body)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -1091,7 +1116,7 @@ struct ChatView: View {
|
||||
Text(
|
||||
pinned.kindSummary
|
||||
?? (pinned.body.isEmpty && pinned.attachment != nil
|
||||
? "Pinned attachment" : pinned.body)
|
||||
? String(localized: "Pinned attachment") : pinned.body)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
@@ -1142,7 +1167,7 @@ struct ChatView: View {
|
||||
|
||||
private static func timerLabel(_ seconds: Int) -> String {
|
||||
ChatViewModel.messageLifetimes.first { $0.seconds == seconds }?.label
|
||||
?? "\(seconds) seconds"
|
||||
?? String(localized: "\(seconds) seconds")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -1150,7 +1175,10 @@ struct ChatView: View {
|
||||
if recorder.isRecording {
|
||||
recordingBar
|
||||
} else {
|
||||
composerRow
|
||||
VStack(spacing: 0) {
|
||||
DraftLengthNotice(draft: model.draft)
|
||||
composerRow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,7 +1325,8 @@ struct ChatView: View {
|
||||
.foregroundStyle(Theme.primary)
|
||||
.frame(width: 30, height: 44)
|
||||
}
|
||||
.accessibilityLabel(showingEmoji ? "Hide emoji" : "Emoji")
|
||||
.accessibilityLabel(
|
||||
showingEmoji ? String(localized: "Hide emoji") : String(localized: "Emoji"))
|
||||
.accessibilityIdentifier("chat.emoji")
|
||||
|
||||
// A second control, not a tab inside the first.
|
||||
@@ -1312,7 +1341,9 @@ struct ChatView: View {
|
||||
.foregroundStyle(Theme.primary)
|
||||
.frame(width: 30, height: 44)
|
||||
}
|
||||
.accessibilityLabel(showingStickers ? "Hide stickers" : "Stickers")
|
||||
.accessibilityLabel(
|
||||
showingStickers
|
||||
? String(localized: "Hide stickers") : String(localized: "Stickers"))
|
||||
.accessibilityIdentifier("chat.stickers")
|
||||
|
||||
HStack(spacing: 4) {
|
||||
@@ -1342,19 +1373,27 @@ struct ChatView: View {
|
||||
|
||||
// Familiar messenger pattern: an empty draft offers the mic, a non-
|
||||
// empty one offers send.
|
||||
if model.supportsAttachments && !model.canSend {
|
||||
// A draft too long to send keeps the send button, disabled, rather
|
||||
// than turning into the microphone.
|
||||
if model.supportsAttachments && !model.canSend && !model.draftTooLong {
|
||||
Button {
|
||||
Task {
|
||||
if await recorder.start() == false {
|
||||
model.errorMessage =
|
||||
switch recorder.lastFailure {
|
||||
case .couldNotOpen:
|
||||
"This device would not open the microphone, so there is "
|
||||
+ "nothing to record with. Nothing is wrong with your "
|
||||
+ "settings."
|
||||
String(
|
||||
localized: """
|
||||
This device would not open the microphone, so there is \
|
||||
nothing to record with. Nothing is wrong with your \
|
||||
settings.
|
||||
""")
|
||||
default:
|
||||
"Microphone access is off. Enable it in Settings to send "
|
||||
+ "voice notes."
|
||||
String(
|
||||
localized: """
|
||||
Microphone access is off. Enable it in Settings to send \
|
||||
voice notes.
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1404,8 +1443,8 @@ struct ChatView: View {
|
||||
if let kind = scrubbed.strippedKind {
|
||||
notice = Notice(
|
||||
title: "Sent",
|
||||
body: "The \(kind.rawValue.uppercased()) had its location and "
|
||||
+ "camera details removed first.")
|
||||
body: String(
|
||||
localized: "The \(kind.rawValue.uppercased()) had its location and camera details removed first."))
|
||||
}
|
||||
try await inbox.sendFile(
|
||||
scrubbed.bytes,
|
||||
@@ -1416,7 +1455,8 @@ struct ChatView: View {
|
||||
caption: nil,
|
||||
to: model.buddy.id)
|
||||
} catch {
|
||||
notice = Notice(title: "Couldn't send that file", body: error.localizedDescription)
|
||||
notice = Notice(
|
||||
title: "Couldn't send that file", body: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1424,15 +1464,18 @@ struct ChatView: View {
|
||||
guard let vault = environment.filesVault, let inbox = model.inbox else { return }
|
||||
do {
|
||||
let bytes = try await inbox.loadAttachment(ref)
|
||||
let name = ref.filename.isEmpty ? "Received file" : ref.filename
|
||||
let name = ref.filename.isEmpty ? String(localized: "Received file") : ref.filename
|
||||
_ = try await vault.add(
|
||||
bytes, named: name,
|
||||
label: SecurityLabel(
|
||||
classification: ref.classification,
|
||||
compartments: Set(ref.compartments)))
|
||||
notice = Notice(title: "Saved to Files", body: "Saved \(name) to Files.")
|
||||
notice = Notice(
|
||||
title: "Saved to Files",
|
||||
body: String(localized: "Saved \(name) to Files."))
|
||||
} catch {
|
||||
notice = Notice(title: "Couldn't save that file", body: error.localizedDescription)
|
||||
notice = Notice(
|
||||
title: "Couldn't save that file", body: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1510,10 +1553,13 @@ private struct UnsealedSendNotice: View {
|
||||
Text("The server was told you sent this")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(
|
||||
"\(buddy.displayName)'s delivery key had gone stale, so this "
|
||||
+ "message could not be sent sealed. It is encrypted either "
|
||||
+ "way; what changed is that the server knows it came from "
|
||||
+ "you. It usually sorts itself out once they write back."
|
||||
String(
|
||||
localized: """
|
||||
\(buddy.displayName)'s delivery key had gone stale, so this \
|
||||
message could not be sent sealed. It is encrypted either \
|
||||
way; what changed is that the server knows it came from \
|
||||
you. It usually sorts itself out once they write back.
|
||||
""")
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
@@ -1555,9 +1601,14 @@ private struct IdentityWarning: View {
|
||||
if let waiting = inbox.heldEnvelopeCounts[buddy.id], waiting > 0 {
|
||||
Text(
|
||||
waiting == 1
|
||||
? "One message has arrived since and is being held. Accepting shows it."
|
||||
: "\(waiting) messages have arrived since and are being held. "
|
||||
+ "Accepting shows them."
|
||||
? String(
|
||||
localized:
|
||||
"One message has arrived since and is being held. Accepting shows it."
|
||||
)
|
||||
: String(
|
||||
localized:
|
||||
"\(waiting) messages have arrived since and are being held. Accepting shows them."
|
||||
)
|
||||
)
|
||||
.font(.caption.weight(.semibold))
|
||||
.accessibilityIdentifier("chat.heldMessages")
|
||||
@@ -1620,7 +1671,11 @@ private struct ScreenshotNoticeBanner: View {
|
||||
// The disclaimer is not optional decoration. It was written to
|
||||
// say exactly what a notice cannot mean, and it existed with
|
||||
// nowhere to appear.
|
||||
Button(showingLimits ? "Hide" : "What this can and cannot tell you") {
|
||||
Button(
|
||||
showingLimits
|
||||
? String(localized: "Hide")
|
||||
: String(localized: "What this can and cannot tell you")
|
||||
) {
|
||||
showingLimits.toggle()
|
||||
}
|
||||
.font(.caption2.weight(.semibold))
|
||||
|
||||
@@ -22,7 +22,7 @@ struct AttachmentFileView: View {
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(ref.filename.isEmpty ? "A file" : ref.filename)
|
||||
Text(ref.filename.isEmpty ? String(localized: "A file") : ref.filename)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(isMine ? .white : Theme.ink)
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -5,7 +5,8 @@ struct SnoozeMenu: View {
|
||||
@ObservedObject var model: ChatViewModel
|
||||
|
||||
static let choices: [(label: String, seconds: Int)] = [
|
||||
("1 hour", 3_600), ("8 hours", 28_800), ("1 day", 86_400),
|
||||
(String(localized: "1 hour"), 3_600), (String(localized: "8 hours"), 28_800),
|
||||
(String(localized: "1 day"), 86_400),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
@@ -25,7 +26,8 @@ struct ReplyReminderMenu: View {
|
||||
@ObservedObject var model: ChatViewModel
|
||||
|
||||
static let choices: [(label: String, seconds: Int)] = [
|
||||
("In 1 hour", 3_600), ("In 3 hours", 10_800), ("Tomorrow", 86_400),
|
||||
(String(localized: "In 1 hour"), 3_600), (String(localized: "In 3 hours"), 10_800),
|
||||
(String(localized: "Tomorrow"), 86_400),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
@@ -45,11 +47,12 @@ enum AttentionSummary {
|
||||
static func line(isSnoozed: Bool, reminderAt: Date?) -> String? {
|
||||
switch (isSnoozed, reminderAt) {
|
||||
case (true, .some):
|
||||
return "Snoozed, and you'll be reminded if there's no reply."
|
||||
return String(localized: "Snoozed, and you'll be reminded if there's no reply.")
|
||||
case (true, .none):
|
||||
return "Snoozed, messages still arrive, this chat just stops asking."
|
||||
return String(localized: "Snoozed, messages still arrive, this chat just stops asking.")
|
||||
case (false, .some):
|
||||
return "You'll be reminded if there's no reply. Cancelled the moment they answer."
|
||||
return String(
|
||||
localized: "You'll be reminded if there's no reply. Cancelled the moment they answer.")
|
||||
case (false, .none):
|
||||
return nil
|
||||
}
|
||||
@@ -61,7 +64,8 @@ struct ScheduleSendMenu: View {
|
||||
@ObservedObject var model: ChatViewModel
|
||||
|
||||
static let choices: [(label: String, seconds: Int)] = [
|
||||
("In 1 hour", 3_600), ("In 3 hours", 10_800), ("Tomorrow", 86_400),
|
||||
(String(localized: "In 1 hour"), 3_600), (String(localized: "In 3 hours"), 10_800),
|
||||
(String(localized: "Tomorrow"), 86_400),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
@@ -84,7 +88,8 @@ enum ScheduledSummary {
|
||||
f.timeStyle = .short
|
||||
let when = f.string(from: next)
|
||||
return count == 1
|
||||
? "1 message waiting to send, \(when), when the app next runs."
|
||||
: "\(count) messages waiting to send, next \(when), when the app next runs."
|
||||
? String(localized: "1 message waiting to send, \(when), when the app next runs.")
|
||||
: String(
|
||||
localized: "\(count) messages waiting to send, next \(when), when the app next runs.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Section 8.9d and 17.5 (decision DA9): ask people to export a backup.
|
||||
///
|
||||
/// Once the contact list and conversation state leave the relay (stage 2-D),
|
||||
/// a phone lost with no backup and no other linked device takes the contacts
|
||||
/// with it. The banner shows when nothing has been exported for 30 days, and
|
||||
/// "Not now" quiets it for another 30.
|
||||
enum BackupNudge {
|
||||
static let lastExportKey = "fnmsg.lastHistoryExportAt"
|
||||
static let snoozedUntilKey = "fnmsg.backupNudgeSnoozedUntil"
|
||||
static let interval: TimeInterval = 30 * 24 * 60 * 60
|
||||
|
||||
static func noteExported(at date: Date = Date()) {
|
||||
UserDefaults.standard.set(date.timeIntervalSince1970, forKey: lastExportKey)
|
||||
}
|
||||
|
||||
static func snooze(from date: Date = Date()) {
|
||||
UserDefaults.standard.set(
|
||||
date.addingTimeInterval(interval).timeIntervalSince1970, forKey: snoozedUntilKey)
|
||||
}
|
||||
|
||||
/// Whether to ask now. Nothing to back up, nothing to ask.
|
||||
static func isDue(now: Date = Date(), hasHistory: Bool, defaults: UserDefaults = .standard)
|
||||
-> Bool
|
||||
{
|
||||
guard hasHistory else { return false }
|
||||
let snoozed = defaults.double(forKey: snoozedUntilKey)
|
||||
if snoozed > 0, now.timeIntervalSince1970 < snoozed { return false }
|
||||
let last = defaults.double(forKey: lastExportKey)
|
||||
return last == 0 || now.timeIntervalSince1970 - last >= interval
|
||||
}
|
||||
}
|
||||
|
||||
struct BackupNudgeBanner: View {
|
||||
@ObservedObject var inbox: E2EInbox
|
||||
@State private var exporting = false
|
||||
@State private var dismissed = false
|
||||
|
||||
var body: some View {
|
||||
if !dismissed,
|
||||
BackupNudge.isDue(hasHistory: inbox.threads.values.contains { !$0.isEmpty })
|
||||
{
|
||||
VStack(alignment: .leading, spacing: Theme.Space.xs) {
|
||||
Text("Back up your chats")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(
|
||||
"Your messages and contacts are stored only on your devices. If you lose this phone, a backup is the only way to get them back."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
HStack(spacing: Theme.Space.sm) {
|
||||
Button("Export a backup") { exporting = true }
|
||||
.buttonStyle(.portalProminent)
|
||||
.accessibilityIdentifier("backup-nudge.export")
|
||||
Button("Not now") {
|
||||
BackupNudge.snooze()
|
||||
dismissed = true
|
||||
}
|
||||
.buttonStyle(.portal)
|
||||
.accessibilityIdentifier("backup-nudge.later")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(Theme.Space.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.avatarFill)
|
||||
.accessibilityIdentifier("backup-nudge")
|
||||
.sheet(isPresented: $exporting, onDismiss: {
|
||||
if !BackupNudge.isDue(hasHistory: true) { dismissed = true }
|
||||
}) {
|
||||
HistoryExportSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ struct CallScreenBody: View {
|
||||
let peerName: String
|
||||
let status: String
|
||||
/// Said plainly and always, because it is the reason to use this at all.
|
||||
var reassurance: String? = "End-to-end encrypted"
|
||||
var reassurance: String? = String(localized: "End-to-end encrypted")
|
||||
let actions: [Action]
|
||||
/// Drawn just above the controls, where the call screen puts its reaction
|
||||
/// row. Absent in previews and in the screenshot harness.
|
||||
|
||||
@@ -7,6 +7,9 @@ import SwiftUI
|
||||
struct CardScanner: View {
|
||||
/// Called once, with the first thing that parses.
|
||||
let onFound: (ContactCard) -> Void
|
||||
/// Section 8.14: a contact link scanned here goes to the first-message
|
||||
/// sheet rather than the address path, which is for other relays.
|
||||
var onLink: ((ContactLink) -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var denied = false
|
||||
@@ -25,8 +28,7 @@ struct CardScanner: View {
|
||||
Text("Flamenet cannot use the camera.")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"Allow it in Settings → Flamenet → Camera, or paste "
|
||||
+ "their address into the search box instead."
|
||||
"Allow it in Settings → Flamenet → Camera, or paste their address into the search box instead."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -35,15 +37,26 @@ struct CardScanner: View {
|
||||
.padding()
|
||||
.accessibilityIdentifier("scan.denied")
|
||||
} else {
|
||||
ScannerViewController.Representable { text in
|
||||
guard let card = ScannedCode.card(from: text) else { return }
|
||||
dismiss()
|
||||
onFound(card)
|
||||
} onRefused: {
|
||||
refused = true
|
||||
} onDenied: {
|
||||
denied = true
|
||||
}
|
||||
ScannerViewController.Representable(
|
||||
onFound: { text in
|
||||
if let onLink, let link = ContactLink(text: text) {
|
||||
dismiss()
|
||||
onLink(link)
|
||||
return
|
||||
}
|
||||
// ScannedCode, never ContactCard.decode: an older
|
||||
// card's `?u=` names a username anybody can claim
|
||||
// (DESIGN 32), and this sheet binds by fnid alone.
|
||||
guard let card = ScannedCode.card(from: text) else { return }
|
||||
dismiss()
|
||||
onFound(card)
|
||||
},
|
||||
onRefused: { refused = true },
|
||||
onDenied: { denied = true },
|
||||
accepts: { text in
|
||||
ScannedCode.card(from: text) != nil
|
||||
|| (onLink != nil && ContactLink(text: text) != nil)
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .bottom) {
|
||||
// A code that is not an address gets a plain answer
|
||||
@@ -85,6 +98,9 @@ final class ScannerViewController: UIViewController {
|
||||
/// Fires for a code that is not an address, and scanning carries on.
|
||||
var onRefused: (() -> Void)?
|
||||
var onDenied: (() -> Void)?
|
||||
/// Which codes count. An address card by default; a device-link code or a
|
||||
/// contact link where a screen scans for those.
|
||||
var accepts: (String) -> Bool = { ScannedCode.card(from: $0) != nil }
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var preview: AVCaptureVideoPreviewLayer?
|
||||
@@ -168,7 +184,10 @@ extension ScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let string = object.stringValue
|
||||
else { return }
|
||||
guard ScannedCode.card(from: string) != nil else {
|
||||
// `accepts` is the only gate, and it is injected. The address sheet
|
||||
// wants an address; the Devices sheet wants its own linking code.
|
||||
// Hard-coding ScannedCode here would refuse every code Devices scans.
|
||||
guard accepts(string) else {
|
||||
onRefused?()
|
||||
return
|
||||
}
|
||||
@@ -181,14 +200,19 @@ extension ScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
|
||||
extension ScannerViewController {
|
||||
struct Representable: UIViewControllerRepresentable {
|
||||
let onFound: (String) -> Void
|
||||
let onRefused: () -> Void
|
||||
/// Called for a code `accepts` turns down, so a screen can say so.
|
||||
/// Defaults to nothing: a sheet that has no notice to show need not
|
||||
/// say so at every call site.
|
||||
var onRefused: () -> Void = {}
|
||||
let onDenied: () -> Void
|
||||
var accepts: (String) -> Bool = { ScannedCode.card(from: $0) != nil }
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let controller = ScannerViewController()
|
||||
controller.onFound = onFound
|
||||
controller.onRefused = onRefused
|
||||
controller.onDenied = onDenied
|
||||
controller.accepts = accepts
|
||||
return controller
|
||||
}
|
||||
|
||||
@@ -196,6 +220,79 @@ extension ScannerViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// A scanner for one kind of code, with a box to paste it into for a device
|
||||
/// with no camera (or a code sent as text).
|
||||
struct CodeScannerSheet: View {
|
||||
let title: String
|
||||
let hint: String
|
||||
var allowsPaste = true
|
||||
let accepts: (String) -> Bool
|
||||
let onFound: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var denied = false
|
||||
@State private var pasted = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
if denied {
|
||||
Text(
|
||||
allowsPaste
|
||||
? "Flamenet can't use the camera. Allow it in Settings, or paste the code below."
|
||||
: "Flamenet can't use the camera. Allow it in Settings.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
} else {
|
||||
ScannerViewController.Representable(
|
||||
onFound: { text in
|
||||
dismiss()
|
||||
onFound(text)
|
||||
},
|
||||
onDenied: { denied = true },
|
||||
accepts: accepts
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.background(
|
||||
.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: Theme.Radius.small))
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
if allowsPaste {
|
||||
HStack {
|
||||
TextField("Or paste the code", text: $pasted)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("scan.paste")
|
||||
Button("Use") {
|
||||
let text = pasted.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
dismiss()
|
||||
onFound(text)
|
||||
}
|
||||
.disabled(!accepts(pasted.trimmingCharacters(in: .whitespacesAndNewlines)))
|
||||
.accessibilityIdentifier("scan.paste.use")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
PortalToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the in-app scanner accepts, and what it hands on.
|
||||
enum ScannedCode {
|
||||
/// A hello link, an address card from this app or an older one
|
||||
|
||||
@@ -6,18 +6,18 @@ struct DisappearingPicker: View {
|
||||
|
||||
/// Presets, not a free-form duration.
|
||||
static let choices: [(label: String, seconds: Int)] = [
|
||||
("Off", 0),
|
||||
("1 hour", 3_600),
|
||||
("1 day", 86_400),
|
||||
("1 week", 604_800),
|
||||
(String(localized: "Off"), 0),
|
||||
(String(localized: "1 hour"), 3_600),
|
||||
(String(localized: "1 day"), 86_400),
|
||||
(String(localized: "1 week"), 604_800),
|
||||
]
|
||||
|
||||
/// How the banner on the thread names the current setting.
|
||||
static func summary(_ seconds: Int) -> String? {
|
||||
switch seconds {
|
||||
case 3_600: return "Messages disappear after an hour"
|
||||
case 86_400: return "Messages disappear after a day"
|
||||
case 604_800: return "Messages disappear after a week"
|
||||
case 3_600: return String(localized: "Messages disappear after an hour")
|
||||
case 86_400: return String(localized: "Messages disappear after a day")
|
||||
case 604_800: return String(localized: "Messages disappear after a week")
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Section 23.7: the composer shows the length limit before somebody reaches
|
||||
/// it, rather than refusing a message they have finished writing.
|
||||
///
|
||||
/// Nothing until the last thousand, then how much is left, then the reason the
|
||||
/// send button stopped working.
|
||||
struct DraftLengthNotice: View {
|
||||
let draft: String
|
||||
|
||||
/// What is left, counted as the wire will count it.
|
||||
static func remaining(_ draft: String) -> Int {
|
||||
Padding.composerLimit - Padding.composerCost(draft)
|
||||
}
|
||||
|
||||
static func isTooLong(_ draft: String) -> Bool { remaining(draft) < 0 }
|
||||
|
||||
/// The words, or nil when there is nothing worth saying yet.
|
||||
static func text(for draft: String) -> String? {
|
||||
let left = remaining(draft)
|
||||
if left < 0 { return Padding.Failure.tooLong.errorDescription }
|
||||
if left <= 1000 {
|
||||
return left == 1
|
||||
? String(localized: "1 character left")
|
||||
: String(localized: "\(left) characters left")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let text = Self.text(for: draft) {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Self.isTooLong(draft) ? Theme.danger : Theme.secondaryText)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 6)
|
||||
.accessibilityIdentifier("composer.length")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,25 @@ struct EmojiPicker: View {
|
||||
}
|
||||
|
||||
/// Grouped the way people think about them, not the way Unicode does.
|
||||
private static let groups: [(String, [String])] = [
|
||||
/// `key` stays English: it names the accessibility identifier.
|
||||
private static let groups: [(key: String, title: String, emoji: [String])] = [
|
||||
(
|
||||
"Smileys",
|
||||
"Smileys", String(localized: "Smileys"),
|
||||
[
|
||||
"😀", "😂", "🙂", "😉", "😍", "🤔", "😅", "😭", "😡", "🥳",
|
||||
"😴", "🤯", "🫠", "😬", "🙃", "😎",
|
||||
]
|
||||
),
|
||||
("Gestures", ["👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"]),
|
||||
("Hearts", ["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"]),
|
||||
(
|
||||
"Things",
|
||||
"Gestures", String(localized: "Gestures"),
|
||||
["👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"]
|
||||
),
|
||||
(
|
||||
"Hearts", String(localized: "Hearts"),
|
||||
["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"]
|
||||
),
|
||||
(
|
||||
"Things", String(localized: "Things"),
|
||||
[
|
||||
"✅", "❌", "⚠️", "📌", "📎", "🔒", "🔑", "☕", "🍕", "🎉",
|
||||
"🎂", "🚀", "🌧️", "☀️", "🌙", "⭐",
|
||||
@@ -34,10 +41,10 @@ struct EmojiPicker: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !recents.isEmpty {
|
||||
section("Recent", recents)
|
||||
section("Recent", String(localized: "Recent"), recents)
|
||||
}
|
||||
ForEach(Self.groups, id: \.0) { name, emoji in
|
||||
section(name, emoji)
|
||||
ForEach(Self.groups, id: \.key) { group in
|
||||
section(group.key, group.title, group.emoji)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -46,7 +53,7 @@ struct EmojiPicker: View {
|
||||
.background(Theme.incomingBubble.opacity(0.6))
|
||||
}
|
||||
|
||||
private func section(_ title: String, _ emoji: [String]) -> some View {
|
||||
private func section(_ key: String, _ title: String, _ emoji: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
@@ -65,7 +72,7 @@ struct EmojiPicker: View {
|
||||
// Scoped by section on purpose: the same emoji can sit
|
||||
// in Recent AND in its own group, and ChatView's quick-
|
||||
// reaction bar shows some of them too.
|
||||
.accessibilityIdentifier("emoji.\(title).\(e)")
|
||||
.accessibilityIdentifier("emoji.\(key).\(e)")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
|
||||
@@ -14,7 +14,7 @@ struct GroupsSection: View {
|
||||
// two sections of one list look like two different kinds of
|
||||
// heading.
|
||||
ZStack(alignment: .trailing) {
|
||||
PortalBand("Groups")
|
||||
PortalBand(String(localized: "Groups"))
|
||||
Button {
|
||||
showingNewGroup = true
|
||||
} label: {
|
||||
@@ -92,16 +92,17 @@ struct GroupsSection: View {
|
||||
let body =
|
||||
last.card?.summary ?? last.event?.event.summary
|
||||
?? (last.attachment != nil
|
||||
? (last.attachment?.kind == .audio ? "🎤 Voice note" : "📷 Photo")
|
||||
? (last.attachment?.kind == .audio
|
||||
? String(localized: "🎤 Voice note") : String(localized: "📷 Photo"))
|
||||
: last.text)
|
||||
let author =
|
||||
last.isMine
|
||||
? "You"
|
||||
? String(localized: "You")
|
||||
: (inbox.knownName(for: last.authorID) ?? group.memberName(last.authorID))
|
||||
return "\(author): \(body)"
|
||||
return String(localized: "\(author): \(body)", comment: "Group list preview: sender name, then the last message")
|
||||
}
|
||||
let count = group.members.count
|
||||
return count == 1 ? "Just you" : "\(count) members"
|
||||
return count == 1 ? String(localized: "Just you") : String(localized: "\(count) members")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ struct MessageBubble: View {
|
||||
RoundedRectangle(cornerRadius: Theme.Radius.small)
|
||||
.fill(Theme.primary)
|
||||
.frame(width: 3)
|
||||
Text(quoted.preview.isEmpty ? "Message" : quoted.preview)
|
||||
Text(
|
||||
quoted.preview.isEmpty
|
||||
? String(localized: "Message", comment: "Noun") : quoted.preview)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
@@ -81,6 +83,7 @@ struct MessageBubble: View {
|
||||
// Whose side of it this is: the person who sent it sees who it
|
||||
// went to, the author sees that nobody else was told.
|
||||
let fromMe = inbox.map { who.contains($0.myUserID) } ?? false
|
||||
let side: LocalizedStringKey = fromMe ? "only them" : "only you"
|
||||
HStack(spacing: 3) {
|
||||
Text(emoji).font(.system(size: 13))
|
||||
if who.count > 1 {
|
||||
@@ -88,7 +91,7 @@ struct MessageBubble: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(fromMe ? "only them" : "only you")
|
||||
Text(side)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -174,8 +177,8 @@ struct MessageBubble: View {
|
||||
if message.replyToStory == true {
|
||||
Text(
|
||||
message.isMine
|
||||
? "You replied to their story"
|
||||
: "Replied to your story"
|
||||
? String(localized: "You replied to their story")
|
||||
: String(localized: "Replied to your story")
|
||||
)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(
|
||||
@@ -505,7 +508,9 @@ private struct AttachmentAudioView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(player.isPlaying ? "Pause voice note" : "Play voice note")
|
||||
.accessibilityLabel(
|
||||
player.isPlaying
|
||||
? String(localized: "Pause voice note") : String(localized: "Play voice note"))
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
|
||||
@@ -58,8 +58,9 @@ struct MyAddressSection: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("profile.address")
|
||||
Text(
|
||||
"Anyone, on any relay, can reach you with this. Scan the code, or paste "
|
||||
+ "the address into their app's search."
|
||||
// The reworded form the release branch carried is not in
|
||||
// Localizable.xcstrings and has no Spanish. This one does.
|
||||
"Anyone, on any relay, can reach you with this, scan it, or paste it into their app's search."
|
||||
)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
|
||||
@@ -74,7 +74,7 @@ struct NewStoryButton: View {
|
||||
width: ready.width, height: ready.height))
|
||||
}
|
||||
guard !prepared.isEmpty else {
|
||||
postError = "None of those pictures could be prepared."
|
||||
postError = String(localized: "None of those pictures could be prepared.")
|
||||
return
|
||||
}
|
||||
setDrafts = prepared
|
||||
@@ -165,8 +165,7 @@ struct NewStoryButton: View {
|
||||
let recipients = audience.recipients(contacts: buddies.map(\.id))
|
||||
guard !recipients.isEmpty else {
|
||||
postError =
|
||||
"Nobody is in your story audience, so this would go "
|
||||
+ "to no one. Change who can see your stories on Account."
|
||||
String(localized: "Nobody is in your story audience, so this would go to no one. Change who can see your stories on Account.")
|
||||
return
|
||||
}
|
||||
let items = setDrafts.map {
|
||||
@@ -176,13 +175,12 @@ struct NewStoryButton: View {
|
||||
items, caption: caption.isEmpty ? nil : caption, to: recipients)
|
||||
if result.failed > 0 {
|
||||
postError =
|
||||
"Posted, but \(result.failed) of the sends did not reach "
|
||||
+ "a device. The pictures that did are on their way."
|
||||
String(localized: "Posted, but \(result.failed) of the sends did not reach a device. The pictures that did are on their way.")
|
||||
}
|
||||
} catch {
|
||||
postError =
|
||||
(error as? MessengerError)?.errorDescription
|
||||
?? (error as? E2EError).map { _ in "None of your buddies can receive stories yet." }
|
||||
?? (error as? E2EError).map { _ in String(localized: "None of your buddies can receive stories yet.") }
|
||||
?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
@@ -192,20 +190,18 @@ struct NewStoryButton: View {
|
||||
let recipients = audience.recipients(contacts: buddies.map(\.id))
|
||||
guard !recipients.isEmpty else {
|
||||
postError =
|
||||
"Nobody is in your story audience, so this would go "
|
||||
+ "to no one. Change who can see your stories on Account."
|
||||
String(localized: "Nobody is in your story audience, so this would go to no one. Change who can see your stories on Account.")
|
||||
return
|
||||
}
|
||||
let result = try await inbox.postTextStory(text, to: recipients)
|
||||
if result.failed > 0 {
|
||||
postError =
|
||||
"Posted, but \(result.failed) of your buddies couldn't "
|
||||
+ "receive it (no registered device)."
|
||||
String(localized: "Posted, but \(result.failed) of your buddies couldn't receive it (no registered device).")
|
||||
}
|
||||
} catch {
|
||||
postError =
|
||||
(error as? MessengerError)?.errorDescription
|
||||
?? (error as? E2EError).map { _ in "None of your buddies can receive stories yet." }
|
||||
?? (error as? E2EError).map { _ in String(localized: "None of your buddies can receive stories yet.") }
|
||||
?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
@@ -226,11 +222,10 @@ struct NewStoryButton: View {
|
||||
duration: draft.duration,
|
||||
caption: caption.isEmpty ? nil : caption, gid: gid)
|
||||
if result.reached == 0 && result.queued == 0 {
|
||||
postError = "Nobody in that group could be reached."
|
||||
postError = String(localized: "Nobody in that group could be reached.")
|
||||
} else if result.queued > 0 {
|
||||
postError =
|
||||
"Posted. \(result.queued) of the group could not be "
|
||||
+ "reached yet, so it goes out when they can be."
|
||||
String(localized: "Posted. \(result.queued) of the group could not be reached yet, so it goes out when they can be.")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -239,8 +234,7 @@ struct NewStoryButton: View {
|
||||
let recipients = audience.recipients(contacts: buddies.map(\.id))
|
||||
guard !recipients.isEmpty else {
|
||||
postError =
|
||||
"Nobody is in your story audience, so this would go "
|
||||
+ "to no one. Change who can see your stories on Account."
|
||||
String(localized: "Nobody is in your story audience, so this would go to no one. Change who can see your stories on Account.")
|
||||
return
|
||||
}
|
||||
let result = try await inbox.postStory(
|
||||
@@ -254,13 +248,12 @@ struct NewStoryButton: View {
|
||||
)
|
||||
if result.failed > 0 {
|
||||
postError =
|
||||
"Posted, but \(result.failed) of your buddies couldn't "
|
||||
+ "receive it (no registered device)."
|
||||
String(localized: "Posted, but \(result.failed) of your buddies couldn't receive it (no registered device).")
|
||||
}
|
||||
} catch {
|
||||
postError =
|
||||
(error as? MessengerError)?.errorDescription
|
||||
?? (error as? E2EError).map { _ in "None of your buddies can receive stories yet." }
|
||||
?? (error as? E2EError).map { _ in String(localized: "None of your buddies can receive stories yet.") }
|
||||
?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ struct PillTabItem: Identifiable {
|
||||
let title: String
|
||||
let symbol: String
|
||||
var badge: Int = 0
|
||||
/// The English name behind `tab.<name>`, so the identifier stays the
|
||||
/// same when `title` is translated. Falls back to the title.
|
||||
var identifier: String? = nil
|
||||
}
|
||||
|
||||
struct PillTabBar<Content: View>: View {
|
||||
@@ -108,7 +111,7 @@ struct PillTabBar<Content: View>: View {
|
||||
tab(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("tab.\(item.title.lowercased())")
|
||||
.accessibilityIdentifier("tab.\(item.identifier ?? item.title.lowercased())")
|
||||
.accessibilityAddTraits(
|
||||
selection == item.id ? [.isSelected, .isButton] : .isButton)
|
||||
}
|
||||
@@ -189,7 +192,7 @@ struct PillTabBar<Content: View>: View {
|
||||
// aloud rather than dropped, because it is the reason somebody looks.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(
|
||||
item.badge > 0 ? "\(item.title), \(item.badge) unread" : item.title
|
||||
item.badge > 0 ? String(localized: "\(item.title), \(item.badge) unread") : item.title
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: PillTabBarMetrics.barHeight)
|
||||
|
||||
@@ -52,7 +52,8 @@ struct PollCard: View {
|
||||
/// vote and sees the tally, and subtracting one from the other gives it,
|
||||
/// with nobody to collude with. The word is not offered for a pair.
|
||||
private var anonymityLabel: String {
|
||||
poll.members.count > 2 ? "Anonymous poll" : "Poll"
|
||||
poll.members.count > 2
|
||||
? String(localized: "Anonymous poll") : String(localized: "Poll", comment: "Noun")
|
||||
}
|
||||
|
||||
private var choices: some View {
|
||||
@@ -92,8 +93,8 @@ struct PollCard: View {
|
||||
case .waitingForVotes(let who):
|
||||
Text(
|
||||
poll.myChoice == nil
|
||||
? "Nobody sees a count until everyone has voted."
|
||||
: "Waiting for \(list(who)) to vote."
|
||||
? String(localized: "Nobody sees a count until everyone has voted.")
|
||||
: String(localized: "Waiting for \(list(who)) to vote.")
|
||||
)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -139,20 +140,28 @@ struct PollCard: View {
|
||||
/// The guarantee, stated as it actually is.
|
||||
private var footnote: String {
|
||||
poll.members.count > 2
|
||||
? "Nobody was sent anybody's vote. Everyone else here would have to "
|
||||
+ "work together to know how you voted."
|
||||
: "Nobody was sent your vote, but in a poll of two the other person "
|
||||
+ "can work it out from the result."
|
||||
? String(
|
||||
localized: """
|
||||
Nobody was sent anybody's vote. Everyone else here would have to \
|
||||
work together to know how you voted.
|
||||
""")
|
||||
: String(
|
||||
localized: """
|
||||
Nobody was sent your vote, but in a poll of two the other person \
|
||||
can work it out from the result.
|
||||
""")
|
||||
}
|
||||
|
||||
private func list(_ ids: [Int]) -> String {
|
||||
let names = ids.map(name)
|
||||
switch names.count {
|
||||
case 0: return "nobody"
|
||||
case 0: return String(localized: "nobody")
|
||||
case 1: return names[0]
|
||||
case 2: return "\(names[0]) and \(names[1])"
|
||||
case 2: return String(localized: "\(names[0]) and \(names[1])")
|
||||
default:
|
||||
return names.dropLast().joined(separator: ", ") + " and " + (names.last ?? "")
|
||||
let head = names.dropLast().joined(separator: ", ")
|
||||
let last = names.last ?? ""
|
||||
return String(localized: "\(head) and \(last)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,16 +232,22 @@ struct NewPollSheet: View {
|
||||
/// is deciding on.
|
||||
private var guarantee: String {
|
||||
if members < 2 {
|
||||
return "A poll needs somebody else in the group."
|
||||
return String(localized: "A poll needs somebody else in the group.")
|
||||
}
|
||||
if members == 2 {
|
||||
return "In a group of two this is not anonymous: the other person "
|
||||
+ "can work out your vote from the result. It is still a poll."
|
||||
return String(
|
||||
localized: """
|
||||
In a group of two this is not anonymous: the other person \
|
||||
can work out your vote from the result. It is still a poll.
|
||||
""")
|
||||
}
|
||||
return "Nobody is sent anybody's vote, not even you as the person "
|
||||
+ "asking. Everyone other than the voter would have to work "
|
||||
+ "together to know how one person voted. Everyone has to vote "
|
||||
+ "before there is a result."
|
||||
return String(
|
||||
localized: """
|
||||
Nobody is sent anybody's vote, not even you as the person \
|
||||
asking. Everyone other than the voter would have to work \
|
||||
together to know how one person voted. Everyone has to vote \
|
||||
before there is a result.
|
||||
""")
|
||||
}
|
||||
|
||||
private func ask() {
|
||||
@@ -244,7 +259,7 @@ struct NewPollSheet: View {
|
||||
options: filledOptions, gid: gid)
|
||||
dismiss()
|
||||
} catch {
|
||||
failure = "That poll could not be sent."
|
||||
failure = String(localized: "That poll could not be sent.")
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct PresenceDot: View {
|
||||
ring ?? .white.opacity(0.6),
|
||||
lineWidth: ring != nil ? ringWidth : (presence == .offline ? 1 : 0))
|
||||
)
|
||||
.accessibilityLabel(showOnlineStatus ? presence.label : "Status hidden")
|
||||
.accessibilityLabel(showOnlineStatus ? presence.label : String(localized: "Status hidden"))
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
|
||||
@@ -26,7 +26,7 @@ struct ProfilePictureSection: View {
|
||||
// The portal's bone face, the same one every other
|
||||
// button in the app wears. A flat pale fill here was
|
||||
// the last stock-looking control on the screen.
|
||||
Text(hasAvatar ? "Change" : "Choose a picture")
|
||||
Text(hasAvatar ? String(localized: "Change") : String(localized: "Choose a picture"))
|
||||
.font(Theme.Font.headline)
|
||||
.foregroundStyle(Theme.ink)
|
||||
.padding(.horizontal, Theme.Space.lg)
|
||||
@@ -66,9 +66,7 @@ struct ProfilePictureSection: View {
|
||||
}
|
||||
|
||||
Text(
|
||||
"Sent to your contacts encrypted, the same way a photo is, this "
|
||||
+ "server stores it and cannot open it. People who have never "
|
||||
+ "messaged you see your initials instead."
|
||||
"Sent to your contacts encrypted, the same way a photo is, this server stores it and cannot open it. People who have never messaged you see your initials instead."
|
||||
)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
@@ -86,12 +84,12 @@ struct ProfilePictureSection: View {
|
||||
picked = nil
|
||||
}
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
||||
note = "That image could not be read."
|
||||
note = String(localized: "That image could not be read.")
|
||||
return
|
||||
}
|
||||
// Downscaled before it is sealed.
|
||||
guard let prepared = ImagePrep.prepare(data) else {
|
||||
note = "That image could not be encoded."
|
||||
note = String(localized: "That image could not be encoded.")
|
||||
return
|
||||
}
|
||||
do {
|
||||
@@ -101,7 +99,7 @@ struct ProfilePictureSection: View {
|
||||
to: buddyIDs)
|
||||
note =
|
||||
result.failed > 0
|
||||
? "Saved, but \(result.failed) of \(buddyIDs.count) contacts could not be told, check their keys."
|
||||
? String(localized: "Saved, but \(result.failed) of \(buddyIDs.count) contacts could not be told, check their keys.")
|
||||
: nil
|
||||
} catch {
|
||||
note = (error as? E2EError)?.errorDescription ?? error.localizedDescription
|
||||
|
||||
@@ -60,15 +60,19 @@ struct RelayIdentityBanner: View {
|
||||
///
|
||||
/// Both readings are true and the app cannot tell them apart, so it says
|
||||
/// both rather than picking the comfortable one.
|
||||
static let detail =
|
||||
"This device remembered the key your server signs with, and the server "
|
||||
+ "is now signing with a different one. That happens when a server is "
|
||||
+ "rebuilt or moved. It also happens when something has got in between "
|
||||
+ "you and it. Your messages stay end-to-end encrypted either way, but "
|
||||
+ "until this is settled the server's word about anyone's keys is worth "
|
||||
+ "less than it was."
|
||||
static let detail = String(
|
||||
localized: """
|
||||
This device remembered the key your server signs with, and the server \
|
||||
is now signing with a different one. That happens when a server is \
|
||||
rebuilt or moved. It also happens when something has got in between \
|
||||
you and it. Your messages stay end-to-end encrypted either way, but \
|
||||
until this is settled the server's word about anyone's keys is worth \
|
||||
less than it was.
|
||||
""")
|
||||
|
||||
static let confirmation =
|
||||
"Ask whoever runs the server whether they changed it, on a channel this "
|
||||
+ "app does not carry. Accepting pins the new key and stops the warning."
|
||||
static let confirmation = String(
|
||||
localized: """
|
||||
Ask whoever runs the server whether they changed it, on a channel this \
|
||||
app does not carry. Accepting pins the new key and stops the warning.
|
||||
""")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user