126 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5.5 11fb7aed76 Store shots: default to the iPhone 16 Pro Max for the 6.9 inch slot
ci / unit-tests (push) Failing after 1h1m39s
ci / ui-tests (push) Failing after 41m37s
ci / parity-scrub (push) Successful in 17s
ci / vectors-head (push) Successful in 22s
ci / secret-sweep (push) Successful in 1m34s
App Store Connect refused the 1284x2778 set on 2026-10-20 and took
1320x2868 in the 6.9 inch slot. The header now names the iPad device
type for 2048x2732 too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:57:50 -04:00
Paul HittandClaude Opus 5.5 67408e02ae String catalogs as Xcode writes them after a build
ci / ui-tests (push) Failing after 41m36s
ci / vectors-head (push) Successful in 9s
ci / parity-scrub (push) Successful in 1m5s
ci / secret-sweep (push) Successful in 16s
ci / unit-tests (push) Failing after 1h1m36s
Xcode reformats both catalogs on every build. Beyond the formatting:
"Send" gains a second translator comment, "Search contacts" and
"Sam Rivera" are marked stale, and InfoPlist gains one extracted key.
No translation changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:38:37 -04:00
Paul HittandClaude Opus 5.5 dda92223d6 Store shots: pass the relay to the app when signing in to an account
ci / vectors-head (push) Successful in 9s
ci / secret-sweep (push) Successful in 24s
ci / parity-scrub (push) Successful in 31s
ci / unit-tests (push) Failing after 1h1m40s
ci / ui-tests (push) Failing after 41m52s
Test launches default to http://127.0.0.1:9 so no suite reaches
production by accident. The shot test set -FNMSGServer only for a
throwaway sign-up, so signing in to a named account hit the dead
loopback and failed with "Could not connect to the server".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:34:55 -04:00
Paul HittandClaude Opus 5.5 abd2a557e9 Clear four compiler warnings in E2EInbox and TodayStore
ci / vectors-head (push) Successful in 10s
ci / secret-sweep (push) Successful in 28s
ci / parity-scrub (push) Successful in 34s
ci / unit-tests (push) Failing after 1h1m38s
ci / ui-tests (push) Failing after 41m35s
Three @Published properties carried internal(set) on an internal
property, which changes nothing. forgetOnThisPhone defaulted its store
to .shared, evaluated outside the main actor; it now takes an optional
and resolves .shared inside the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:21:38 -04:00
Paul HittandClaude Opus 5.5 a50d12722f Hide the wallet on iOS for App Store guideline 3.1.5
ci / vectors-head (push) Successful in 10s
ci / secret-sweep (push) Successful in 30s
ci / parity-scrub (push) Successful in 33s
ci / unit-tests (push) Failing after 1h2m10s
ci / ui-tests (push) Failing after 42m22s
App Review rejected 0.32.0 under 3.1.5: crypto wallets are allowed only
from developers enrolled as an organization, and this account is an
individual one. WalletAvailability.hidden removes the Wallet tab, the
Home header button and the Privacy setting. The store shot test no
longer captures a wallet screen. Android and F-Droid keep the wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:50:03 -04:00
Paul HittandClaude Opus 5 d04c398f69 Build and test are two calls now, and three more assertions race no more
ci / secret-sweep (pull_request) Successful in 15s
ci / vectors-head (pull_request) Successful in 11s
ci / parity-scrub (pull_request) Successful in 15s
ci / unit-tests (pull_request) Failing after 1h1m45s
ci / ui-tests (pull_request) Failing after 4m33s
The 2026-10-16 run gave the first real diagnostic in six attempts.
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".
The watchdog killed it and the gate printed that tail. So on pauls-imac
the build succeeds and the test phase never begins; the same commit
builds and runs 1289 XCTest plus 578 Swift Testing tests in about 7
minutes on an M3.

One xcodebuild call cannot tell a slow compile from a test phase that
never starts. build-for-testing and test-without-building each run under
their own cap, and the log says which one stopped.

Running test-without-building against a warm build exposed a second race
in BridgeFailoverTests, of the same kind as the first: onWorked and
onAllFailed append to a plain `var` from the transport's queue while the
test reads it, which races the callback and shares a variable across two
threads. Three assertions read a locked box on a 5-second deadline now.

Verified: build-for-testing reports ** TEST BUILD SUCCEEDED **,
test-without-building runs all 1289, and BridgeFailoverTests passed 11 of
11 three times in a row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 15:14:53 -04:00
Paul HittandClaude Opus 5 0e949b38ac The unit job was collecting a sysdiagnose, not running tests
ci / vectors-head (pull_request) Successful in 8s
ci / secret-sweep (pull_request) Successful in 17s
ci / parity-scrub (pull_request) Successful in 15s
ci / unit-tests (pull_request) Failing after 2h30m47s
ci / ui-tests (pull_request) Failing after 1h40m38s
unit-tests and ui-tests have failed on all five CI runs since the move to
Gitea Actions, every one of them with "context deadline exceeded" and no
test summary. Both causes are here.

The hang. One test fails, and on a failure xcodebuild's default
-collect-test-diagnostics on-failure runs `simctl diagnose`: a sysdiagnose
plus 8 minutes of simulator logs, 600 seconds of timeout, before it
reports anything. Reproduced on an M3 by watching the pid: the suite
finished 1289 tests in 286 seconds, then xcodebuild sat on `simctl
diagnose` for over 10 minutes with the log frozen at the last line. On the
runner that is what burned the 180-minute deadline. Set to `never`.

The silence. The in-job cap was `command -v gtimeout || command -v
timeout`, and the runner that printed "no timeout command, xcodebuild runs
uncapped" is the one that then hit the job deadline, where a cancelled
step runs no gate and prints no tail. The cap is a shell watchdog now,
holding xcodebuild's own pid because the log is written by a redirect
rather than a pipe, and `tail -f` keeps the progress lines in the job log.

The failing test. BridgeFailoverTests.testTheStatusNamesTheLineThatCarried
Traffic read Obfs4Proxy.shared.state once, immediately after reading the
HTTP body. onWorked fires on the transport's queue when the tunnel ends,
not when the body arrives, so the read raced it and saw waiting(lines: 2).
The client socket is closed first and the state is read on a 5-second
deadline. The same race was in the noneReachable assertion.

Verified on this machine, Xcode 27.0 (27A266a): BridgeFailoverTests 11 of
11, then the whole suite, 1289 XCTest and 578 Swift Testing tests, zero
failures, ** TEST SUCCEEDED **.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:45:04 -04:00
Paul HittandClaude Opus 5 b2d6230a0a ci: boot the simulator and wait for it, because xcodebuild waits forever
ci / vectors-head (pull_request) Successful in 8s
ci / secret-sweep (pull_request) Successful in 17s
ci / parity-scrub (pull_request) Successful in 17s
ci / unit-tests (pull_request) Failing after 3h0m3s
ci / ui-tests (pull_request) Failing after 2h0m2s
Two runs were cancelled at their timeout with no error, and raising the
number was treating a hang as slowness. The build was finishing both
times.

The evidence is in the artifact. ci-test.log from the 90-minute run was
889,539 bytes and from the 180-minute run 889,544, five bytes apart. A
slow build produces more output, not the same amount. Both end at
`PruneExplicitPrecompiledModules`, which is the last line of a
successful build, immediately before xcodebuild hands off to the
simulator.

So the suite compiles and then xcodebuild waits for `iPhone 17` to come
up. The job shut that device down before building and left xcodebuild to
boot it, and nothing reported when it did not.

`xcrun simctl bootstatus "iPhone 17" -b` boots the device and blocks
until it is usable. It exits 0 on success and non-zero when the device
does not exist, checked both ways on an M-series Mac: 0 after a
shutdown, 148 for a name that is not there. On failure the job now
prints the available device list and stops, so a runner whose simulator
will not boot says so in seconds.

A cap also goes INSIDE each job, 150 minutes for the unit suite and 100
for the UI suite, under job timeouts of 180 and 120. A hang now reaches
the gate below and prints the tail of the log instead of being cancelled
with nothing. `coreutils` is not guaranteed on a workstation runner, so
the cap resolves gtimeout, then timeout, and otherwise runs uncapped
with a note rather than failing.

Both jobs get both changes. Every `run:` block still passes `bash -n`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 21:06:08 -04:00
Paul HittandClaude Opus 5 907bce9d87 ci: let these jobs be re-run without a commit
ci / vectors-head (pull_request) Successful in 6s
ci / secret-sweep (pull_request) Successful in 14s
ci / parity-scrub (pull_request) Successful in 14s
ci / unit-tests (pull_request) Failing after 3h0m3s
ci / ui-tests (pull_request) Failing after 2h0m3s
The unit job can spend 90 minutes before it prints anything, and when it
times out the only way to try again was to push another commit. Two of
today's three runs were exactly that.

workflow_dispatch costs nothing and makes a re-run a button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 14:23:59 -04:00
Paul HittandClaude Opus 5 dc483d5e22 ci: 180 minutes for the iOS unit suite, 120 for the UI suite
90 was not enough. The run on 50ce3c86 spent all 90 minutes inside
xcodebuild and printed no `Executed` line at all, so the cold compile
alone outlasts the limit on pauls-imac. The artifact it uploaded held
889 KB of build output and no test result.

The same commit builds and runs the whole suite in about 37 minutes on
an M-series Mac:

  Executed 1289 tests, with 0 failures
  ** TEST SUCCEEDED **

So the suite is green and the limit was measuring an Intel compile. 180
leaves room for a cold build; the workspace on that runner now holds a
partial ci-build, so a warm run should come in far under it.

If 180 is not enough either, the answer is the runner rather than the
number: move these two jobs to an Apple Silicon runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 14:11:40 -04:00
Paul HittandClaude Opus 5 50ce3c8610 ci: 90 minutes for the iOS unit suite, 60 for the UI suite
ci / unit-tests (pull_request) Failing after 1h30m4s
ci / secret-sweep (pull_request) Successful in 15s
ci / vectors-head (pull_request) Successful in 7s
ci / parity-scrub (pull_request) Successful in 15s
ci / ui-tests (pull_request) Failing after 1h0m2s
The first run of this workflow died at exactly 30 minutes with "context
deadline exceeded", fifteen seconds past the vector gate and still inside
xcodebuild. 30 was chosen when the suite was 741 tests; the 0.32 merge makes
it 1289, and a cold `ci-build` compiles the app, its tests and the obfs4
package before any of them run.

pauls-imac has capacity 1, so ui-tests waits for unit-tests and then builds
from its own derived data; 60 covers that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 12:22:27 -04:00
Paul HittandClaude Opus 5.5 9099b6866c ci: move CI to Gitea Actions
ci / vectors-head (pull_request) Successful in 11s
ci / secret-sweep (pull_request) Successful in 22s
ci / parity-scrub (pull_request) Failing after 13s
ci / unit-tests (pull_request) Failing after 30m2s
ci / ui-tests (pull_request) Failing after 30m2s
Translates .gitlab-ci.yml to .gitea/workflows/ci.yml and deletes it.
Every gate is kept: secret sweep, unit tests with the vector pin check and
the 700-test floor, UI tests with the 10-ran floor, vectors-head (advisory),
parity scrub.

Xcode jobs run on the pauls-imac runner; the sweep, vectors-head and the
scrub run on mac1/mac2. Builds and tests only, no signing.

The Obfs4 submodule and the sibling clones now come from
git.extant2000.com/extant2000, as do the README links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:03:09 -04:00
Paul HittandClaude Opus 5 b3c47c9926 Spanish for the 79 strings the 0.32 features added
`Scripts/check-spanish-catalog.py` reported 1466 keys in code against 1391 in
the catalog. The 79 without Spanish were all from the release branch: greeting
cards, events, games, the Today tab, the small-web directory, story
take-downs, and the two scanner refusal lines added by this merge. It now
reports 0 missing.

Terms follow docs/i18n/glossary-es.md and, where Android already shipped the
same label, Android's wording: Today is "Hoy", Card "Tarjeta", Event "Evento",
Game "Juego", story "historia", take down "retirar", directory "directorio",
bridge line "línea del puente". Every entry is `needs_review`, like the rest
of the es column: nothing ships until the owner or a Localization Lab
volunteer has read it.

The old parental-controls footer is deleted. The merge replaced it with the
longer text that also covers Today and the directory, so its key was dead.
"Attach photo" and "Search contacts" are still reported unused, as on main:
both reach the catalog through a `String` parameter the compiler does not
record, and "Search contacts" has three live call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 11:02:23 -04:00
Paul HittandClaude Opus 5 21c23852c8 Merge release/0-32-0 into main: the 0.32 features meet the Spanish strings
32 conflict hunks across 21 files. The two sides are the 0.32 feature branch
(cards, events, games, Today, the small-web directory, hello links, story
take-downs, stickers) and main (the Spanish catalog, attachment padding, the
stage 2-B request gate, bridge offers). Neither side is the answer anywhere
the conflict is about what the app does.

What the resolutions keep, and why:

Info.plist and project.yml: BOTH URL types. One claims `flamenet` for contact
links, the other `flamenetmessenger` for hello links. Taking either side alone
would have stopped the other kind of link opening the app at all.

CardScanner: git had already merged the controller to the union, with both the
injected `accepts` predicate and the new `onRefused` callback. The call sites
now match it. `accepts` is the only gate, because hard-coding `ScannedCode`
there would have refused every code the Devices screen scans. The address
sheet's default is `ScannedCode.card`, never `ContactCard.decode`, so an older
card's `?u=` username is still never trusted (DESIGN 32).

E2EInbox: `seedAccountStateFromDeviceIfNeeded()` moved to the factory rather
than into the release branch's new `connect()`. It reads this device's own
store and never the relay, and the offline launch path passes `connect: false`,
which would otherwise show every existing conversation as a request until the
relay answered. `deliverRemote` keeps both gates: main's `remoteMailShows` and
the release branch's control-traffic cases.

E2EEnvelopeRoute: bridgeOffer, rsvp and unpost all three, in the enum, in
`all`, and in the shape test.

MainTabView and PillTabBar: the extracted `PillTabItem` and `tabItems()` are
kept, with the `identifier` field put back and every title through
`String(localized:)`. `title` is a plain String, so the release branch's bare
literals would have left every tab English in every language.

ParentalControlsView: the release branch's fuller footer, written as ONE
literal. `Text` localizes a literal and not a `+` chain, so the concatenated
form would have taken that footer out of the catalog.

Eleven more places take the union with the localized string kept: the
attachment and sticker previews in E2EInbox, GroupsSection, BuddyListView,
ChatView, ChatViewModel and StoriesFeedView.

Also here: 35 spec references renumbered. `a3b05c3` in flamenet-e2e moved
anonymous reads and contact links to §8.13 and §8.14 to make room for the push
token and the Today file at §8.11 and §8.12, so every reference in this tree to
"8.11, anonymous reads" or "8.12, contact links" now pointed at the wrong
section. The Today and push references were already right and are untouched.

.e2e-ref is pinned to 4b4042f72880758ee2dea7713f7a2cb63fa12f73, the unified
spec, which is also what Android pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:21:32 -04:00
Paul Hitt 01b969d12f Merge branch 'i18n/es' into 'main'
Bridge sharing (friend to friend) and Spanish (draft, needs native review)

See merge request extant2000/flamenet-messenger-ios!240
2026-10-10 16:12:11 +00:00
Paul HittandClaude Opus 5.5 7110f11472 Reading the bridge settings writes nothing: the wallet send sheet hung
WalletUITests failed 2 of 8 in CI (!240, pipeline 2933642198): after
"Send" opened its sheet, the app's main thread was busy for 30 s and every
UI query timed out. A sample showed WalletView.body running without end.

Cause: Obfs4Keychain.migrate ran on every read of the bridge settings and
always removed its old UserDefaults key, even when it was not there. That
posts UserDefaults.didChangeNotification. WalletView reads
AroundTheBridge.active in body and holds an @AppStorage value, so each
change re-rendered it, which read the settings, which posted again.

Fix: migrate only when the old key exists. A unit test counts
UserDefaults changes across 20 rounds of reads: 100 before, 0 after.
WalletUITests: 8 of 8 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:52:50 -04:00
Paul HittandClaude Opus 5.5 d325bb5e43 Spanish (es) draft for the bridge screens
DRAFT, needs native review.

Rebased onto bridge/f2f-apps (4b92274a). NetworkSettingsView.swift and
Obfs4Settings.swift were taken from the bridge branch whole and wrapped
again, so every bridge behaviour stays as that branch wrote it.

- String(localized:) on the bridge list, failover, offer, sharing and
  scanning text, the Obfs4 line problems and Obfs4Proxy check results, and
  the four AroundTheBridge notices (link previews, calls, wallet, live
  streams). Sentences joined with + are one literal; English is unchanged.
- 24 new catalog keys with Spanish, "%lld bridges saved, not used yet" with
  plural forms. check-spanish-catalog.py: 1,390 keys in code, 0 missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:48 -04:00
Paul HittandClaude Opus 5.5 f207a35b81 Spanish screens photographed on a local relay; "Appear offline" shortened
SpanishScreensUITests signs up two accounts against a relay on this machine
(it refuses any other), then relaunches the app with -AppleLanguages
(es-MX) and attaches 16 screenshots: sign-in, home, add contact, chat,
contact profile with the safety number, Settings, Privacy and the contact
links screen. Off unless TEST_RUNNER_FNMSG_ES_SHOTS=1.

The run found one cut-off label: "Aparecer desconectado" showed as
"Aparecer..." on the Account status buttons. It is "Invisible" now and fits.

DRAFT, needs native review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:11:29 -04:00
Paul HittandClaude Opus 5.5 a2177e1a97 Spanish (es) draft: string catalogs, every UI string made localizable
DRAFT, needs native review. Nothing in the es column ships until the owner
or a Localization Lab volunteer has read it.

- project.yml: developmentLanguage en, SWIFT_EMIT_LOC_STRINGS and
  LOCALIZATION_PREFERS_STRING_CATALOGS on; xcodegen adds es to knownRegions
  from the catalogs.
- Resources/Localizable.xcstrings: 1,366 keys from the compiler, 1,356 with
  Spanish (the rest are punctuation and placeholders, marked not
  translatable), 23 with plural variations. Every es value is
  "needs_review".
- Resources/InfoPlist.xcstrings: the four permission prompts in Spanish.
- String(localized:) on 729 changed lines across 115 files, so status, error and
  warning text held in a String reaches the catalog. Sentences built with +
  are one literal now. English output is unchanged.
- Integer identifiers (user ids, HTTP and CallKit codes, signals) are
  interpolated as String, because String(localized:) groups digits: a
  CallKit code 9999 read "9,999".
- SpanishCatalogTests: every key has Spanish, identical placeholders, the
  draft marker is present; short labels over 1.6x are listed, not failed.
- Scripts/check-spanish-catalog.py: every key the compiler extracts is in
  the catalog (xcodebuild does not add new keys by itself).
- Source-reading tests updated to the String(localized:) form.
- docs/i18n/glossary-es.md: the terms, matched to the /es/ site pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:11:29 -04:00
Paul HittandClaude Opus 5.5 4b92274aa3 Vectors: pin flamenet-e2e 4b85000 (SPEC-only change on bridge/f2f-followups)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:07:43 -04:00
Paul HittandClaude Opus 5.5 8fbfbc45c0 Vectors: pin flamenet-e2e 4e36d5a (bridge offers in three encodings); keep the interop-driver files free of bridge code
The interop driver compiles E2EAttachment, LinkPreview and RelayDiscovery
alone, with no networking code. The bridge gate for link previews and the
bridged default for relay discovery move to app-only extensions, and the
offer size limit lives in E2EAttachment. The vector test reads all three
encodings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:25:28 -04:00
Paul HittandClaude Opus 5.5 00c7f21883 Nothing goes around the bridge: previews, calls, the wallet and live streams are off while one is saved
SPEC §24.6: a client with bridges turned on must not connect directly. Four
features connect to a host other than the Flamenet edge a bridge forwards
to, so while a line is saved each is off and says why in plain words:

- Link previews: the fetch returns nothing and makes no connection; the
  setting is greyed with a note.
- Calls: refused at start, at answer, and where the peer connection is built.
- The wallet: the chain client refuses before connecting; the screen says so.
- Live streams in stories: the player is replaced by a note naming the host.

The Files package's own sessions now go through the bridges
(FilesTransport, set at launch). Nothing in Sources or the Files client uses
URLSession.shared any more; a test reads the sources to keep it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:14:34 -04:00
Paul HittandClaude Opus 5.5 a30dcafd03 Bridges: a screen before sign-in, several lines with failover, offers from contacts (SPEC §24)
- A bridge screen on the sign-in and sign-up screens: paste or scan a line,
  see the live status, remove it. Offers and sharing appear once signed in.
- Everything before sign-in goes through the bridges when one is set: relay
  discovery, the sign-up proof of work, the account service and the REST
  probe. Clients made before a bridge was set follow it (BridgedSession).
- Up to 8 lines, tried one at a time, the one that last worked first. A line
  fails on no handshake within 15 s, a refused connection, or nothing coming
  back through it; the unanswered request is sent again through the next
  line. Every line failing never falls back to a direct connection, and a
  local proxy that cannot start blocks traffic instead of sending it direct.
- The dialler takes IPv6 addresses and names, every address of a name.
- Offers: received offers route to their own branch and are kept for review
  (proven senders only, never from this account or a federated address);
  the Network screen shows them with accept and dismiss, and sends this
  phone's line to one contact after saying what they learn.
- §24.9 gaps: certs decode with or without padding; offers with a gid or a
  line over 1024 bytes are dropped and never built; an envelope whose known
  key has the wrong type is consumed or shown as its caption or the
  placeholder, never as raw JSON; the bytes test compares against both
  writers in the vectors.
- Held to bridge-offer-v1.json; .e2e-ref moves to flamenet-e2e c75b861.
- Citations of §6.19 for the bridge offer now say §24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:56:22 -04:00
Paul Hitt d9c5c5a64d Merge branch 'release/0.32.0' into 'main'
FlameNet Messenger 0.32.0 (iOS): padding, pairs 2-B, multi-device, sealed first contact, Unlisted and contact links

See merge request extant2000/flamenet-messenger-ios!239
2026-10-10 02:18:58 +00:00
Paul HittandClaude Opus 5.5 f2123f063e Vectors: pin flamenet-e2e 50fcc99, the merged 0.32 spec with §7's PQ number
e2e!66 merged; protocol-v1.json gains `safetyNumberPQ` and nothing else
changed. ProtocolVectorTests "testSafetyNumberCoversThePostQuantumIdentities":
red against the old copy (no vector), green with the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:49 -04:00
Paul HittandClaude Opus 5.5 180d8efd5d Contact links from the shipped relays open the app (universal links)
SPEC 8.12: https://<relay>/add#... opened Safari. The entitlements now
claim applinks:relay.flamenet.io and applinks:relay1.flamenet.io; the
relay serves the matching apple-app-site-association (team M5923Y84P5,
/add*). A universal link arrives as a web-browsing activity and goes to the
same handler as the flamenet: scheme (AppEnvironment.open).

Signing needs the Associated Domains capability on the
net.flamenet.messenger App ID before an archive will sign.

AContactLinkOpensTheAppTests: 3/3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:49 -04:00
Paul HittandClaude Opus 5.5 646d5f32dc Safety number: the answering side reads the bundle once, so both screens match
SPEC §7 (flamenet-e2e 50fcc99, the owner's decision): the number covers
both post-quantum identities when a device holds both. A device pins a
contact's ik_pq only when it reads their bundle, which only the side that
opened the session does; the side that answered showed the Ed25519 number
while the other showed the covered one, and the digits never matched.

pinPeerPQIdentityIfNeeded reads the contact's bundles once, for devices
whose IK_sig is already pinned, and checks and pins ik_pq (§20.5). The
profile runs it when the safety number is shown and reads the number
again. It spends one prekey per contact device, once.

The test stub relay now carries ik_pq and spk_pq_sig in bundles, as the
relay does. FirstContactTests "testBothScreensShowTheSameCoveredNumber":
red (no pin, no coverage), then green; full suite 1074 XCTest and 408
swift-testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:49 -04:00
Paul HittandClaude Opus 5.5 17e54acb98 A refused first message names the person plainly
Against a local relay, a one-time code used a second time was refused as
it should be, but the words read "Could not send. nora1009 (not verified
yet) needs to message you or send you a new link.": the refusal took the
conversation title, which carries the unverified mark. It now takes the
name without it (nameForRefusal), at all three call sites.

FirstContactTests "testTheRefusalNamesThePersonPlainly": red on the call
sites, then 15/15 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:05:22 -04:00
Paul HittandClaude Opus 5.5 71709d55fd A first message by contact link opens its conversation
Found on the iPhone-to-Android pair against a local relay: a first message
sent through an Android account's one-time link reached that account's
Requests, but the iPhone opened nothing and Home said "No buddies yet".
The deep link opened a conversation only with somebody on the relay's
contact list, and the person behind a link is not on it. Android opens the
conversation either way.

BuddyListView.deepLinkTarget opens the buddy, or the person by the name
the inbox holds ("luke (not verified yet)" until their certificate).

AFirstMessageByLinkOpensItsConversationTests: 2 red, then 3/3 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:27:33 -04:00
Paul HittandClaude Opus 5.5 3c1a6cfce4 Numeric linking: the confirmed device asks for the account's state
Found linking an Android emulator to this iPhone's account by the numeric
fallback (17.3) against a local relay. The existing device sends its
snapshot right behind the offer; the new device, still waiting for the
person to compare the number, drops it as traffic from a device it has not
linked. Confirming then linked a device with none of the contacts,
acceptances or flags. Both apps had it.

confirmPendingNumericOffer now asks the offering device for its state
(sync_state with want), which both apps answer with a snapshot.
adoptLinkOffer says whether anything was linked. History sent behind a
numeric offer is still dropped; only the QR path backfills it.

LinkedDevicesTests "testTheNumericFallbackStillCarriesTheState": red
without the change (the contact list and acceptance did not cross), green
with it; 15/15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:46:58 -04:00
Paul HittandClaude Opus 5.5 7274896c80 Vectors: pin flamenet-e2e 5a5b7e7 and check all eleven copies
Tests/Vectors already held the 0.32 vectors (padding, sync-merge,
handle-cert, links, poll-tally), byte-identical to flamenet-e2e 5a5b7e7,
the commit Android pins. The pin still named 3564030, which has six vector
files, and CI diffed only those six, so the five new copies were checked
against nothing. The pin now matches Android's and both loops (the pinned
gate and vectors-head) diff all eleven.

5a5b7e7 is on flamenet-e2e's privacy/sweep-2026-10, not yet pushed: the
clone in CI fails until it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:04:20 -04:00
Paul HittandClaude Opus 5.5 632653674d Finding you: show each contact link as text as well as a code
The one-time code was a QR and a share button, and a reusable link was a
share button with six characters of its capability on it. Neither showed
the link itself, so it could not be read, checked or copied by hand. Each
now shows its https form, selectable, under the code. Android shows the
code and shares the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:04:14 -04:00
Paul HittandClaude Opus 5.5 132ec7d71b Contact links: an unverified name says so; a link for another server says which
Section 8.12: a one-time link involves no introduction, so nothing vouches
for its `h` until the recipient's first reply carries their certificate.
The sheet said so, but the conversation it opened showed the bare name.
It now shows "bob (not verified yet)", the words Android's chat title uses,
until `adoptHandleCertificate` writes the certified name over it.

A link made on another relay was refused as "already used or turned off".
It now says "That link is for another server (<relay>).", as Android does.

TheListedSettingReachesTheRelayTests pins the Listed setting's wire: GET
and PUT/POST of `flamenet/v1/messenger/me` with `discoverable`, the field
RoutesAccount.updateMe reads, and nil from a relay that does not say.

Tests first: FirstContactTests two red, then green; 14/14, 36/36, 4/4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:40:29 -04:00
Paul HittandClaude Opus 5.5 8dde239834 Build and pass 0.32 on Xcode 27, after main's fixes
The release branch had never been through a full test run. On Xcode 27:

- E2EInbox.transmit called `bundles(of:features:)` while a local named
  `bundles` was in scope, which does not compile. Now `self.bundles`.
- FirstContactTests awaited inside XCTUnwrap's autoclosure and read a
  main-actor property from a nonisolated test.
- Seven source-reading tests still looked for 0.31 shapes: adoptStrangers
  (now nameStrangers, DC2), transmit's old one-line signature, two
  functions that lost `private` for multi-device, and the Snapshot fields
  that exist only for an export (16.2.1) or are carried as their projection.
- LinkedDevicesTests added a contact without accepting it, which no
  production path does (ContactAdd accepts), so acceptance had nothing to
  carry across.
- ABlockedSenderStaysOutTests (from main) now checks the block survives a
  relaunch through the inbox, since the block is the `blocked/` register,
  and purges a history file a previous run sealed under a wiped key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:40:29 -04:00
Paul HittandClaude Opus 5.5 74260197f8 Copy: say what blocking and replacing the key do now
Run through the avoid-ai-writing audit. Blocking now replaces the shared
delivery key and the app pushes the new one to every other contact
(section 14.4, DC6), so the Privacy footers that said a gap was left and
that contacts lose sealed delivery until messaged again were no longer
true, and neither was the "contacts can reach you sealed again once you
next message them" result. Three negation tails rewritten as plain
statements (web search, reusable links, the backup banner), and a curly
quote in code made straight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 5db98ae7c6 Web search through the relay, when it offers it
A Web search screen (Account), shown only when the relay's descriptor says
`features.webSearch` (relay branch privacy/web-search, unmerged and
undeployed). POST /api/flamenet/v1/search with the member's credential,
the query in the body and never the URL, nothing logged; results open in
the browser, up to three pages; "search isn't working right now" (the
relay's `unavailable`) is told apart from "nothing found", and too many
searches is said as such. The copy says what the relay can and cannot see.

Tests: WebSearchTests (4). Written while builds were paused; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 4f5902e777 First contact that names nobody: certificates, introductions, anonymous reads, links
Stages C2, C3, D2 and B3 (SPEC 14.5, 8.11, 8.12). Every part waits for
the relay's descriptor to say it can (`introductions`, `anonymousReads`,
`contactLinks`, `unlisted`); the relay code is on privacy/wave0-relay,
unmerged and undeployed, and does not advertise these switches yet, so on
every relay today the app behaves exactly as 0.31 did.

C2, handle certificates: a first message's delivery-key prelude carries
the sender's own certificate (`hc`, from GET /e2e/handle-cert); the
recipient keeps it only if it verifies against the pinned relay key, for
this relay's audience, about the sender the ratchet proved, before exp.
Requests and the call screen use the certified name, and with the switch
on the relay is no longer asked who user N is.

C3, sealed first contact: with no token, key or intro for somebody, the
app asks POST /e2e/intro once (by the reusable link the conversation
started from, or a listed handle after the proof of work), verifies the
answer is about that account, and sends sealed under the intro using the
bundles it carried. A refused key or token tries one fresh intro. With the
switch on nothing goes out named: no way to seal means the send is
refused with "Could not send. <name> needs to message you or send you a
new link." A batch of delivery tokens is now split into disjoint slices,
one per recipient device, so two devices never spend the same token.

D2, anonymous reads: directory, bundles and presence for other people go
to POST /e2e/anon/* with the target in the body, through a separate
ephemeral HTTP client (no credential, cookies, cache or shared pool, a
generic User-Agent), metered by the presence token the person issued;
presence in random batches of at most eight with up to 20 s before each;
own devices from GET /e2e/devices/me.

B3, Unlisted and contact links: "Finding you" (Privacy) has the Listed
switch (worded as the spec requires: nobody can find or add you by your
username, which is not secret), one-time QR codes (a delivery token, works
on today's relays) and up to five reusable links, each shareable and
revocable; sign-up offers the choice (Listed by default) and applies it
once the relay can. Contact links open from the flamenet: scheme (now
registered), the address scanners, and send one sealed first message to
the person's Requests, the name shown as unchecked until they reply.

Also here: MessengerAPI.webSearch, used by the next commit.

Tests: FirstContactTests (14) on the stub relay, which now answers the
wave 0b routes. Written while builds were paused; not yet run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 6565b4ca7a Multi-device: linking, sync, sent copies and history (SPEC 17, A2 to A4)
Stage A2, linking (section 17.3). A device signed in with the password is
on the account and nothing more. A person links it from a device that
already is: the new device shows flamenet:link?d=&k=&s= as a QR code (ten
minutes, one use, never shared as text); the existing device checks the
relay lists that device on this account under exactly that key and that the
key log holds no revoke or expire for it, asks, links it locally, and sends
it a link_offer (secret, linked set, account delivery key) through an
ordinary session, then a snapshot of every register, then history if the
person wants it. The new device adopts only an offer whose secret matches
the code it is showing, from a sender in the set under the key its session
proved, and links each member only if the relay lists it under the key the
offer names. Numeric fallback: both screens show the two devices' safety
number and the new device holds the offer until the person confirms there
too (LinkOfferWatcher, from any tab). The linked set follows the relay:
a device retired in the key log, no longer listed, or listed under another
key is unlinked and the person is told which.

Stage A3, sync (sections 17.2, 17.6). Every register write goes to the
linked devices, batched (one op as `sync`, several as `sync_state`), sealed
to this account under the account delivery key. Incoming ops merge by the
vector-checked rule and are applied: acceptance, blocks, flags and read
marks, and the mirrored families (contacts, muted authors, group names and
keys, pins, story audience, contacts' delivery keys, issued token batches,
presence tokens issued and held, the account delivery key, the linked set,
each link checked against the relay first). State older than the registers
is written as registers once, so a snapshot carries all of it. A device
back after a week asks for snapshots; after 90 days it keeps only links.
Everything a person sends to someone (text, photo, file, sticker, reaction,
edit, pin, timer) and every group message once is copied to the linked
devices as `sent` and drawn there as outgoing, never notified; calls,
stories, avatars, polls and anything off the record are not (DA5). Only a
linked own device, under the key it was linked with, may send any of this;
a link_offer is the one kind taken from an unlinked one.

Stage A4, history (section 17.4): newest first, at most 10,000 messages,
16 KiB chunks, attachments by reference, off-the-record threads left out;
merged by message id in time order, a partial backfill tolerated.

UI: Devices has Link a new device (scanner), Show this device's link code,
Link (numeric) and Unlink per device, and the notices; the scanner takes a
filter so it can read link codes. Pending content envelopes from
content-v1.json are written to the byte (the sync_state chunk by content).

Tests: LinkedDevicesTests (15, two real devices of one account plus a
contact). Builds were paused by the owner while CI runs, so these were
written but not yet run; they run in the next build window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 2b11f1e33c Pairs stage 2-B: acceptance, blocks, flags and Requests held on the device
Section 8.9d and 14.5 (decision DC2). What the relay kept as pairs of
account ids now lives on the device as sync registers (section 17.6), so
linked devices can merge them later (stage A3):

- Acceptance (`acc/`): seeded once from the contact list and from every
  thread this device wrote in, then once from the relay's buddy list;
  writing to somebody, adding them, or accepting a request accepts them;
  removing a contact takes it back.
- Requests: a first message from somebody not accepted waits in a Requests
  folder (RequestsView: accept, block, delete) and never notifies. The app
  no longer adds strangers to the contact list on its own; `adoptStrangers`
  is now `nameStrangers`, which only puts a name to them, and the test that
  pinned the old adoption is replaced.
- Blocks (`blocked/`): seeded once from the relay; a block is the relay
  call (still enforced there until 2-E), the local register, the token
  revoke and a delivery-key rotation, and anything from a blocked person
  that still arrives is dropped on the device. The blocked list shows both.
- Star and archive (`conv/`): local only, flags written only when true, a
  register deleted when both are off; the Messages list shows starred
  first, keeps archived chats in an Archived folder, and /star and
  /archive toggle locally instead of posting to the relay.
- Read marks (`read/`): coalesced to one op per thread per 30 seconds.
- Adding a contact goes through ContactAdd everywhere: by lookup onto the
  sealed list when the relay no longer keeps one, never around it.
- A rotated delivery key is pushed at once, prelude only, to every contact
  that held the old one and is not blocked (section 14.4, DC6).
- A contact's device with no session yet is handed the delivery-key
  prelude with the presence token they already hold.
- Legacy groups this account owns are moved to sealed groups on refresh.
- Backups carry `acc`, `blocked`, `convFlags`, `dk`,
  `deliveryTokensIssued` and `read` (section 16.2.1 pending); a restore is
  a union and never unblocks anybody; `dk` is adopted only by a device
  with no key of its own.
- A banner asks for a backup when nothing was exported for 30 days.

Multi-device envelopes get their own route (`.multiDevice`), handled by a
stub until stage A2.

Tests: PairsStageTwoBTests (13, real inboxes on the stub relay and the
pending backup vector), the route order and a branch test for every
multi-device kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:03:15 -04:00
Paul HittandClaude Opus 5.5 1609ee6630 Sync registers, link codes and handle certificates, held to the vectors
The pieces sections 17.6, 17.3, 8.12 and 14.5 have to agree on with every
other engine, before anything uses them:

- SyncRegisters.swift: the register merge (later at wins, a block wins a
  tie, then the greater device id as text by UTF-16 unit, read marks only
  move forward, ops a day ahead dropped, malformed ops ignored whole, unknown
  keys kept as registers), tombstones forgotten after 90 days, snapshot
  registers merged one by one, and chunking at 16 KiB. JSONValue keeps a
  register this build does not understand exactly as it came.
- LinkCodes.swift: the device-link QR (flamenet:link?d=&k=&s=), both forms
  of a contact link (https://<relay>/add#... and flamenet:<fnid>@<relay>?...),
  base64url, and the intro proof of work (smallest nonce, zero BITS).
- HandleCertificate.swift: fnhandle-v1 verification against the pinned
  relay key, for this relay, for the account it is about, before exp, with
  `|` and non-integer ids refused before any signature check.

MultiDeviceVectorTests checks sync-merge-v1 in every order of every case's
ops and snapshot registers, each order also delivered twice; handle-cert-v1
valid and invalid; links-v1 encode, decode and refuse for both kinds. The
three files are copied from flamenet-e2e 5a5b7e7 (privacy/sweep-2026-10),
with content-v1 and backup-payload-v1 refreshed from the same commit (only
their new pending sections changed); PROVENANCE.md says why they are not
in the CI diff list yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:45 -04:00
Paul HittandClaude Opus 5.5 48b0f205f4 Trust a contact's second device; check a handoff by key (SPEC 17.3, 6.30)
Two bugs part 1 found.

Receiving, one key from one envelope was classified against everything
pinned for the sender. A contact with two pinned devices whose phone opened
a session read as "a device was removed" and was re-pinned down to the
phone, and the laptop's next first message then read as a new key beside a
missing one: a substitution, held behind "accept new keys". With nothing
pinned, the second device to write was held the same way.
`recordInboundTrust` now checks the one key: pinned is this person and
nothing is re-pinned; unpinned asks the directory (no prekey spent) and
classifies the whole listed set as a send does; unlisted, or a directory
that cannot be read, is refused. Android's Trust.recordInbound is the same
rule.

A call handoff from an own device was checked by device id alone, a name
the relay hands out. It is now checked against the identity key the
session that opened the envelope proved (the half of the X3DH associated
data that is not ours), via `isFromLinkedOwnDevice`, which the sync and
sent-copy kinds will use too.

Tests: ATwoDeviceContactIsNotAKeyChangeTests (4) and
AHandoffIsCheckedByKeyTests (3), with two real devices of one account in
one process: KeychainE2EStore and E2EHistoryStore take a `slot` (nil, and
unchanged, for every real install). The stub relay now hands back the
sending device on an unsealed row, as the real one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:45 -04:00
Paul HittandClaude Opus 5.5 fcff16bccd A group message too long to send is refused, not shown as delivered
SPEC 23.7 says a refusal is surfaced and never swallowed. The group
fan-out caught every error per member, so a group text over the top step
read as a send that reached nobody, and sendGroupText recorded it as
delivered. Now the fan-out checks the plaintext once against the largest
header an envelope can carry before any member is sent anything, so a
message is refused whole or sent whole, and a Padding failure from any
member is rethrown rather than caught.

The padded envelope and seal inner JSON are written by hand in a fixed
key order. A new test holds both to what JSONEncoder writes, key for key,
in every header shape, so a field added to the envelope later cannot be
dropped from every send without a red test.

Tests on My Mac (Designed for iPad): EverySendIsPaddedTests 6,
PaddingPropertyTests 8, ProtocolVectorTests 16, OwnDeviceTraffic 7,
TheDeliveryKeyIsRepublished 4, E2EInboxConversationTests 42, all green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 c12ecbdcd5 Every send padded, refused visibly above the top step; poll honours more
Padding stage 1.2 in the inbox (SPEC 23.7, 23.9, 8.5).

Both wrap points pad now: the local `wrap` (unsealed envelopes with
`pad` beside `ct`, sealed ones through SealedSender.sealedPayload) and
the federated seal. That covers text, control, group fan-out, the
delivery-key prelude, call signalling, own-device copies and federated
sends, each envelope on its own. A payload that is not exactly a ladder
step throws and is never sent; nothing falls back to unpadded.

A message too long for the top step is refused before anything is
spent: for a body over 16 KiB each device's envelope is built once on
an unsaved copy of its session and measured in the sealed form, before
any token is taken, batch minted or ratchet step saved. The person sees
"Too long to send as a message. Send it as a file." Both composers show
how much is left in the last thousand characters, counted as the wire
counts them, and keep the send button disabled past the limit (20,000).

Attachments are sealed through sealAttachment, padded only when the
relay's descriptor says features.padAttachments; absent, false or an
unreadable descriptor means off. The descriptor's switches are cached
for an hour, and a failed read for a minute.

A poll the relay cut short at its byte cap ("more": true) is followed
at once from the new cursor, up to 16 pages, on the home relay and on
every extra mailbox.

Tests on My Mac (Designed for iPad): EverySendIsPaddedTests 5 (every
payload a ladder length, sealed and unsealed; the refusal spends no
token, batch or session step; the composer limit is sendable; the
attachment switch both ways, opened at the other end; a capped poll
finished in one call). Six sabotages, each red, each restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 1fb722cfa0 Own-device traffic to linked devices only; per-device digests (A1)
Two of the live bugs in the compete designs, findings 1 and 3.

Finding 1. transmit(_, to: myUserID) sent to every device on the
account, so a device signed in by anybody holding the password received
story copies, group keys and names, and the offer to take over a call.
The store now keeps a linked-device set (device id and the IK_sig it was
linked under), in the Keychain and wiped with the account. It starts
empty, and nothing in this release adds to it: linking is stage A2. Every
send to this account goes only to devices in it, matched on id AND key,
so a relay listing a linked id under another key is not believed. The
one exception is by design: a call-end saying answered-elsewhere or
declined-elsewhere still goes to every device, because the caller rang
them all. Receiving, a call handoff from an own device that is not linked
is dropped (SPEC 6.30).

Consequence until A2 ships: call handoff, story copies and group names or
keys no longer reach a second device of the same account. That is what
SPEC 17.3 asks for at A1.

Finding 3. When the relay's descriptor says features.perDeviceDigests,
the device publishes its delivery-key digest once more, once per relay
(a flag per relay host, set only when the relay accepts it), and sends
device_id with its roster tags. Without the republish, a device whose
digest was overwritten under the old one-per-account rule would never
publish again: the app remembered that it had.

Tests on My Mac (Designed for iPad), no simulator runtime on this Mac:
OwnDeviceTrafficGoesOnlyToLinkedDevicesTests 7, TheDeliveryKeyIs-
RepublishedOncePerRelayTests 4, E2EInboxConversationTests 42 (the story
test now links its second device first). Nine sabotages, each red, each
restored and checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 ef83cd8f70 PaddingPropertyTests: walk the lengths off the main thread
In a debug build the two walks take about a minute each, and the test
host's main thread not answering for that long ended the host mid-walk,
which xcodebuild reports as "Restarting after unexpected exit, crash, or
test timeout" rather than as a slow test. They now run detached and the
walk is thinner past 256 bytes (every 401st length instead of every
211th); every length near a step change is still checked.

On My Mac (Designed for iPad): 6 passed, 78 s and 56 s for the walks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 3603113dc1 Padding: every payload a ladder length, attachments and rosters too (SPEC 23)
Sources/E2E/Padding.swift holds section 23's arithmetic: the message
ladder {16384, 65536}, the targets derived from it (never written out),
the plan, the pad drawn uniformly from [A-Za-z0-9] by rejection sampling,
and the length check on the bytes after the fact. A payload that misses
its step throws `wrongLength` and is never sent; one that does not fit
the top step throws `tooLong`, "Too long to send as a message. Send it as
a file."

SealedSender.sealedPayload pads the inner JSON and writes the outer blob
by template in the canonical form. JSONEncoder escapes every `/` in the
base64, about one in 32, so the length would miss the step; the vector's
escaped-slash case shows exactly that. The envelope and the inner JSON
are written in one fixed key order, because JSONEncoder's order changes
from run to run (measured) and the vector is byte-exact.

Attachments: `open` honours `clen` now, digest first, range-checked
before anything is decrypted; a `clen` that is not an integer keeps the
message and refuses only the attachment. `seal(pad:)` adds the random
tail to the attachment ladder, for use only when the relay says so.
Sealed rosters are padded to 8192 bytes or the next multiple.

Tests: padding-v1.json copied from flamenet-e2e privacy/sweep-2026-10
(7036115) and every case asserted in ProtocolVectorTests, the sealed
payload reproduced to the byte; PaddingPropertyTests walks 0 to 70,000
bytes in four header shapes, sealed with and without a return address
and unsealed, plus the composer limit, the pad's distribution and roster
sizes. Run on macOS against the app's own sources: 22 passed. Eleven
sabotages, each red, each restored and checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul HittandClaude Opus 5.5 88562ec07d Version 0.32.0, build 32
0.31.0 is in App Review. Everything new from here goes into 0.32.0,
the privacy batch: padded payloads (SPEC section 23) and own-device
traffic held to linked devices (section 17.3). Build 32 keeps the
number lined up with the release, as every earlier one is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:30 -04:00
Paul Hitt 0c3ba09615 Merge branch 'crossplat/ios-cross-platform-fixes' into 'main'
Keep blocked contacts out of the inbox; call this account "You" in its groups

See merge request extant2000/flamenet-messenger-ios!238
2026-10-09 11:19:39 +00:00
Paul HittandClaude Opus 5.5 0dfb2692fe Call this account "You" in its own groups
Every group header on the iPhone read "xpa6698, Member #7", and 7 was the
iPhone's own account. The group screen names each member through the
buddy list and then the roster. Nobody is their own buddy and the relay's
roster carries no names, so the one member present in every group fell
through to the placeholder.

Found on the iPhone-to-Android pair against a local relay.

E2EGroup.displayName(of:me:known:) answers "You" for this account, then a
buddy's name, then the placeholder, and every name on the group screen
goes through it: the header, the roster, and the mention picker.
AGroupNamesYouAsYouTests: 3 red before, 5 green after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:46:34 -04:00
Paul HittandClaude Opus 5.5 a0a886b3fa Keep a blocked contact out of the inbox
The relay refuses a blocked sender only on an identified send. A sealed
send is authorised by the recipient's delivery key and does not say who
sent it, so a blocked contact who already holds that key keeps getting
through. Revoking delivery tokens does not close it: a refused token
falls back to the delivery key by design. The recipient learns the sender
when it unseals, so the recipient has to refuse.

Found on the iPhone-to-Android pair against a local relay: after this app
blocked the emulator, the emulator's next message went sealed, the relay
accepted it with the block row present, and this app showed it. Checked
twice, the second time minutes later with the block still in place.

The blocked list is kept on the device (KeychainE2EStore.blockedSenders),
written on every block and unblock and whenever the relay's list is
fetched, and deliver() drops anything from a blocked sender, calls
included. ABlockedSenderStaysOutTests: 2 red before, 4 green after;
BlockingTakesPresenceWithItTests and KeychainStoreWipeTests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:09:19 -04:00
Paul HittandClaude Opus 5.5 4afa76403c CI: pin flamenet-e2e at the 0.32 spec and gate its new vectors
.e2e-ref moves to d07ed14 (docs/spec-0-32), the commit the copies in
Tests/Vectors were taken from. The unit-tests gate and vectors-head now
also compare content-kinds-v1, today-v1 and hello-link-v1, which the
0.32 loader suites read.

The pinned commit has to be on the remote before this pipeline runs, so
the spec branch is pushed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:59:48 -04:00
Paul HittandClaude Opus 5.5 697ea2542b Test launches never reach production by default
With no site stored, every launch probes its relay, and the default is
relay.flamenet.io: the unit-test host did so on every unit run, and so
did every UI suite that did not name a server, previews included.

A DEBUG test launch now resolves its site through
SiteConfig.forThisLaunch. A test launch is the unit-test host
(XCTestConfigurationFilePath) or the app launched by a UI suite, which
says so in FNMSG_TEST_LAUNCH. For one, the default site is a closed port
on 127.0.0.1, and a stored production site left by an earlier suite is
replaced by it as well, unless a UI launch named that very host with
-FNMSGServer, the way an opt-in live suite says it means it. The unit
host has no way to name one. Release builds and ordinary launches are
untouched: the shipped default is still relay.flamenet.io.

Every UI suite now builds the app through XCUIApplication.underTest(),
which sets FNMSG_TEST_LAUNCH. UITestSafetyTests pins it: no suite builds
an XCUIApplication directly; a test launch with nothing stored, or a
production site stored, resolves to loopback; production only when a
UI launch names it, never in the unit host; an ordinary launch is
unchanged; and this unit-test host itself resolves off production.

Suites that signed in on production by relying on the default must now
pass -FNMSGServer with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:34:53 -04:00
Paul HittandClaude Opus 5.5 79adfe0195 Test: the Today vectors as the spec has them now (d07ed14)
The copy merged from test/vectors-0-32 predates the spec's 86def06,
which took lat and lon out of the "cc, tz, lat and lon" case, and its
d07ed14 adds a case for an envelope version written as a string,
unknown-version, for 34 cases in all. Refreshed from the spec branch at
d07ed14; the loader's pinned count moves to 34. The other in-tree
vector files already match it byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:29:36 -04:00
Paul HittandClaude Opus 5.5 080827e804 The card and composer UI suites stay on this machine
UITestSafetyTests requires every UI suite that launches the app to be
able to skip or to stay on the device, and the two new suites did
neither. Both run a DEBUG preview needing no account, but the app still
probes its relay at start-up, and with no site stored that relay is the
production one. Each suite now points the app at a closed port on
127.0.0.1 with -FNMSGServer, and the safety test counts their two
preview flags as local, beside Files and the wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:26:46 -04:00
Paul HittandClaude Opus 5.5 0cc2ea10c4 Merge branch 'test/vectors-0-32' into 'release/0-32-0'
The 0.32 shared vectors, loaded by the app's own code: content kinds,
the Today file, hello links and the refreshed backup payload (TEST-A).
Test-only. Merged after the Today refusal reasons, which its Today
loader expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:16:52 -04:00
Paul HittandClaude Opus 5.5 66cb22b2c1 Today: a v that is there and not exactly 1 is unknown-version
DESIGN decision 48 sets one list of refusal reasons for both apps and
the vectors: unknown-version is a v present but not exactly the integer
1, in the envelope or the body. iOS called a body v of 1.4 or 1.5
bad-body, and an envelope v of 1.4, 1.0 or true malformed, because a v
that was not an exact integer failed the integer check before the
version was compared.

Presence and value are now asked apart. No v at all is still the shape
being wrong: malformed for the envelope, bad-body for the body. A JSON
null counts as no v.

The version test now expects unknown-version for 1.4, 1.0, 0.6, 1e0,
"1" and true in the body and 1.4, 1.0 and true in the envelope, plus the
two missing-v cases; it failed on every one before the change. The
malformed-file test's boolean envelope v moves to unknown-version with
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:16:43 -04:00
Paul HittandClaude Opus 5.5 e0def50749 An offline start opens to the chats, and connects behind them
DESIGN decision 47 (owner). The app signed in only once the relay had
answered who the stored credential belonged to, so with no network at
launch a signed-in phone opened on the sign-in screen.

An account signed in on this phone now opens from what is on it: the
credential is presented without a request, the inbox is built from its
own keys and history without registering, and the phase is signed in,
so the chats and the saved Today page are there at once. Behind them the
app asks the relay who this is, waiting 1, 2, 4 seconds and so on up to
a minute between tries; the first answer finishes connecting (device
registration, delivery key) and fills in the account's names. Only a
relay that answers and refuses the credential goes back to sign-in, as
before. No stored credential is signed out, network or not.

Opening without the relay needs the account's user id, which the relay
used to supply; it is now kept beside the username at sign-in, sign-up
and every restore, and cleared at sign-out. An install updated from an
earlier build learns it on its first launch with a network.

The test plays the relay with a real socket on 127.0.0.1: closed at
launch, the app is signed in with an inbox within seconds; opened on the
same port, the app asks for the account and connects, still on the
chats. A relay answering 401 still means sign-in, and no credential is
signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:11:44 -04:00
Paul HittandClaude Opus 5.5 b8ec413028 Story composers draw their words in the page's own ink
The test pass found the picture composer's audience line barely
readable on the pale page and, once, the typed caption invisible. The
Stories tab opens its composers from a button in its header, and the
header hands its buttons white text and a white tint so they read on
the navy; a sheet inherits both from the button it is opened from, so
the composers drew near-white words on a light page.

All three composers (a picture, a sequence of pictures, and words) now
put the theme's ink and tint back at their root, and their secondary
lines use the theme's secondary text colour, which is chosen for the
page in light and in dark, rather than a shade of whatever was handed
down.

The UI test opens the picture and the words composer from a header
button exactly as the tab does, in light and in dark, and reads the
pixels of the audience line, the typed caption and the typed words:
each must reach WCAG's 4.5:1 between text and page. Before the change
they measured 1.05, 1.39 and 1.22 to 1. The DEBUG stories preview now
follows the appearance setting, as the app proper does, so the test can
ask for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:09:19 -04:00
Paul HittandClaude Opus 5.5 3c0db18179 A sealed card on screen opens at its time
DESIGN decision 49. The test pass watched a sealed card stay sealed 25
seconds past its moment, through scrolling, opening only when the chat
was left and opened again. The bubble drew its face inside a
TimelineView whose content never read its context's date, and that
tick did not redraw it.

The face is now drawn for a time held in state. A task waits for the
moment the card opens, a quarter second past it, and sets the time, and
a state change always redraws. The task runs again whenever the card
comes back on screen, so one that opened while away is drawn open.

The UI test launches a DEBUG preview, a scrolling thread with one card
sealed for eight seconds, and touches nothing: the card has to open by
itself. Before the change it was still sealed after 20 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:06:09 -04:00
Paul HittandClaude Opus 5.5 915ff5b6e1 Test: the 0.32 shared vectors, loaded by the app's own code
Copies content-kinds-v1, today-v1 and hello-link-v1, and the extended
backup-payload-v1 canonical set, from flamenet-e2e docs/spec-0-32 at
cbe2226 into Tests/Vectors, and adds a loader suite for each new file:

- content kinds: every written envelope parsed and written back by the
  app's encoders, byte for byte; every envelope through parse() and the
  route deliver() takes, checked field by field against the file.
- Today: TodayFile.verify on the sample, every case and the 512 KB edge;
  the sequence through TodayStore; If-None-Match through TodayPolicy.
- hello links: the link cases through HelloLink.address(inText:) and the
  scan cases through HelloLink.scannedAddress.

Test files only. The copied backup-payload-v1.json differs from the
flamenet-e2e commit in .e2e-ref, so the CI vector gate reads red here
until .e2e-ref moves to the spec commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:24:14 -04:00
Paul HittandClaude Opus 5.5 ec1912d1db A snooze holds the incoming-call banner too, as mute does
Mute holds the banner the app posts for a call CallKit is not showing;
a snooze did not, so a snoozed chat still asked when its caller rang.
postIncomingCall takes a required snoozed flag and folds it into mute,
and the call manager passes whether the caller's chat is snoozed right
now, so the banner is held exactly as mute holds it and comes back when
the snooze ends. CallKit's own ringing is not touched.

The tests ring a call manager with a fresh offer, as the inbox hands
one over, and read a new debug hook for the banners that the settings,
mute and a snooze let through, ahead of the app-in-front check a test
host always fails. Snoozed, the offer rings and no banner gets through;
with the snooze run out, the same banner does. Each test hangs the call
up, so no ring or poll outlives it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:14:35 -04:00
Paul HittandClaude Opus 5.5 fe89b90a71 A profile picture and a delivery key sent alone are submitted silent
§8.4 submits silent the traffic that is not a message to a person, and
names delivery keys among it; §6.10 counts avatar as control with them.
Both went out announced. A delivery key sent on its own, as a session
repair or carrying a sealed group's key or a group's name, parses as an
unsupported attachment, and a profile picture carries an attachment, so
warrantsAlert counted either as something to see. Creating or renaming
a group, adding a member, or changing a picture buzzed the other phones
with "New message" and raised the icon badge, with nothing new to read.

warrantsAlert now answers no for a delivery_key and for an avatar. A
delivery key riding ahead of a message is unchanged: it shares the
message's request and takes the message's flag. Android already sends
both silent.

The new test reads the flag off the request for a group's name at
creation and on a rename, a sealed group's key, a new profile picture,
and a repair sent after the receiving side lost its session. The table
test moves the four kinds from their reported list to the silent one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:11:33 -04:00
Paul HittandClaude Opus 5.5 2aea4127ea A snoozed chat holds its notices until the snooze ends
A snoozed thread says "Snoozed, messages still arrive, this chat just
stops asking", and nothing on the way to a notification read the
snooze: it was stored and drawn, and every notice was posted as before.

postIncomingMessage takes a required snoozed flag and folds it into
mute, so a snoozed chat is held exactly as a muted one is, sound
included, and only while the snooze lasts. Every notice the inbox
raises passes it: messages, cards, events and their changes, and game
notices. Only a one-to-one chat can be snoozed, so group and federated
threads pass false. Reply reminders still fire, as the snoozed thread's
own line says, and the incoming-call banner is not changed.

The tests listen at both ends of the notification path, through a new
debug hook for what is handed to the system. In a snoozed chat a
message, a card, an event, its revision, a game invitation and a move
all arrive, all six reach the path, and none is posted; once the snooze
runs out, or "Wake now" is chosen, the chat is heard again; the same
person in a group, and somebody else one to one, are heard throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:39:46 -04:00
Paul HittandClaude Opus 5.5 72067284e3 Edits, reactions and pins are submitted silent
An edit carries its new wording in the text field, so warrantsAlert
counted it as something to see and the relay was asked to announce it:
the other phone got a second alert, sound and badge for a corrected
typo. §6.15 says an edit MUST NOT raise a notification, as §6.9 and
§6.19 say of reactions and pins.

warrantsAlert now answers no for any edit, reaction (to a message or a
story) and pin. Reactions, story reactions, pins and story caption edits
already went out silent, because their text is empty or they were
special-cased; now they do by rule.

The first two tests read the flag off the POST /e2e/messages request: a
one-to-one edit, reaction, reaction taken back, pin and pin taken down;
a group reaction, a private group reaction, a story reaction and a
story's words changing. An ordinary message and a group message are
the announced controls. The third pins the whole table of kinds as this
app sends them, including four that go out announced against §8.4 (a
profile picture, and a delivery key sent on its own as a repair, a
sealed group's key or a group's name), reported and not changed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:37:05 -04:00
Paul HittandClaude Opus 5.5 d7d0c83e7f Test: a ctl written as the string "0" or "false" is control
As on Android: only the number 0, false and null leave an envelope as
anything but control. The strings "0", "false" and "null", and an empty
string, are values like any other, so an unknown kind carrying one is
dropped and a card carrying one is not drawn. iOS already read them this way
after 9d1612f5; this pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:30:46 -04:00
Paul HittandClaude Opus 5.5 12712e910b Merge branch 'feature/0-32-stories' into 'release/0-32-0'
Stories: take one down for everyone, fix its caption, and the whole photo,
never cut (DESIGN items 25 to 28).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:29:59 -04:00
Paul HittandClaude Opus 5.5 9291274b04 Cards, events and games ignore a per-message timer
DESIGN decision 46. A §6.17 secs on a card, an event or a game set that
message's own timer; in 0.32 it is ignored, and the chat's disappearing
timer still applies to them. A text keeps its timer. The test failed on the
old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 073c703dd5 Events: an all-day event runs 1 to 31 days
DESIGN decision 45. days was clamped to 1 to 366 on arrival and the composer
sent up to 366; now above 31 shows as 31, below 1 as 1, and the composer
sends at most 31. The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 9d1612f5e5 Any ctl but 0, false or null makes an envelope control
DESIGN decision 43, §6.10's "carrying ctl". ctl was read as a flag, so a
value it did not know, "1" written as a string among them, read as off and
the envelope was drawn: an unknown kind as the unsupported placeholder, and
a card or an event as a bubble although ctl wins over both. Now any value
other than 0, false or null is control. The other flags keep their own
reading. The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 cf4fe8ade3 Today: notes, dates, made, country and zone, as DESIGN 42 has them
At most five notes of 200 characters each (the app kept ten of 300). A
weather day whose date is not a real yyyy-MM-dd is dropped, where it was cut
to ten characters and kept. A fractional made refuses the file, where it was
rounded. A country is two ASCII letters or is ignored. A zone over 64
characters is ignored rather than cut, and a zone the phone does not know
shows the days as the file gives them, where they were cut by the phone's
own date. Market figures were already capped at 24, and lat and lon are not
read at all. Each new test failed on the old code first, the 24-character
one excepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:04 -04:00
Paul HittandClaude Opus 5.5 b9a63ecb93 Hello links: ASCII hosts lowercased by ASCII rules, and no spaces
DESIGN decision 41. The host was lowercased with Unicode rules before it was
checked, so a Kelvin sign became a "k" and one host read as another. The
address is now checked to be ASCII first and lowercased by ASCII rules
alone; a pasted address gets the same check. The scanner read codes through
the paste path, which trims round the at-sign for somebody typing, so
"fnid:… @ relay" was taken; it now reads the code itself and refuses any
space in the address. A slash after the app link's hi was already refused.
The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:07:28 -04:00
Paul HittandClaude Opus 5.5 5105b18bb1 Story composers: the feed's page colour behind the preview
The preview's bars were white on the white sheet, so a narrow photo did not
look the way the feed will draw it. Both composers now sit on the feed's
own page colour. The debug preview's fixture photos keep their labels
inside the picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:55:47 -04:00
Paul HittandClaude Opus 5.5 3699d02135 Stories: an unpost naming both a story and a set is dropped
An `unpost` names exactly one of `sid` and `set`. One naming both is now
dropped, like one naming neither, instead of taking down both. A per-message
`secs` (§6.17) on an `unpost` or a caption edit plays no part; a test pins
both rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:55:47 -04:00
Paul HittandClaude Opus 5.5 c744a2b0bb Stories feed: the whole photo, never cut; take-down and caption controls
The feed card drew every photo in a fixed 240pt band, filled and cropped,
so the top of a tall photo was cut off. The card now takes the photo's own
shape between half and one and a quarter times its width (180 to 451pt on
a 393pt iPhone, 204 to 510pt on a 440pt Pro Max, at most 560pt anywhere),
with plain bars beside or above a photo narrower or wider than that. The
composer previews the photo with the same view at the same width, so what
is posted is what friends see. Nothing was cropped on upload: ImagePrep and
VideoPrep both scale to fit.

My own story has Edit caption and Take down on the card, in the long-press
menu, in the full-screen viewer, and Take down in the archive. The take-down
asks first, in the words both apps use. Small thumbnails in the sequence
composer centre on the top third. `-FNMSGStoriesPreview` (debug builds only)
shows the feed, the confirmation, an edited caption, the composer and the
caption editor with fixture photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:43 -04:00
Paul HittandClaude Opus 5.5 e7114480e1 Stories: take one down for everyone, and change its caption
Taking a story down (§6.35) removes my copy at once: the strip, the archive,
the decrypted bytes, the reactions and seen marks it carried, and any copy
still queued for somebody who could not be reached. Then an `unpost` goes to
the people the story went to and to my other devices, as silent control
traffic: `{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}`, or
`"set":"<set id>"` for a sequence, which comes down whole. A story now
records who it was sent to when it is posted; one posted before that, or
restored from a backup, goes to today's audience, or the group's roster
today. The relay has no way for an uploader to delete a blob, so the blob
waits for the retention purge.

A caption change is §6.15's edit with `st`, naming the story by the id
replies use, and a text story changes its words the same way. It is shown
as edited, refused when empty, ordered by the relay's time, sent silently,
and never notifies.

A receiver applies either only to a story it holds from the sender of the
envelope, and only a proven device of my own can take down or change a
story of mine. An unknown id is dropped. `set` is read as an object on a
story and as a string on an `unpost`, and a `set` or `sid` of any other
shape no longer costs the whole envelope. The backup marks an edited
caption with "edited".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:43 -04:00
Paul HittandClaude Opus 5.5 0fa0354762 Today: a lowercase kid, versions that are exactly 1, markets with a figure
DESIGN decision 38. The kid is lowercase hex, the form the app computes; the
app lowercased whatever arrived, so A9A94E74 was taken for a9a94e74. Both
versions, the envelope's and the body's, must be the JSON integer 1: the
lenient integer reader rounded, so 1.4 and 0.6 were taken for 1. A market
needs a name and its last figure, the symbol staying optional; an empty
figure was kept. Each test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:39:30 -04:00
Paul HittandClaude Opus 5.5 5dc7872aa5 Today: never follow a redirect
DESIGN decision 38: the file comes from the home relay and nowhere else.
URLSession follows redirects by default, so a relay answering 302 sent the
request on to wherever it pointed. The session now has a delegate that
refuses every redirect. The 3xx comes back as the relay's own answer, which
counts as a relay without a file right now: the last good copy stays and the
page says so.

The test points a loopback relay's 302 at a second loopback server holding a
good file. It failed on the old code first, the second server receiving the
request; now the fetch reports 302 and the second server hears nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:31:03 -04:00
Paul HittandClaude Opus 5.5 2adea2699d Games: a draw offer before the invitee's first move does not count
DESIGN decision 37. Until the invitee has moved, the invitation is still
open: the invitee can decline it and the starter can cancel it, and there is
no game yet to call a draw. This phone's own button already held back, but an
offer from the other phone in that window was taken. Now it is ignored. The
test failed on the old code first; its control, the same offer after the
invitee's first move, counts.

The other parts of decision 37 already held here: a gmove with no log or an
empty log and a gend with no log are dropped when read, and a draw-accept
counts only at the full known log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:39 -04:00
Paul HittandClaude Opus 5.5 3fe70b0890 Hello links: the heads exactly, with no query and no trailing slash
DESIGN decision 35. Only https://flamenetmessenger.com/hi#, /es/hi# and
flamenetmessenger://hi# are hello links: no query string, the path exactly
/hi or /es/hi in its own case with no trailing slash, the scheme and host in
any case. The app took /hi/# and /hi?ref=x#, which the other app and the
relay's page do not. The test that now refuses them failed on the old code
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:39 -04:00
Paul HittandClaude Opus 5.5 9f32a14c20 Cite the Today file as SPEC §8.12
DESIGN decision 34: §8.11 is push-token registration, which older code
already cites, and the spec gains that section to match. The Today file is
§8.12. Every 0.32 citation of §8.11 for it changes; the push-token ones stay.
A test reads every Today file and the tab bar for a stray §8.11, with the
push-token citation as its control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:28 -04:00
Paul HittandClaude Opus 5.5 64218c5270 AFailedRetirementIsRetriedTests waits for the app, not the stub
The test waited until the stub relay had recorded the revoke request, then
checked that the app had cleared its note. The stub records the request
before it answers, and the app clears the note only once the answer reaches
it on the main actor, so on a busy host the check could land in between and
fail an app that was about to do the right thing. It now waits for the note
itself, the app's own record of the reply, and then checks the relay was
asked.

Measured with the two waits side by side while the main actor was kept busy
in 40 ms stretches, ten rounds each: the old wait failed 10 of 10 and the new
one passed 10 of 10. The committed test also ran 30 times in a row with the
machine at a load average over 160: 30 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:30:18 -04:00
Paul HittandClaude Opus 5.5 0b1969ddcf Search in Forward and Send a file can be read again
On iOS 26 and later `.searchable` puts its field at the foot of a sheet, on
a light ground, while the app styles every search field's text white for the
navy bar, so what somebody typed was white on near-white. Both sheets now put
the search box on a strip of the chrome under their own bar,
PortalSearchStrip, which carries on from the bar's gradient. The same thing
was seen and fixed on the 0.32 weather and send pickers.

Nothing in the app uses `.searchable` now, and a test keeps it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 6632094948 Scanners bind by fnid; an older card's username is ignored
DESIGN decision 32. Both places the scanner is opened, Home and Messages,
already handed on the address alone, and the card the scanner returns has no
place for the `?u=` an older card carries. A test now holds both: a code with
a username comes back as the same card without it, and every scanner call
site passes the address on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 aff420b180 Sign-out and a lost-device wipe clear the Today file and weather picks
DESIGN decision 32. signOut now clears the kept Today file and the cities
picked for weather, and every way out of an account goes through it: the
sign-out screen, a device removed from the account, and a lost-device wipe.
A request already on its way when the phone is cleared is dropped when it
returns, rather than putting the file back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 168c2fdb24 Parental controls hide Today and the directory
DESIGN decision 31, the owner's: while supervision is on, the Today tab is
not in the bar and its screen is not drawn even if it was the tab last open,
and the small-web directory, reached only from Today, goes with it. Both lead
straight out to the open web. It is not one of the gates a parent can
release; the supervision screen says so.

Tested beside the other supervision tests, which share the one process-wide
PIN: Today is in the bar without supervision, leaves when it is switched on,
stays hidden with every gate released, and comes back when it is switched
off. A source check confirms the screen sits behind the same rule and that
nothing but Today opens the directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 2134efe05e Games: tic-tac-toe is never turned round, and a test says so
DESIGN decision 30. Chess, checkers and reversi turn round for the second
player; tic-tac-toe's cells keep their places on both phones. The board view
already did this inside a private property nothing could test. The rule and
the cell mapping are now GameBoardView.turnsRound and boardCell, tested for
every game and both sides, with chess and reversi as the control that a
turning game does turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:57 -04:00
Paul HittandClaude Opus 5.5 8800186062 Games: an invitation counts as unread, a move does not
DESIGN decision 29. The invitation already read "Sam invited you to play
chess" and came with a sound. The unread rule did not hold: every game
message that arrived as a new bubble added to unread, and a move whose
invitation was lost builds a new bubble from its log (rule 1), so a lost
invitation turned the first move into an unread one. Now only an invitation
adds to unread.

Tested between two inboxes on a stub relay: an invitation is unread and said
out loud; a first move whose invitation was dropped on the way builds the
board, is said quietly and adds nothing to unread; a later move adds nothing
either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:18:56 -04:00
Paul HittandClaude Opus 5.5 626dcfe318 Version 0.32.0, build 32
The 0.32 release: stickers in FlameNet's art, cards with sealed until,
events with answers, games in a chat, the Today tab, the small-web
directory, and hello links. Build number follows the release, per the rule
above it in this file.

Checked: after a regenerate, project.pbxproj carries MARKETING_VERSION =
0.32.0 and CURRENT_PROJECT_VERSION = 32. No archive and no upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:01:37 -04:00
Paul HittandClaude Opus 5.5 c1e58ad632 Ship the directory as a folder, as stickers and cards are
The two branches met in project.yml without a textual conflict: the
stickers and cards folders became folder references so the bundle keeps the
paths both apps name, while directory/directory-v1.json was still copied to
the bundle root by the Resources group. It now keeps its path too. The
loader looks in directory/ first and at the root second, so either layout
reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:01:30 -04:00
Paul HittandClaude Opus 5.5 d1432a378f Merge branch 'feature/0-32-today-directory-hello' into 'release/0-32-0'
The Today tab, the small-web directory shipped in the app, and hello links
(DESIGN sections 4 to 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:00:34 -04:00
Paul HittandClaude Opus 5.5 0cc118d1f4 Merge branch 'feature/0-32-portal-kinds' into 'release/0-32-0'
Stickers in FlameNet's art, cards with sealed until, events with answers,
and games in a chat, with the games engine (DESIGN sections 1 to 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:00:28 -04:00
Paul HittandClaude Opus 5.5 a9bf45388a Today preview: start with no weather cities when the UI suite asks
The picks are kept in the phone's settings, so a run inherited whatever the
run before it had picked, and tapping a city an earlier run had picked took
it away again. -FNMSGTodayNoPlaces clears them when the preview opens; the
suite passes it and checks each city is picked after its tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:47:47 -04:00
Paul HittandClaude Opus 5.5 59d529c1bc Game card: Pass shares the button row; the marks read in the dark
The lone Pass button sat on a row of its own above Resign, and buttons that
appear together belong in one row. Tic-tac-toe's O was drawn in the channel
red, which is hard to read on the dark board, so both marks now have colours
of their own for each appearance, and the dots marking where a move can go
are smaller on the big tic-tac-toe cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:27:28 -04:00
Paul HittandClaude Opus 5.5 09a8a1bf84 Hello links: the host rule the relay's /hi page uses, word for word
The /hi page checks the host as letters, digits, hyphens and dots with at
least one dot, which is DESIGN decision 21 as written, and caps the fragment
at 400 characters. The app also refused empty labels, a leading or trailing
hyphen and labels over 63 characters, so a link the page offered to open
could be refused by the app it opened. Now both apply the same rule, and the
app takes the same 400-character cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:13:29 -04:00
Paul HittandClaude Opus 5.5 88ae6340f4 A DEBUG preview of the new screens, and a UI suite that drives it
-FNMSGTodayPreview opens the Today tab, the directory or the address card
with no account, because simctl can launch the app but cannot sign in. The
Today screen is the real one: the store, the request, the signature check and
the copy on disk, against whatever relay is named on the command line, with a
folder of its own per state name so the app's own store is never touched.
DEBUG builds only.

TodayScreensUITests drives it: the page with a file, picking two cities, the
foot of the page, the same page with the relay gone, a relay that does not
offer the file, browsing and searching the directory, and the hello link card.
The Today cases need TEST_RUNNER_FN_TODAY_SERVER and
TEST_RUNNER_FN_TODAY_UNOFFERED pointing at relays on 127.0.0.1 and skip
without them; screenshots go to TEST_RUNNER_FN_TODAY_SHOTS when it is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:12:06 -04:00
Paul HittandClaude Opus 5.5 41da4f1588 Hello links: on the address card, opened by the app, read by the scanner
A hello link (SPEC §6.34) is https://flamenetmessenger.com/hi#<address>.
The address is in the fragment, which a browser never sends, so the page's
web server never receives it.

The address card gains "Hello link": the link, Copy link and Share in one
row, and what it does and does not tell anybody. The QR code now holds the
hello link rather than flamenet:<address>?u=<handle>, so a phone camera with
no Flamenet app lands on a page that explains it; the code no longer carries
the username.

The app registers the flamenetmessenger scheme. Opening
flamenetmessenger://hi#<address> hands the address to Home's search box, the
landing a scanned code has, and does nothing else: the person reads who it is
and decides, first contact goes the way it always does, and nothing is sent
until they send it. Signed out, the address waits for sign-in and the person
is told. A link that is not one, or holds no address, gets a plain message.
Home now also goes back to its list first, so the address is seen even when a
chat was open.

Parsing follows DESIGN decision 21: the https link (/hi or /es/hi), the
app link, and bare or flamenet:-prefixed addresses; the fragment decoded
once; fnid: plus 52 characters of the FNID alphabet; the host lowercased,
letters, digits, hyphens and dots, with at least one dot. ContactCard.decode
reads a pasted hello link as its address and refuses one that holds none,
and no longer takes a URL with an at-sign in it for an address. The scanner
accepts only what passes the strict form, says so when a code is not an
address, and keeps scanning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:58 -04:00
Paul HittandClaude Opus 5.5 3389187590 The Today tab, and the directory's screens
Today sits in the tab bar right after Messages, as on Android: Home,
Messages, Today, Calls, Stories, Files, Wallet, Account. The file is asked
for when the tab opens and when the app comes back to the front, within the
hourly rule. The sections run in SPEC §8.11's order: headlines with their
source and the host each link goes to, markets under "Prices are delayed.
Not advice.", weather for the cities picked on this phone in °C or °F as the
phone's region and temperature setting say, the site of the day with the
file's note about its description, the blog post when there is one, then the
way into the directory and the credits. Every link opens in the browser on a
tap and not before. The header says when the file was made; offline or
refused, the kept copy shows with one plain line saying why. A relay that
answers 404 gets only "Today's page is not offered by this relay." and the
directory.

The directory browses by category and searches on the phone; the paper plane
on a site opens "Send to a chat", which lists 1:1 contacts and shows what will
be sent. Both pickers draw their search box on the chrome: a .searchable
field sits at the foot of a sheet on this system, light, where the app's
white search text cannot be read.

Eight tabs is one more than the bar was drawn for. On a 375pt phone each tab
has 46pt and "Messages" is 48pt wide, 51 in bold, so tab labels may shrink to
75 percent rather than lose their ends. A test measures every label against
that width. The tab list moved into MainTabView.tabItems so the screenshot
preview draws the same bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:38 -04:00
Paul HittandClaude Opus 5.5 4a3598da6c The small-web directory, shipped in the app, and what a share sends
directory/directory-v1.json is FlameNet's export of public, approved
listings: 2,964 sites in 90 categories. It is read from the app bundle, so
browsing and searching it ask nothing of any server and work with no
connection. Search folds case, accents and width, needs every word typed, and
looks at the title, the host and the description. Sites with no category are
found by search only, as on the site. A description is optional, and the
file's note about who wrote the descriptions is kept to be shown once.

"Send to a chat" sends an ordinary 1:1 text holding the link, with a §6.12
preview made from the directory itself, so nobody fetches the site, not the
sender and not the receiver. The preview carries the title only: the
descriptions were drafted with software and a preview reads as the site's own
words (DESIGN decisions 6 and 18). desc is present and empty, because iOS
builds up to 0.31 cannot read a preview without it. With link previews
turned off the link goes alone.

E2EInbox.send takes the preview as an optional argument; when given, it is
sent as it is and the fetcher is not called.

Checked between two inboxes on a stub relay: the receiver gets the link and
the title-only preview, and a trap on the shared URL session saw no request,
while the same link sent the ordinary way was fetched, so the trap works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:27 -04:00
Paul HittandClaude Opus 5.5 f114710564 Today: the signed file, the keys it is checked against, and the hourly fetch
The Today tab shows one public file from FlameNet (SPEC §8.11), served by
the home relay at /today.json and the same for everybody. This is the part
underneath the screen.

The file is shown only when its Ed25519 signature checks out against a key
compiled into the app, chosen by the kid the app computes for each pinned
key. The development key is pinned in DEBUG builds only; the production list
is empty until the release step creates that key, so a build without it
verifies nothing. Anything over 512 KB, unparseable, of another version,
signed by an unknown or wrong key, or without a valid date is refused and the
last good copy stays. Lists are cut to their bounds, strings are cut and
flattened, only http(s) links survive, and an older file never replaces a
newer one.

The request carries nothing about the person: its own ephemeral session with
no cookie store, no credential and no cache, a fixed User-Agent and
Accept-Language, no query string, and the obfs4 bridge applied as the other
relay clients apply it. It goes out at most once an hour, counted from the
relay's last answer, and five minutes after a connection that got none
(DESIGN decision 19). If-None-Match is sent only when the stored ETag is the
SHA-256 of the copy held, so no relay can tag a phone. The last good
envelope is kept on disk, excluded from backups, and checked again when it is
read back.

Tests sign their own files with keys they make, plus one fixture signed by
the development key with OpenSSL. The request is checked twice: as built, and
on the wire through a loopback server, with a control proving that server
sees a cookie when one is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:11:18 -04:00
Paul HittandClaude Opus 5.5 cdccfce895 Group sends: a queued bubble leaves pending once it goes
A group message that reached nobody at first, because no member had a
device yet, said "pending" for ever. Its queued copies carried an empty
`mid`, and the restate after they went matched only a bubble with no `mid`
at that moment, while every group send gives its bubble one. The queued
copies of group text, photos, stickers, cards and events now name their
bubble, and the restate after a flush, or after a refusal waiting cannot
fix, finds it by that id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:04:15 -04:00
Paul HittandClaude Opus 5.5 60b6139991 Group stickers: send them to a group, and file them in the group
A sticker that named a group went into the sender's one-to-one thread, and
a group had no way to send one. A sticker with `gid` is now a group message,
delivered and notified like a group text, and the group composer has the
same sticker control as the one-to-one composer. The group envelope carries
`gid` and the group's name in `gname`, with one `mid` for the whole fan-out,
and members with no device yet get it from the queue like any group send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:04:11 -04:00
Paul HittandClaude Opus 5.5 bc03ecadd7 Federated threads: draw cards and events
A card or an event from someone on another relay was kept on the message
but the screen drew only its text, which is empty for both, so it showed
as an empty bubble. The thread now draws the card, or the event card
without buttons, since nothing there can answer or change one, and the
conversation list shows the same summary line as other chats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:41:32 -04:00
Paul HittandClaude Opus 5.5 7a1a2a2ac1 Game screens: the board, the card, starting a game
Game is in the one-to-one attach menu: pick chess, checkers, reversi or
tic-tac-toe and who goes first. The card shows the board as the engine
draws it, turned round for the second player (tic-tac-toe keeps its
numbers in place), whose move it is, and the moves so far under a fold.

Tap a piece and the squares it can reach are marked; tap one to move. A
pawn reaching the far rank asks what it becomes. A checkers jump is entered
landing by landing and sent once it is a whole move. Reversi marks its
legal squares and shows a lone Pass button when that is the only move.
Every square reads out its name and what stands on it.

The buttons sit in one row: Decline or Cancel invitation while the
invitation is open, Resign and Offer a draw after it, Accept draw and
Decline on an offer, and Start a new game once the boards disagree, which
sends a fresh game of the same kind with the same sides. A game is never
offered for forwarding.

`-FNMSGKindsPreview` (debug builds only) has a mode for each board, a
finished game, an invitation, the out-of-step card and the new-game sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:41:32 -04:00
Paul HittandClaude Opus 5.5 7a7045a8d4 Games: the envelopes, the rules, the inbox and the backup
A game is played inside a one-to-one chat (§6.33). `game` starts it and is
the message its board lives on. `gmove` carries the whole log after each
move and `gend` an ending or a draw offer, both as control traffic. Each
phone rebuilds the board from the log with the engine's capped replay and
checks that every new ply is the sender's and legal. A log that disagrees,
a move for this person's side or a log past the cap stops the game as out
of step, and nothing more is taken for it.

A draw is offered only by the side that has just moved and accepted only
against that pending offer. The invitee can decline and the starter can
cancel until the invitee's first move, judged on the log the gend carries,
so a cancel that crosses that move ends the game on both phones. A move for
a game this phone never saw rebuilds it from the log. A move whose send
fails is taken back with an error and never queued; the start is queued
like a card.

Moves and endings are announced, and the app says them itself: with no
sound for "Your move: chess with Sam" and "Sam offers a draw: Chess", with
one for an invitation and for "Chess with Sam: They resigned. You won".
Nothing is said for this person's own moves or a turned-down offer.

A group's game draws as unsupported and its moves are dropped; a start
from another relay draws as unsupported too. The game lives on its start
message, so history, the disappearing timer, delete and off the record
cover it. The backup carries the game object both apps read: id, t, me,
log, and r with rby when a gend ended it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:41:32 -04:00
Paul HittandClaude Opus 5.5 0d57c3c888 CI: run the games engine's own tests
A games-engine job runs `swift test --package-path Packages/FlamenetGames`
on the mac-local runner for merge requests and main, the same rules as the
other jobs. It needs no simulator; a cold run on this Mac took 12 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:56:16 -04:00
Paul HittandClaude Opus 5.5 254b8fbe10 Build the app against FlamenetGames
The app target depends on the local games package from the merge before
this, so the chat code can replay a game from its log. Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:56:16 -04:00
Paul HittandClaude Opus 5.5 4cb4807203 Merge the games engine (FlamenetGames) into the 0.32 chat kinds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:48:08 -04:00
Paul HittandClaude Opus 5.5 60e1687293 STICKERS.md: several packs, pictures, and what a sticker draws
Describes the packs as they ship: every manifest under `Resources/stickers/`
is a pack, a sticker is named by pack and id together, `image` lives in a
folder named for the pack, what a sticker draws when something is missing,
and one picker section per pack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:47:59 -04:00
Paul HittandClaude Opus 5.5 245155afe6 Events: answers by the second, cancel wording, the organizer's weekday
Brings events in line with the Android app:

- An answer's `n` is the unix second it was given, or one past the last `n`
  this device sent for the event when that is later. The highest `n` wins,
  and on an equal `n` the answer that sorts higher as a string (`yes`, then
  `no`, then `maybe`), so every phone settles on the same answer whatever
  order they arrive in. This also covers one person answering from two
  devices.
- A cancellation notifies "<title> was cancelled" and the card reads
  "Cancelled: <title>". A revision notifies "<title> changed" by the title
  the reader had before. A rev 1 that settles a provisional organizer, a
  duplicate and a stale revision say nothing.
- An event id only has to be a non-empty string.
- The organizer's time line names the weekday when the organizer's date
  differs from the reader's ("Wed 7:00 PM New York time").
- The backup writes a card or an event with empty `text`, whatever the
  message held, so a sealed card's words stay inside `card`.

Code and test names now cite §6.31 (greeting cards) and §6.32 (events and
answers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:47:59 -04:00
Paul HittandClaude Opus 5.5 b220b9e7a9 Cards: show a sealed card's line on first draw; event links in readable blue
A sealed card read the time from its timeline's context, and the first draw
can be handed the scheduled opening time itself, so a card still sealed drew
as open: no "Opens on" under it and no "Sealed until" under the sender's
copy. It now reads the clock each time it draws and is woken a second after
it opens. Add to calendar and Edit take the deeper blue, which stays
readable on the dark card. The preview shows both sealed lines on one screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:04:36 -04:00
Paul HittandClaude Opus 5.5 d948e038cc Sticker picker: key each cell by pack and sticker id
The core pack and FlameNet's both have a `star`, a `cake` and a `coffee`.
The picker keyed its cells by sticker id alone, so SwiftUI drew the second
of each pair as an empty cell. `Sticker.key` is the pack and the id together,
the picker uses it, and a test checks the shipped keys are unique. The picker
also takes a height, for the debug preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:04:36 -04:00
Paul HittandClaude Opus 5.5 86ce6cbcd0 Add FlamenetGames, the rules engines for games in chats
A local SwiftPM package with no dependencies: chess, checkers, reversi
and tic-tac-toe as DESIGN.md section 2 describes them. Moves are strings
in the wire notation, positions are immutable values, and apply accepts
exactly the strings legalMoves returns, so a log replays the same way on
both apps. Replay refuses a log over the game's cap before playing it and
names the index of the first move it cannot play.

The tests run games-v1.json, which the Android :games module runs byte
for byte: the perft counts from DESIGN.md and 147 cases. Deeper published
perft counts run with FLAMENET_GAMES_DEEP=1.

Not wired into project.yml or the app target yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:02:32 -04:00
Paul HittandClaude Opus 5.5 7f80e1346c Card and event screens: compose, sealed until, answers, Add to calendar
Card and Event join Photo and File in a one-to-one chat's attach menu, and a
group's paperclip becomes the same menu. A card is one of the nine
flamenet.io fronts, shipped as `cards/<design>.png` with its title drawn as
text, and can stay sealed until a day up to 400 days ahead. The receiver sees
the front and "Opens on <date>" until then, and the card opens by itself on
the day; the sender's copy says "Sealed until <date> for <name>". Copy,
reply, forward, report, the conversation list and the notification never
show the words of a card that is still sealed.

An event card shows when and where, the organizer's time when their clock
reads differently, Going, Maybe and Can't go with counts and names, and
"Everyone in this chat sees your answer." The organizer can change or cancel
it. Add to calendar opens the system's own add-event sheet, which on iOS 17
and later needs no calendar access and no Info.plist key; a test presents it
and checks that nothing asked.

`-FNMSGKindsPreview` (debug builds only) shows the new screens with sample
data, for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:46:27 -04:00
Paul HittandClaude Opus 5.5 8d32368b3c Cards, events and answers: the envelopes, the inbox and the backup
Three content kinds that travel inside the ratchet, so the relay sees nothing
new: `card`, a greeting card that can stay sealed until a date; `event`, an
invitation its organizer can revise or cancel under a higher `rev`; and
`rsvp`, an answer, marked `ctl` so older builds drop it.

Both apps write the same bytes: compact JSON, keys in the shared order,
slashes left unescaped. Reading is lenient: unknown fields are ignored, flags
take 1 or true, a number with a fraction is read as a whole number, a field
of the wrong type is absent, and a card or event that carries `ctl` is
consumed as control.

A card or an event lives on the message it arrived on, so history, the
disappearing timer, delete and off the record (held in memory, filtered from
every save) apply with no new store. A revision folds onto the card it
revises: the highest `rev` wins, and only the sender of rev 1 may revise. An
answer folds by the highest `n` per person, never notifies, and goes to every
member of a group; one that arrives before its event waits in memory for it.
With no sent copies yet, a proven envelope from this account's own device is
placed as its own in a group and dropped in a one-to-one chat.

The backup carries `card`, `ev`, `org` and `rsvp` on each message, in the
shape both apps read. The field test now reads the whole type, so the fields
declared after a computed property on a message are checked too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:46:16 -04:00
Paul HittandClaude Opus 5.5 a13c7831f5 Stickers: load every shipped pack and draw its art
The picker shows each pack under `stickers/` as its own section: the core
pack first, then FlameNet's 24 drawings (`flamenet.retro`). A sticker draws
its image when it has one, its glyph when it has none or the image will not
load, and its pack's fallback when this build does not know the id. A pack
this build does not ship draws the core fallback, as docs/STICKERS.md
describes.

`Resources/stickers` is now a folder in the bundle, not a group, so each
pack's PNGs sit in a folder named for the pack and two drawings with the same
file name cannot collide at the bundle root. The SVG sources and the render
script are in `Art/stickers/`; the PNGs are the bytes the Android app ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:46:01 -04:00
Paul Hitt 3666a1b771 Merge branch 'docs/roadmap-refresh-2026-09-29' into 'main'
Roadmap: recheck every item against main, 2026-09-29

See merge request extant2000/flamenet-messenger-ios!236
2026-09-29 20:19:49 +00:00
Paul HittandClaude Opus 5.5 db16e82592 MIGRATION.md: record what is built; roadmap entry to match
Steps 1 and 2 are built on both apps (Mailboxes in Settings), step 4 partly
(publishes first, but does not delete the account left behind and cannot
remove the relay you are signed in to), step 3 not at all. The roadmap entry
said most of this was still to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:05:25 -04:00
Paul HittandClaude Opus 5.5 b8d930d3b9 Roadmap: recheck every item against main, 2026-09-29
Most of the queue has shipped. Moved those to a Done section with where each
lives. Open: quiet delivery (the wire flag exists, the choice does not),
relay migration steps 2-4, groups off the relay, per-contact identity,
local-network delivery, multi-device on the apps, passphrase floor,
key-log default, web client, duress, attachment tokens, credential
rotation, age-off and tombstones, the group-outbox recipient test. Held:
bridge distribution (OTF IFF concept note) and protocol-shape changes until
the audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:01:52 -04:00
Paul Hitt f05f43dc84 Merge branch 'docs/export-renotification-2026-09-28' into 'main'
Export compliance: record the 2026-09-28 re-notification

See merge request extant2000/flamenet-messenger-ios!235
2026-09-28 19:32:00 +00:00
Paul HittandClaude Opus 5.5 4d3b95eb7f Export compliance: record the 2026-09-28 re-notification
The source moved to gitlab.com/extant2000 and flamenet-elligator2 was
published, so BIS and NSA were notified again. The first entry is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:12:07 -04:00
Paul Hitt 3e623e1fdd Merge branch 'chore/move-to-extant2000-group' into 'main'
Point links at the extant2000 GitLab group

See merge request extant2000/flamenet-messenger-ios!234
2026-09-28 19:08:28 +00:00
Paul HittandClaude Opus 5.5 371e6b4a59 Point links at the extant2000 GitLab group
The messenger repositories move from gitlab.com/paulhitt to
gitlab.com/extant2000. Old web and git URLs redirect; the relay's container
image path does not, so docker-compose.yml names the new registry path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:38:57 -04:00
Paul HittandClaude Opus 5.5 7b45ca7acc Merge branch 'fix/pin-the-ios-packages' into 'main'
Pin the six packages Obfs4 brings in, to the versions it proved

See merge request paulhitt/flamenet-messenger-ios!233

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:50:38 -04:00
Paul HittandClaude Opus 5.5 900cde30e9 Pin the six packages Obfs4 brings in, to the versions it proved
Scripts/Package.resolved had `"pins": []`. It emptied correctly when WebRTC
moved into the local Packages/WebRTCBinary (b66d2991), and stayed empty when
the Obfs4 submodule added swift-crypto and swift-nio from GitHub. So every
build resolved those against the network, and a build with
-disableAutomaticPackageResolution failed:

  an out-of-date resolved file was detected ... dependencies were added:
  'swift-crypto'

Now pinned to exactly what Packages/Obfs4/Package.resolved pins, the set its
lyrebird interop suite runs against: swift-crypto 3.15.1, swift-nio 2.102.0,
swift-asn1 1.7.2, swift-atomics 1.3.1, swift-collections 1.6.0,
swift-system 1.8.1. A fresh `generate.sh --update` would have taken newer
asn1, collections and nio than Obfs4 has tested.

Checked: after generate.sh, xcodebuild with -disableAutomaticPackageResolution
builds and reports those exact versions; the old empty file fails the same
strict resolve with the error above. iOS unit tests on the pinned set:
1371 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:36:33 -04:00
Paul Hitt 7601d46982 Merge branch 'fix/donations-are-not-in-the-app' into 'main'
No Donate row, and version 0.31.0

See merge request paulhitt/flamenet-messenger-ios!232
2026-09-25 22:24:35 +00:00
Paul HittandClaude Opus 5 634a881ac5 No Donate row, and version 0.31.0
App Review rejected 0.29.0 under guideline 3.1.1 on 2026-09-25: the
Profile row opening the project's donate page counts as a donation
toward the app's own development, which that guideline requires to go
through In-App Purchase whether or not anything is unlocked. The old
comment argued the opposite, citing 3.2.1, and was wrong. Opening the
page outside the app does not avoid it either.

So the row is gone. The choice 3.1.1 leaves is In-App Purchase or
nothing, and this app does not put a payment flow in front of people to
fund itself. The page is still served at /donate for anybody who looks.

`SiteConfig.donateURL` stays, unlinked, with a comment saying why it is
not dead code: the address is still right, and a constant explaining
itself is a better answer to "why is there no Donate row" than an empty
file and a git log.

`DonatingGoesToTheProjectTests` is deleted. It existed to assert the row
was present and pointed at the project's own site.

Version 0.31.0, build 31. Not 0.30.0: that was tagged before this fix and
would archive with the row still in it, so the tag is left alone and the
release moves up rather than being rewritten under itself.

One thing worth leaving a marker for. The first attempt at this comment
named the host in prose, and `ThePolicyIsReachableFromTheAppTests`
forbids that string ANYWHERE in this file, comments included, so a
self-hosted relay's users cannot be sent to somebody else's policy. It
reads raw source and cannot tell prose from code, which is the right
kind of blunt. It surfaced as "964 tests, 0 failures" followed by
"** TEST FAILED **", because that count is XCTest's and the guard is a
Swift Testing expectation, reported separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:04:49 -04:00
394 changed files with 58797 additions and 3072 deletions
+1 -1
View File
@@ -1 +1 @@
3564030409e2ac0b192ae1f0f049421996e75b7c
4b4042f72880758ee2dea7713f7a2cb63fa12f73
+2 -2
View File
@@ -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
+355
View File
@@ -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
-371
View File
@@ -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 -1
View File
@@ -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
+3
View File
@@ -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.
+191
View File
@@ -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();
+13
View File
@@ -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

+20
View File
@@ -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

+43
View File
@@ -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

+18
View File
@@ -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

+22
View File
@@ -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

+21
View File
@@ -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

+51
View File
@@ -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

+7
View File
@@ -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

+19
View File
@@ -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

+42
View File
@@ -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

+23
View File
@@ -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

+7
View File
@@ -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

+27
View File
@@ -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

+11
View File
@@ -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

+16
View File
@@ -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

+13
View File
@@ -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

+30
View File
@@ -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

+13
View File
@@ -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

+15
View File
@@ -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

+27
View File
@@ -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

+8
View File
@@ -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

+15
View File
@@ -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

+36
View File
@@ -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

+24
View File
@@ -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

+31
View File
@@ -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
+1 -1
View File
@@ -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)
}
+22
View File
@@ -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)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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)"
}
}
+4 -4
View File
@@ -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
+8
View File
@@ -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>
+21
View File
@@ -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>
+108
View File
@@ -0,0 +1,108 @@
{
"sourceLanguage" : "en",
"strings" : {
"CFBundleDisplayName" : {
"comment" : "App name under the icon. Brand, not translated.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet"
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet"
}
}
}
},
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "FlamenetMessenger"
}
}
}
},
"NSCameraUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet uses the camera to scan a contact's address code. Nothing is recorded and no image leaves your device."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet usa la cámara para escanear el código de dirección de un contacto. No se graba nada y ninguna imagen sale de tu dispositivo."
}
}
}
},
"NSFaceIDUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet can require Face ID before opening the app, so somebody holding your unlocked phone cannot read your conversations."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet puede pedir Face ID antes de abrir la aplicación, para que nadie que tenga tu teléfono desbloqueado en la mano pueda leer tus conversaciones."
}
}
}
},
"NSMicrophoneUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet uses the microphone for voice notes and voice calls, which are end-to-end encrypted before they leave your device."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet usa el micrófono para las notas de voz y las llamadas de voz, que se cifran de extremo a extremo antes de salir de tu dispositivo."
}
}
}
},
"NSPhotoLibraryAddUsageDescription" : {
"comment" : "Permission prompt. DRAFT, needs native review.",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Flamenet can save a story you posted yourself to your photos, so you keep it after it disappears for everyone else. It only ever adds; it cannot see the photos you already have."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Flamenet puede guardar en tus fotos una historia que publicaste tú, para que la conserves cuando desaparezca para todos los demás. Solo puede agregar; no puede ver las fotos que ya tienes."
}
}
}
}
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because it is too large Load Diff
+31
View File
@@ -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" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+58 -4
View File
@@ -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
}
+51
View File
@@ -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)
+5 -3
View File
@@ -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:-}"
+251 -97
View File
@@ -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 }
+9 -9
View File
@@ -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.")
}
}
+71 -1
View File
@@ -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()
+48 -11
View File
@@ -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)
}
+6
View File
@@ -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) {
+44
View File
@@ -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 {
+2 -2
View File
@@ -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)))).")
}
}
}
+11 -10
View File
@@ -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
+2 -6
View File
@@ -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
}
+8
View File
@@ -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)
}
}
+5 -1
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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()
}
}
+46 -29
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More