Compare commits
@@ -1 +1 @@
|
||||
3564030409e2ac0b192ae1f0f049421996e75b7c
|
||||
4b4042f72880758ee2dea7713f7a2cb63fa12f73
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Why this repository pins flamenet-e2e
|
||||
|
||||
`.e2e-ref` is the commit of [flamenet-e2e](https://gitlab.com/paulhitt/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
|
||||
@@ -1,371 +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/paulhitt/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; 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 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/paulhitt/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; 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/paulhitt/flamenet-messenger-android.git ci-scrub/android
|
||||
- git clone --depth 1 https://gitlab.com/paulhitt/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,3 +1,3 @@
|
||||
[submodule "Packages/Obfs4"]
|
||||
path = Packages/Obfs4
|
||||
url = https://gitlab.com/paulhitt/flamenet-elligator2.git
|
||||
url = https://git.extant2000.com/extant2000/flamenet-elligator2.git
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
FlameNet sticker pack `flamenet.retro`: 24 original drawings in `svg/`, rendered to the 288x288 transparent PNGs the app ships in `Resources/stickers/flamenet.retro/`, in the picker order of `Resources/stickers/flamenet.retro.json`. The Android app ships the same PNG bytes.
|
||||
|
||||
To re-render after editing an SVG, run this from this folder with Node 22 or later: `MANIFEST=../../Resources/stickers/flamenet.retro.json OUT=../../Resources/stickers/flamenet.retro PW_DIR=<folder with node_modules/playwright-core> CHROME=<Chromium binary> node render.mjs`. It also writes two contact sheets here, which are not shipped.
|
||||
@@ -0,0 +1,191 @@
|
||||
// Renders the FlameNet sticker pack: svg/<id>.svg -> flamenet.retro/<id>.png
|
||||
// (288x288, transparent, 8-bit palette PNG) and the two contact sheets.
|
||||
// Picker order and names come from flamenet.retro.json.
|
||||
//
|
||||
// Needs Node 22 or later and playwright-core with a Chromium build:
|
||||
// PW_DIR=<folder whose node_modules has playwright-core> \
|
||||
// CHROME=<chromium or chrome-headless-shell binary> node render.mjs
|
||||
// Without PW_DIR it imports playwright-core from here; without CHROME it
|
||||
// uses the browser Playwright installed. MANIFEST and OUT point it at other
|
||||
// copies of flamenet.retro.json and the PNG folder; both default to here.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SIZE = 288;
|
||||
const SHEET = 96;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(process.env.PW_DIR
|
||||
? require.resolve("playwright-core", { paths: [process.env.PW_DIR] })
|
||||
: "playwright-core");
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST || path.join(HERE, "flamenet.retro.json"), "utf8"));
|
||||
|
||||
// The sources have to render the same everywhere: no ids, no references,
|
||||
// no fonts, one canvas size.
|
||||
function check(id, svg) {
|
||||
const bad = [
|
||||
[/\sid\s*=/, "an id"],
|
||||
[/href|url\(/, "a reference"],
|
||||
[/<(text|tspan|image|style|script|foreignObject)\b|font-/, "text, fonts or embedded content"],
|
||||
].find(([re]) => re.test(svg));
|
||||
if (bad) throw new Error(`${id}.svg has ${bad[1]}`);
|
||||
if (!/^<svg [^>]*viewBox="0 0 200 200"/.test(svg)) throw new Error(`${id}.svg needs viewBox="0 0 200 200"`);
|
||||
}
|
||||
|
||||
// 8-bit palette PNG.
|
||||
|
||||
// Colours compared premultiplied, so faint edge pixels count for little.
|
||||
const unpack = k => [k >>> 24, (k >>> 16) & 255, (k >>> 8) & 255, k & 255];
|
||||
const pm = k => { const [r, g, b, a] = unpack(k); return [r * a / 255, g * a / 255, b * a / 255, a]; };
|
||||
const pack = (r, g, b, a) => ((r << 24) | (g << 16) | (b << 8) | a) >>> 0;
|
||||
|
||||
// Splits the leftover edge colours into n boxes, each time cutting the box
|
||||
// with the largest squared error along its widest channel.
|
||||
function medianCut(entries, n) {
|
||||
const sse = box => {
|
||||
let w = 0; const s = [0, 0, 0, 0], q = [0, 0, 0, 0];
|
||||
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) { s[d] += p.v[d] * p.c; q[d] += p.v[d] * p.v[d] * p.c; } }
|
||||
const per = q.map((x, d) => x - s[d] * s[d] / w);
|
||||
return { total: per.reduce((a, b) => a + b, 0), dim: per.indexOf(Math.max(...per)), w };
|
||||
};
|
||||
let boxes = [entries.map(([k, c]) => ({ k, c, v: pm(k) }))].filter(b => b.length);
|
||||
while (boxes.length && boxes.length < n) {
|
||||
let best = -1, bestErr = 0, dim = 0;
|
||||
boxes.forEach((b, i) => {
|
||||
if (b.length < 2) return;
|
||||
const e = sse(b);
|
||||
if (e.total > bestErr) { bestErr = e.total; best = i; dim = e.dim; }
|
||||
});
|
||||
if (best < 0) break;
|
||||
const box = boxes[best].slice().sort((a, b) => a.v[dim] - b.v[dim] || a.k - b.k);
|
||||
const half = box.reduce((a, p) => a + p.c, 0) / 2;
|
||||
let acc = 0, cut = 1;
|
||||
for (let i = 0; i < box.length - 1; i++) { acc += box[i].c; if (acc >= half) { cut = i + 1; break; } }
|
||||
boxes.splice(best, 1, box.slice(0, cut), box.slice(cut));
|
||||
}
|
||||
return boxes.map(box => {
|
||||
let w = 0; const s = [0, 0, 0, 0];
|
||||
for (const p of box) { w += p.c; for (let d = 0; d < 4; d++) s[d] += p.v[d] * p.c; }
|
||||
const a = Math.round(s[3] / w);
|
||||
if (a === 0) return 0;
|
||||
const ch = d => Math.max(0, Math.min(255, Math.round((s[d] / w) * 255 / (s[3] / w))));
|
||||
return pack(ch(0), ch(1), ch(2), a);
|
||||
});
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const head = Buffer.alloc(4); head.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(body) >>> 0);
|
||||
return Buffer.concat([head, body, crc]);
|
||||
}
|
||||
|
||||
function encodePalettePNG(rgba, w, h) {
|
||||
const n = w * h, keys = new Uint32Array(n), hist = new Map();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = rgba[4 * i + 3];
|
||||
const k = a === 0 ? 0 : pack(rgba[4 * i], rgba[4 * i + 1], rgba[4 * i + 2], a);
|
||||
keys[i] = k;
|
||||
hist.set(k, (hist.get(k) || 0) + 1);
|
||||
}
|
||||
const byCount = [...hist.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]);
|
||||
let palette;
|
||||
if (byCount.length <= 256) {
|
||||
palette = byCount.map(e => e[0]);
|
||||
} else {
|
||||
// Flat fills keep their exact colour; only edge blends are merged.
|
||||
const fixed = byCount.filter(e => e[1] >= 24).slice(0, 128).map(e => e[0]);
|
||||
const taken = new Set(fixed);
|
||||
palette = [...new Set(fixed.concat(medianCut(byCount.filter(e => !taken.has(e[0])), 256 - fixed.length)))];
|
||||
}
|
||||
// Translucent entries first, so tRNS can stop at the last one.
|
||||
palette.sort((a, b) => ((a & 255) === 255) - ((b & 255) === 255) || (a & 255) - (b & 255) || a - b);
|
||||
const pv = palette.map(pm);
|
||||
const index = new Map();
|
||||
for (const k of hist.keys()) {
|
||||
const v = pm(k);
|
||||
let best = 0, bd = Infinity;
|
||||
for (let j = 0; j < pv.length; j++) {
|
||||
const d = (v[0] - pv[j][0]) ** 2 + (v[1] - pv[j][1]) ** 2 + (v[2] - pv[j][2]) ** 2 + 2 * (v[3] - pv[j][3]) ** 2;
|
||||
if (d < bd) { bd = d; best = j; }
|
||||
}
|
||||
index.set(k, best);
|
||||
}
|
||||
const raw = Buffer.alloc(h * (w + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (w + 1)] = 0;
|
||||
for (let x = 0; x < w; x++) raw[y * (w + 1) + 1 + x] = index.get(keys[y * w + x]);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 3;
|
||||
const plte = Buffer.alloc(palette.length * 3), alpha = [];
|
||||
palette.forEach((k, j) => {
|
||||
const [r, g, b, a] = unpack(k);
|
||||
plte[3 * j] = r; plte[3 * j + 1] = g; plte[3 * j + 2] = b;
|
||||
if (a < 255) alpha.push(a);
|
||||
});
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("PLTE", plte),
|
||||
...(alpha.length ? [chunk("tRNS", Buffer.from(alpha))] : []),
|
||||
chunk("IDAT", zlib.deflateSync(raw, { level: 9, memLevel: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Render.
|
||||
|
||||
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
|
||||
const page = await browser.newPage({ viewport: { width: SIZE, height: SIZE }, deviceScaleFactor: 1 });
|
||||
const outDir = process.env.OUT || path.join(HERE, "flamenet.retro");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const pngs = {};
|
||||
for (const s of manifest.stickers) {
|
||||
const svg = fs.readFileSync(path.join(HERE, "svg", `${s.id}.svg`), "utf8").trim();
|
||||
check(s.id, svg);
|
||||
if (s.image !== `${s.id}.png`) throw new Error(`${s.id}: image should be ${s.id}.png`);
|
||||
const sized = svg.replace("<svg ", `<svg width="${SIZE}" height="${SIZE}" `);
|
||||
const b64 = await page.evaluate(async ({ sized, size }) => {
|
||||
const img = new Image();
|
||||
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(sized);
|
||||
await img.decode();
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const g = canvas.getContext("2d");
|
||||
g.drawImage(img, 0, 0, size, size);
|
||||
const d = g.getImageData(0, 0, size, size).data;
|
||||
let bin = "";
|
||||
for (let i = 0; i < d.length; i += 0x8000) bin += String.fromCharCode.apply(null, d.subarray(i, i + 0x8000));
|
||||
return btoa(bin);
|
||||
}, { sized, size: SIZE });
|
||||
const png = encodePalettePNG(Buffer.from(b64, "base64"), SIZE, SIZE);
|
||||
fs.writeFileSync(path.join(outDir, s.image), png);
|
||||
pngs[s.id] = png;
|
||||
console.log(`${s.image.padEnd(22)} ${String(png.length).padStart(6)} bytes`);
|
||||
}
|
||||
|
||||
// Contact sheets, 96 px on white and on black.
|
||||
|
||||
const esc = t => t.replace(/[&<>"]/g, c => `&#${c.charCodeAt(0)};`);
|
||||
for (const [name, bg, fg] of [["sheet-light.png", "#ffffff", "#333333"], ["sheet-dark.png", "#000000", "#e8e8e8"]]) {
|
||||
const cells = manifest.stickers.map(s =>
|
||||
`<figure><img src="data:image/png;base64,${pngs[s.id].toString("base64")}" width="${SHEET}" height="${SHEET}" alt=""><figcaption>${esc(s.name)}</figcaption></figure>`).join("");
|
||||
await page.setViewportSize({ width: 6 * 124 + 24, height: 200 });
|
||||
await page.setContent(`<!doctype html><html><head><style>
|
||||
body { margin: 0; padding: 12px; background: ${bg}; color: ${fg}; font: 12px -apple-system, Helvetica, Arial, sans-serif; }
|
||||
main { display: grid; grid-template-columns: repeat(6, 124px); }
|
||||
figure { margin: 0; padding: 8px 0 10px; text-align: center; }
|
||||
img { display: block; margin: 0 auto 4px; }
|
||||
</style></head><body><main>${cells}</main></body></html>`);
|
||||
await page.screenshot({ path: path.join(HERE, name), fullPage: true });
|
||||
console.log(`${name} written`);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-8 100 100)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z"/>
|
||||
<path d="M92 150 L100 139 L108 150 Z"/>
|
||||
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none"/>
|
||||
</g>
|
||||
<path d="M100 150 C91 160 108 167 100 175 C94 181 99 183 97 186" fill="none" stroke="#333" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M100 14 C136 14 156 42 156 76 C156 110 128 138 100 140 C72 138 44 110 44 76 C44 42 64 14 100 14 Z" fill="#FF3B3B" stroke="#333" stroke-width="2"/>
|
||||
<path d="M92 150 L100 139 L108 150 Z" fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<ellipse cx="76" cy="50" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(20 76 50)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 938 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 108)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<circle cx="100" cy="24" r="7"/>
|
||||
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke-width="17"/>
|
||||
<rect x="24" y="64" width="152" height="88" rx="10"/>
|
||||
</g>
|
||||
<path d="M100 26 L44 66 M100 26 L156 66" fill="none" stroke="#6B3E1F" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="100" cy="24" r="7" fill="#B8B8B8" stroke="#333" stroke-width="2"/>
|
||||
<rect x="24" y="64" width="152" height="88" rx="10" fill="#FFCC00" stroke="#333" stroke-width="2"/>
|
||||
<rect x="31" y="71" width="138" height="74" rx="6" fill="none" stroke="#E6A800" stroke-width="2"/>
|
||||
<circle cx="44" cy="68" r="3" fill="#6B3E1F"/>
|
||||
<circle cx="156" cy="68" r="3" fill="#6B3E1F"/>
|
||||
<g fill="#003399" fill-rule="evenodd">
|
||||
<path transform="translate(46.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
|
||||
<path transform="translate(84.5 0)" d="M0 131 V85 H18 C26 85 30 90.5 30 98 C30 104 27 108 22.5 110 L31 131 H19.5 L12 112.5 H11 V131 Z M11 94 H17 C19.5 94 20 96 20 98.5 C20 101 19.5 103.5 17 103.5 H11 Z"/>
|
||||
<path transform="translate(122.5 0)" d="M0 85 H19 C26 85 29 90 29 96.5 C29 101 27 104 23.5 105.5 C28 107 31 111 31 117.5 C31 125.5 26 131 18 131 H0 Z M11 94 H16 C18.5 94 19 95.5 19 97 C19 98.5 18.5 100 16 100 H11 Z M11 111 H18 C20.5 111 21 113 21 115.5 C21 118 20.5 121 18 121 H11 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<ellipse cx="100" cy="174" rx="80" ry="12"/>
|
||||
<rect x="26" y="92" width="148" height="80" rx="10"/>
|
||||
<rect x="57" y="56" width="10" height="40"/>
|
||||
<rect x="95" y="56" width="10" height="40"/>
|
||||
<rect x="133" y="56" width="10" height="40"/>
|
||||
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
|
||||
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
|
||||
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<ellipse cx="100" cy="174" rx="80" ry="12" fill="#CCE6FF"/>
|
||||
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="#FF99BB"/>
|
||||
<rect x="30" y="146" width="140" height="8" fill="#FFFDF5" stroke-width="1.5"/>
|
||||
<path d="M30 106 H170 V162 Q170 172 160 172 H40 Q30 172 30 162 Z" fill="none"/>
|
||||
<path d="M26 102 Q26 92 36 92 H164 Q174 92 174 102 V112 C174 120 160 120 160 112 C160 108 152 108 150 112 V124 C150 132 136 132 136 124 V114 C136 108 124 108 120 114 C118 120 108 120 106 114 C104 108 96 108 94 114 V128 C94 136 80 136 80 128 V114 C80 108 70 108 66 114 C64 120 54 120 52 114 C50 108 42 108 40 114 V120 C40 128 26 128 26 120 Z" fill="#FFFDF5"/>
|
||||
<rect x="57" y="62" width="10" height="30" fill="#3366FF"/>
|
||||
<rect x="95" y="62" width="10" height="30" fill="#FFCC00"/>
|
||||
<rect x="133" y="62" width="10" height="30" fill="#33AA33"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M62 62 V55 M100 62 V55 M138 62 V55"/>
|
||||
</g>
|
||||
<g fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M62 30 C70 40 70 48 62 54 C54 48 54 40 62 30 Z"/>
|
||||
<path d="M100 30 C108 40 108 48 100 54 C92 48 92 40 100 30 Z"/>
|
||||
<path d="M138 30 C146 40 146 48 138 54 C130 48 130 40 138 30 Z"/>
|
||||
</g>
|
||||
<g fill="#FFCC00">
|
||||
<ellipse cx="62" cy="46" rx="3.5" ry="5"/>
|
||||
<ellipse cx="100" cy="46" rx="3.5" ry="5"/>
|
||||
<ellipse cx="138" cy="46" rx="3.5" ry="5"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="46" y="135" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(30 50 137)"/>
|
||||
<rect x="112" y="136" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-25 116 138)"/>
|
||||
<rect x="152" y="134" width="9" height="4" rx="2" fill="#FFFFFF" transform="rotate(40 156 136)"/>
|
||||
<rect x="60" y="160" width="9" height="4" rx="2" fill="#FFCC00" transform="rotate(-30 64 162)"/>
|
||||
<rect x="96" y="161" width="9" height="4" rx="2" fill="#3366FF" transform="rotate(15 100 163)"/>
|
||||
<rect x="136" y="160" width="9" height="4" rx="2" fill="#33AA33" transform="rotate(-35 140 162)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z"/>
|
||||
<path d="M100 60 V74"/>
|
||||
<rect x="64" y="72" width="72" height="100" rx="5"/>
|
||||
<ellipse cx="100" cy="176" rx="62" ry="14"/>
|
||||
</g>
|
||||
<path d="M100 60 V74" stroke="#333" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M100 12 C118 34 119 52 100 64 C81 52 82 34 100 12 Z" fill="#FF9900" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M100 30 C109 43 109 54 100 60 C91 54 91 43 100 30 Z" fill="#FFCC00"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<ellipse cx="100" cy="177" rx="62" ry="13" fill="#E6A800"/>
|
||||
<ellipse cx="100" cy="171" rx="62" ry="13" fill="#FFCC00"/>
|
||||
<rect x="64" y="72" width="72" height="100" rx="5" fill="#FFF6CC"/>
|
||||
<path d="M64 77 Q64 72 69 72 H131 Q136 72 136 77 V92 C136 98 127 98 127 92 C127 87 120 87 120 93 V104 C120 113 108 113 108 104 V93 C108 87 99 87 97 93 C95 99 86 99 86 93 C86 88 79 87 77 93 V98 C77 105 64 105 64 98 Z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
<rect x="73" y="112" width="8" height="50" rx="4" fill="#fff" opacity=".8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z"/>
|
||||
<ellipse cx="90" cy="66" rx="50" ry="10"/>
|
||||
<path d="M140 86 C178 84 180 138 140 136" fill="none" stroke-width="35"/>
|
||||
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" fill="none" stroke-width="19"/>
|
||||
</g>
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<path d="M70 46 C62 36 78 30 70 18 M92 44 C84 34 100 28 92 14 M114 46 C106 36 122 30 114 18" stroke="#8C9BB0" stroke-width="5"/>
|
||||
<path d="M140 86 C178 84 180 138 140 136" stroke="#333" stroke-width="21"/>
|
||||
<path d="M140 86 C178 84 180 138 140 136" stroke="#CC0000" stroke-width="17"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M40 66 H140 V146 Q140 176 110 176 H70 Q40 176 40 146 Z" fill="#CC0000"/>
|
||||
<ellipse cx="90" cy="66" rx="50" ry="10" fill="#CC0000"/>
|
||||
<ellipse cx="90" cy="67" rx="43" ry="6.5" fill="#6B3E1F"/>
|
||||
</g>
|
||||
<rect x="40" y="88" width="100" height="10" fill="#fff"/>
|
||||
<path d="M40 70 V146 Q40 176 70 176 H110 Q140 176 140 146 V70" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M40 88 H140 M40 98 H140" stroke="#333" stroke-width="1.5"/>
|
||||
<rect x="50" y="106" width="8" height="52" rx="4" fill="#fff" opacity=".4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="24" y="20" width="152" height="126" rx="16"/>
|
||||
<path d="M80 144 H120 L126 160 H74 Z"/>
|
||||
<rect x="46" y="158" width="108" height="20" rx="9"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M80 144 H120 L126 160 H74 Z" fill="#D6CFB6"/>
|
||||
<rect x="46" y="158" width="108" height="20" rx="9" fill="#E8E2CC"/>
|
||||
<rect x="24" y="20" width="152" height="126" rx="16" fill="#E8E2CC"/>
|
||||
<rect x="40" y="34" width="120" height="88" rx="14" fill="#003399"/>
|
||||
<rect x="50" y="129" width="18" height="8" rx="3" fill="#D6CFB6" stroke-width="1.5"/>
|
||||
<circle cx="148" cy="133" r="4.5" fill="#33AA33" stroke-width="1.5"/>
|
||||
</g>
|
||||
<rect x="50" y="42" width="28" height="6" rx="3" fill="#fff" opacity=".35"/>
|
||||
<g fill="#FFCC00">
|
||||
<rect x="73" y="56" width="12" height="21" rx="6"/>
|
||||
<rect x="115" y="56" width="12" height="21" rx="6"/>
|
||||
</g>
|
||||
<path d="M72 90 Q100 114 128 90" fill="none" stroke="#FFCC00" stroke-width="7" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<circle cx="46" cy="38" r="13"/>
|
||||
<circle cx="154" cy="38" r="13"/>
|
||||
<rect x="40" y="46" width="12" height="130"/>
|
||||
<rect x="148" y="46" width="12" height="130"/>
|
||||
<rect x="28" y="168" width="36" height="12" rx="3"/>
|
||||
<rect x="136" y="168" width="36" height="12" rx="3"/>
|
||||
<rect x="24" y="66" width="152" height="80"/>
|
||||
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22" stroke-width="17" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="40" y="50" width="12" height="122" fill="#E6E6E6"/>
|
||||
<rect x="148" y="50" width="12" height="122" fill="#E6E6E6"/>
|
||||
<rect x="28" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
|
||||
<rect x="136" y="168" width="36" height="12" rx="3" fill="#FF9900"/>
|
||||
<rect x="24" y="66" width="152" height="32" fill="#FFCC00"/>
|
||||
<rect x="24" y="116" width="152" height="30" fill="#FFCC00"/>
|
||||
</g>
|
||||
<g fill="#333">
|
||||
<polygon points="24,88 24,72 30,66 46,66"/>
|
||||
<polygon points="46,98 30,98 62,66 78,66"/>
|
||||
<polygon points="78,98 62,98 94,66 110,66"/>
|
||||
<polygon points="110,98 94,98 126,66 142,66"/>
|
||||
<polygon points="142,98 126,98 158,66 174,66"/>
|
||||
<polygon points="158,98 176,80 176,96 174,98"/>
|
||||
<polygon points="24,138 24,122 30,116 46,116"/>
|
||||
<polygon points="48,146 32,146 62,116 78,116"/>
|
||||
<polygon points="80,146 64,146 94,116 110,116"/>
|
||||
<polygon points="112,146 96,146 126,116 142,116"/>
|
||||
<polygon points="144,146 128,146 158,116 174,116"/>
|
||||
<polygon points="160,146 176,130 176,146"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="24" y="66" width="152" height="32"/>
|
||||
<rect x="24" y="116" width="152" height="30"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="38" y="44" width="16" height="8" rx="2" fill="#555"/>
|
||||
<rect x="146" y="44" width="16" height="8" rx="2" fill="#555"/>
|
||||
<path d="M34 44 A12 12 0 0 1 58 44 Z" fill="#FF9900"/>
|
||||
<path d="M142 44 A12 12 0 0 1 166 44 Z" fill="#FF9900"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".7">
|
||||
<ellipse cx="41" cy="37" rx="2.5" ry="4"/>
|
||||
<ellipse cx="149" cy="37" rx="2.5" ry="4"/>
|
||||
</g>
|
||||
<g stroke="#FF9900" stroke-width="3" stroke-linecap="round">
|
||||
<path d="M46 20 V14 M30 26 L25 22 M62 26 L67 22 M154 20 V14 M138 26 L133 22 M170 26 L175 22"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<path d="M100 188 C68 188 44 166 44 136 C44 110 48 86 52 62 C60 78 68 90 76 96 C74 68 88 40 116 12 C110 36 116 58 132 80 C140 72 150 64 156 54 C162 84 158 108 158 136 C158 166 132 188 100 188 Z" fill="#FF5A1F" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M100 180 C76 180 62 162 62 140 C62 114 84 96 106 58 C102 84 138 108 138 140 C138 162 122 180 100 180 Z" fill="#FF9900"/>
|
||||
<path d="M100 176 C86 176 78 166 78 152 C78 134 92 124 102 104 C110 124 122 136 122 152 C122 166 114 176 100 176 Z" fill="#FFCC00"/>
|
||||
<ellipse cx="100" cy="160" rx="9" ry="11" fill="#FFF3B0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 924 B |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 100)">
|
||||
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M38 28 H150 L172 50 V164 Q172 172 164 172 H36 Q28 172 28 164 V38 Q28 28 38 28 Z" fill="#003399"/>
|
||||
<rect x="58" y="28" width="80" height="48" fill="#C9D1DE"/>
|
||||
<rect x="110" y="37" width="16" height="30" rx="2" fill="#003399"/>
|
||||
<rect x="46" y="96" width="108" height="76" rx="4" fill="#FFFFFF"/>
|
||||
<path d="M46 106 V100 Q46 96 50 96 H150 Q154 96 154 100 V106 Z" fill="#FF3B3B"/>
|
||||
</g>
|
||||
<g fill="#B8C8F0">
|
||||
<rect x="56" y="120" width="88" height="5" rx="2.5"/>
|
||||
<rect x="56" y="136" width="88" height="5" rx="2.5"/>
|
||||
<rect x="56" y="152" width="58" height="5" rx="2.5"/>
|
||||
</g>
|
||||
<rect x="34" y="38" width="6" height="46" rx="3" fill="#fff" opacity=".35"/>
|
||||
<rect x="64" y="34" width="5" height="36" rx="2.5" fill="#fff" opacity=".6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<circle cx="100" cy="48" r="30"/>
|
||||
<circle cx="62" cy="70" r="30"/>
|
||||
<circle cx="138" cy="70" r="30"/>
|
||||
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)"/>
|
||||
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)"/>
|
||||
<path d="M62 90 L100 112 L138 90 Z"/>
|
||||
<path d="M40 106 H160 L104 188 H96 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<ellipse cx="40" cy="106" rx="20" ry="9" transform="rotate(-30 40 106)" fill="#43A047"/>
|
||||
<ellipse cx="160" cy="106" rx="20" ry="9" transform="rotate(30 160 106)" fill="#43A047"/>
|
||||
</g>
|
||||
<g stroke="#2E7D32" stroke-width="5" stroke-linecap="round">
|
||||
<path d="M62 92 L88 110 M100 70 V110 M138 92 L112 110"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2">
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="100" cy="31" r="13"/><circle cx="114.7" cy="39.5" r="13"/><circle cx="114.7" cy="56.5" r="13"/><circle cx="100" cy="65" r="13"/><circle cx="85.3" cy="56.5" r="13"/><circle cx="85.3" cy="39.5" r="13"/>
|
||||
</g>
|
||||
<circle cx="100" cy="48" r="9" fill="#FF9900"/>
|
||||
<g fill="#FF7AA2">
|
||||
<circle cx="62" cy="53" r="13"/><circle cx="76.7" cy="61.5" r="13"/><circle cx="76.7" cy="78.5" r="13"/><circle cx="62" cy="87" r="13"/><circle cx="47.3" cy="78.5" r="13"/><circle cx="47.3" cy="61.5" r="13"/>
|
||||
</g>
|
||||
<circle cx="62" cy="70" r="9" fill="#FFCC00"/>
|
||||
<g fill="#B388FF">
|
||||
<circle cx="138" cy="53" r="13"/><circle cx="152.7" cy="61.5" r="13"/><circle cx="152.7" cy="78.5" r="13"/><circle cx="138" cy="87" r="13"/><circle cx="123.3" cy="78.5" r="13"/><circle cx="123.3" cy="61.5" r="13"/>
|
||||
</g>
|
||||
<circle cx="138" cy="70" r="9" fill="#FFCC00"/>
|
||||
</g>
|
||||
<path d="M40 106 H160 L104 188 H96 Z" fill="#CCE6FF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M112 106 H160 L104 188 Z" fill="#B3D6FA"/>
|
||||
<path d="M40 106 H160 L104 188 H96 Z" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g fill="#FF3B3B" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M97 152 L87 173 L95 171 L100 157 Z"/>
|
||||
<path d="M103 152 L113 173 L105 171 L100 157 Z"/>
|
||||
<ellipse cx="86" cy="147" rx="14" ry="8" transform="rotate(-20 86 147)"/>
|
||||
<ellipse cx="114" cy="147" rx="14" ry="8" transform="rotate(20 114 147)"/>
|
||||
<circle cx="100" cy="150" r="6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="38" y="96" width="124" height="84" rx="4"/>
|
||||
<rect x="30" y="72" width="140" height="28" rx="4"/>
|
||||
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z"/>
|
||||
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="38" y="96" width="124" height="84" rx="4" fill="#3366FF"/>
|
||||
<g fill="#fff" stroke="none" opacity=".45">
|
||||
<circle cx="58" cy="120" r="6"/><circle cx="70" cy="156" r="6"/><circle cx="132" cy="134" r="6"/><circle cx="148" cy="164" r="6"/>
|
||||
</g>
|
||||
<rect x="88" y="100" width="24" height="80" fill="#FFCC00"/>
|
||||
<rect x="30" y="72" width="140" height="28" rx="4" fill="#5C85FF"/>
|
||||
<rect x="86" y="72" width="28" height="28" fill="#FFCC00"/>
|
||||
<path d="M100 72 C84 42 52 44 56 64 C58 74 84 76 100 72 Z" fill="#FFCC00"/>
|
||||
<path d="M100 72 C116 42 148 44 144 64 C142 74 116 76 100 72 Z" fill="#FFCC00"/>
|
||||
<path d="M96 69 C86 56 70 54 69 62 C69 68 84 70 96 69 Z" fill="#E6A800" stroke="none"/>
|
||||
<path d="M104 69 C114 56 130 54 131 62 C131 68 116 70 104 69 Z" fill="#E6A800" stroke="none"/>
|
||||
<ellipse cx="100" cy="70" rx="10" ry="9" fill="#FFCC00"/>
|
||||
</g>
|
||||
<rect x="36" y="76" width="40" height="5" rx="2.5" fill="#fff" opacity=".45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 100)">
|
||||
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<path d="M16 58 C16 2 100 2 100 58 C100 2 184 2 184 58 C184 114 100 156 100 184 C100 156 16 114 16 58 Z" fill="#E0245E" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<ellipse cx="48" cy="52" rx="10" ry="19" fill="#fff" opacity=".55" transform="rotate(-35 48 52)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(8 100 100)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="38" y="18" width="124" height="18" rx="6"/>
|
||||
<rect x="38" y="164" width="124" height="18" rx="6"/>
|
||||
<rect x="46" y="36" width="9" height="128"/>
|
||||
<rect x="145" y="36" width="9" height="128"/>
|
||||
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z"/>
|
||||
</g>
|
||||
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="#E6F2FF"/>
|
||||
<path d="M76 72 H124 C116 82 106 88 104 98 H96 C94 88 84 82 76 72 Z" fill="#FFCC00"/>
|
||||
<path d="M100 98 V150" stroke="#FFCC00" stroke-width="3"/>
|
||||
<path d="M68 163 C70 147 88 139 100 137 C112 139 130 147 132 163 Z" fill="#FFCC00"/>
|
||||
<path d="M73 44 C73 60 80 70 88 78" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M64 36 H136 C136 74 108 86 108 100 C108 114 136 126 136 164 H64 C64 126 92 114 92 100 C92 86 64 74 64 36 Z" fill="none"/>
|
||||
<rect x="46" y="36" width="9" height="128" fill="#A0632F"/>
|
||||
<rect x="145" y="36" width="9" height="128" fill="#A0632F"/>
|
||||
<rect x="38" y="18" width="124" height="18" rx="6" fill="#C8874A"/>
|
||||
<rect x="38" y="164" width="124" height="18" rx="6" fill="#C8874A"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".35">
|
||||
<rect x="46" y="22" width="40" height="4" rx="2"/>
|
||||
<rect x="46" y="168" width="40" height="4" rx="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-5 100 100)">
|
||||
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<path d="M48 28 H152 Q182 28 182 58 V110 Q182 140 152 140 H88 L56 174 L64 140 H48 Q18 140 18 110 V58 Q18 28 48 28 Z" fill="#FFFFFF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g fill="#CC0000" fill-rule="evenodd">
|
||||
<polygon transform="translate(44 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
|
||||
<path transform="translate(79 0)" d="M18 59 H24 A18 18 0 0 1 42 77 V91 A18 18 0 0 1 24 109 H18 A18 18 0 0 1 0 91 V77 A18 18 0 0 1 18 59 Z M19 71 H23 A7 7 0 0 1 30 78 V90 A7 7 0 0 1 23 97 H19 A7 7 0 0 1 12 90 V78 A7 7 0 0 1 19 71 Z"/>
|
||||
<polygon transform="translate(128 0)" points="0,59 12,59 12,97 28,97 28,109 0,109"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-8 100 108)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<rect x="22" y="56" width="156" height="104" rx="8"/>
|
||||
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="22" y="56" width="156" height="104" rx="8" fill="#FFE0EC"/>
|
||||
<path d="M25 156 L84 112 M175 156 L116 112" fill="none" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M22 64 Q22 56 30 56 H170 Q178 56 178 64 L100 120 Z" fill="#FFC9DC"/>
|
||||
<path d="M79.6 103.8 C79.6 90.2 100 90.2 100 103.8 C100 90.2 120.4 90.2 120.4 103.8 C120.4 117.4 100 127.6 100 134.4 C100 127.6 79.6 117.4 79.6 103.8 Z" fill="#E0245E"/>
|
||||
<path d="M138 34.6 C138 25.8 151.2 25.8 151.2 34.6 C151.2 25.8 164.4 25.8 164.4 34.6 C164.4 43.4 151.2 50 151.2 54.4 C151.2 50 138 43.4 138 34.6 Z" fill="#FF99BB"/>
|
||||
</g>
|
||||
<ellipse cx="88" cy="100" rx="3" ry="5" fill="#fff" opacity=".6" transform="rotate(-30 88 100)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z"/>
|
||||
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
|
||||
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
|
||||
</g>
|
||||
<path d="M83.7 24.4 A80 80 0 1 0 167.1 131.7 A68 68 0 0 1 83.7 24.4 Z" fill="#FFE680" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<ellipse cx="30" cy="100" rx="5" ry="20" fill="#fff" opacity=".6" transform="rotate(8 30 100)"/>
|
||||
<g fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<polygon points="108.3,99.2 111.3,109.4 121.3,112.8 112.6,118.8 112.5,129.4 104.1,122.9 94,126.1 97.6,116.1 91.4,107.5 102,107.8"/>
|
||||
<polygon points="76.8,70.5 80.5,76 87.2,76 83.1,81.3 85.1,87.6 78.9,85.4 73.5,89.2 73.7,82.6 68.4,78.7 74.7,76.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="translate(-15.3 -15.3) scale(1.15)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
|
||||
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
|
||||
<rect x="73" y="54" width="10" height="96"/>
|
||||
<rect x="149" y="38" width="10" height="96"/>
|
||||
<polygon points="73,54 159,38 159,62 73,78"/>
|
||||
</g>
|
||||
<g fill="#333" stroke="#333" stroke-width="3.5" stroke-linejoin="round">
|
||||
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
|
||||
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
|
||||
<rect x="73" y="54" width="10" height="96"/>
|
||||
<rect x="149" y="38" width="10" height="96"/>
|
||||
<polygon points="73,54 159,38 159,62 73,78"/>
|
||||
</g>
|
||||
<g fill="#5B2C8F">
|
||||
<ellipse cx="64" cy="150" rx="20" ry="15" transform="rotate(-22 64 150)"/>
|
||||
<ellipse cx="140" cy="134" rx="20" ry="15" transform="rotate(-22 140 134)"/>
|
||||
<rect x="73" y="54" width="10" height="96"/>
|
||||
<rect x="149" y="38" width="10" height="96"/>
|
||||
<polygon points="73,54 159,38 159,62 73,78"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".55">
|
||||
<ellipse cx="56" cy="145" rx="7" ry="3.5" transform="rotate(-22 56 145)"/>
|
||||
<ellipse cx="132" cy="129" rx="7" ry="3.5" transform="rotate(-22 132 129)"/>
|
||||
<polygon points="80,58 152,45 152,49 80,62"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-10 100 100)">
|
||||
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<polygon points="100,12 114,29.4 133.7,18.7 140,40.1 162.2,37.8 159.9,60 181.3,66.3 170.6,86 188,100 170.6,114 181.3,133.7 159.9,140 162.2,162.2 140,159.9 133.7,181.3 114,170.6 100,188 86,170.6 66.3,181.3 60,159.9 37.8,162.2 40.1,140 18.7,133.7 29.4,114 12,100 29.4,86 18.7,66.3 40.1,60 37.8,37.8 60,40.1 66.3,18.7 86,29.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g fill="#CC0000">
|
||||
<polygon transform="translate(37.5 0)" points="0,121 0,79 10.4,79 22,101.6 22,79 32,79 32,121 21.6,121 10,98.4 10,121"/>
|
||||
<polygon transform="translate(73.5 0)" points="0,79 24,79 24,88 10,88 10,95.5 21,95.5 21,104.5 10,104.5 10,112 24,112 24,121 0,121"/>
|
||||
<polygon transform="translate(101.5 0)" points="0,79 11,79 14.9,99.3 17.5,91 28.5,91 31.1,99.3 35,79 46,79 38,121 27,121 23,108.4 19,121 8,121"/>
|
||||
<polygon transform="translate(151.5 0)" points="0,79 11,79 9,107 2,107"/>
|
||||
<rect x="152" y="111" width="10" height="10" rx="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<polygon points="186,18 30,84 104,122"/>
|
||||
<polygon points="186,18 104,122 130,110"/>
|
||||
<polygon points="104,122 130,110 112,156"/>
|
||||
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke-width="18.5"/>
|
||||
</g>
|
||||
<path d="M92 136 C76 156 52 168 40 152 C30 138 50 124 58 140 C66 156 44 178 18 182" fill="none" stroke="#3366FF" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="8 8"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<polygon points="104,122 130,110 112,156" fill="#8FA8E0"/>
|
||||
<polygon points="186,18 104,122 130,110" fill="#B8C8F0"/>
|
||||
<polygon points="186,18 30,84 104,122" fill="#EEF2FF"/>
|
||||
</g>
|
||||
<path d="M164 32 L56 80" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 914 B |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-10 100 112)">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round">
|
||||
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z"/>
|
||||
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M30 64 Q100 46 170 64 L104 184 Q100 190 96 184 Z" fill="#FFCC00"/>
|
||||
<g fill="#FFE680" stroke="none">
|
||||
<circle cx="56" cy="80" r="4.5"/><circle cx="146" cy="76" r="3.5"/><circle cx="108" cy="158" r="3.5"/><circle cx="98" cy="72" r="3"/>
|
||||
</g>
|
||||
<circle cx="72" cy="90" r="13" fill="#CC0000"/>
|
||||
<circle cx="126" cy="92" r="13" fill="#CC0000"/>
|
||||
<circle cx="99" cy="130" r="12" fill="#CC0000"/>
|
||||
<g fill="#33AA33" stroke-width="1.5">
|
||||
<ellipse cx="100" cy="98" rx="7" ry="3.5" transform="rotate(30 100 98)"/>
|
||||
<ellipse cx="80" cy="120" rx="6" ry="3" transform="rotate(-40 80 120)"/>
|
||||
<ellipse cx="116" cy="114" rx="6" ry="3" transform="rotate(50 116 114)"/>
|
||||
</g>
|
||||
<path d="M22 58 Q100 34 178 58 Q182 66 174 72 Q100 52 26 72 Q18 66 22 58 Z" fill="#E0A050"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity=".5">
|
||||
<circle cx="67" cy="85" r="3.5"/><circle cx="121" cy="87" r="3.5"/><circle cx="94" cy="125" r="3"/>
|
||||
</g>
|
||||
<path d="M44 54 Q100 40 156 54" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g transform="rotate(-6 100 106)">
|
||||
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round"/>
|
||||
<polygon points="100,18 125.9,70.4 183.7,78.8 141.8,119.6 151.7,177.2 100,150 48.3,177.2 58.2,119.6 16.3,78.8 74.1,70.4" fill="#FFCC00" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
|
||||
<polygon points="100,60 112.9,86.2 141.8,90.4 120.9,110.8 125.9,139.6 100,126 74.1,139.6 79.1,110.8 58.2,90.4 87.1,86.2" fill="#FFE066"/>
|
||||
<ellipse cx="93" cy="50" rx="4.5" ry="13" fill="#fff" opacity=".6" transform="rotate(25 93 50)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<circle cx="100" cy="100" r="62" fill="#fff" stroke="#fff" stroke-width="14"/>
|
||||
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#fff" stroke-width="27"/>
|
||||
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#333" stroke-width="13"/>
|
||||
<path d="M164 100 H184 M155.4 132 L172.7 142 M132 155.4 L142 172.7 M100 164 V184 M68 155.4 L58 172.7 M44.6 132 L27.3 142 M36 100 H16 M44.6 68 L27.3 58 M68 44.6 L58 27.3 M100 36 V16 M132 44.6 L142 27.3 M155.4 68 L172.7 58" stroke="#FF9900" stroke-width="9"/>
|
||||
</g>
|
||||
<circle cx="100" cy="100" r="52" fill="#FFCC00" stroke="#333" stroke-width="2"/>
|
||||
<ellipse cx="74" cy="68" rx="11" ry="5.5" fill="#fff" opacity=".6" transform="rotate(-38 74 68)"/>
|
||||
<circle cx="70" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
|
||||
<circle cx="130" cy="113" r="7" fill="#FF7AA2" opacity=".65"/>
|
||||
<ellipse cx="83" cy="92" rx="5" ry="8" fill="#333"/>
|
||||
<ellipse cx="117" cy="92" rx="5" ry="8" fill="#333"/>
|
||||
<path d="M78 110 Q100 130 122 110" fill="none" stroke="#333" stroke-width="4.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,36 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="16" stroke-linejoin="round">
|
||||
<rect x="18" y="96" width="40" height="88" rx="6"/>
|
||||
<rect x="50" y="92" width="70" height="88" rx="18"/>
|
||||
<rect x="92" y="88" width="68" height="26" rx="13"/>
|
||||
<rect x="92" y="112" width="74" height="26" rx="13"/>
|
||||
<rect x="92" y="136" width="70" height="24" rx="12"/>
|
||||
<rect x="92" y="158" width="62" height="22" rx="11"/>
|
||||
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
|
||||
</g>
|
||||
<g fill="#333" stroke="#333" stroke-width="4" stroke-linejoin="round">
|
||||
<rect x="50" y="92" width="70" height="88" rx="18"/>
|
||||
<rect x="92" y="88" width="68" height="26" rx="13"/>
|
||||
<rect x="92" y="112" width="74" height="26" rx="13"/>
|
||||
<rect x="92" y="136" width="70" height="24" rx="12"/>
|
||||
<rect x="92" y="158" width="62" height="22" rx="11"/>
|
||||
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
|
||||
</g>
|
||||
<g fill="#FFCC00">
|
||||
<rect x="50" y="92" width="70" height="88" rx="18"/>
|
||||
<rect x="92" y="88" width="68" height="26" rx="13"/>
|
||||
<rect x="92" y="112" width="74" height="26" rx="13"/>
|
||||
<rect x="92" y="136" width="70" height="24" rx="12"/>
|
||||
<rect x="92" y="158" width="62" height="22" rx="11"/>
|
||||
<path d="M56 104 C54 80 58 56 68 38 C76 24 96 22 98 38 C100 54 96 74 104 96 L100 104 Z"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M108 113 H152 M108 137 H155 M108 159 H149"/>
|
||||
<path d="M66 100 C76 104 90 103 98 96"/>
|
||||
</g>
|
||||
<ellipse cx="73" cy="52" rx="4" ry="13" fill="#fff" opacity=".55" transform="rotate(18 73 52)"/>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="18" y="96" width="40" height="88" rx="6" fill="#3366FF"/>
|
||||
<path d="M46 96 H52 Q58 96 58 102 V178 Q58 184 52 184 H46 Z" fill="#003399"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<g fill="#fff" stroke="#fff" stroke-width="14" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" fill="none" stroke-width="29"/>
|
||||
<rect x="44" y="24" width="112" height="12" rx="5"/>
|
||||
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z"/>
|
||||
<path d="M92 118 H108 L110 140 H90 Z"/>
|
||||
<rect x="78" y="138" width="44" height="10" rx="4"/>
|
||||
<rect x="56" y="148" width="88" height="34" rx="5"/>
|
||||
</g>
|
||||
<g fill="none" stroke-linecap="round">
|
||||
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#333" stroke-width="15"/>
|
||||
<path d="M56 42 C24 40 22 82 60 90 M144 42 C176 40 178 82 140 90" stroke="#FFCC00" stroke-width="11"/>
|
||||
</g>
|
||||
<g stroke="#333" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M92 118 H108 L110 140 H90 Z" fill="#E6B800"/>
|
||||
<path d="M50 30 H150 V62 C150 98 128 118 100 120 C72 118 50 98 50 62 Z" fill="#FFCC00"/>
|
||||
<rect x="44" y="24" width="112" height="12" rx="5" fill="#FFE066"/>
|
||||
<rect x="78" y="138" width="44" height="10" rx="4" fill="#FFCC00"/>
|
||||
<rect x="56" y="148" width="88" height="34" rx="5" fill="#003399"/>
|
||||
<rect x="76" y="157" width="48" height="15" rx="3" fill="#FFCC00" stroke-width="1.5"/>
|
||||
<polygon points="100,53 105,63.1 116.2,64.7 108.1,72.6 110,83.8 100,78.5 90,83.8 91.9,72.6 83.8,64.7 95,63.1" fill="#FFFFFF" stroke-width="1.5"/>
|
||||
</g>
|
||||
<ellipse cx="66" cy="66" rx="5.5" ry="20" fill="#fff" opacity=".5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -84,6 +84,37 @@ The text as sent:
|
||||
Re-notify if the source moves to a different location. Notification is about
|
||||
where the code is, so a new commit needs nothing; a new URL does.
|
||||
|
||||
## The re-notification: sent 2026-09-28
|
||||
|
||||
On 28 September 2026 the source moved from `gitlab.com/paulhitt` to
|
||||
`gitlab.com/extant2000`. On the same day `flamenet-elligator2`, which holds the
|
||||
obfs4 bridge and the iPhone app's obfs4 transport, was published under MIT.
|
||||
Both changes call for a new notification, so one was sent.
|
||||
|
||||
Sent to both addresses on 2026-09-28 at 19:10 UTC, in one message from
|
||||
`security@flamenetmessenger.com`, and accepted by both:
|
||||
|
||||
- BIS, `crypt@bis.doc.gov`: `250 2.6.0 ... Queued mail for delivery`
|
||||
- NSA, `enc@nsa.gov`: `250 ok: Message 199757828 accepted`
|
||||
|
||||
The locations it gave:
|
||||
|
||||
https://gitlab.com/extant2000/flamenet-messenger-ios
|
||||
https://gitlab.com/extant2000/flamenet-messenger-android
|
||||
https://gitlab.com/extant2000/flamenet-relay
|
||||
https://gitlab.com/extant2000/flamenet-e2e
|
||||
https://gitlab.com/extant2000/flamenet-elligator2
|
||||
|
||||
It also listed the four earlier locations above and said they now redirect.
|
||||
The algorithm list repeats the one in the first notification, and adds what
|
||||
the obfs4 transport uses: the Elligator2 encoding of Curve25519 public keys,
|
||||
the ntor handshake (Curve25519 with HMAC-SHA256), XSalsa20-Poly1305 (NaCl
|
||||
secretbox) and SipHash-2-4.
|
||||
|
||||
The same rule applies from here: a new commit needs nothing, and a new
|
||||
location or a newly published repository with cryptography in it needs
|
||||
another notification.
|
||||
|
||||
## Open question worth an hour of a lawyer
|
||||
|
||||
Whether the binary Apple distributes counts as the object code corresponding
|
||||
|
||||
@@ -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/paulhitt/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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version:6.0
|
||||
import PackageDescription
|
||||
|
||||
/// Rules for the games played inside chats: chess, checkers, reversi and tic-tac-toe.
|
||||
///
|
||||
/// Both apps rebuild every game from its move log, so this package and the Android `:games`
|
||||
/// module have to agree move for move. `games-v1.json` holds the vectors both suites run.
|
||||
let package = Package(
|
||||
name: "FlamenetGames",
|
||||
platforms: [.iOS("26.0"), .macOS(.v13)],
|
||||
products: [
|
||||
.library(name: "FlamenetGames", targets: ["FlamenetGames"])
|
||||
],
|
||||
targets: [
|
||||
.target(name: "FlamenetGames"),
|
||||
.testTarget(
|
||||
name: "FlamenetGamesTests",
|
||||
dependencies: ["FlamenetGames"],
|
||||
resources: [.copy("Resources/games-v1.json")]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
/// An English draughts position on squares 1-32 in standard PDN numbering.
|
||||
///
|
||||
/// Keeps the plies since the last capture or man move and the positions in that stretch, for the
|
||||
/// 80-ply draw and threefold repetition.
|
||||
public struct CheckersPosition: Hashable, Sendable {
|
||||
/// Index 0 is square 1. First's (dark) men are 1 and kings 2; second's are -1 and -2.
|
||||
let squares: [Int8]
|
||||
public let sideToMove: Side
|
||||
let quietPlies: Int
|
||||
/// The positions since the last capture or man move, oldest first, ending with this one.
|
||||
let history: [Key]
|
||||
|
||||
struct Key: Hashable, Sendable {
|
||||
let squares: [Int8]
|
||||
let side: Side
|
||||
}
|
||||
|
||||
struct Move: Hashable, Sendable {
|
||||
/// The starting square, then every square the piece lands on (indexes, not numbers).
|
||||
let path: [Int]
|
||||
let captures: Bool
|
||||
}
|
||||
|
||||
init(squares: [Int8], side: Side, quietPlies: Int, earlier: [Key]) {
|
||||
self.squares = squares
|
||||
self.sideToMove = side
|
||||
self.quietPlies = quietPlies
|
||||
self.history = earlier + [Key(squares: squares, side: side)]
|
||||
}
|
||||
|
||||
static let start = CheckersPosition(
|
||||
setup: "B:W21,22,23,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,8,9,10,11,12")!
|
||||
|
||||
var repetitions: Int {
|
||||
let current = history[history.count - 1]
|
||||
return history.reduce(0) { $0 + ($1 == current ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// Every move the rules allow, without the automatic draws: all capturing sequences when any
|
||||
/// capture exists (not only the longest), otherwise all steps.
|
||||
func movesByTheRules() -> [Move] {
|
||||
let us: Int8 = sideToMove == .first ? 1 : -1
|
||||
var board = squares
|
||||
var path: [Int] = []
|
||||
var jumps: [Move] = []
|
||||
for from in 0..<32 where squares[from] != 0 && (squares[from] > 0) == (us > 0) {
|
||||
path = [from]
|
||||
CheckersRules.collectJumps(&board, from, squares[from], &path, &jumps)
|
||||
}
|
||||
if !jumps.isEmpty { return jumps }
|
||||
var steps: [Move] = []
|
||||
for from in 0..<32 where squares[from] != 0 && (squares[from] > 0) == (us > 0) {
|
||||
for heading in CheckersRules.headings(for: squares[from]) {
|
||||
let to = CheckersRules.neighbour[from][heading]
|
||||
if to >= 0 && squares[to] == 0 {
|
||||
steps.append(Move(path: [from, to], captures: false))
|
||||
}
|
||||
}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
}
|
||||
|
||||
extension CheckersPosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if movesByTheRules().isEmpty { return .win(sideToMove.opponent, .noMoves) }
|
||||
if quietPlies >= 80 { return .draw(.fortyMoves) }
|
||||
if repetitions >= 3 { return .draw(.repetition) }
|
||||
return .ongoing
|
||||
}
|
||||
|
||||
func legalMoveList() -> [Move] {
|
||||
quietPlies >= 80 || repetitions >= 3 ? [] : movesByTheRules()
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
move.path.map { String($0 + 1) }.joined(separator: move.captures ? "x" : "-")
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> CheckersPosition {
|
||||
var board = squares
|
||||
let piece = board[move.path[0]]
|
||||
board[move.path[0]] = 0
|
||||
if move.captures {
|
||||
for (from, to) in zip(move.path, move.path.dropFirst()) {
|
||||
let heading = (0..<4).first { CheckersRules.landing[from][$0] == to }!
|
||||
board[CheckersRules.neighbour[from][heading]] = 0
|
||||
}
|
||||
}
|
||||
let end = move.path[move.path.count - 1]
|
||||
board[end] = CheckersRules.crowns(piece, at: end) ? piece * 2 : piece
|
||||
let reset = move.captures || abs(piece) == 1
|
||||
return CheckersPosition(
|
||||
squares: board, side: sideToMove.opponent, quietPlies: reset ? 0 : quietPlies + 1,
|
||||
earlier: reset ? [] : history)
|
||||
}
|
||||
|
||||
/// `11-15` for a step, `15x24` or `15x24x31` for captures, squares 1-32 without leading zeros.
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
let step = text.contains("-")
|
||||
guard step != text.contains("x") else { return false }
|
||||
let parts = text.split(separator: step ? "-" : "x", omittingEmptySubsequences: false)
|
||||
guard step ? parts.count == 2 : parts.count >= 2 else { return false }
|
||||
return parts.allSatisfy { CheckersRules.number($0) != nil }
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
var cells: [Piece?] = []
|
||||
var names: [String?] = []
|
||||
for row in 0..<8 {
|
||||
for column in 0..<8 {
|
||||
// The first player sees the standard diagram turned round, own men at the bottom.
|
||||
guard let index = CheckersRules.index(row: 7 - row, column: 7 - column) else {
|
||||
cells.append(nil)
|
||||
names.append(nil)
|
||||
continue
|
||||
}
|
||||
let piece = squares[index]
|
||||
cells.append(
|
||||
piece == 0
|
||||
? nil
|
||||
: Piece(
|
||||
side: piece > 0 ? .first : .second, kind: abs(piece) == 2 ? .king : .man
|
||||
)
|
||||
)
|
||||
names.append(String(index + 1))
|
||||
}
|
||||
}
|
||||
return Board(rows: 8, columns: 8, cells: cells, squares: names)
|
||||
}
|
||||
|
||||
/// PDN FEN with the second (W) list first, as in `B:W21,...,32:B1,...,12`.
|
||||
var setup: String {
|
||||
func list(_ sign: Int8) -> String {
|
||||
(0..<32).compactMap { index -> String? in
|
||||
let piece = squares[index]
|
||||
guard piece != 0, (piece > 0) == (sign > 0) else { return nil }
|
||||
return (abs(piece) == 2 ? "K" : "") + String(index + 1)
|
||||
}.joined(separator: ",")
|
||||
}
|
||||
return "\(sideToMove == .first ? "B" : "W"):W\(list(-1)):B\(list(1))"
|
||||
}
|
||||
|
||||
/// Reads PDN FEN: the side to move (B first, W second), then both piece lists in either
|
||||
/// order, K marking a king. A man on the row where it would have been crowned is refused.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard fields.count == 3 else { return nil }
|
||||
let side: Side
|
||||
switch fields[0] {
|
||||
case "B": side = .first
|
||||
case "W": side = .second
|
||||
default: return nil
|
||||
}
|
||||
var board = [Int8](repeating: 0, count: 32)
|
||||
var listed: Set<Character> = []
|
||||
for field in fields[1...] {
|
||||
guard let colour = field.first, colour == "B" || colour == "W",
|
||||
listed.insert(colour).inserted
|
||||
else { return nil }
|
||||
let sign: Int8 = colour == "B" ? 1 : -1
|
||||
let items = field.dropFirst()
|
||||
if items.isEmpty { continue }
|
||||
for item in items.split(separator: ",", omittingEmptySubsequences: false) {
|
||||
let crowned = item.first == "K"
|
||||
guard let number = CheckersRules.number(crowned ? item.dropFirst() : item),
|
||||
board[number - 1] == 0
|
||||
else { return nil }
|
||||
let piece = crowned ? sign * 2 : sign
|
||||
guard !CheckersRules.crowns(piece, at: number - 1) else { return nil }
|
||||
board[number - 1] = piece
|
||||
}
|
||||
}
|
||||
self.init(squares: board, side: side, quietPlies: 0, earlier: [])
|
||||
}
|
||||
}
|
||||
|
||||
enum CheckersRules {
|
||||
/// Rows as in the standard diagram: row 0 holds squares 1-4, row 7 holds 29-32.
|
||||
static func row(_ index: Int) -> Int { index / 4 }
|
||||
|
||||
static func column(_ index: Int) -> Int {
|
||||
row(index) % 2 == 0 ? 2 * (index % 4) + 1 : 2 * (index % 4)
|
||||
}
|
||||
|
||||
static func index(row: Int, column: Int) -> Int? {
|
||||
guard row >= 0 && row < 8 && column >= 0 && column < 8 && (row + column) % 2 == 1 else {
|
||||
return nil
|
||||
}
|
||||
return row * 4 + column / 2
|
||||
}
|
||||
|
||||
/// (row, column) steps. Headings 0 and 1 lead toward row 7, where first's men are crowned.
|
||||
static let steps = [(1, -1), (1, 1), (-1, -1), (-1, 1)]
|
||||
|
||||
/// The square one step away in each heading, or -1 off the board.
|
||||
static let neighbour: [[Int]] = (0..<32).map { index in
|
||||
steps.map { step in
|
||||
CheckersRules.index(row: row(index) + step.0, column: column(index) + step.1) ?? -1
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a jump in each heading lands, or -1 off the board.
|
||||
static let landing: [[Int]] = (0..<32).map { index in
|
||||
(0..<4).map { heading in
|
||||
let next = neighbour[index][heading]
|
||||
return next < 0 ? -1 : neighbour[next][heading]
|
||||
}
|
||||
}
|
||||
|
||||
static func headings(for piece: Int8) -> [Int] {
|
||||
switch piece {
|
||||
case 1: return [0, 1]
|
||||
case -1: return [2, 3]
|
||||
default: return [0, 1, 2, 3]
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a man that ends on `index` is crowned there.
|
||||
static func crowns(_ piece: Int8, at index: Int) -> Bool {
|
||||
(piece == 1 && row(index) == 7) || (piece == -1 && row(index) == 0)
|
||||
}
|
||||
|
||||
/// A square number 1-32 written without leading zeros.
|
||||
static func number(_ text: Substring) -> Int? {
|
||||
let bytes = Array(text.utf8)
|
||||
guard (1...2).contains(bytes.count), bytes[0] != UInt8(ascii: "0"),
|
||||
bytes.allSatisfy({ (UInt8(ascii: "0")...UInt8(ascii: "9")).contains($0) }),
|
||||
let value = Int(text), (1...32).contains(value)
|
||||
else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Adds every complete capturing sequence for the piece on `at`. A sequence goes on while
|
||||
/// the piece can jump again, and stops where a man is crowned. Captured pieces come off as
|
||||
/// they are jumped; with one-square kings that is the same as lifting them at the end.
|
||||
static func collectJumps(
|
||||
_ board: inout [Int8], _ at: Int, _ piece: Int8, _ path: inout [Int],
|
||||
_ found: inout [CheckersPosition.Move]
|
||||
) {
|
||||
var jumped = false
|
||||
for heading in headings(for: piece) {
|
||||
let over = neighbour[at][heading]
|
||||
let to = landing[at][heading]
|
||||
guard to >= 0, board[to] == 0, board[over] != 0, (board[over] > 0) != (piece > 0)
|
||||
else { continue }
|
||||
jumped = true
|
||||
let taken = board[over]
|
||||
board[over] = 0
|
||||
board[at] = 0
|
||||
board[to] = piece
|
||||
path.append(to)
|
||||
if crowns(piece, at: to) {
|
||||
found.append(CheckersPosition.Move(path: path, captures: true))
|
||||
} else {
|
||||
collectJumps(&board, to, piece, &path, &found)
|
||||
}
|
||||
path.removeLast()
|
||||
board[to] = 0
|
||||
board[at] = piece
|
||||
board[over] = taken
|
||||
}
|
||||
if !jumped && path.count > 1 {
|
||||
found.append(CheckersPosition.Move(path: path, captures: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
/// A chess position.
|
||||
///
|
||||
/// Besides the placement it keeps what the rules need: castling rights, the en passant square
|
||||
/// (only while an en passant capture is legal, which is also what repetition compares), the
|
||||
/// clocks, and the positions since the last capture or pawn move, for threefold repetition.
|
||||
public struct ChessPosition: Hashable, Sendable {
|
||||
/// Square 0 is a1, 7 is h1, 63 is h8. First's (white) pieces are positive, second's negative.
|
||||
let squares: [Int8]
|
||||
public let sideToMove: Side
|
||||
let castling: UInt8
|
||||
/// -1 when no en passant capture is legal.
|
||||
let enPassant: Int
|
||||
let halfmoveClock: Int
|
||||
let fullmoveNumber: Int
|
||||
/// The positions since the last capture or pawn move, oldest first, ending with this one.
|
||||
let history: [Key]
|
||||
|
||||
/// What makes two positions the same for repetition.
|
||||
struct Key: Hashable, Sendable {
|
||||
let squares: [Int8]
|
||||
let side: Side
|
||||
let castling: UInt8
|
||||
let enPassant: Int
|
||||
}
|
||||
|
||||
struct Move: Hashable, Sendable {
|
||||
let from: Int
|
||||
let to: Int
|
||||
/// 0, or the code of the piece a pawn becomes.
|
||||
let promotion: Int8
|
||||
}
|
||||
|
||||
init(
|
||||
squares: [Int8], side: Side, castling: UInt8, enPassant: Int, clock: Int, fullmove: Int,
|
||||
earlier: [Key]
|
||||
) {
|
||||
self.squares = squares
|
||||
self.sideToMove = side
|
||||
self.castling = castling
|
||||
self.enPassant = enPassant
|
||||
self.halfmoveClock = clock
|
||||
self.fullmoveNumber = fullmove
|
||||
self.history =
|
||||
earlier + [Key(squares: squares, side: side, castling: castling, enPassant: enPassant)]
|
||||
}
|
||||
|
||||
static let start = ChessPosition(
|
||||
setup: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")!
|
||||
|
||||
/// Whether the side to move is in check.
|
||||
public var inCheck: Bool {
|
||||
ChessRules.isAttacked(squares, kingSquare(sign), by: -sign)
|
||||
}
|
||||
|
||||
var sign: Int8 { sideToMove == .first ? 1 : -1 }
|
||||
|
||||
func kingSquare(_ sign: Int8) -> Int {
|
||||
// Every position has exactly one king a side: setup refuses anything else.
|
||||
squares.firstIndex(of: sign * ChessRules.king)!
|
||||
}
|
||||
|
||||
var repetitions: Int {
|
||||
let current = history[history.count - 1]
|
||||
return history.reduce(0) { $0 + ($1 == current ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// K v K, K+B v K, K+N v K, and K+B v K+B with both bishops on one colour. Nothing else.
|
||||
var insufficientMaterial: Bool {
|
||||
var others: [(piece: Int8, square: Int)] = []
|
||||
for (square, piece) in squares.enumerated()
|
||||
where piece != 0 && abs(piece) != ChessRules.king {
|
||||
others.append((piece, square))
|
||||
if others.count > 2 { return false }
|
||||
}
|
||||
switch others.count {
|
||||
case 0:
|
||||
return true
|
||||
case 1:
|
||||
let kind = abs(others[0].piece)
|
||||
return kind == ChessRules.bishop || kind == ChessRules.knight
|
||||
default:
|
||||
let (a, b) = (others[0], others[1])
|
||||
return abs(a.piece) == ChessRules.bishop && abs(b.piece) == ChessRules.bishop
|
||||
&& (a.piece > 0) != (b.piece > 0)
|
||||
&& ChessRules.colour(a.square) == ChessRules.colour(b.square)
|
||||
}
|
||||
}
|
||||
|
||||
var drawnAutomatically: Bool {
|
||||
insufficientMaterial || halfmoveClock >= 100 || repetitions >= 3
|
||||
}
|
||||
|
||||
/// Every move the rules of movement allow, without the automatic draws.
|
||||
func movesByTheRules() -> [Move] {
|
||||
let us = sign
|
||||
let king = kingSquare(us)
|
||||
var board = squares
|
||||
var legal: [Move] = []
|
||||
for move in pseudoLegalMoves() {
|
||||
let moving = board[move.from]
|
||||
let taken = board[move.to]
|
||||
board[move.to] = move.promotion != 0 ? us * move.promotion : moving
|
||||
board[move.from] = 0
|
||||
var victim = -1
|
||||
if abs(moving) == ChessRules.pawn && move.to == enPassant && taken == 0
|
||||
&& move.to % 8 != move.from % 8
|
||||
{
|
||||
victim = move.to - 8 * Int(us)
|
||||
board[victim] = 0
|
||||
}
|
||||
let guarded = abs(moving) == ChessRules.king ? move.to : king
|
||||
if !ChessRules.isAttacked(board, guarded, by: -us) {
|
||||
legal.append(move)
|
||||
}
|
||||
board[move.from] = moving
|
||||
board[move.to] = taken
|
||||
if victim >= 0 { board[victim] = -us * ChessRules.pawn }
|
||||
}
|
||||
return legal
|
||||
}
|
||||
|
||||
/// Moves by the pieces' rules of movement, before checking the mover's king is safe.
|
||||
private func pseudoLegalMoves() -> [Move] {
|
||||
let us = sign
|
||||
var moves: [Move] = []
|
||||
moves.reserveCapacity(64)
|
||||
for from in 0..<64 {
|
||||
let piece = squares[from]
|
||||
guard piece != 0, (piece > 0) == (us > 0) else { continue }
|
||||
switch abs(piece) {
|
||||
case ChessRules.pawn: addPawnMoves(&moves, from)
|
||||
case ChessRules.knight: addSteps(&moves, from, ChessRules.knightSteps)
|
||||
case ChessRules.bishop: addSlides(&moves, from, ChessRules.diagonal)
|
||||
case ChessRules.rook: addSlides(&moves, from, ChessRules.straight)
|
||||
case ChessRules.queen:
|
||||
addSlides(&moves, from, ChessRules.straight)
|
||||
addSlides(&moves, from, ChessRules.diagonal)
|
||||
default:
|
||||
addSteps(&moves, from, ChessRules.kingSteps)
|
||||
addCastling(&moves, from)
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
private func addPawnMoves(_ moves: inout [Move], _ from: Int) {
|
||||
let us = sign
|
||||
let file = from % 8
|
||||
let rank = from / 8
|
||||
// A pawn never stands on its last rank, so the rank ahead is always on the board.
|
||||
let ahead = rank + Int(us)
|
||||
let promotes = ahead == (us > 0 ? 7 : 0)
|
||||
func add(_ to: Int) {
|
||||
if promotes {
|
||||
for piece in ChessRules.promotions {
|
||||
moves.append(Move(from: from, to: to, promotion: piece))
|
||||
}
|
||||
} else {
|
||||
moves.append(Move(from: from, to: to, promotion: 0))
|
||||
}
|
||||
}
|
||||
let one = ahead * 8 + file
|
||||
if squares[one] == 0 {
|
||||
add(one)
|
||||
let two = one + 8 * Int(us)
|
||||
if rank == (us > 0 ? 1 : 6) && squares[two] == 0 {
|
||||
moves.append(Move(from: from, to: two, promotion: 0))
|
||||
}
|
||||
}
|
||||
for step in [-1, 1] {
|
||||
let target = file + step
|
||||
guard target >= 0 && target < 8 else { continue }
|
||||
let to = ahead * 8 + target
|
||||
let there = squares[to]
|
||||
if there != 0 && (there > 0) != (us > 0) {
|
||||
add(to)
|
||||
} else if there == 0 && to == enPassant {
|
||||
moves.append(Move(from: from, to: to, promotion: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSteps(_ moves: inout [Move], _ from: Int, _ steps: [(Int, Int)]) {
|
||||
let file = from % 8
|
||||
let rank = from / 8
|
||||
for (df, dr) in steps {
|
||||
let f = file + df
|
||||
let r = rank + dr
|
||||
guard f >= 0 && f < 8 && r >= 0 && r < 8 else { continue }
|
||||
let there = squares[r * 8 + f]
|
||||
if there == 0 || (there > 0) != (sign > 0) {
|
||||
moves.append(Move(from: from, to: r * 8 + f, promotion: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSlides(_ moves: inout [Move], _ from: Int, _ directions: [(Int, Int)]) {
|
||||
let file = from % 8
|
||||
let rank = from / 8
|
||||
for (df, dr) in directions {
|
||||
var f = file + df
|
||||
var r = rank + dr
|
||||
while f >= 0 && f < 8 && r >= 0 && r < 8 {
|
||||
let there = squares[r * 8 + f]
|
||||
if there == 0 || (there > 0) != (sign > 0) {
|
||||
moves.append(Move(from: from, to: r * 8 + f, promotion: 0))
|
||||
}
|
||||
if there != 0 { break }
|
||||
f += df
|
||||
r += dr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addCastling(_ moves: inout [Move], _ home: Int) {
|
||||
let us = sign
|
||||
guard home == (us > 0 ? 4 : 60) else { return }
|
||||
let kingside = us > 0 ? ChessRules.whiteKingside : ChessRules.blackKingside
|
||||
let queenside = us > 0 ? ChessRules.whiteQueenside : ChessRules.blackQueenside
|
||||
let rook = us * ChessRules.rook
|
||||
func attacked(_ square: Int) -> Bool {
|
||||
ChessRules.isAttacked(squares, square, by: -us)
|
||||
}
|
||||
// Not out of, through or into check. On the queen's side b1 (b8) only has to be empty.
|
||||
if castling & kingside != 0 && squares[home + 1] == 0 && squares[home + 2] == 0
|
||||
&& squares[home + 3] == rook && !attacked(home) && !attacked(home + 1)
|
||||
&& !attacked(home + 2)
|
||||
{
|
||||
moves.append(Move(from: home, to: home + 2, promotion: 0))
|
||||
}
|
||||
if castling & queenside != 0 && squares[home - 1] == 0 && squares[home - 2] == 0
|
||||
&& squares[home - 3] == 0 && squares[home - 4] == rook && !attacked(home)
|
||||
&& !attacked(home - 1) && !attacked(home - 2)
|
||||
{
|
||||
moves.append(Move(from: home, to: home - 2, promotion: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChessPosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if movesByTheRules().isEmpty {
|
||||
return inCheck ? .win(sideToMove.opponent, .checkmate) : .draw(.stalemate)
|
||||
}
|
||||
if insufficientMaterial { return .draw(.insufficientMaterial) }
|
||||
if halfmoveClock >= 100 { return .draw(.fiftyMoves) }
|
||||
if repetitions >= 3 { return .draw(.repetition) }
|
||||
return .ongoing
|
||||
}
|
||||
|
||||
func legalMoveList() -> [Move] {
|
||||
drawnAutomatically ? [] : movesByTheRules()
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
var text = ChessRules.name(move.from) + ChessRules.name(move.to)
|
||||
if move.promotion != 0 {
|
||||
text.append(ChessRules.letters[Int(move.promotion) - 1])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> ChessPosition {
|
||||
let us = sign
|
||||
var board = squares
|
||||
let moving = board[move.from]
|
||||
let kind = abs(moving)
|
||||
var captured = board[move.to] != 0
|
||||
board[move.to] = move.promotion != 0 ? us * move.promotion : moving
|
||||
board[move.from] = 0
|
||||
if kind == ChessRules.pawn && move.to == enPassant && !captured
|
||||
&& move.to % 8 != move.from % 8
|
||||
{
|
||||
board[move.to - 8 * Int(us)] = 0
|
||||
captured = true
|
||||
}
|
||||
if kind == ChessRules.king && abs(move.to - move.from) == 2 {
|
||||
// Castling: the rook lands on the square the king crossed.
|
||||
let (rookFrom, rookTo) =
|
||||
move.to > move.from
|
||||
? (move.from + 3, move.from + 1) : (move.from - 4, move.from - 1)
|
||||
board[rookTo] = board[rookFrom]
|
||||
board[rookFrom] = 0
|
||||
}
|
||||
var rights = castling
|
||||
if kind == ChessRules.king {
|
||||
rights &=
|
||||
us > 0
|
||||
? ~(ChessRules.whiteKingside | ChessRules.whiteQueenside)
|
||||
: ~(ChessRules.blackKingside | ChessRules.blackQueenside)
|
||||
}
|
||||
rights &= ~ChessRules.rightTiedTo(move.from) & ~ChessRules.rightTiedTo(move.to)
|
||||
var target = -1
|
||||
if kind == ChessRules.pawn && abs(move.to - move.from) == 16 {
|
||||
let skipped = (move.from + move.to) / 2
|
||||
if ChessRules.enPassantIsLegal(board, target: skipped, by: -us) {
|
||||
target = skipped
|
||||
}
|
||||
}
|
||||
let reset = captured || kind == ChessRules.pawn
|
||||
return ChessPosition(
|
||||
squares: board, side: sideToMove.opponent, castling: rights, enPassant: target,
|
||||
clock: reset ? 0 : halfmoveClock + 1,
|
||||
fullmove: fullmoveNumber + (sideToMove == .second ? 1 : 0),
|
||||
earlier: reset ? [] : history)
|
||||
}
|
||||
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 4 || bytes.count == 5 else { return false }
|
||||
let files = UInt8(ascii: "a")...UInt8(ascii: "h")
|
||||
let ranks = UInt8(ascii: "1")...UInt8(ascii: "8")
|
||||
guard files.contains(bytes[0]), ranks.contains(bytes[1]), files.contains(bytes[2]),
|
||||
ranks.contains(bytes[3])
|
||||
else { return false }
|
||||
return bytes.count == 4 || "qrbn".utf8.contains(bytes[4])
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
var cells: [Piece?] = []
|
||||
var names: [String?] = []
|
||||
for row in 0..<8 {
|
||||
for column in 0..<8 {
|
||||
let square = (7 - row) * 8 + column
|
||||
let piece = squares[square]
|
||||
cells.append(
|
||||
piece == 0
|
||||
? nil
|
||||
: Piece(
|
||||
side: piece > 0 ? .first : .second,
|
||||
kind: ChessRules.kinds[Int(abs(piece)) - 1]))
|
||||
names.append(ChessRules.name(square))
|
||||
}
|
||||
}
|
||||
return Board(rows: 8, columns: 8, cells: cells, squares: names)
|
||||
}
|
||||
|
||||
/// FEN, with the en passant square shown only while the capture is legal.
|
||||
var setup: String {
|
||||
var text = ""
|
||||
for rank in stride(from: 7, through: 0, by: -1) {
|
||||
var empty = 0
|
||||
for file in 0..<8 {
|
||||
let piece = squares[rank * 8 + file]
|
||||
if piece == 0 {
|
||||
empty += 1
|
||||
continue
|
||||
}
|
||||
if empty > 0 { text += String(empty) }
|
||||
empty = 0
|
||||
let letter = ChessRules.letters[Int(abs(piece)) - 1]
|
||||
text.append(piece > 0 ? Character(letter.uppercased()) : letter)
|
||||
}
|
||||
if empty > 0 { text += String(empty) }
|
||||
if rank > 0 { text += "/" }
|
||||
}
|
||||
var rights = ""
|
||||
if castling & ChessRules.whiteKingside != 0 { rights += "K" }
|
||||
if castling & ChessRules.whiteQueenside != 0 { rights += "Q" }
|
||||
if castling & ChessRules.blackKingside != 0 { rights += "k" }
|
||||
if castling & ChessRules.blackQueenside != 0 { rights += "q" }
|
||||
let side = sideToMove == .first ? "w" : "b"
|
||||
let target = enPassant >= 0 ? ChessRules.name(enPassant) : "-"
|
||||
return
|
||||
"\(text) \(side) \(rights.isEmpty ? "-" : rights) \(target) \(halfmoveClock) \(fullmoveNumber)"
|
||||
}
|
||||
|
||||
/// Reads FEN (four or six fields). Refuses positions the rules cannot reach: a side without
|
||||
/// exactly one king, a pawn on the first or last rank, the side not to move in check, or an
|
||||
/// en passant square with no pawn that just made the double step.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
|
||||
guard fields.count == 6 || fields.count == 4 else { return nil }
|
||||
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard rows.count == 8 else { return nil }
|
||||
var board = [Int8](repeating: 0, count: 64)
|
||||
for (index, row) in rows.enumerated() {
|
||||
let rank = 7 - index
|
||||
var file = 0
|
||||
for character in row {
|
||||
if let gap = character.wholeNumberValue, character.isASCII, (1...8).contains(gap) {
|
||||
file += gap
|
||||
} else {
|
||||
guard file < 8, let code = ChessRules.code(character) else { return nil }
|
||||
board[rank * 8 + file] = code
|
||||
file += 1
|
||||
}
|
||||
guard file <= 8 else { return nil }
|
||||
}
|
||||
guard file == 8 else { return nil }
|
||||
}
|
||||
let side: Side
|
||||
switch fields[1] {
|
||||
case "w": side = .first
|
||||
case "b": side = .second
|
||||
default: return nil
|
||||
}
|
||||
var rights: UInt8 = 0
|
||||
if fields[2] != "-" {
|
||||
guard !fields[2].isEmpty else { return nil }
|
||||
for character in fields[2] {
|
||||
guard let bit = ChessRules.rightBits[character], rights & bit == 0 else {
|
||||
return nil
|
||||
}
|
||||
rights |= bit
|
||||
}
|
||||
}
|
||||
// A right without its king and rook at home could never be used; drop it.
|
||||
let rook = ChessRules.rook
|
||||
let king = ChessRules.king
|
||||
if board[4] != king || board[7] != rook { rights &= ~ChessRules.whiteKingside }
|
||||
if board[4] != king || board[0] != rook { rights &= ~ChessRules.whiteQueenside }
|
||||
if board[60] != -king || board[63] != -rook { rights &= ~ChessRules.blackKingside }
|
||||
if board[60] != -king || board[56] != -rook { rights &= ~ChessRules.blackQueenside }
|
||||
|
||||
guard board.filter({ $0 == king }).count == 1, board.filter({ $0 == -king }).count == 1
|
||||
else { return nil }
|
||||
for file in 0..<8
|
||||
where abs(board[file]) == ChessRules.pawn || abs(board[56 + file]) == ChessRules.pawn {
|
||||
return nil
|
||||
}
|
||||
let us: Int8 = side == .first ? 1 : -1
|
||||
guard let theirKing = board.firstIndex(of: -us * king),
|
||||
!ChessRules.isAttacked(board, theirKing, by: us)
|
||||
else { return nil }
|
||||
|
||||
var target = -1
|
||||
if fields[3] != "-" {
|
||||
guard let square = ChessRules.square(named: fields[3]),
|
||||
square / 8 == (us > 0 ? 5 : 2)
|
||||
else { return nil }
|
||||
// The pawn that stepped two squares stands in front of the target, its start is empty.
|
||||
let pushed = square - 8 * Int(us)
|
||||
let origin = square + 8 * Int(us)
|
||||
guard board[pushed] == -us * ChessRules.pawn, board[square] == 0, board[origin] == 0
|
||||
else { return nil }
|
||||
if ChessRules.enPassantIsLegal(board, target: square, by: us) {
|
||||
target = square
|
||||
}
|
||||
}
|
||||
var clock = 0
|
||||
var fullmove = 1
|
||||
if fields.count == 6 {
|
||||
guard let c = Int(fields[4]), c >= 0, let f = Int(fields[5]), f >= 1 else {
|
||||
return nil
|
||||
}
|
||||
clock = c
|
||||
fullmove = f
|
||||
}
|
||||
self.init(
|
||||
squares: board, side: side, castling: rights, enPassant: target, clock: clock,
|
||||
fullmove: fullmove, earlier: [])
|
||||
}
|
||||
}
|
||||
|
||||
enum ChessRules {
|
||||
static let pawn: Int8 = 1
|
||||
static let knight: Int8 = 2
|
||||
static let bishop: Int8 = 3
|
||||
static let rook: Int8 = 4
|
||||
static let queen: Int8 = 5
|
||||
static let king: Int8 = 6
|
||||
|
||||
static let whiteKingside: UInt8 = 1
|
||||
static let whiteQueenside: UInt8 = 2
|
||||
static let blackKingside: UInt8 = 4
|
||||
static let blackQueenside: UInt8 = 8
|
||||
static let rightBits: [Character: UInt8] = [
|
||||
"K": whiteKingside, "Q": whiteQueenside, "k": blackKingside, "q": blackQueenside,
|
||||
]
|
||||
|
||||
/// Indexed by piece code minus one.
|
||||
static let letters: [Character] = ["p", "n", "b", "r", "q", "k"]
|
||||
static let kinds: [PieceKind] = [.pawn, .knight, .bishop, .rook, .queen, .king]
|
||||
/// The order promotions are listed in.
|
||||
static let promotions: [Int8] = [queen, rook, bishop, knight]
|
||||
|
||||
/// (file, rank) steps.
|
||||
static let knightSteps = [
|
||||
(1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2),
|
||||
]
|
||||
static let kingSteps = [(0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1)]
|
||||
static let straight = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||
static let diagonal = [(1, 1), (-1, 1), (1, -1), (-1, -1)]
|
||||
|
||||
static func name(_ square: Int) -> String {
|
||||
let file = Character(UnicodeScalar(UInt8(ascii: "a") + UInt8(square % 8)))
|
||||
let rank = Character(UnicodeScalar(UInt8(ascii: "1") + UInt8(square / 8)))
|
||||
return String([file, rank])
|
||||
}
|
||||
|
||||
static func square(named text: Substring) -> Int? {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 2,
|
||||
(UInt8(ascii: "a")...UInt8(ascii: "h")).contains(bytes[0]),
|
||||
(UInt8(ascii: "1")...UInt8(ascii: "8")).contains(bytes[1])
|
||||
else { return nil }
|
||||
return Int(bytes[1] - UInt8(ascii: "1")) * 8 + Int(bytes[0] - UInt8(ascii: "a"))
|
||||
}
|
||||
|
||||
static func code(_ character: Character) -> Int8? {
|
||||
guard character.isASCII,
|
||||
let index = letters.firstIndex(of: Character(character.lowercased()))
|
||||
else { return nil }
|
||||
let code = Int8(index + 1)
|
||||
return character.isUppercase ? code : -code
|
||||
}
|
||||
|
||||
/// 0 for a dark square, 1 for a light one.
|
||||
static func colour(_ square: Int) -> Int {
|
||||
(square % 8 + square / 8) % 2
|
||||
}
|
||||
|
||||
/// The castling right lost when a piece leaves or is taken on this corner.
|
||||
static func rightTiedTo(_ square: Int) -> UInt8 {
|
||||
switch square {
|
||||
case 0: return whiteQueenside
|
||||
case 7: return whiteKingside
|
||||
case 56: return blackQueenside
|
||||
case 63: return blackKingside
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a piece of the side with `sign` (1 white, -1 black) attacks `square`.
|
||||
static func isAttacked(_ board: [Int8], _ square: Int, by sign: Int8) -> Bool {
|
||||
let file = square % 8
|
||||
let rank = square / 8
|
||||
// A pawn attacks the rank ahead of it, so a white attacker stands one rank lower.
|
||||
let pawnRank = rank - Int(sign)
|
||||
if pawnRank >= 0 && pawnRank < 8 {
|
||||
for step in [-1, 1] {
|
||||
let f = file + step
|
||||
if f >= 0 && f < 8 && board[pawnRank * 8 + f] == sign * pawn { return true }
|
||||
}
|
||||
}
|
||||
if reaches(board, file, rank, knightSteps, sign * knight)
|
||||
|| reaches(board, file, rank, kingSteps, sign * king)
|
||||
{
|
||||
return true
|
||||
}
|
||||
return slides(board, file, rank, straight, sign * rook, sign * queen)
|
||||
|| slides(board, file, rank, diagonal, sign * bishop, sign * queen)
|
||||
}
|
||||
|
||||
private static func reaches(
|
||||
_ board: [Int8], _ file: Int, _ rank: Int, _ steps: [(Int, Int)], _ piece: Int8
|
||||
) -> Bool {
|
||||
for (df, dr) in steps {
|
||||
let f = file + df
|
||||
let r = rank + dr
|
||||
if f >= 0 && f < 8 && r >= 0 && r < 8 && board[r * 8 + f] == piece { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether the first piece met along any direction is `slider` or `queen`.
|
||||
private static func slides(
|
||||
_ board: [Int8], _ file: Int, _ rank: Int, _ directions: [(Int, Int)], _ slider: Int8,
|
||||
_ queen: Int8
|
||||
) -> Bool {
|
||||
for (df, dr) in directions {
|
||||
var f = file + df
|
||||
var r = rank + dr
|
||||
while f >= 0 && f < 8 && r >= 0 && r < 8 {
|
||||
let piece = board[r * 8 + f]
|
||||
if piece != 0 {
|
||||
if piece == slider || piece == queen { return true }
|
||||
break
|
||||
}
|
||||
f += df
|
||||
r += dr
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether the side with `sign` can legally capture en passant onto `target`.
|
||||
static func enPassantIsLegal(_ board: [Int8], target: Int, by sign: Int8) -> Bool {
|
||||
let victim = target - 8 * Int(sign)
|
||||
guard let king = board.firstIndex(of: sign * self.king) else { return false }
|
||||
for step in [-1, 1] {
|
||||
let file = victim % 8 + step
|
||||
guard file >= 0 && file < 8 else { continue }
|
||||
let from = victim - victim % 8 + file
|
||||
guard board[from] == sign * pawn else { continue }
|
||||
var after = board
|
||||
after[target] = sign * pawn
|
||||
after[from] = 0
|
||||
after[victim] = 0
|
||||
if !isAttacked(after, king, by: -sign) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/// The games played inside chats, by their wire names (DESIGN.md section 2).
|
||||
public enum GameType: String, CaseIterable, Hashable, Sendable {
|
||||
case chess
|
||||
case checkers
|
||||
case reversi
|
||||
case tictactoe
|
||||
|
||||
/// The longest log accepted for this game. A longer one is refused before any of it is played.
|
||||
public var maxPlies: Int {
|
||||
switch self {
|
||||
case .chess: return 600
|
||||
case .checkers: return 400
|
||||
case .reversi: return 130
|
||||
case .tictactoe: return 9
|
||||
}
|
||||
}
|
||||
|
||||
/// The side that played entry `index` (zero-based) of a log played from the start.
|
||||
///
|
||||
/// Every entry, a reversi `pass` included, is one ply by the side to move, so the sides
|
||||
/// alternate and entry 0 is always `first`'s. A receiver checks each new entry in a peer's log
|
||||
/// against the peer's side with this.
|
||||
public func side(ofPly index: Int) -> Side {
|
||||
index % 2 == 0 ? .first : .second
|
||||
}
|
||||
}
|
||||
|
||||
/// Who plays. `first` is chess white, checkers dark, reversi black and tic-tac-toe X.
|
||||
public enum Side: String, CaseIterable, Hashable, Sendable {
|
||||
case first
|
||||
case second
|
||||
|
||||
public var opponent: Side { self == .first ? .second : .first }
|
||||
}
|
||||
|
||||
/// Why a game was won. The raw values are the names used in `games-v1.json`.
|
||||
public enum WinReason: String, Hashable, Sendable {
|
||||
case checkmate
|
||||
/// Checkers: the side to move has no legal move, which includes having no pieces left.
|
||||
case noMoves = "no-moves"
|
||||
/// Reversi: neither side can move and the winner has more discs.
|
||||
case moreDiscs = "more-discs"
|
||||
case threeInARow = "three-in-a-row"
|
||||
}
|
||||
|
||||
/// Why a game was drawn. The raw values are the names used in `games-v1.json`.
|
||||
public enum DrawReason: String, Hashable, Sendable {
|
||||
case stalemate
|
||||
case insufficientMaterial = "insufficient-material"
|
||||
/// Chess: 100 plies without a capture or a pawn move.
|
||||
case fiftyMoves = "fifty-moves"
|
||||
/// Checkers: 80 plies without a capture or a man move.
|
||||
case fortyMoves = "forty-moves"
|
||||
/// The same position for the third time.
|
||||
case repetition
|
||||
/// Reversi: neither side can move and the discs are level.
|
||||
case equalDiscs = "equal-discs"
|
||||
case fullBoard = "full-board"
|
||||
}
|
||||
|
||||
/// How a position stands. Resigning and agreed draws travel on the wire; only the natural ends
|
||||
/// that both sides compute from the log are here.
|
||||
public enum Outcome: Hashable, Sendable {
|
||||
case ongoing
|
||||
case win(Side, WinReason)
|
||||
case draw(DrawReason)
|
||||
|
||||
public var isOver: Bool { self != .ongoing }
|
||||
}
|
||||
|
||||
/// Why a move was refused.
|
||||
public enum MoveError: Error, Hashable, Sendable {
|
||||
/// Not written in the game's notation, for example `E2E4` or `11 - 15`.
|
||||
case malformed
|
||||
/// Written correctly but not legal here. Every move is illegal once the game is over.
|
||||
case illegal
|
||||
}
|
||||
|
||||
/// Why a log could not be replayed.
|
||||
public enum ReplayError: Error, Hashable, Sendable {
|
||||
/// The log is longer than `GameType.maxPlies` allows.
|
||||
case tooLong(plies: Int, max: Int)
|
||||
/// The entry at this zero-based index of the log was refused.
|
||||
case atPly(Int, MoveError)
|
||||
}
|
||||
|
||||
/// What a piece is. Checkers uses `man` and `king`, reversi `disc`, tic-tac-toe `mark`.
|
||||
public enum PieceKind: String, Hashable, Sendable {
|
||||
case pawn
|
||||
case knight
|
||||
case bishop
|
||||
case rook
|
||||
case queen
|
||||
case king
|
||||
case man
|
||||
case disc
|
||||
case mark
|
||||
}
|
||||
|
||||
/// One piece on the board.
|
||||
public struct Piece: Hashable, Sendable {
|
||||
public let side: Side
|
||||
public let kind: PieceKind
|
||||
|
||||
public init(side: Side, kind: PieceKind) {
|
||||
self.side = side
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/// A board for drawing. Row 0 is the top row as the first player sees the board: chess has rank
|
||||
/// 8 at the top, checkers has squares 29-32 there (dark's men start at the bottom), reversi has
|
||||
/// rank 1 there, tic-tac-toe has cells 1-3. Turn it round to show the second player's view.
|
||||
public struct Board: Hashable, Sendable {
|
||||
public let rows: Int
|
||||
public let columns: Int
|
||||
/// Row-major from the top-left.
|
||||
public let cells: [Piece?]
|
||||
/// The wire name of each cell (`e4`, `15`, `d3`, `5`), nil where nothing can stand.
|
||||
public let squares: [String?]
|
||||
|
||||
public func piece(row: Int, column: Int) -> Piece? {
|
||||
cells[row * columns + column]
|
||||
}
|
||||
|
||||
public func square(row: Int, column: Int) -> String? {
|
||||
squares[row * columns + column]
|
||||
}
|
||||
}
|
||||
|
||||
/// A position in one of the four games. Values are immutable; applying a move makes a new one.
|
||||
public enum GamePosition: Hashable, Sendable {
|
||||
case chess(ChessPosition)
|
||||
case checkers(CheckersPosition)
|
||||
case reversi(ReversiPosition)
|
||||
case tictactoe(TicTacToePosition)
|
||||
|
||||
public var type: GameType {
|
||||
switch self {
|
||||
case .chess: return .chess
|
||||
case .checkers: return .checkers
|
||||
case .reversi: return .reversi
|
||||
case .tictactoe: return .tictactoe
|
||||
}
|
||||
}
|
||||
|
||||
public var sideToMove: Side { rules.sideToMove }
|
||||
|
||||
public var board: Board { rules.board }
|
||||
|
||||
/// This position in its game's setup notation; see `Games.position(_:setup:)`.
|
||||
public var setup: String { rules.setup }
|
||||
|
||||
var rules: any GameRules {
|
||||
switch self {
|
||||
case .chess(let p): return p
|
||||
case .checkers(let p): return p
|
||||
case .reversi(let p): return p
|
||||
case .tictactoe(let p): return p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules engine for the chat games.
|
||||
///
|
||||
/// Moves are strings in the wire notation of DESIGN.md section 2. `apply` accepts exactly the
|
||||
/// strings `legalMoves` returns, so a log that one app wrote replays identically on the other.
|
||||
public enum Games {
|
||||
public static func start(_ type: GameType) -> GamePosition {
|
||||
switch type {
|
||||
case .chess: return .chess(.start)
|
||||
case .checkers: return .checkers(.start)
|
||||
case .reversi: return .reversi(.start)
|
||||
case .tictactoe: return .tictactoe(.start)
|
||||
}
|
||||
}
|
||||
|
||||
/// A position from a setup string, for tests and tools; the wire never carries one.
|
||||
///
|
||||
/// Chess takes FEN. Checkers takes PDN FEN such as `B:W21,22:BK1,9` (B is the first, dark
|
||||
/// side; K marks a king). Reversi takes eight rows of `x` (first), `o` and `-` from rank 1
|
||||
/// to rank 8 split by `/`, a space and the side to move, `x` or `o`; tic-tac-toe the same
|
||||
/// with three rows of three. Returns nil for anything malformed or impossible.
|
||||
public static func position(_ type: GameType, setup: String) -> GamePosition? {
|
||||
switch type {
|
||||
case .chess: return ChessPosition(setup: setup).map { .chess($0) }
|
||||
case .checkers: return CheckersPosition(setup: setup).map { .checkers($0) }
|
||||
case .reversi: return ReversiPosition(setup: setup).map { .reversi($0) }
|
||||
case .tictactoe: return TicTacToePosition(setup: setup).map { .tictactoe($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Every legal move, empty once the game is over.
|
||||
public static func legalMoves(_ position: GamePosition) -> [String] {
|
||||
position.rules.legalNotations()
|
||||
}
|
||||
|
||||
public static func apply(_ position: GamePosition, _ move: String) throws(MoveError)
|
||||
-> GamePosition
|
||||
{
|
||||
switch position {
|
||||
case .chess(let p): return .chess(try p.applying(notation: move))
|
||||
case .checkers(let p): return .checkers(try p.applying(notation: move))
|
||||
case .reversi(let p): return .reversi(try p.applying(notation: move))
|
||||
case .tictactoe(let p): return .tictactoe(try p.applying(notation: move))
|
||||
}
|
||||
}
|
||||
|
||||
public static func outcome(_ position: GamePosition) -> Outcome {
|
||||
position.rules.outcome
|
||||
}
|
||||
|
||||
/// The position after a whole log played from the start.
|
||||
public static func replay(_ type: GameType, _ log: [String]) throws(ReplayError)
|
||||
-> GamePosition
|
||||
{
|
||||
guard log.count <= type.maxPlies else {
|
||||
throw .tooLong(plies: log.count, max: type.maxPlies)
|
||||
}
|
||||
return try replay(from: start(type), log)
|
||||
}
|
||||
|
||||
/// The position after `moves` played from `position`. An error names the index in `moves`.
|
||||
public static func replay(from position: GamePosition, _ moves: [String]) throws(ReplayError)
|
||||
-> GamePosition
|
||||
{
|
||||
var current = position
|
||||
for (index, move) in moves.enumerated() {
|
||||
do {
|
||||
current = try apply(current, move)
|
||||
} catch {
|
||||
throw .atPly(index, error)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/// The number of move sequences of length `depth`, for checking move generation.
|
||||
public static func perft(_ position: GamePosition, depth: Int) -> Int {
|
||||
position.rules.perft(depth)
|
||||
}
|
||||
}
|
||||
|
||||
/// What each game's position type provides to `Games`.
|
||||
protocol GameRules: Hashable, Sendable {
|
||||
associatedtype Move
|
||||
|
||||
var sideToMove: Side { get }
|
||||
var board: Board { get }
|
||||
var setup: String { get }
|
||||
var outcome: Outcome { get }
|
||||
|
||||
/// The legal moves, empty once the game is over.
|
||||
func legalMoveList() -> [Move]
|
||||
func notation(_ move: Move) -> String
|
||||
func applying(_ move: Move) -> Self
|
||||
/// Whether `text` is written in the game's notation, legal or not.
|
||||
static func isWellFormed(_ text: String) -> Bool
|
||||
}
|
||||
|
||||
extension GameRules {
|
||||
func legalNotations() -> [String] {
|
||||
legalMoveList().map(notation)
|
||||
}
|
||||
|
||||
func applying(notation text: String) throws(MoveError) -> Self {
|
||||
guard Self.isWellFormed(text) else { throw .malformed }
|
||||
for move in legalMoveList() where notation(move) == text {
|
||||
return applying(move)
|
||||
}
|
||||
throw .illegal
|
||||
}
|
||||
|
||||
func perft(_ depth: Int) -> Int {
|
||||
if depth <= 0 { return 1 }
|
||||
let moves = legalMoveList()
|
||||
if depth == 1 { return moves.count }
|
||||
var total = 0
|
||||
for move in moves {
|
||||
total += applying(move).perft(depth - 1)
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/// A reversi position. Files a-h run left to right and ranks 1-8 top to bottom as the first
|
||||
/// player draws the board.
|
||||
public struct ReversiPosition: Hashable, Sendable {
|
||||
/// Index is row * 8 + column: row 0 is rank 1, column 0 is file a. First's (black) discs
|
||||
/// are 1, second's (white) -1.
|
||||
let squares: [Int8]
|
||||
public let sideToMove: Side
|
||||
|
||||
/// A square index, or `pass`.
|
||||
typealias Move = Int
|
||||
static let pass: Move = -1
|
||||
|
||||
static let start = ReversiPosition(
|
||||
setup: "--------/--------/--------/---ox---/---xo---/--------/--------/-------- x")!
|
||||
|
||||
var sign: Int8 { sideToMove == .first ? 1 : -1 }
|
||||
}
|
||||
|
||||
extension ReversiPosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if !legalMoveList().isEmpty { return .ongoing }
|
||||
let first = squares.filter { $0 == 1 }.count
|
||||
let second = squares.filter { $0 == -1 }.count
|
||||
if first == second { return .draw(.equalDiscs) }
|
||||
return .win(first > second ? .first : .second, .moreDiscs)
|
||||
}
|
||||
|
||||
/// Placements, or `pass` only when there are none and the opponent has one. Empty when
|
||||
/// neither side can place, which ends the game.
|
||||
func legalMoveList() -> [Move] {
|
||||
let mine = ReversiRules.placements(squares, sign)
|
||||
if !mine.isEmpty { return mine }
|
||||
return ReversiRules.placements(squares, -sign).isEmpty ? [] : [Self.pass]
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
move == Self.pass ? "pass" : ReversiRules.name(move)
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> ReversiPosition {
|
||||
guard move != Self.pass else {
|
||||
return ReversiPosition(squares: squares, sideToMove: sideToMove.opponent)
|
||||
}
|
||||
var board = squares
|
||||
board[move] = sign
|
||||
for flipped in ReversiRules.flips(squares, move, sign) {
|
||||
board[flipped] = sign
|
||||
}
|
||||
return ReversiPosition(squares: board, sideToMove: sideToMove.opponent)
|
||||
}
|
||||
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
text == "pass" || ReversiRules.square(named: text) != nil
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
Board(
|
||||
rows: 8, columns: 8,
|
||||
cells: squares.map {
|
||||
$0 == 0 ? nil : Piece(side: $0 > 0 ? .first : .second, kind: .disc)
|
||||
},
|
||||
squares: (0..<64).map { ReversiRules.name($0) })
|
||||
}
|
||||
|
||||
var setup: String {
|
||||
let rows = (0..<8).map { row in
|
||||
String(squares[row * 8..<row * 8 + 8].map { $0 == 1 ? "x" : $0 == -1 ? "o" : "-" })
|
||||
}
|
||||
return rows.joined(separator: "/") + (sideToMove == .first ? " x" : " o")
|
||||
}
|
||||
|
||||
/// Reads eight rows of `x`, `o` and `-` from rank 1 to rank 8 split by `/`, a space, and
|
||||
/// the side to move.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2 else { return nil }
|
||||
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard rows.count == 8, rows.allSatisfy({ $0.count == 8 }) else { return nil }
|
||||
var board: [Int8] = []
|
||||
for character in rows.joined() {
|
||||
switch character {
|
||||
case "x": board.append(1)
|
||||
case "o": board.append(-1)
|
||||
case "-": board.append(0)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
switch fields[1] {
|
||||
case "x": self.init(squares: board, sideToMove: .first)
|
||||
case "o": self.init(squares: board, sideToMove: .second)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReversiRules {
|
||||
/// (row, column) steps.
|
||||
static let steps = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||
|
||||
static func name(_ index: Int) -> String {
|
||||
let file = Character(UnicodeScalar(UInt8(ascii: "a") + UInt8(index % 8)))
|
||||
return String(file) + String(index / 8 + 1)
|
||||
}
|
||||
|
||||
static func square(named text: String) -> Int? {
|
||||
let bytes = Array(text.utf8)
|
||||
guard bytes.count == 2,
|
||||
(UInt8(ascii: "a")...UInt8(ascii: "h")).contains(bytes[0]),
|
||||
(UInt8(ascii: "1")...UInt8(ascii: "8")).contains(bytes[1])
|
||||
else { return nil }
|
||||
return Int(bytes[1] - UInt8(ascii: "1")) * 8 + Int(bytes[0] - UInt8(ascii: "a"))
|
||||
}
|
||||
|
||||
/// The discs a placement on `index` by the side with `sign` would turn over.
|
||||
static func flips(_ board: [Int8], _ index: Int, _ sign: Int8) -> [Int] {
|
||||
guard board[index] == 0 else { return [] }
|
||||
var turned: [Int] = []
|
||||
for (dr, dc) in steps {
|
||||
var row = index / 8 + dr
|
||||
var column = index % 8 + dc
|
||||
var line: [Int] = []
|
||||
while row >= 0 && row < 8 && column >= 0 && column < 8
|
||||
&& board[row * 8 + column] == -sign
|
||||
{
|
||||
line.append(row * 8 + column)
|
||||
row += dr
|
||||
column += dc
|
||||
}
|
||||
if !line.isEmpty && row >= 0 && row < 8 && column >= 0 && column < 8
|
||||
&& board[row * 8 + column] == sign
|
||||
{
|
||||
turned += line
|
||||
}
|
||||
}
|
||||
return turned
|
||||
}
|
||||
|
||||
static func placements(_ board: [Int8], _ sign: Int8) -> [Int] {
|
||||
(0..<64).filter { !flips(board, $0, sign).isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/// A tic-tac-toe position. Cells are named 1-9 row by row from the top-left; X moves first.
|
||||
public struct TicTacToePosition: Hashable, Sendable {
|
||||
/// Cell 0 is `1`. X (first) is 1, O (second) is -1.
|
||||
let cells: [Int8]
|
||||
public let sideToMove: Side
|
||||
|
||||
/// A cell index.
|
||||
typealias Move = Int
|
||||
|
||||
static let start = TicTacToePosition(cells: [Int8](repeating: 0, count: 9), sideToMove: .first)
|
||||
|
||||
static let lines = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6],
|
||||
]
|
||||
|
||||
var winner: Side? {
|
||||
for line in Self.lines where cells[line[0]] != 0 {
|
||||
if cells[line[0]] == cells[line[1]] && cells[line[1]] == cells[line[2]] {
|
||||
return cells[line[0]] > 0 ? .first : .second
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension TicTacToePosition: GameRules {
|
||||
var outcome: Outcome {
|
||||
if let winner { return .win(winner, .threeInARow) }
|
||||
return cells.contains(0) ? .ongoing : .draw(.fullBoard)
|
||||
}
|
||||
|
||||
func legalMoveList() -> [Move] {
|
||||
winner != nil ? [] : (0..<9).filter { cells[$0] == 0 }
|
||||
}
|
||||
|
||||
func notation(_ move: Move) -> String {
|
||||
String(move + 1)
|
||||
}
|
||||
|
||||
func applying(_ move: Move) -> TicTacToePosition {
|
||||
var next = cells
|
||||
next[move] = sideToMove == .first ? 1 : -1
|
||||
return TicTacToePosition(cells: next, sideToMove: sideToMove.opponent)
|
||||
}
|
||||
|
||||
static func isWellFormed(_ text: String) -> Bool {
|
||||
text.utf8.count == 1 && (UInt8(ascii: "1")...UInt8(ascii: "9")).contains(text.utf8.first!)
|
||||
}
|
||||
|
||||
var board: Board {
|
||||
Board(
|
||||
rows: 3, columns: 3,
|
||||
cells: cells.map {
|
||||
$0 == 0 ? nil : Piece(side: $0 > 0 ? .first : .second, kind: .mark)
|
||||
},
|
||||
squares: (1...9).map { String($0) })
|
||||
}
|
||||
|
||||
var setup: String {
|
||||
let rows = (0..<3).map { row in
|
||||
String(cells[row * 3..<row * 3 + 3].map { $0 == 1 ? "x" : $0 == -1 ? "o" : "-" })
|
||||
}
|
||||
return rows.joined(separator: "/") + (sideToMove == .first ? " x" : " o")
|
||||
}
|
||||
|
||||
/// Reads three rows of `x`, `o` and `-` split by `/`, a space, and the side to move, which
|
||||
/// must be the one the counts say; a board where both sides have a line is refused.
|
||||
init?(setup: String) {
|
||||
let fields = setup.split(separator: " ", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2 else { return nil }
|
||||
let rows = fields[0].split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard rows.count == 3, rows.allSatisfy({ $0.count == 3 }) else { return nil }
|
||||
var cells: [Int8] = []
|
||||
for character in rows.joined() {
|
||||
switch character {
|
||||
case "x": cells.append(1)
|
||||
case "o": cells.append(-1)
|
||||
case "-": cells.append(0)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
let xs = cells.filter { $0 == 1 }.count
|
||||
let os = cells.filter { $0 == -1 }.count
|
||||
let side: Side
|
||||
switch (fields[1], xs - os) {
|
||||
case ("x", 0): side = .first
|
||||
case ("o", 1): side = .second
|
||||
default: return nil
|
||||
}
|
||||
let lines = Self.lines.filter { line in
|
||||
cells[line[0]] != 0 && cells[line[0]] == cells[line[1]]
|
||||
&& cells[line[1]] == cells[line[2]]
|
||||
}
|
||||
guard Set(lines.map { cells[$0[0]] }).count < 2 else { return nil }
|
||||
self.init(cells: cells, sideToMove: side)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import FlamenetGames
|
||||
import XCTest
|
||||
|
||||
/// The parts of the API the vectors do not reach: names, caps, board orientation, setup refusals.
|
||||
final class GamesTests: XCTestCase {
|
||||
func testWireNames() {
|
||||
XCTAssertEqual(
|
||||
GameType.allCases.map(\.rawValue), ["chess", "checkers", "reversi", "tictactoe"])
|
||||
XCTAssertEqual(Side.allCases.map(\.rawValue), ["first", "second"])
|
||||
XCTAssertEqual(GameType.allCases.map(\.maxPlies), [600, 400, 130, 9])
|
||||
}
|
||||
|
||||
func testPliesAlternateFromFirst() {
|
||||
for type in GameType.allCases {
|
||||
XCTAssertEqual(
|
||||
(0..<4).map { type.side(ofPly: $0) }, [.first, .second, .first, .second])
|
||||
}
|
||||
}
|
||||
|
||||
func testTheCapIsCheckedBeforeAnyMove() {
|
||||
XCTAssertThrowsError(try Games.replay(.tictactoe, Array(repeating: "x", count: 10))) {
|
||||
XCTAssertEqual($0 as? ReplayError, .tooLong(plies: 10, max: 9))
|
||||
}
|
||||
}
|
||||
|
||||
func testApplyReportsWhichKindOfRefusal() throws {
|
||||
let start = Games.start(.chess)
|
||||
XCTAssertThrowsError(try Games.apply(start, "e2e5")) {
|
||||
XCTAssertEqual($0 as? MoveError, .illegal)
|
||||
}
|
||||
XCTAssertThrowsError(try Games.apply(start, "E2E4")) {
|
||||
XCTAssertEqual($0 as? MoveError, .malformed)
|
||||
}
|
||||
let moved = try Games.apply(start, "e2e4")
|
||||
XCTAssertEqual(start, Games.start(.chess), "applying a move left the old position alone")
|
||||
XCTAssertEqual(moved, try Games.replay(.chess, ["e2e4"]))
|
||||
XCTAssertEqual(moved.sideToMove, .second)
|
||||
}
|
||||
|
||||
func testChessBoardHasRankEightOnTop() throws {
|
||||
let board = Games.start(.chess).board
|
||||
XCTAssertEqual(board.rows, 8)
|
||||
XCTAssertEqual(board.square(row: 0, column: 0), "a8")
|
||||
XCTAssertEqual(board.piece(row: 0, column: 0), Piece(side: .second, kind: .rook))
|
||||
XCTAssertEqual(board.square(row: 7, column: 4), "e1")
|
||||
XCTAssertEqual(board.piece(row: 7, column: 4), Piece(side: .first, kind: .king))
|
||||
XCTAssertEqual(board.cells.compactMap { $0 }.count, 32)
|
||||
}
|
||||
|
||||
func testChessReportsCheck() throws {
|
||||
let mated = try Games.replay(.chess, ["f2f3", "e7e5", "g2g4", "d8h4"])
|
||||
guard case .chess(let position) = mated else { return XCTFail("not chess") }
|
||||
XCTAssertTrue(position.inCheck)
|
||||
}
|
||||
|
||||
func testCheckersBoardHasTheFirstPlayersMenAtTheBottom() {
|
||||
let board = Games.start(.checkers).board
|
||||
XCTAssertEqual(
|
||||
(0..<8).map { board.square(row: 7, column: $0) },
|
||||
["4", nil, "3", nil, "2", nil, "1", nil])
|
||||
XCTAssertEqual(
|
||||
(0..<8).map { board.square(row: 0, column: $0) },
|
||||
[nil, "32", nil, "31", nil, "30", nil, "29"])
|
||||
XCTAssertEqual(board.piece(row: 7, column: 0), Piece(side: .first, kind: .man))
|
||||
XCTAssertEqual(board.piece(row: 0, column: 1), Piece(side: .second, kind: .man))
|
||||
XCTAssertNil(board.piece(row: 4, column: 3))
|
||||
}
|
||||
|
||||
func testReversiBoardHasRankOneOnTop() {
|
||||
let board = Games.start(.reversi).board
|
||||
XCTAssertEqual(board.square(row: 0, column: 0), "a1")
|
||||
XCTAssertEqual(board.square(row: 3, column: 3), "d4")
|
||||
XCTAssertEqual(board.piece(row: 3, column: 3), Piece(side: .second, kind: .disc))
|
||||
XCTAssertEqual(board.piece(row: 3, column: 4), Piece(side: .first, kind: .disc))
|
||||
XCTAssertEqual(board.piece(row: 4, column: 3), Piece(side: .first, kind: .disc))
|
||||
XCTAssertEqual(board.piece(row: 4, column: 4), Piece(side: .second, kind: .disc))
|
||||
}
|
||||
|
||||
func testTicTacToeBoardIsNumberedRowByRow() throws {
|
||||
let board = try Games.replay(.tictactoe, ["5", "1"]).board
|
||||
XCTAssertEqual(board.squares, ["1", "2", "3", "4", "5", "6", "7", "8", "9"])
|
||||
XCTAssertEqual(board.piece(row: 1, column: 1), Piece(side: .first, kind: .mark))
|
||||
XCTAssertEqual(board.piece(row: 0, column: 0), Piece(side: .second, kind: .mark))
|
||||
}
|
||||
|
||||
func testImpossibleSetupsAreRefused() {
|
||||
let refused: [(GameType, String)] = [
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K2K w - - 0 1"), // two white kings
|
||||
(.chess, "4k2P/8/8/8/8/8/8/4K3 w - - 0 1"), // a pawn on the last rank
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K2R w - - 0 1 extra"),
|
||||
(.chess, "4k3/4R3/8/8/8/8/8/4K3 w - - 0 1"), // black in check with white to move
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K3 w - e6 0 1"), // en passant with no pawn that stepped
|
||||
(.chess, "4k3/8/8/8/8/8/8/4K3 w KK - 0 1"),
|
||||
(.checkers, "B:W21:B29"), // a dark man where it would have been crowned
|
||||
(.checkers, "B:W21,21:B1"),
|
||||
(.checkers, "B:W33:B1"),
|
||||
(.checkers, "B:W21:W1"),
|
||||
(.reversi, "--------/--------/--------/---ox---/---xo---/--------/--------/------- x"),
|
||||
(.reversi, "--------/--------/--------/---ox---/---xo---/--------/--------/-------- b"),
|
||||
(.tictactoe, "x--/---/--- x"), // X has moved, so O is to move
|
||||
(.tictactoe, "xxx/ooo/--- x"),
|
||||
]
|
||||
for (type, setup) in refused {
|
||||
XCTAssertNil(Games.position(type, setup: setup), setup)
|
||||
}
|
||||
}
|
||||
|
||||
func testSetupReadsBackTheStart() {
|
||||
for type in GameType.allCases {
|
||||
let start = Games.start(type)
|
||||
XCTAssertEqual(Games.position(type, setup: start.setup), start, type.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import FlamenetGames
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Runs `games-v1.json`. The Android `:games` module runs the same file, byte for byte, so the
|
||||
/// two engines agree on every vector or one of the suites is red.
|
||||
final class VectorTests: XCTestCase {
|
||||
struct Vectors: Decodable {
|
||||
let v: Int
|
||||
let perft: [Perft]
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
struct Perft: Decodable {
|
||||
let name: String
|
||||
let t: String
|
||||
let setup: String?
|
||||
let counts: [Int]
|
||||
let deep: [[Int]]?
|
||||
}
|
||||
|
||||
struct Case: Decodable {
|
||||
let name: String
|
||||
let t: String
|
||||
let setup: String?
|
||||
let log: [String]
|
||||
let expect: Expect
|
||||
}
|
||||
|
||||
struct Expect: Decodable {
|
||||
let error: String?
|
||||
let ply: Int?
|
||||
let outcome: String?
|
||||
let toMove: String?
|
||||
let legal: [String]?
|
||||
let includes: [String]?
|
||||
let excludes: [String]?
|
||||
let setup: String?
|
||||
let trace: [Int]?
|
||||
}
|
||||
|
||||
func loadVectors() throws -> Vectors {
|
||||
let url = try XCTUnwrap(Bundle.module.url(forResource: "games-v1", withExtension: "json"))
|
||||
return try JSONDecoder().decode(Vectors.self, from: Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func testPerftMatchesTheLiterature() throws {
|
||||
for entry in try loadVectors().perft {
|
||||
let position = try startingPosition(entry.t, entry.setup)
|
||||
for (index, expected) in entry.counts.enumerated() {
|
||||
assertPerft(entry.name, position, depth: index + 1, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The deeper published counts take seconds to minutes, so they run only on request.
|
||||
func testDeepPerft() throws {
|
||||
guard ProcessInfo.processInfo.environment["FLAMENET_GAMES_DEEP"] == "1" else {
|
||||
throw XCTSkip("set FLAMENET_GAMES_DEEP=1 to run the deep perft counts")
|
||||
}
|
||||
for entry in try loadVectors().perft {
|
||||
let position = try startingPosition(entry.t, entry.setup)
|
||||
for pair in entry.deep ?? [] {
|
||||
assertPerft(entry.name, position, depth: pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testEveryCase() throws {
|
||||
let cases = try loadVectors().cases
|
||||
for vector in cases {
|
||||
check(vector)
|
||||
}
|
||||
print("games-v1.json: \(cases.count) cases checked")
|
||||
}
|
||||
|
||||
/// Guards against a vectors file that lost entries and still parses.
|
||||
func testTheVectorsCoverEveryGame() throws {
|
||||
let vectors = try loadVectors()
|
||||
XCTAssertEqual(vectors.v, 1)
|
||||
XCTAssertGreaterThanOrEqual(vectors.cases.count, 40)
|
||||
for type in GameType.allCases {
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
vectors.cases.filter { $0.t == type.rawValue }.count, 10, type.rawValue)
|
||||
XCTAssertTrue(vectors.perft.contains { $0.t == type.rawValue }, type.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func assertPerft(_ name: String, _ position: GamePosition, depth: Int, _ expected: Int)
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
var count = 0
|
||||
let elapsed = clock.measure { count = Games.perft(position, depth: depth) }
|
||||
print("perft \(name) depth \(depth): \(count) in \(elapsed)")
|
||||
XCTAssertEqual(count, expected, "perft \(name) depth \(depth)")
|
||||
}
|
||||
|
||||
private func startingPosition(_ name: String, _ setup: String?) throws -> GamePosition {
|
||||
let type = try XCTUnwrap(GameType(rawValue: name), "no game called \(name)")
|
||||
guard let setup else { return Games.start(type) }
|
||||
return try XCTUnwrap(Games.position(type, setup: setup), "setup refused: \(setup)")
|
||||
}
|
||||
|
||||
private func check(_ vector: Case) {
|
||||
let name = vector.name
|
||||
guard let start = try? startingPosition(vector.t, vector.setup),
|
||||
let type = GameType(rawValue: vector.t)
|
||||
else { return XCTFail("\(name): cannot start") }
|
||||
let expect = vector.expect
|
||||
|
||||
let result: Result<GamePosition, ReplayError>
|
||||
do throws(ReplayError) {
|
||||
result = .success(
|
||||
vector.setup == nil
|
||||
? try Games.replay(type, vector.log) : try Games.replay(from: start, vector.log)
|
||||
)
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
XCTAssertEqual(describe(error), expectedError(expect), name)
|
||||
case .success(let position):
|
||||
XCTAssertNil(expect.error, "\(name): played through, expected \(expectedError(expect))")
|
||||
if let outcome = expect.outcome {
|
||||
XCTAssertEqual(describe(Games.outcome(position)), outcome, name)
|
||||
}
|
||||
if let toMove = expect.toMove {
|
||||
XCTAssertEqual(position.sideToMove.rawValue, toMove, name)
|
||||
}
|
||||
let legal = Games.legalMoves(position)
|
||||
if let exact = expect.legal {
|
||||
XCTAssertEqual(Set(legal), Set(exact), name)
|
||||
XCTAssertEqual(legal.count, exact.count, "\(name): duplicate moves")
|
||||
}
|
||||
for move in expect.includes ?? [] {
|
||||
XCTAssertTrue(legal.contains(move), "\(name): \(move) should be legal")
|
||||
}
|
||||
for move in expect.excludes ?? [] {
|
||||
XCTAssertFalse(legal.contains(move), "\(name): \(move) should not be legal")
|
||||
}
|
||||
if let setup = expect.setup {
|
||||
XCTAssertEqual(position.setup, setup, name)
|
||||
}
|
||||
}
|
||||
walk(vector, from: start)
|
||||
}
|
||||
|
||||
/// Plays the log one move at a time and checks what must hold at every step: the game is over
|
||||
/// exactly when there are no legal moves, `apply` accepts exactly the listed moves, the side
|
||||
/// to move is the one `side(ofPly:)` names, the setup string reads back unchanged, and the
|
||||
/// number of legal moves matches `trace` when the vector has one.
|
||||
private func walk(_ vector: Case, from start: GamePosition) {
|
||||
let name = vector.name
|
||||
let trace = vector.expect.trace
|
||||
if let trace {
|
||||
XCTAssertEqual(trace.count, vector.log.count + 1, "\(name): trace length")
|
||||
}
|
||||
var position = start
|
||||
for (index, move) in vector.log.enumerated() {
|
||||
let legal = Games.legalMoves(position)
|
||||
if let trace, index < trace.count {
|
||||
XCTAssertEqual(
|
||||
legal.count, trace[index], "\(name): legal moves before ply \(index)")
|
||||
}
|
||||
XCTAssertEqual(
|
||||
Games.outcome(position).isOver, legal.isEmpty, "\(name): before ply \(index)")
|
||||
if vector.setup == nil {
|
||||
XCTAssertEqual(position.sideToMove, position.type.side(ofPly: index), name)
|
||||
}
|
||||
XCTAssertEqual(
|
||||
Games.position(position.type, setup: position.setup)?.setup, position.setup,
|
||||
"\(name): setup round trip before ply \(index)")
|
||||
do {
|
||||
position = try Games.apply(position, move)
|
||||
XCTAssertTrue(legal.contains(move), "\(name): applied \(move), not listed")
|
||||
} catch {
|
||||
XCTAssertFalse(legal.contains(move), "\(name): refused \(move), but listed")
|
||||
return
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(
|
||||
Games.outcome(position).isOver, Games.legalMoves(position).isEmpty, "\(name): at end")
|
||||
if let trace, let last = trace.last {
|
||||
XCTAssertEqual(
|
||||
Games.legalMoves(position).count, last, "\(name): legal moves at the end")
|
||||
}
|
||||
}
|
||||
|
||||
private func describe(_ outcome: Outcome) -> String {
|
||||
switch outcome {
|
||||
case .ongoing: return "ongoing"
|
||||
case .win(let side, let reason): return "win \(side.rawValue) \(reason.rawValue)"
|
||||
case .draw(let reason): return "draw \(reason.rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
private func describe(_ error: ReplayError) -> String {
|
||||
switch error {
|
||||
case .tooLong: return "too-long"
|
||||
case .atPly(let ply, .malformed): return "malformed at \(ply)"
|
||||
case .atPly(let ply, .illegal): return "illegal at \(ply)"
|
||||
}
|
||||
}
|
||||
|
||||
private func expectedError(_ expect: Expect) -> String {
|
||||
guard let error = expect.error else { return "no error" }
|
||||
return error == "too-long" ? error : "\(error) at \(expect.ply ?? -1)"
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,9 @@ pipeline:
|
||||
|
||||
| Repo | What it is |
|
||||
|---|---|
|
||||
| [flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e) | The protocol: JS engine, specification, published vectors, the interop driver |
|
||||
| [flamenet-relay](https://gitlab.com/paulhitt/flamenet-relay) | The server |
|
||||
| [flamenet-messenger-android](https://gitlab.com/paulhitt/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/paulhitt/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>
|
||||
|
||||
@@ -18,6 +18,27 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<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>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>net.flamenet.messenger.hello</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>flamenetmessenger</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"pack": "flamenet.retro",
|
||||
"name": "FlameNet",
|
||||
"fallback": "🙂",
|
||||
"stickers": [
|
||||
{ "id": "flame", "glyph": "🔥", "name": "Flame", "image": "flame.png" },
|
||||
{ "id": "heart", "glyph": "❤️", "name": "Heart", "image": "heart.png" },
|
||||
{ "id": "thumbs-up", "glyph": "👍", "name": "Thumbs up", "image": "thumbs-up.png" },
|
||||
{ "id": "lol", "glyph": "😂", "name": "Laughing", "image": "lol.png" },
|
||||
{ "id": "brb", "glyph": "🔙", "name": "Back soon", "image": "brb.png" },
|
||||
{ "id": "new", "glyph": "🆕", "name": "New", "image": "new.png" },
|
||||
{ "id": "star", "glyph": "⭐", "name": "Star", "image": "star.png" },
|
||||
{ "id": "trophy", "glyph": "🏆", "name": "Trophy", "image": "trophy.png" },
|
||||
{ "id": "cake", "glyph": "🎂", "name": "Birthday cake", "image": "cake.png" },
|
||||
{ "id": "balloon", "glyph": "🎈", "name": "Balloon", "image": "balloon.png" },
|
||||
{ "id": "gift", "glyph": "🎁", "name": "Gift", "image": "gift.png" },
|
||||
{ "id": "flowers", "glyph": "💐", "name": "Flowers", "image": "flowers.png" },
|
||||
{ "id": "love-letter", "glyph": "💌", "name": "Love letter", "image": "love-letter.png" },
|
||||
{ "id": "music", "glyph": "🎶", "name": "Music", "image": "music.png" },
|
||||
{ "id": "sun", "glyph": "☀️", "name": "Sun", "image": "sun.png" },
|
||||
{ "id": "moon", "glyph": "🌙", "name": "Moon", "image": "moon.png" },
|
||||
{ "id": "coffee", "glyph": "☕", "name": "Coffee", "image": "coffee.png" },
|
||||
{ "id": "pizza", "glyph": "🍕", "name": "Pizza", "image": "pizza.png" },
|
||||
{ "id": "candle", "glyph": "🕯️", "name": "Candle", "image": "candle.png" },
|
||||
{ "id": "hourglass", "glyph": "⏳", "name": "Hourglass", "image": "hourglass.png" },
|
||||
{ "id": "computer", "glyph": "🖥️", "name": "Computer", "image": "computer.png" },
|
||||
{ "id": "floppy", "glyph": "💾", "name": "Floppy disk", "image": "floppy.png" },
|
||||
{ "id": "construction", "glyph": "🚧", "name": "Under construction", "image": "construction.png" },
|
||||
{ "id": "paper-plane", "glyph": "✈️", "name": "Paper plane", "image": "paper-plane.png" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -1,5 +1,59 @@
|
||||
{
|
||||
"originHash": "8d024ac8341fa904bae46c7ae1cf5e120defc5000f67fdbf85db0a6623703bfc",
|
||||
"pins": [],
|
||||
"version": 3
|
||||
}
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "d9a5b37470adc940d22c3bcd5ca6953a516b727f",
|
||||
"version" : "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
|
||||
"version" : "3.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "a931f2c1de8dd49381ce3bf2e279d033f68d8865",
|
||||
"version" : "2.102.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750",
|
||||
"version" : "1.8.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -80,11 +84,25 @@ final class AppEnvironment: ObservableObject {
|
||||
init(store: SessionStore = SessionStore()) {
|
||||
self.store = store
|
||||
// A previously chosen site wins over the bundled default.
|
||||
let config = store.config ?? SiteConfig.default
|
||||
// The stored site, or the default; a DEBUG test launch is kept off
|
||||
// production (see `SiteConfig.forThisLaunch`).
|
||||
let config = SiteConfig.forThisLaunch(stored: store.config)
|
||||
self.phase = .signedOut(config)
|
||||
buildGraph(for: config)
|
||||
// Ask the server what it serves before anything reads the answer.
|
||||
Task { @MainActor in
|
||||
// DESIGN decision 47. An account signed in on this phone opens to
|
||||
// its chats from what is here and connects behind them, so a
|
||||
// launch with no network is not a sign-in screen. The site's
|
||||
// settings were settled and stored when it was first signed in.
|
||||
if let user = self.store.username, let pwd = self.store.appPassword,
|
||||
let userID = self.store.userID
|
||||
{
|
||||
await self.openFromPhone(
|
||||
userID: userID, username: user, appPassword: pwd,
|
||||
config: SiteConfig.forThisLaunch(stored: self.store.config))
|
||||
return
|
||||
}
|
||||
await self.resolveAccountProvider(for: config.baseURL)
|
||||
await self.detectCapabilities(for: config.baseURL)
|
||||
// Restore only after the probe, so the restored session is built on
|
||||
@@ -92,9 +110,11 @@ final class AppEnvironment: ObservableObject {
|
||||
guard let user = self.store.username, let pwd = self.store.appPassword else { return }
|
||||
let settled =
|
||||
self.phase.siteBaseURL == config.baseURL
|
||||
? (self.store.config ?? config) : config
|
||||
? SiteConfig.forThisLaunch(stored: self.store.config ?? config) : config
|
||||
if let auth = self.auth, await auth.restore(username: user, appPassword: pwd) {
|
||||
if let info = auth.currentUser {
|
||||
// Kept, so the next launch can open without the relay.
|
||||
self.store.userID = info.userID
|
||||
// Stand up the inbox BEFORE flipping phase, so the chat
|
||||
// view never sees a transient `e2eInbox == nil` and falls
|
||||
// back to the legacy nonce-scraping path.
|
||||
@@ -107,6 +127,54 @@ final class AppEnvironment: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connecting behind a launch that opened from the phone.
|
||||
private var connectTask: Task<Void, Never>?
|
||||
|
||||
/// Open a signed-in account from the phone alone: the inbox from its own
|
||||
/// keys and history, the chats and the saved Today page with it, and then
|
||||
/// the relay in the background.
|
||||
private func openFromPhone(
|
||||
userID: Int, username: String, appPassword: String, config: SiteConfig
|
||||
) async {
|
||||
auth?.presentStoredCredential(username: username, appPassword: appPassword)
|
||||
await startE2E(
|
||||
userId: userID, username: username, appPassword: appPassword, config: config,
|
||||
connect: false)
|
||||
phase = .signedIn(config)
|
||||
connectTask?.cancel()
|
||||
connectTask = Task { [weak self] in
|
||||
await self?.connectInBackground(
|
||||
username: username, appPassword: appPassword, config: config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the relay who this is until it answers, waiting longer each time up
|
||||
/// to a minute. An answer finishes connecting; a refusal of the credential
|
||||
/// is the one outcome that goes back to sign-in, as it did before.
|
||||
private func connectInBackground(
|
||||
username: String, appPassword: String, config: SiteConfig
|
||||
) async {
|
||||
var wait: UInt64 = 1
|
||||
while !Task.isCancelled {
|
||||
guard case .signedIn = phase, let auth else { return }
|
||||
switch await auth.restoreOutcome(username: username, appPassword: appPassword) {
|
||||
case .restored:
|
||||
if let info = auth.currentUser { store.userID = info.userID }
|
||||
await e2eInbox?.connect()
|
||||
// Names and anything else read from the account, now there.
|
||||
objectWillChange.send()
|
||||
return
|
||||
case .refused:
|
||||
stopE2E()
|
||||
phase = .signedOut(config)
|
||||
return
|
||||
case .unreachable:
|
||||
try? await Task.sleep(nanoseconds: wait * 1_000_000_000)
|
||||
wait = min(wait * 2, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one fact that belongs beside a screen's title: who you are, and
|
||||
/// where. The Android header has carried it since the restyle.
|
||||
var headerSubtitle: String? {
|
||||
@@ -142,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
|
||||
@@ -170,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?
|
||||
@@ -185,15 +345,19 @@ final class AppEnvironment: ObservableObject {
|
||||
|
||||
/// Stand up the E2E inbox using the App-Password credential (HTTP Basic)
|
||||
/// the REST sign-in already established.
|
||||
private func startE2E(userId: Int, username: String, appPassword: String, config: SiteConfig)
|
||||
async
|
||||
{
|
||||
private func startE2E(
|
||||
userId: Int, username: String, appPassword: String, config: SiteConfig,
|
||||
connect: Bool = true
|
||||
) async {
|
||||
let client = E2EClient(
|
||||
siteBaseURL: config.baseURL, basicAuth: (username, appPassword),
|
||||
restPrefix: config.restPrefix)
|
||||
let store = KeychainE2EStore(userId: userId)
|
||||
// nil means we couldn't produce a usable signed prekey.
|
||||
guard let inbox = await E2EInbox.make(myUserId: userId, client: client, store: store) else {
|
||||
guard
|
||||
let inbox = await E2EInbox.make(
|
||||
myUserId: userId, client: client, store: store, connect: connect)
|
||||
else {
|
||||
return
|
||||
}
|
||||
// Our return address, before the first send.
|
||||
@@ -226,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,
|
||||
@@ -273,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
|
||||
@@ -289,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
|
||||
@@ -333,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"),
|
||||
@@ -379,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
|
||||
@@ -452,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
|
||||
}
|
||||
|
||||
@@ -580,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
|
||||
}
|
||||
@@ -646,6 +789,8 @@ final class AppEnvironment: ObservableObject {
|
||||
}
|
||||
|
||||
private func stopE2E() {
|
||||
connectTask?.cancel()
|
||||
connectTask = nil
|
||||
// Stop ringing before anything else goes.
|
||||
VoIPCallReceiver.shared.finish(reason: .failed)
|
||||
callManager?.teardown()
|
||||
@@ -674,6 +819,7 @@ final class AppEnvironment: ObservableObject {
|
||||
let info = try await auth.signIn(usernameOrEmail: usernameOrEmail, password: password)
|
||||
store.username = info.username
|
||||
store.appPassword = info.appPassword
|
||||
store.userID = info.userID
|
||||
await startE2E(
|
||||
userId: info.userID, username: info.username, appPassword: info.appPassword,
|
||||
config: config)
|
||||
@@ -704,6 +850,7 @@ final class AppEnvironment: ObservableObject {
|
||||
)
|
||||
store.username = info.username
|
||||
store.appPassword = info.appPassword
|
||||
store.userID = info.userID
|
||||
await startE2E(
|
||||
userId: info.userID, username: info.username, appPassword: info.appPassword,
|
||||
config: config)
|
||||
@@ -760,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."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,6 +983,7 @@ final class AppEnvironment: ObservableObject {
|
||||
|
||||
// 1.
|
||||
store.appPassword = nil
|
||||
store.userID = nil
|
||||
auth?.signOut()
|
||||
PushRegistrar.shared.signedOut()
|
||||
stopE2E()
|
||||
@@ -842,7 +992,11 @@ final class AppEnvironment: ObservableObject {
|
||||
filesIdentity = nil
|
||||
// Signing out keeps the chosen site: it is a property of the
|
||||
// installation, not of the session.
|
||||
phase = .signedOut(store.config ?? SiteConfig.default)
|
||||
phase = .signedOut(SiteConfig.forThisLaunch(stored: store.config))
|
||||
// The Today file and the weather picks go too, on every way out: the
|
||||
// sign-out screen, a device removed from the account, and a lost-device
|
||||
// wipe all come through here.
|
||||
TodayStore.forgetOnThisPhone()
|
||||
|
||||
guard let inbox else { return }
|
||||
Task { @MainActor in
|
||||
@@ -903,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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,12 @@ struct FlamenetMessengerApp: App {
|
||||
@StateObject private var lock = AppLock()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage(SettingsKeys.appearance) private var appearanceRaw = AppearanceMode.system.rawValue
|
||||
/// What opening a hello link came to, when it needs saying.
|
||||
@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") {
|
||||
@@ -111,7 +115,25 @@ struct FlamenetMessengerApp: App {
|
||||
WindowGroup {
|
||||
// Open straight to the wallet, on a keypair generated in memory.
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-FNMSGFilesPreview") {
|
||||
if let index = ProcessInfo.processInfo.arguments.firstIndex(of: "-FNMSGKindsPreview")
|
||||
{
|
||||
// Cards, events and stickers with fixture data, for screenshots.
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
ChatKindsPreview(mode: index + 1 < arguments.count ? arguments[index + 1] : "thread")
|
||||
.tint(Theme.primary)
|
||||
} else if let index = ProcessInfo.processInfo.arguments.firstIndex(
|
||||
of: "-FNMSGStoriesPreview")
|
||||
{
|
||||
// The stories feed, take-down and caption screens with
|
||||
// fixture data, for screenshots.
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
StoriesPreview(mode: index + 1 < arguments.count ? arguments[index + 1] : "feed")
|
||||
.tint(Theme.primary)
|
||||
// The appearance setting, as the app proper applies
|
||||
// it, so a test can look at both.
|
||||
.preferredColorScheme(
|
||||
(AppearanceMode(rawValue: appearanceRaw) ?? .system).colorScheme)
|
||||
} else if ProcessInfo.processInfo.arguments.contains("-FNMSGFilesPreview") {
|
||||
// FilesView reads AppEnvironment for the signed-in handle
|
||||
// and the wallet request.
|
||||
FilesBrowserPreview()
|
||||
@@ -148,11 +170,30 @@ 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
|
||||
}
|
||||
}
|
||||
// Hello links (SPEC §6.34), handed over by the relay's /hi page.
|
||||
.onOpenURL { url in openHelloLink(url) }
|
||||
.alert(
|
||||
linkNotice?.title ?? "",
|
||||
isPresented: Binding(
|
||||
get: { linkNotice != nil },
|
||||
set: { if !$0 { linkNotice = nil } }),
|
||||
presenting: linkNotice
|
||||
) { _ in
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { notice in
|
||||
Text(notice.message)
|
||||
}
|
||||
// The cover goes OVER everything, including any sheet, and
|
||||
// ignores the safe area -- a lock screen that leaves the
|
||||
// navigation bar readable has not covered the screen.
|
||||
@@ -191,11 +232,40 @@ struct FlamenetMessengerApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
extension FlamenetMessengerApp {
|
||||
/// `flamenetmessenger://hi#<address>`: Home's add-by-address screen,
|
||||
/// filled in, and nothing more. See `HelloLink.landing`.
|
||||
fileprivate func openHelloLink(_ url: URL) {
|
||||
let signedIn: Bool
|
||||
if case .signedIn = environment.phase { signedIn = true } else { signedIn = false }
|
||||
let landing = HelloLink.landing(for: url, signedIn: signedIn)
|
||||
if let address = landing.address { environment.pendingAddress = address }
|
||||
linkNotice = landing.notice
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes between the two top-level phases.
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var environment: AppEnvironment
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
// Screenshots of the Today tab, the directory and the hello link
|
||||
// card, with no account. See TodayScreensPreview.
|
||||
if let request = TodayScreensPreview.requested {
|
||||
TodayScreensPreview(request: request)
|
||||
} else if let seconds = CardOpensPreview.requested {
|
||||
// A sealed card opening on screen. See CardOpensPreview.
|
||||
CardOpensPreview(opensIn: seconds)
|
||||
} else {
|
||||
phases
|
||||
}
|
||||
#else
|
||||
phases
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var phases: some View {
|
||||
switch environment.phase {
|
||||
case .signedOut:
|
||||
SignInView()
|
||||
|
||||
@@ -92,6 +92,20 @@ final class NotificationManager {
|
||||
/// can tell "they have not seen this" from "they are reading it right now".
|
||||
static var visibleConversation: ConversationKey?
|
||||
|
||||
#if DEBUG
|
||||
/// Every message notice asked for, before any setting is consulted, so
|
||||
/// a test can prove what would have been said, and that an answer to an
|
||||
/// event said nothing at all. Debug builds only.
|
||||
static var testObserver: ((ConversationKey, String, Bool) -> Void)?
|
||||
/// Every message notice actually handed to the system, after every
|
||||
/// setting, mute and snooze has had its say. Debug builds only.
|
||||
static var testPostedObserver: ((ConversationKey, String, Bool) -> Void)?
|
||||
/// Every incoming-call banner the settings, mute and a snooze let
|
||||
/// through, by caller, before the check that the app is not in front,
|
||||
/// which a test host always is. Debug builds only.
|
||||
static var testCallBannerObserver: ((Int) -> Void)?
|
||||
#endif
|
||||
|
||||
/// Surface an incoming message.
|
||||
func postReplyReminder(fromBuddyID buddyID: Int, name: String?) {
|
||||
let d = UserDefaults.standard
|
||||
@@ -103,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]
|
||||
|
||||
@@ -115,20 +129,28 @@ final class NotificationManager {
|
||||
}
|
||||
|
||||
/// Announce a call as a CALL.
|
||||
func postIncomingCall(fromBuddyID buddyID: Int, fromName: String, video: Bool) {
|
||||
///
|
||||
/// - Parameter snoozed: the caller's chat is snoozed right now, which
|
||||
/// holds this banner exactly as mute does, until the snooze ends.
|
||||
/// CallKit's own ringing is not decided here.
|
||||
func postIncomingCall(
|
||||
fromBuddyID buddyID: Int, fromName: String, video: Bool, snoozed: Bool
|
||||
) {
|
||||
let d = UserDefaults.standard
|
||||
let pushOn = d.object(forKey: SettingsKeys.pushEnabled) as? Bool ?? true
|
||||
let msgOn = d.object(forKey: SettingsKeys.notifyNewMessage) as? Bool ?? true
|
||||
let preview = d.object(forKey: SettingsKeys.showPreviews) as? Bool ?? false
|
||||
let muted = Self.muteKey(for: buddyID).map { d.bool(forKey: $0) } ?? false
|
||||
let muted = (Self.muteKey(for: buddyID).map { d.bool(forKey: $0) } ?? false) || snoozed
|
||||
guard pushOn, msgOn, !muted else { return }
|
||||
#if DEBUG
|
||||
Self.testCallBannerObserver?(buddyID)
|
||||
#endif
|
||||
// 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]
|
||||
|
||||
@@ -147,11 +169,22 @@ final class NotificationManager {
|
||||
|
||||
/// - Parameter thread: the conversation this arrived in, a buddy, a
|
||||
/// group, or a federated address.
|
||||
/// - Parameter snoozed: the conversation is snoozed right now. A snooze
|
||||
/// holds its notices exactly as mute does, until it ends. Only a
|
||||
/// one-to-one chat can be snoozed, and only the inbox knows until when,
|
||||
/// so every caller says, rather than leaning on a default that forgets.
|
||||
/// - Parameter quiet: shown without a sound, for news that can wait, such
|
||||
/// as the other person's move in a game (§6.33).
|
||||
func postIncomingMessage(
|
||||
in thread: ConversationKey, fromBuddyID buddyID: Int,
|
||||
fromName: String, body: String,
|
||||
mentionsMe: Bool = false
|
||||
snoozed: Bool,
|
||||
mentionsMe: Bool = false,
|
||||
quiet: Bool = false
|
||||
) {
|
||||
#if DEBUG
|
||||
Self.testObserver?(thread, body, quiet)
|
||||
#endif
|
||||
let d = UserDefaults.standard
|
||||
let pushOn = d.object(forKey: SettingsKeys.pushEnabled) as? Bool ?? true
|
||||
let msgOn = d.object(forKey: SettingsKeys.notifyNewMessage) as? Bool ?? true
|
||||
@@ -161,7 +194,8 @@ final class NotificationManager {
|
||||
// Keyed on the CONVERSATION, not on who is talking in it, and hashed
|
||||
// so the key does not name it. A Keychain that cannot answer reads as
|
||||
// NOT muted: the alternative is a call that silently never rings.
|
||||
let muted = Self.muteKey(for: thread).map { d.bool(forKey: $0) } ?? false
|
||||
// A snooze is a mute that ends: "this chat just stops asking".
|
||||
let muted = (Self.muteKey(for: thread).map { d.bool(forKey: $0) } ?? false) || snoozed
|
||||
|
||||
// Not for the conversation you are already reading.
|
||||
let reading =
|
||||
@@ -180,13 +214,13 @@ final class NotificationManager {
|
||||
mentionsMe: mentionsMe,
|
||||
mentionsBreakMute: mentionsBreakMute)
|
||||
else { return }
|
||||
if sounds { AudioServicesPlaySystemSound(1007) }
|
||||
if sounds, !quiet { AudioServicesPlaySystemSound(1007) }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
// The name is part of the preview.
|
||||
content.title = preview ? fromName : "Flamenet"
|
||||
content.body = preview ? body : "New message"
|
||||
content.sound = .default
|
||||
content.body = preview ? body : String(localized: "New message")
|
||||
content.sound = quiet ? nil : .default
|
||||
content.userInfo = ["buddyID": buddyID]
|
||||
|
||||
// Says WHICH buddy and how long the body was, never the body.
|
||||
@@ -202,6 +236,9 @@ final class NotificationManager {
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
#if DEBUG
|
||||
Self.testPostedObserver?(thread, body, quiet)
|
||||
#endif
|
||||
center.add(req, withCompletionHandler: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,12 @@ enum ParentalControls {
|
||||
isEnabled && UserDefaults.standard.bool(forKey: callsDisabledKey)
|
||||
}
|
||||
|
||||
/// The Today tab, and the small-web directory reached from it, are not
|
||||
/// there. Not a gate a parent releases: they go whenever supervision is on
|
||||
/// (the owner's decision, DESIGN decision 31), because both lead straight
|
||||
/// out to the open web.
|
||||
static var todayHidden: Bool { isEnabled }
|
||||
|
||||
// MARK: - Setting and checking
|
||||
|
||||
static func enable(pin: String) {
|
||||
|
||||
@@ -94,6 +94,50 @@ final class AuthService {
|
||||
return account
|
||||
}
|
||||
|
||||
/// How a restore went. A relay that cannot be reached is not one that
|
||||
/// refused the credential, and only the second means signing in again.
|
||||
enum RestoreOutcome: Equatable {
|
||||
case restored
|
||||
/// The relay answered and turned the credential away.
|
||||
case refused
|
||||
/// No answer that says anything about the credential: no network, the
|
||||
/// relay down, or a failure on its side.
|
||||
case unreachable
|
||||
}
|
||||
|
||||
/// Restore a stored session, and say which way it went. On `unreachable`
|
||||
/// the credential stays presented, so whatever runs next uses it.
|
||||
func restoreOutcome(username: String, appPassword: String) async -> RestoreOutcome {
|
||||
client.setBasicAuth(username: username, appPassword: appPassword)
|
||||
do {
|
||||
currentUser = try await fetchMe()
|
||||
return .restored
|
||||
} catch {
|
||||
guard Self.isRefusal(error) else { return .unreachable }
|
||||
client.clearBasicAuth()
|
||||
return .refused
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an error is the relay refusing the credential, as opposed to
|
||||
/// the relay not being reached.
|
||||
static func isRefusal(_ error: Error) -> Bool {
|
||||
switch error as? MessengerError {
|
||||
case .ajaxFailure("bad_credentials")?, .unauthorized?, .refused?, .notAuthenticated?:
|
||||
return true
|
||||
case .httpStatus(let code)?:
|
||||
return code == 401 || code == 403
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Present the stored credential on every request from here on, without
|
||||
/// making one: what a launch with no network does before it can ask.
|
||||
func presentStoredCredential(username: String, appPassword: String) {
|
||||
client.setBasicAuth(username: username, appPassword: appPassword)
|
||||
}
|
||||
|
||||
/// Restore a previously-cached session without prompting the user.
|
||||
@discardableResult
|
||||
func restore(username: String, appPassword: String) async -> Bool {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,60 @@ struct SiteConfig: Codable, Equatable {
|
||||
messengerPagePath: "messenger/"
|
||||
)
|
||||
|
||||
/// Where a DEBUG test launch talks instead: a closed port on this
|
||||
/// machine, so nothing it does reaches a relay at all.
|
||||
static let testLoopback = SiteConfig(baseURL: URL(string: "http://127.0.0.1:9")!)
|
||||
|
||||
/// Hosts that are the production service. A test launch never talks to
|
||||
/// one unless that launch named it itself.
|
||||
static func isProduction(_ url: URL) -> Bool {
|
||||
guard let host = url.host?.lowercased() else { return false }
|
||||
return host == "flamenet.io" || host.hasSuffix(".flamenet.io")
|
||||
|| host == "flamenetmessenger.com" || host.hasSuffix(".flamenetmessenger.com")
|
||||
}
|
||||
|
||||
/// Whether this process is a test: the unit-test host, or the app launched
|
||||
/// by a UI suite, which says so in `FNMSG_TEST_LAUNCH`. Release builds are
|
||||
/// never one.
|
||||
static func isTestLaunch(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
#if DEBUG
|
||||
return environment["XCTestConfigurationFilePath"] != nil
|
||||
|| environment["FNMSG_TEST_LAUNCH"] == "1"
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The site this launch uses: the stored one, or the shipped default.
|
||||
///
|
||||
/// A test launch (DEBUG only) never ends up on production by default. Its
|
||||
/// default is `testLoopback`, and a stored production site, left behind by
|
||||
/// an earlier suite on the same simulator, is replaced by it too, unless
|
||||
/// this launch named that site itself with `-FNMSGServer`, which is how an
|
||||
/// opt-in live suite says it means it. The unit-test host has no way to
|
||||
/// name one. The shipped app is untouched: `isTestLaunch` is false there.
|
||||
static func forThisLaunch(
|
||||
stored: SiteConfig?,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
arguments: [String] = ProcessInfo.processInfo.arguments
|
||||
) -> SiteConfig {
|
||||
let chosen = stored ?? .default
|
||||
guard isTestLaunch(environment: environment), isProduction(chosen.baseURL) else {
|
||||
return chosen
|
||||
}
|
||||
let unitHost = environment["XCTestConfigurationFilePath"] != nil
|
||||
if !unitHost, let index = arguments.firstIndex(of: "-FNMSGServer"),
|
||||
index + 1 < arguments.count,
|
||||
let named = makeBaseURL(from: arguments[index + 1]),
|
||||
named.host?.lowercased() == chosen.baseURL.host?.lowercased()
|
||||
{
|
||||
return chosen
|
||||
}
|
||||
return .testLoopback
|
||||
}
|
||||
|
||||
/// Where to send somebody who wants to pay for the project rather than
|
||||
/// for the relay they happen to be signed in to.
|
||||
///
|
||||
@@ -87,8 +141,17 @@ struct SiteConfig: Codable, Equatable {
|
||||
/// deliberately follow the signed-in relay, because on a self-hosted one
|
||||
/// the operator's policy is the one that applies. A donation is the other
|
||||
/// way round: a self-hoster's users paying their operator's hosting bill
|
||||
/// is not what this row offers, and their relay has no form on that page
|
||||
/// is not what this row offered, and their relay has no form on that page
|
||||
/// unless they configured a processor.
|
||||
///
|
||||
/// NOTHING LINKS TO THIS, deliberately, and it is not dead code to delete.
|
||||
/// The Profile row that opened it was removed on 2026-09-25 after App
|
||||
/// Review rejected the build under guideline 3.1.1: a link out to a
|
||||
/// donation page that funds the app's own development is the thing that
|
||||
/// guideline covers. Kept because the address is still correct and still
|
||||
/// serves a page, and because a constant with this comment on it is a
|
||||
/// better answer to "why is there no Donate row" than an empty file and a
|
||||
/// git log. See the note at the removal site in ProfileView.
|
||||
static let donateURL = URL(string: "https://flamenetmessenger.com/donate")!
|
||||
|
||||
/// Where a crash report goes when somebody chooses to send one.
|
||||
@@ -152,6 +215,7 @@ struct SessionStore {
|
||||
private let configKey = "fnmsg.siteConfig"
|
||||
private let usernameKey = "fnmsg.username"
|
||||
private let appPasswordKey = "fnmsg.appPassword"
|
||||
private let userIDKey = "fnmsg.userID"
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -176,6 +240,20 @@ struct SessionStore {
|
||||
nonmutating set { defaults.set(newValue, forKey: usernameKey) }
|
||||
}
|
||||
|
||||
/// The account's user id, kept so a launch with no network can open this
|
||||
/// account's own chats from the phone (DESIGN decision 47). Not a secret:
|
||||
/// it names the account to the relay, and the relay already knows it.
|
||||
var userID: Int? {
|
||||
get { (defaults.object(forKey: userIDKey) as? NSNumber)?.intValue }
|
||||
nonmutating set {
|
||||
if let newValue {
|
||||
defaults.set(newValue, forKey: userIDKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: userIDKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// the relay Application Password, the account credential, so it lives in
|
||||
/// the Keychain (device-only, no iCloud sync).
|
||||
var appPassword: String? {
|
||||
@@ -203,6 +281,7 @@ struct SessionStore {
|
||||
defaults.removeObject(forKey: configKey)
|
||||
defaults.removeObject(forKey: usernameKey)
|
||||
defaults.removeObject(forKey: appPasswordKey)
|
||||
defaults.removeObject(forKey: userIDKey)
|
||||
CredentialKeychain.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -324,7 +328,8 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// on a real phone.
|
||||
if !VoIPCallReceiver.shared.isShowingACall {
|
||||
NotificationManager.shared.postIncomingCall(
|
||||
fromBuddyID: userID, fromName: peerName, video: signal.video)
|
||||
fromBuddyID: userID, fromName: peerName, video: signal.video,
|
||||
snoozed: inbox.isSnoozed(userID))
|
||||
}
|
||||
startFastPolling()
|
||||
// Somebody already said no on the lock screen, before the offer
|
||||
@@ -335,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
|
||||
}
|
||||
|
||||
@@ -358,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,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)
|
||||
}
|
||||
@@ -514,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,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.
|
||||
@@ -605,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")
|
||||
}
|
||||
}
|
||||
@@ -696,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
|
||||
@@ -753,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -803,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
|
||||
@@ -838,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,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.
|
||||
@@ -1058,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1086,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1120,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1136,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1259,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
|
||||
@@ -1340,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
|
||||
|
||||