Skip to content

chore: version management via /VERSION + Xcode Cloud post-clone hook#16

Merged
torlando-tech merged 2 commits into
mainfrom
chore/version-management
Apr 25, 2026
Merged

chore: version management via /VERSION + Xcode Cloud post-clone hook#16
torlando-tech merged 2 commits into
mainfrom
chore/version-management

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

Adds a single source of truth for the app version (/VERSION) and an Xcode Cloud post-clone script that rewrites the project's MARKETING_VERSION and CURRENT_PROJECT_VERSION on every build. Lets you bump the visible version (any of major/minor/patch) by editing one file, and gets unique TestFlight build numbers for free per push.

The first TestFlight upload after merge will be 0.0.2 — picking up from the existing 0.0.1 build.

How it works

What Where it lives When it changes
MARKETING_VERSION (CFBundleShortVersionString, what users see — 0.0.2) /VERSION You edit it manually
CURRENT_PROJECT_VERSION (CFBundleVersion / build number) computed in CI git rev-list --count HEAD on every Xcode Cloud build
Network extension version Sources/ColumbaNetworkExtension/Info.plist via $(MARKETING_VERSION) / $(CURRENT_PROJECT_VERSION) substitution Auto-follows host app (Apple requires this)

The CI script (ci_scripts/ci_post_clone.sh) is invoked automatically by Xcode Cloud after clone, before build. It uses sed to rewrite the four MARKETING_VERSION and four CURRENT_PROJECT_VERSION build settings in project.pbxproj.

Workflow

  • Just iterating — push as normal. Build number auto-increments. TestFlight will see 0.0.2 (47), 0.0.2 (48), etc.
  • Releasing a new version — edit /VERSION (e.g., 0.0.20.1.0) and push. Next upload becomes 0.1.0 (N).

Notes

  • Sources/ColumbaNetworkExtension/Info.plist had hardcoded 1.0 / 1 for CFBundleShortVersionString / CFBundleVersion, which would have caused upload rejection once the host app version drifted away. Switched both to $(MARKETING_VERSION) / $(CURRENT_PROJECT_VERSION) to track the host automatically (matches how every other field in that file already used $(...) substitution).
  • Local builds (e.g., columba-deploy) don't run the CI script and use whatever's in pbxproj at the time. The committed pbxproj has MARKETING_VERSION = 0.0.2 and CURRENT_PROJECT_VERSION = 1 so local builds produce a sensible version. CI overwrites both at build time.

Test plan

  • Script runs cleanly locally and rewrites pbxproj correctly
  • Release sim build embeds 0.0.2 / 204 (script-set count) in ColumbaApp.app/Info.plist
  • Xcode Cloud build Cannot build - Swift dependencies seems not right #10 (or next) shows 0.0.2 (N) in TestFlight where N is current commit count
  • Network extension .appex Info.plist resolves to 0.0.2 (N) — verify after first device-target archive

🤖 Generated with Claude Code

- /VERSION holds the human-controlled MAJOR.MINOR.PATCH (currently 0.0.2,
  picking up from the existing 0.0.1 TestFlight build). You bump any
  component by editing this single file.
- ci_scripts/ci_post_clone.sh runs on every Xcode Cloud build and:
  - Sets MARKETING_VERSION (CFBundleShortVersionString) to the contents
    of /VERSION.
  - Sets CURRENT_PROJECT_VERSION (CFBundleVersion / build number) to
    `git rev-list --count HEAD` so every push is a uniquely-numbered
    TestFlight upload without you having to bump anything.
- Sources/ColumbaNetworkExtension/Info.plist switched from hardcoded
  "1.0" / "1" to $(MARKETING_VERSION) / $(CURRENT_PROJECT_VERSION) so
  the extension version follows the host app (Apple validates that they
  match at upload time).
- pbxproj MARKETING_VERSION bumped from 1.0 to 0.0.2 so local builds
  reflect what CI will produce. CURRENT_PROJECT_VERSION stays at 1 in
  the committed state; the CI script overwrites it at build time.

Workflow:
  - Want a release bump? Edit /VERSION (e.g., 0.0.2 -> 0.1.0), push.
  - Just iterating? Push as normal — build number auto-increments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 25, 2026

Greptile Summary

This PR introduces a single source of truth for the app version via /VERSION and an Xcode Cloud post-clone hook that rewrites MARKETING_VERSION and CURRENT_PROJECT_VERSION in project.pbxproj at build time. It also fixes a latent App Store upload blocker by replacing hardcoded version strings in the network extension's Info.plist with $(MARKETING_VERSION)/$(CURRENT_PROJECT_VERSION) substitutions.

Confidence Score: 5/5

Safe to merge — no logic errors, security issues, or correctness problems found.

All four changed files are clean. The script has solid error handling (set -euo pipefail, explicit guards, semver validation), uses a hardcoded project path (avoids the prior pipefail/SIGPIPE concern), and macOS-compatible sed -i.bak. The pbxproj and plist changes are mechanical and correct. No P1 or P0 findings.

No files require special attention.

Important Files Changed

Filename Overview
ci_scripts/ci_post_clone.sh New Xcode Cloud post-clone hook that reads VERSION, validates semver format, computes build number from git commit count, and rewrites pbxproj build settings in-place via sed. Well-structured with proper set -euo pipefail, explicit guards, and hardcoded project path.
VERSION New single-source-of-truth file for MARKETING_VERSION, containing 0.0.2.
Sources/ColumbaNetworkExtension/Info.plist Replaced hardcoded 1.0/1 with $(MARKETING_VERSION)/$(CURRENT_PROJECT_VERSION) so the network extension tracks the host app version automatically, preventing App Store upload rejection on version drift.
Columba.xcodeproj/project.pbxproj Updated four MARKETING_VERSION build settings from 1.0 to 0.0.2; CURRENT_PROJECT_VERSION remains 1 (to be overwritten at CI build time by the post-clone script).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Xcode Cloud: repo cloned] --> B[ci_post_clone.sh invoked]
    B --> C{VERSION file exists?}
    C -- No --> D[exit 1: error]
    C -- Yes --> E[Read MARKETING_VERSION from /VERSION]
    E --> F{Matches MAJOR.MINOR.PATCH?}
    F -- No --> D
    F -- Yes --> G[BUILD_NUMBER = git rev-list --count HEAD]
    G --> H{project.pbxproj exists?}
    H -- No --> D
    H -- Yes --> I["sed: rewrite MARKETING_VERSION + CURRENT_PROJECT_VERSION in pbxproj"]
    I --> J[Remove .bak file]
    J --> K[Xcode build proceeds with updated versions]
    K --> L["TestFlight upload: 0.0.2 (N)"]
Loading

Reviews (2): Last reviewed commit: "fix: hardcode pbxproj path in CI script ..." | Re-trigger Greptile

Comment thread ci_scripts/ci_post_clone.sh Outdated
`find | head -1` can trip `set -euo pipefail` when find has more
results than head reads — SIGPIPE makes find exit 141 and pipefail
kills the script before the empty-string guard runs. Hardcoded the
known relative path; if the project is renamed again, this script
needs updating too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit a1c0a9c into main Apr 25, 2026
2 checks passed
@torlando-tech torlando-tech deleted the chore/version-management branch April 26, 2026 03:10
torlando-tech added a commit that referenced this pull request May 11, 2026
LXMF-swift 0.4.0 (PR #7 — perf/stamper-parallel-primed-digest, merged):
  - Parallel stamp generation (LXStamper TaskGroup, 8 workers, primed
    SHA256 digest) — cost=16 from multi-minute to ~1-2s on iPhone.
  - PROPAGATED state machine fixes: drops wrong link.identify(); wires
    RESOURCE_PRF to .sent (not .delivered); ERROR_INVALID_STAMP handler
    via pendingPropagationSends FIFO + pendingPropagationRejections
    set; handlePropagationAccepted + handleOutboundResourceFailed with
    awaited DB writes that preserve deliveryAttempts budget.
  - DIRECT path: self-send identity resolution before path table;
    drops premature link.identify(); broadcast-relay-only self-echo
    gate; DIRECT resource crash-recovery parity with PROPAGATED.
  - Stamp-rejected resource short-circuit prevents retry-loop spam.

reticulum-swift 0.3.0 (PR #16):
  - HEADER_2 link DATA conversion fix.
  - sendLinkData signature: destinationHash param removed (breaking).

Package.swift, pbxproj, and Xcode-shared Package.resolved all updated.
Build verified: xcodebuild for iOS Simulator, CODE_SIGNING_ALLOWED=NO,
BUILD SUCCEEDED. Smoke pipeline (PROPAGATED/DIRECT/OPP bidirectional
with Mac echo bot) to follow on PR ready→draft transition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
torlando-tech added a commit that referenced this pull request May 11, 2026
LXMF-swift 0.4.0 (PR #7 — perf/stamper-parallel-primed-digest, merged):
  - Parallel stamp generation (LXStamper TaskGroup, 8 workers, primed
    SHA256 digest) — cost=16 from multi-minute to ~1-2s on iPhone.
  - PROPAGATED state machine fixes: drops wrong link.identify(); wires
    RESOURCE_PRF to .sent (not .delivered); ERROR_INVALID_STAMP handler
    via pendingPropagationSends FIFO + pendingPropagationRejections
    set; handlePropagationAccepted + handleOutboundResourceFailed with
    awaited DB writes that preserve deliveryAttempts budget.
  - DIRECT path: self-send identity resolution before path table;
    drops premature link.identify(); broadcast-relay-only self-echo
    gate; DIRECT resource crash-recovery parity with PROPAGATED.
  - Stamp-rejected resource short-circuit prevents retry-loop spam.

reticulum-swift 0.3.0 (PR #16):
  - HEADER_2 link DATA conversion fix.
  - sendLinkData signature: destinationHash param removed (breaking).

Package.swift, pbxproj, and Xcode-shared Package.resolved all updated.
Build verified: xcodebuild for iOS Simulator, CODE_SIGNING_ALLOWED=NO,
BUILD SUCCEEDED. Smoke pipeline (PROPAGATED/DIRECT/OPP bidirectional
with Mac echo bot) to follow on PR ready→draft transition.

Co-authored-by: torlando-tech <torlando-tech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
torlando-tech added a commit that referenced this pull request May 11, 2026
* fix: hot-swap TCP interfaces without disturbing the others

Toggling/editing any TCP interface in Interfaces settings was tearing
down every other healthy TCP connection alongside the one the user
actually changed. Each reconnect triggered the relay to redeliver its
full announce table, swamping the app for ~90s per change (90k+
announces in one minute, observed on rmap.world).

Two layers of fix:

1. `AppServices.connectTCPInterface(entityId:host:port:)` is now
   idempotent. It tracks the last-applied host:port per entity and
   returns immediately when called with the same endpoint as the
   currently-running interface. Calling it with a different endpoint
   still disconnects-and-recreates as before.

2. `InterfaceManagementViewModel.applyChanges` loops over every
   enabled TCP entity (not just the one that changed). It now skips
   entities whose endpoint hasn't moved, avoiding both the connect
   call AND the brief `.connecting` UI flicker.

Stop and shutdown paths clear the endpoint dictionary alongside
`tcpInterfaces` so a future re-add doesn't short-circuit against a
stale entry.

Auto/BLE/RNode/Multipeer sections of `applyChanges` already gate on
existence checks and don't trigger this. Config changes for those
types still don't take effect without a manual disable/re-enable —
separate issue, smaller blast radius, not addressed here.

* fix: hot-swap TCP interfaces without disturbing the others

Toggling/editing any TCP interface in Interfaces settings was tearing
down every other healthy TCP connection alongside the one the user
actually changed. Each reconnect triggered the relay to redeliver its
full announce table, swamping the app for ~90s per change (90k+
announces in one minute, observed on rmap.world).

Two layers of fix:

1. `AppServices.connectTCPInterface(entityId:host:port:)` is now
   idempotent. It tracks the last-applied host:port per entity and
   returns immediately when called with the same endpoint as the
   currently-running interface. Calling it with a different endpoint
   still disconnects-and-recreates as before.

2. `InterfaceManagementViewModel.applyChanges` loops over every
   enabled TCP entity (not just the one that changed). It now skips
   entities whose endpoint hasn't moved, avoiding both the connect
   call AND the brief `.connecting` UI flicker.

Stop and shutdown paths clear the endpoint dictionary alongside
`tcpInterfaces` so a future re-add doesn't short-circuit against a
stale entry.

Auto/BLE/RNode/Multipeer sections of `applyChanges` already gate on
existence checks and don't trigger this. Config changes for those
types still don't take effect without a manual disable/re-enable —
separate issue, smaller blast radius, not addressed here.

* feat: multi-TCP tunnel — extension manages a connection per entity

Previously the Network Extension kept a single `tcpConnection` and a
single `currentTCP` endpoint, so enabling two TCP relays in the app
silently dropped one — the extension's config loader overwrote
`result.tcp` on every iteration and only the last enabled tcpClient
in the JSON array got a socket. The other relay was unreachable
through the tunnel and inbound from the wrong relay was routed back
to whichever `TCPInterface` happened to be first in the app's
dictionary.

This commit lifts the entire tunnel TCP layer to per-entity:

- `SharedFrameQueue` frame format gains a 1-byte entityId-length
  field and a length-prefixed UTF-8 entity id between the interface
  tag and the frame payload. Old format frames in flight at the
  upgrade are lost on first read; the queue is append-and-clear
  so the lifetime is short.
- `TunnelManager.sendFrame` adds an `entityId` parameter and writes
  it into the IPC envelope sent via `sendProviderMessage`.
  `connectTCPInterface` and `applyTunnelModeToInterfaces` now
  capture the entity id in the per-interface tunnel-mode hook so
  outbound frames from each `TCPInterface` carry their own id.
- `ExtensionFrameReader.onTCPFrameReceived` is now `(entityId, data)`
  and the AppServices handler routes inbound frames to the matching
  `TCPInterface` by id, with safe fallbacks for empty/legacy ids.
- `PacketTunnelProvider` replaces `tcpConnection` /
  `tcpReceiveBuffer` / `currentTCP` with per-entity dicts. Each
  `NWConnection` has its own HDLC receive buffer (sharing one
  buffer between two streams would corrupt frame boundaries),
  its own state-update handler that only tears down its own entry,
  and its own `receiveTCPData` recursion so inbound frames are
  tagged with the right id when appended to the queue.
- `applyConfigsLocked` diffs per-entity: an entry whose endpoint is
  unchanged keeps its connection, a removed entry tears down only
  its own socket, an edited entry restarts only that socket. Adding
  a second relay no longer disturbs the first.
- `loadInterfaceConfigs` returns `tcps: [String: (host, port)]`
  keyed by `InterfaceEntity.id` instead of a single optional.

`handleAppMessage` parses the new wire format (entityId-length +
entityId in front of frame data) and looks up the connection by id,
falling back to the sole connection when the id is empty so a
hypothetical legacy single-TCP build still routes correctly.

* chore: extension diag logs for TCP config/state changes

Lifecycle events only — config (re)apply, config removal, state
transitions, failure. Per-frame and per-drain logging is omitted
to keep the file small. Per-entity tagging in the messages makes
multi-TCP behaviour observable without needing syslog access.

Used to diagnose the silent-inbound regression that turned out to
be the SharedFrameQueue wire-format roll-out interacting with a
not-yet-relaunched extension; left in place for future debugging.

* feat(InterfaceManagement): add TCP client community-server wizard

Mirrors Android Columba's 2-step TCP client wizard at the post-onboarding
add-interface surface: server selection (bootstrap/community/custom) →
review & configure. Routes Settings → Network Interfaces → + → TCP Client
through the wizard instead of the blank manual entry sheet, and reroutes
edit-existing for TCP entries to the same flow with pre-filled values.

Scoped to the fields TCPClientConfig already supports (host, port,
networkName, passphrase). Bootstrap-only flag and SOCKS proxy are deferred.

Closes #51

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* fix(MicronParser): persist formatting state across lines (#63)

* fix(MicronParser): persist formatting state across lines

The line-by-line parse loop hardcoded `currentStyle: .plain` on every
parseInline call, so a `Fxxx`Bxxx preamble line consumed its colors
into an empty span and the following ASCII art rendered with no fg/bg.
Match python NomadNet's MicronParser by promoting currentStyle to a
parser-loop local that threads through every parseInline call, with
parseInline returning the terminal style so the caller can carry it
forward. `< at line-start additionally resets currentStyle to .plain,
matching python's `<` semantics.

Repro: the index.mu at github.com/fr33n0w/thechatroom uses the
preamble shape `F0ff`B52f then ASCII art then `f`b — before this fix
the colors were silently dropped.

Closes #31

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* fix(NodeDetailsView): allow tapping action buttons on stale-path contacts

Browse Site / Start Chat / Set as My Relay were `.disabled(!isOnline)`
on a contact's NodeDetailsView, where `isOnline` is just `Date() <
entry.expires` from the path table. After cleanupLinks runs `expirePath`
on a failed-link destination, the contact's path becomes "expired" until
a new announce arrives — but Reticulum's path discovery is exactly
designed for that case (issue a path-request, any peer with a recent
announce will respond). Greying the button blocks the user from the very
operation that would heal the path.

Drops the `.disabled` and `.opacity` modifiers from `actionButton(...)`
and the relay-toggle button. The underlying flow
(`NomadNetBrowserService.resolveValidPath`) already does
`pathTable.remove` + `transport.requestPath` + 10s poll, so taps now
flow through to the working recovery path.

Also reword the expired-hint copy from "Ask them to send an announce
from their app, or wait for one to arrive automatically" to "Tap an
action to issue a path request — any node on the network with a recent
announce will respond." — the original copy is wrong about how
Reticulum path discovery works and discourages users from doing the
right thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(MicronDocumentView): render the chat-room ASCII art correctly

Three bugs surfaced once the parser carried `Bxxx background colors
forward across lines (faf17e4):

1. Centering broke against the document, not the screen. A wide row
   (e.g. fr33n0w/thechatroom's 550-char trailing-whitespace line)
   pushed the VStack out to ~4600pt; centered shorter rows landed
   at the middle of *that* width — way past the viewport. Fixed by
   capturing the actual screen viewport via GeometryReader in
   MonospaceScrollContainer (mirrors Android's
   `Modifier.widthIn(min = viewportLineWidth)` from
   NomadNetBrowserScreen.kt:474) and wrapping each scroll-mode row
   in `.frame(minWidth: viewportWidth, alignment: alignment.swiftUI)`.

2. Row-to-row column alignment drifted by half a cell because
   Core Text's `textAlignment = .center` strips trailing whitespace
   when computing the centered offset. Lines with a trailing space
   centered as if one cell narrower than lines without — visible as
   the letter "T" of "the chat room" wandering in the ASCII art.
   UILabel now always renders left-aligned (paragraphStyle and
   textAlignment) and visual centering is the SwiftUI .frame's job.

3. SF Mono renders Block-Elements (▗▄▖▝▀▘▙▟ etc.) at slightly
   different pixel widths than ASCII spaces, so 85-char rows of
   mixed content didn't end up the same width. Bundled JetBrains
   Mono (Apache 2.0/OFL, Regular + Bold, ~270KB each) for the
   monospace renderer — every glyph in the file has advance=600
   confirmed via fontTools, matching what Android already uses
   (MicronComposables.kt's `JetBrainsMonoFamily`). Falls back to
   the system font if the bundled one fails to load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: torlando-agent[bot] <281092095+torlando-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude claude-opus-4-7 <noreply@anthropic.com>

* fix(TCPClientWizard): mirror android server list, drop bootstrap split

Addresses PR review comments:
#64 (comment)
#64 (comment)

Replace the iOS community-server directory with the canonical Android
list at app/src/main/java/network/columba/app/data/model/TcpCommunityServer.kt.
Removes decommissioned / non-existent entries (RNS Amsterdam, RNS
BetweenTheBorders, RNS Frankfurt, i2p Reticulum, Reticulum Ireland,
TheHub, Kosciuszko, Reticulum Ireland v2, RNS Roaming) and adds the
servers that are actually present on the network. i2p is dropped
entirely because iOS has no i2p transport.

Also collapse the "Bootstrap Servers" / "Community Servers" split in
TCPClientWizard into a single "Community Servers" section, since
Reticulum-Swift does not yet implement bootstrap-interface mode and
splitting them would mislead users into expecting bootstrap behavior.
The isBootstrap flag on the data model is preserved so the Android
table stays mirrorable.

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* feat(auto-announce): granular trigger toggles + new wiring

Splits the auto-announce path into three independently-toggleable
triggers, all gated behind the existing `auto_announce_enabled` master:

  - `auto_announce_on_interval`       — periodic timer (existing)
  - `auto_announce_on_tcp_reconnect`  — fires on TCP / RNode reconnect
  - `auto_announce_on_peer_spawned`   — fires when AutoInterface / BLE /
                                        MPC accepts a new peer

All three default true to preserve the previous "all triggers active
when master is on" behaviour.

Wiring:
  - `AppServices.configureTransportCallbacks` now uses
    reticulum-swift's split callbacks (`setOnInterfaceConnected` /
    `setOnInterfacePeerSpawned`), each with its own user-setting gate.
    The polled state-observer's connect-trigger is gated to match.
  - `AutoAnnounceManager.start` (and the in-loop re-check) honour the
    `auto_announce_on_interval` toggle in addition to master.
  - `autoAnnounce()` itself bails on master-off as defense in depth.
  - SettingsView's Auto Announce card grows three sub-toggles +
    interval picker hides when the on-interval trigger is off.

Pairs with reticulum-swift's onInterfaceAdded → onInterfacePeerSpawned /
onInterfaceConnected split (see that repo). Ship-ready behaviour change
on its own; no diagnostic logging in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump reticulum-swift pin to 0.2.4

Picks up the onInterfaceAdded → onInterfacePeerSpawned/onInterfaceConnected
split (reticulum-swift PR #14) that this PR's wiring requires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(AppServices): only resetTimer when announce was actually sent

The polled state-observer's connect path was calling
`autoAnnounceManager.resetTimer()` unconditionally — even when the
TCP-reconnect gate had blocked the announce. Because `resetTimer()`
restarts the periodic loop with a fresh `Next auto-announce in 3h
(±1h)` schedule, every TCP reconnect on a flap-y network (mobile
data ↔ WiFi, RNode in poor RF) would push the next interval-announce
a full interval into the future without ever emitting one. The
periodic schedule could be perpetually starved even though the user
left "On interval" enabled and only disabled the reconnect trigger.

Move the `resetTimer()` call inside the gate so it only fires when an
announce actually went out.

Greptile review feedback on PR #70.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(auto-announce): extract AutoAnnouncePolicy + cover trigger gates

The auto-announce trigger gates were inlined as `defaults.bool(forKey: ...)`
calls at seven sites across AppServices and AutoAnnounceManager, which
made them impractical to unit-test without bringing up the full
AppServices stack (transport, identity, router, …).

Extract the gating decision into a pure value type, AutoAnnouncePolicy,
that snapshots the four UserDefaults keys and exposes:
  - shouldFireOnInterval
  - shouldFireOnTcpReconnect
  - shouldFireOnPeerSpawned

…all derived from the master enable plus the corresponding granular
toggle. Routes the seven existing call sites through the policy so the
inline string-key reads no longer appear in service code (which makes a
typo-rename harder and gives every gate the same code path).

Tests in AutoAnnouncePolicyTests cover:
  - Direct init stores all four flags.
  - Master off suppresses all three triggers regardless of granulars.
  - Each granular toggle gates its own trigger independently.
  - All-on / all-off boundary cases.
  - Empty defaults reports all-off (raw read behavior).
  - Snapshot is immutable after capture (catches future refactors that
    might keep a defaults reference).
  - register(defaults: true) produces the fresh-install all-fire baseline
    that SettingsViewModel.loadLocalSettings sets up.
  - Explicit false overrides registered default-true.

9 tests, all passing locally on iOS Simulator. Total suite went from
71 to 80 tests; no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auto-announce): attribute peer-child connected events to peer-spawned gate

Reticulum-swift fires `onInterfacePeerSpawned` when an AutoInterface /
BLEInterface / MPCInterface accepts a peer, then a moment later fires
`onInterfaceConnected` for the peer's child transport's `.connected`
transition. The previous gating treated the second event as a generic
TCP-reconnect, so a user who turned the peer-spawned toggle off but
left tcp-reconnect on would still get an announce on every peer-add —
defeating the purpose of having a separate peer-spawned gate.

Changes:

  - `AutoAnnouncePolicy.shouldFireOnInterfaceConnected(isPeerChild:)`
    new accessor that gates by `onPeerSpawned` for peer-children and
    `onTcpReconnect` for everything else (both still subject to
    `masterEnabled`).
  - `AppServices` tracks ids passed through `onInterfacePeerSpawned` in
    a `peerChildInterfaceIds` set, then queries it in the
    `onInterfaceConnected` handler to pick the right gate.
  - Diagnostic log line distinguishes the two attribution paths so a
    future investigation can tell whether an announce came from the
    tcp-reconnect or peer-child-reconnect branch.

Tests cover the four corners of the cross-trigger matrix plus the
master-off override:

  - peer-child + peer-spawned-off + tcp-reconnect-on   → does NOT fire
  - peer-child + peer-spawned-on  + tcp-reconnect-off  → fires
  - non-peer-child + tcp-reconnect-on / off            → fires / not
  - master off                                         → never fires
  - all-on / all-off across peer-child boundaries

Greptile review feedback on PR #70 (4/5 confidence comment about peer-child overlap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auto-announce): make peer-child attribution race-free

The peer-spawned and connected callbacks fire from independent
reticulum-swift Tasks. The previous implementation used MainActor-
isolated record / lookup, which meant both operations had to await an
actor hop. Swift's task scheduler doesn't guarantee record-before-lookup
ordering between unrelated Tasks, so a fast peer-add → child-connect
sequence could in theory mis-attribute the connected event to
tcp-reconnect instead of peer-spawned (the user-facing bug
fixed in the prior commit).

Replace the MainActor-isolated Set with a synchronous, lock-protected
PeerChildInterfaceRegistry (OSAllocatedUnfairLock-backed). The peer-
spawned closure now records on its first line, *before* any await
suspension, so the record is committed before any subsequent
onInterfaceConnected for the same id can possibly run its attribution
lookup. The connected closure's lookup is also synchronous, so
attribution is correct regardless of how the schedulers interleave the
rest of the closure bodies.

Tests:
  - PeerChildInterfaceRegistryTests: empty / record-then-contains /
    idempotent / reset / immediate-visibility on same thread.
  - testConcurrentRecordAndContainsObservesAllPriorRecords: 1000-way
    concurrent record+contains stress, asserts no crash and full
    visibility after group completes.

Total suite: 90 tests, all passing.

Greptile review feedback on PR #70 (4/5 confidence comment about Task
ordering between MainActor hops).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(greptile): iteration 1 — applied 2, rejected 0

Snapshot dictionary keys before mutating during iteration in
PacketTunnelProvider:

- applyConfigsLocked() stale-entry teardown: collect stale ids via
  filter() before the loop instead of iterating currentTCPs.keys
  while teardownTCPConnectionLocked + removeValue mutate it.
- wake() reaper: iterate Array(self.tcpConnections.keys) instead of
  the live Keys view while teardownTCPConnectionLocked mutates the
  same dictionary.

Both paths run on configQueue (the only mutator), but Swift's
Dictionary.Keys is documented as a live view and mutation during
iteration is undefined behavior — can silently skip entries or
crash. Both fixes are inert for the single-TCP case but matter as
soon as 2+ TCPs are active and a config-change or wake event fires.

Co-Authored-By: Claude opus-4-7-1m <noreply@anthropic.com>

* chore(greptile): iteration 1 — applied 1, rejected 0

Roll back tcpInterfaces[entityId] and defer tcpEndpoints[entityId] until
after transport.addInterface succeeds. Without this, a transient
addInterface throw left both dictionary entries populated for a dead,
un-attached interface; the next connectTCPInterface call with the same
endpoint hit the idempotency guard at the top of the function and
silently no-op'd, breaking self-healing reconnects until the user
manually edited host/port.

Greptile thread 2 (the matching skip in InterfaceManagementViewModel.
applyChanges) is satisfied by this same fix — once tcpEndpoints reflects
only successfully-applied endpoints, the VM's
`tcpEndpoints[id] == desired` guard correctly distinguishes "running
cleanly" from "stale dead entry waiting to retry".

Co-Authored-By: Claude claude-opus-4-7[1m] <noreply@anthropic.com>

* chore(greptile): iteration 2 — applied 1, rejected 0

Extend the connectTCPInterface write-after-success + rollback pattern to
the three remaining tcp-server init sites: both initialize() overloads
and reinitializeConnection(). Without this, an addInterface throw during
init left tcpInterfaces["tcp-server"] and tcpEndpoints["tcp-server"]
populated with a dead interface; reconnectTCPOnly delegates to
connectTCPInterface(entityId: "tcp-server", ...) which then silently
no-op'd on a same-address retry through the new idempotency guard.

For the two initialize overloads, the catch block preserves the
"non-fatal" semantics (init proceeds without TCP, no rethrow) but now
also clears the partial dictionary writes so a later reconnectTCPOnly
retry isn't stuck. For reinitializeConnection — which had no catch and
propagates errors to its caller — the new do/catch rolls back and
rethrows, mirroring connectTCPInterface.

Co-Authored-By: Claude claude-opus-4-7[1m] <noreply@anthropic.com>

* feat(Map): follow app dark mode for OpenFreeMap style

Picks the OpenFreeMap style URL (liberty / dark) based on
ThemeManager.isDarkMode and reapplies it from updateUIView when
the active scheme changes. Coordinator caches the last applied
URL to skip the no-op reassignment that would otherwise fire on
every peer-location tick.

Offline regions remain pinned to the liberty style at download
time; switching to dark while fully offline yields unstyled
tiles. To be addressed in a follow-up that caches both style
packs.

Closes #59

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* Update Sources/ColumbaApp/Views/Map/MapLibreMapView.swift

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore(greptile): iteration 1 — applied 4, rejected 0

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* feat(InterfaceManagement): add TCP client community-server wizard (#64)

* feat(InterfaceManagement): add TCP client community-server wizard

Mirrors Android Columba's 2-step TCP client wizard at the post-onboarding
add-interface surface: server selection (bootstrap/community/custom) →
review & configure. Routes Settings → Network Interfaces → + → TCP Client
through the wizard instead of the blank manual entry sheet, and reroutes
edit-existing for TCP entries to the same flow with pre-filled values.

Scoped to the fields TCPClientConfig already supports (host, port,
networkName, passphrase). Bootstrap-only flag and SOCKS proxy are deferred.

Closes #51

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* fix(TCPClientWizard): mirror android server list, drop bootstrap split

Addresses PR review comments:
#64 (comment)
#64 (comment)

Replace the iOS community-server directory with the canonical Android
list at app/src/main/java/network/columba/app/data/model/TcpCommunityServer.kt.
Removes decommissioned / non-existent entries (RNS Amsterdam, RNS
BetweenTheBorders, RNS Frankfurt, i2p Reticulum, Reticulum Ireland,
TheHub, Kosciuszko, Reticulum Ireland v2, RNS Roaming) and adds the
servers that are actually present on the network. i2p is dropped
entirely because iOS has no i2p transport.

Also collapse the "Bootstrap Servers" / "Community Servers" split in
TCPClientWizard into a single "Community Servers" section, since
Reticulum-Swift does not yet implement bootstrap-interface mode and
splitting them would mislead users into expecting bootstrap behavior.
The isBootstrap flag on the data model is preserved so the Android
table stays mirrorable.

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* chore(greptile): iteration 1 — applied 4, rejected 0

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

* fix(TcpCommunityServer): remove unwanted servers from wizard list

The following entries should not be surfaced in the on-device wizard:

- interloper node + interloper node (Tor)
- Jon's Node
- Quortal TCP Node
- R-Net TCP
- RNS bnZ-NODE01, RNS COMSEC-RD, RNS HAM RADIO
- RNS Testnet StoppedCold
- RNS_Transport_US-East
- Tidudanka.com

Surviving list: 3 bootstrap-class (Beleth RNS Hub, Quad4 TCP Node 1,
FireZen) + 7 community (g00n.cloud Hub, noDNS1, noDNS2, NomadNode
SEAsia TCP, 0rbit-Net, Quad4 TCP Node 2, SparkN0de).

NOTE: the file's docstring claims this list mirrors Android's
`TcpCommunityServer.kt`. Pruning here breaks that mirror; a follow-up
PR should make the equivalent removal on the Android side, OR the
"keep in sync" claim should be relaxed to "originally derived from."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: torlando-agent[bot] <281092095+torlando-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude claude-opus-4-7 <noreply@anthropic.com>
Co-authored-by: torlando-agent[bot] <torlando-agent@noreply.github.com>

* feat: add Maestro UI flows for columba-suite ui-screenshotter (#69)

* feat: add Maestro UI flows for columba-suite ui-screenshotter agent

Adds flows/ with 4 deterministic Maestro flows (contacts-list, chats-list,
settings, map) plus a README. The columba-suite ui-screenshotter agent
captures each flow at BASE_REF and HEAD in both light and dark Simulator
appearances on every UI-touching PR, linking the resulting PNG pair from
PLAN.md so reviewers see the visual change before merging.

This PR exists primarily to land flows/ on main so subsequent PRs have
flow coverage at BASE_REF. The screenshotter will fire on this PR itself,
but cleanly skip with screenshot_status: skipped_no_flows because the
PR's BASE_REF (this branch's parent) doesn't yet have flows/.

Voice-call flows are deferred — they need a debug-only lxma://debug/...
URL handler that doesn't exist yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(greptile): iteration 1 — applied 1, rejected 2

Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>

---------

Co-authored-by: torlando-agent[bot] <217870594+torlando-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: torlando-agent[bot] <281092095+torlando-agent[bot]@users.noreply.github.com>

* chore(test): add debug-only iOS test surface for phone smoke-test pipeline

Mirror of the Android `app/src/debug/.../TestController.kt` +
TestReceiver.kt surface, adapted to iOS via a sibling URL scheme
(`lxma-test://`) routed through the existing `.onOpenURL` handler in
ColumbaApp.swift. The 17 actions, log shape (`event=key=value`), and
whitespace-escape rules match Android byte-for-byte so the python
orchestrator's regexes work cross-platform.

- Sources/ColumbaApp/Test/TestController.swift — singleton coordinating
  the test-action surface; binds to live AppServices/router/interface
  repository, observes inbound LXMF + delivery-state via a relay
  delegate, emits structured os_log lines under subsystem
  `network.columba.app.test` / category `harness` so idevicesyslog
  filters cleanly.

- Sources/ColumbaApp/Test/TestURLHandler.swift — `lxma-test://<action>?<query>`
  dispatcher; mirrors Android's TestReceiver `when (action)` switch,
  routes to TestController. Wired into ColumbaApp.swift's `.onOpenURL`
  with a `#if DEBUG` guard.

- Both files are wrapped in `#if DEBUG` so they compile out of release
  `.ipa`s. Defense in depth: every entry trips an `assertionFailure`
  with a release-misconfig message. Verified empirically — release
  build's binary contains zero references to TestController /
  TestURLHandler / harness log strings.

- `lxma-test` URL scheme registered in Info.plist alongside `lxma`. The
  scheme stays present in release builds (no per-config plist on this
  project) but is harmless because no code in release handles it; the
  release `.onOpenURL` `#if DEBUG` block compiles to a guard-pass and
  the URL falls through.

The Python orchestrator at ~/.claude-runner/columba-harness/smoke_test_ios.py
drives this surface end-to-end (devicectl URL dispatch + idevicesyslog
tail) and is the iOS sibling of smoke_test.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test-harness): unbreak release-guard + add file-based event log

Two bugs that prevented end-to-end smoke runs against a physical iPhone:

1. assertionFailure_releaseGuard() was calling assertionFailure(...)
   UNCONDITIONALLY in both TestController.swift and TestURLHandler.swift.
   That's exactly inverted from the intent — `assertionFailure` ALWAYS
   crashes in DEBUG builds. So every URL dispatch and every public
   handler entry crashed the app on the guard before any logic ran.

   Mirrors the Android side's `check(BuildConfig.DEBUG)` semantics:
   crash only when DEBUG is FALSE. New impl wraps the body in
   `#if !DEBUG ... #endif` so it's a no-op in normal debug builds and
   a hard crash if a release ever gets misconfigured to compile this
   file in.

2. TestLog.emit() now ALSO writes each line to
   `Documents/test_log.txt`, prefixed `seq=<n> ts=<iso8601>`. Reason:
   the Python orchestrator originally tailed device syslog via
   `idevicesyslog`, but iOS 17+ moved live-syslog behind the new
   CoreDevice / RemoteXPC tunnel that libimobiledevice can't speak.
   `pymobiledevice3` would work but needs a developer-tunnel daemon.
   The orchestrator now polls Documents/test_log.txt via
   `xcrun devicectl device copy from --domain-type appDataContainer`,
   which works out of the box and is more robust (no race window,
   survives disconnects). os_log writes are kept for human readers.

Verified end-to-end: smoke_test_ios.py runs the propagated_bidirectional
scenario all the way through interface setup, propagation-node config,
HAS_PATH=1, SEND_PROP, msg_sent. (Stalls at OUTBOUND-never-advances-to-
PROPAGATED — separate LXMFSwift outbound state-machine issue, NOT a
harness bug. Diagnostic for that lands in a follow-up.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(harness): add lxma-test://dump_log for OSLogStore extraction

iOS 17+ moved live syslog behind the new CoreDevice / RemoteXPC
tunnel that libimobiledevice can't speak, so the smoke harness
couldn't observe library-internal events on the device. Added a
debug-only `dump_log` URL action that uses OSLogStore to extract
recent unified-log entries from the app process and forwards them
into Documents/test_log.txt as `lib_log subsys=… cat=… level=… msg=…`
lines that the orchestrator can parse with its existing devicectl
copy-from poll mechanism.

Filter defaults to `(com.columba.core, net.reticulum.lxmf)` ×
(Propagation, Sync, LXMRouter, Stamper, Identity, PropagationNodeManager)
to surface just the propagation-path observability we need to
diagnose stuck `state=OUTBOUND` failures. `?since=<sec>` sets the
window (default 120s); `?cat=<comma>` overrides categories; `?cat=*`
disables category filtering.

Critical first finding when wired up: processOutbound IS running and
calling sendPropagated; the failure is `LXMRouter` emitting
"Delivery failed: No path available to destination, retrying in 15s/120s"
because `pathTable.lookup(destinationHash: nodeHash)` returns nil for
the propagation node hash even though `pathTable.hasPath(for:)`
returns true on the same hash from the harness. Likely actor-
isolation race or stale-snapshot bug in the path-table view; needs
deeper investigation in LXMF-swift / reticulum-swift.

Sticks to existing test-surface contract — `lib_log_done count=<n>` /
`lib_log_err reason=<msg>` reply tokens; debug-only via the existing
`#if DEBUG` source-set isolation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(harness): wire iOS PROPAGATED smoke end-to-end

Three bug-fix-and-instrument changes to make the PROPAGATED self-send
round-trip pass on iOS. Mirrors the Android smoke pipeline shipped in
PR #882.

1. TestRelayDelegate retention. LXMRouter holds the delegate weakly
   (LXMRouter.swift `weak var delegate`); attachDelegate handed in a
   stack-local relay that immediately deallocated, leaving the router
   with a nil delegate and no didUpdateMessage callbacks for outbound
   state changes. Pin the relay to TestController.attachedDelegate.

2. set_prop_node now goes through PropagationNodeManager.selectNode
   (via TestPathBridge.selectPropNode) instead of router.setOutboundPropagationNode.
   The manager is the only path that wires the announce-derived stamp
   cost into the router; the bare router setter left cost=0 and
   sendPropagated shipped a random stamp that lxmd rejected with
   ERROR_INVALID_STAMP. selectNode also now (a) reads stamp cost from
   pathTable.appData when knownNodes is empty and (b) waits up to ~5s
   for either source to populate, covering the smoke-test race where
   set_prop_node fires immediately after add_tcp_client (before the
   announce arrives).

3. PropagationNodeManager.processPathEntry re-applies the stamp cost
   to the router whenever an announce updates the currently-selected
   node, so a delayed announce can correct an earlier cost=0 setting.

Plus instrumentation: dump_log now emits each OSLog entry's actual
recorded timestamp (`entry_ts=`) alongside the dump-time `seq=N ts=`
prefix, and includes `network.columba.Columba` in the allowed-subsystem
set so app-side managers (PropagationNodeManager) show up.

Direct + opportunistic self-send scenarios are still WIP — they
require LXMRouter-level loopback for self-addressed packets (single
device can't actually transit a packet to itself through the network)
which is a future stage. PROPAGATED works today via the lxmd round-trip.

* chore: bump LXMF-swift to a3e5b00 (DIRECT identify-drop fix)

* chore(deps): pin reticulum-swift to fix/link-data-no-header2-conversion

reticulum-swift @ d19919a — drops incorrect HEADER_2 conversion of link
DATA packets that broke multi-hop DIRECT delivery (state=SENT but the
echo bot never received the message). Mirrors python RNS/Transport.py
:1063, 1122-1130 — link DATA always sends HEADER_1 to the link's
attached_interface, never through path-table lookup.

LXMF-swift @ fe3ce84 (perf/stamper-parallel-primed-digest) — pins
reticulum-swift to the same fix branch.

Smoke results after fix (today's run #5):
  propagated_bidirectional: PASS (6.7s)
  direct_echo:              PASS (3.5s)  ← was FAIL pre-fix
  opp_echo:                 PASS (3.4s)

* test(harness): add diagnostic ticker + screenshot capture to TestController

Spawned by TestController.bind() on first init; runs every 2s for the
app's lifetime, snapping the key window into Documents/screenshots/<seq>.png
and emitting:

  diag_tick seq=N state=<active|inactive|background> snapshot=<path|<skip>>
  lifecycle event=<did_become_active|will_resign_active|...>

Diagnoses the iOS smoke harness wedge: "lxma-test:// URLs stop reaching
the URL handler after 2-3 sequential runs." The ticker is driven by an
internal Task, NOT URL dispatch, so it keeps emitting even when URLs are
wedged. If ticks ALSO stop, the OS suspended/killed the app. If ticks
keep coming with state != .active, the app went background. If ticks
keep firing AND state stays .active but URLs still don't reach the
handler, the wedge is below SwiftUI (CoreDevice tunnel / launch
services). Last is the smoking gun pattern.

Field finding from this commit's first run (2026-05-10):
  iter 1: 3/3 PASS
  iter 2: 3/3 PASS
  iter 3: 0/3 FAIL — "TCP client interface ADD never confirmed"
  iter 4: total wedge — TestController never answered get_dest

After the wedge, even `devicectl device copy from` hangs for 30+s,
which proves the wedge is at the **CoreDevice tunnel layer**, not the
app's URL handler. The iPhone-side dev tunnel (RemoteServiceDiscovery)
goes degraded after rapid `process launch --payload-url` bursts.
Recovery: pkill devicectl + relaunch app via process launch (which
still works because process control rides a different RSD service).

Screenshots written to Documents/screenshots/, capped at 30 most-recent.
Pull via `xcrun devicectl device copy from --domain-type
appDataContainer --domain-identifier network.columba.Columba --source
Documents/screenshots --destination /tmp/...`.

#if DEBUG-only — does not ship in release, same as the rest of the
test surface.

* fix(prop): single checkmark + 'sent to relay' text + dump_db diag

LXMF-swift bump → b2e14cd: caps PROPAGATED outbound state at .sent
(per python LXMessage.py:568-578); large prop messages no longer
falsely advance to .delivered via the Resource path.

iOS UI:
- MessageBubble.deliveryStatusIcon: defensively coerce
  delivered/read → sent for any message with deliveryMethod ==
  'propagated' (handles stale rows from before the fix).
- MessageDetailView.statusCard: method-aware text for prop messages.
  'Sent' → 'Sent to relay' with subtitle explaining propagation
  nodes don't ack recipient receipt.

Diagnostic surface:
- New lxma-test://dump_db URL action. Walks the full
  conversations + messages tables, emits one line per row to
  test_log.txt. Diagnoses Tyler's 2026-05-10 observation that
  prop messages appear in a separate conversation from
  direct/opp — DB inspection is the source of truth (UI
  faithfully renders whatever conversations table has).

Refs:
- LXMF/LXMessage.py:568-578 (__mark_propagated → state=SENT)
- LXMF-swift b2e14cd (resource-handler split, port-aligned)

* chore(deps): bump LXMF-swift to 0.4.0 + reticulum-swift to 0.3.0

LXMF-swift 0.4.0 (PR #7 — perf/stamper-parallel-primed-digest, merged):
  - Parallel stamp generation (LXStamper TaskGroup, 8 workers, primed
    SHA256 digest) — cost=16 from multi-minute to ~1-2s on iPhone.
  - PROPAGATED state machine fixes: drops wrong link.identify(); wires
    RESOURCE_PRF to .sent (not .delivered); ERROR_INVALID_STAMP handler
    via pendingPropagationSends FIFO + pendingPropagationRejections
    set; handlePropagationAccepted + handleOutboundResourceFailed with
    awaited DB writes that preserve deliveryAttempts budget.
  - DIRECT path: self-send identity resolution before path table;
    drops premature link.identify(); broadcast-relay-only self-echo
    gate; DIRECT resource crash-recovery parity with PROPAGATED.
  - Stamp-rejected resource short-circuit prevents retry-loop spam.

reticulum-swift 0.3.0 (PR #16):
  - HEADER_2 link DATA conversion fix.
  - sendLinkData signature: destinationHash param removed (breaking).

Package.swift, pbxproj, and Xcode-shared Package.resolved all updated.
Build verified: xcodebuild for iOS Simulator, CODE_SIGNING_ALLOWED=NO,
BUILD SUCCEEDED. Smoke pipeline (PROPAGATED/DIRECT/OPP bidirectional
with Mac echo bot) to follow on PR ready→draft transition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(deps): bump LXMF-swift to 0.4.0 + reticulum-swift to 0.3.0 (#73)

LXMF-swift 0.4.0 (PR #7 — perf/stamper-parallel-primed-digest, merged):
  - Parallel stamp generation (LXStamper TaskGroup, 8 workers, primed
    SHA256 digest) — cost=16 from multi-minute to ~1-2s on iPhone.
  - PROPAGATED state machine fixes: drops wrong link.identify(); wires
    RESOURCE_PRF to .sent (not .delivered); ERROR_INVALID_STAMP handler
    via pendingPropagationSends FIFO + pendingPropagationRejections
    set; handlePropagationAccepted + handleOutboundResourceFailed with
    awaited DB writes that preserve deliveryAttempts budget.
  - DIRECT path: self-send identity resolution before path table;
    drops premature link.identify(); broadcast-relay-only self-echo
    gate; DIRECT resource crash-recovery parity with PROPAGATED.
  - Stamp-rejected resource short-circuit prevents retry-loop spam.

reticulum-swift 0.3.0 (PR #16):
  - HEADER_2 link DATA conversion fix.
  - sendLinkData signature: destinationHash param removed (breaking).

Package.swift, pbxproj, and Xcode-shared Package.resolved all updated.
Build verified: xcodebuild for iOS Simulator, CODE_SIGNING_ALLOWED=NO,
BUILD SUCCEEDED. Smoke pipeline (PROPAGATED/DIRECT/OPP bidirectional
with Mac echo bot) to follow on PR ready→draft transition.

Co-authored-by: torlando-tech <torlando-tech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tunnel): guard applyTunnelModeToInterfaces(active:false) against initial .invalid VPN state

iOS emits an `.invalid` / `.disconnected` VPN status notification on
every cold start — fired by `TunnelManager.onStatusChange` regardless
of whether the user has enabled Background Transport, because the
session machinery probes whatever is currently loaded. The previous
code unconditionally scheduled `applyTunnelModeToInterfaces(active:
false)` via the 5s debounce, which iterated every TCPInterface and
called `endTunnelMode()`.

`endTunnelMode()` in reticulum-swift 0.3.0 is NOT idempotent
(TCPInterface.swift:257-269): it unconditionally tears down the
working NWConnection (via `transport?.disconnect()` -> nil) and
re-runs `setupTransport()`. Calling it on an interface that was never
in tunnel mode (outboundHook == nil) is destructive — it kills the
live socket Step 7 brought up moments earlier.

Reproduced 2026-05-11 on smoke run iter1 against
`feat/multi-tcp-tunnel @ 0f7cf3e`: all 4 scenarios FAILED at the
earliest `send_*` step. has_path returned 1 for both PN and bot
(path table populated via inbound announces), but outbound sends
never advanced past `state=OUTBOUND`. Console showed `[TUNNEL]
disabled tunnel mode` ~5s after cold start with no prior
`[TUNNEL] enabled` line, confirming the debounce was tearing down
TCP without ever having activated it.

Fix tracks an `isTunnelModeActive` bool. The active=false branch
guards on it and returns early if tunnel mode was never activated.
Mirrors the "undo what you did" contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: torlando-tech <torlando-tech@users.noreply.github.com>
Co-authored-by: torlando-agent[bot] <281092095+torlando-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude claude-opus-4-7 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: torlando-agent[bot] <torlando-agent@noreply.github.com>
Co-authored-by: torlando-agent[bot] <217870594+torlando-agent[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant