Skip to content

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

Merged
torlando-tech merged 4 commits into
mainfrom
columba-suite/issue-51-add-community-tcp-server-wizard
May 10, 2026
Merged

feat(InterfaceManagement): add TCP client community-server wizard#64
torlando-tech merged 4 commits into
mainfrom
columba-suite/issue-51-add-community-tcp-server-wizard

Conversation

@torlando-agent
Copy link
Copy Markdown
Contributor

@torlando-agent torlando-agent Bot commented May 5, 2026

Implements the approved plan for #51.

Summary

iOS already has TcpCommunityServer with a curated list and shows a server picker during onboarding (ConnectivityPage's serverPickerSheet). What's missing is the post-onboarding wizard: from Settings → Network Interfaces → Add → TCP, the user currently lands on TCPInterfaceConfigSheet (InterfaceManagementScreen.swift:484) — a blank manual-entry form with no community server option. Android exposes a 2-step wizard (SERVER_SELECTIONREVIEW_CONFIGURE) at this same surface (columba/.../tcpclient/TcpClientWizardScreen.kt, TcpClientWizardViewModel.kt). This PR ports that flow to iOS using only the iOS TCP fields that TCPClientConfig (InterfaceRepository.swift:190) already supports.

Changes

  • Sources/ColumbaApp/ViewModels/TCPClientWizardViewModel.swift — new @Observable @MainActor view model: TCPClientWizardStep { serverSelection, reviewConfigure }, selectServer, enableCustomMode, loadExisting (matches host:port to community list else custom mode), canProceed(from:), and save(into:) forwarding through a TCPClientWizardSaveSink protocol.
  • Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift — new SwiftUI wizard: TCPClientWizard container + TCPServerSelectionStep (Bootstrap / Community / Custom) + TCPReviewConfigureStep (server summary, name/host/port, advanced: network name, passphrase, mode picker). Uses existing Theme tokens; no new design tokens introduced.
  • Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift — adds showTCPWizard flag, branches selectInterfaceType(.tcpClient) and showEditInterface(...) for TCP entries to the wizard, conforms to TCPClientWizardSaveSink with a focused saveTCPInterface(...) helper that goes through the existing repository + applyChanges pipeline. Old TCPInterfaceConfigSheet is left in place behind the now-unreached showConfigSheet route to keep the diff focused — confirmed rg "TCPInterfaceConfigSheet" shows no other production callers.
  • Sources/ColumbaApp/Views/Settings/InterfaceManagementScreen.swift — presents the wizard via .sheet(isPresented: $viewModel.showTCPWizard) parallel to the existing config-sheet/RNode-wizard branches.
  • Tests/ColumbaAppTests/TCPClientWizardViewModelTests.swift — 10 unit tests covering the spec from the plan: selectServer pre-fill, enableCustomMode blanking, loadExisting host:port match vs. custom-mode fall-through, canProceed step-1 / step-2 / port-range / empty-name, save for create / edit / empty-advanced-fields. Uses a RecordingSink stub — pure Swift, no persistence or network.
  • Columba.xcodeproj/project.pbxproj — adds the three new files (two app sources, one test) to the appropriate groups and Sources build phases (SRCBP, TSRCBP).

Test plan

  • xcodebuild test -only-testing:ColumbaAppTests/TCPClientWizardViewModelTests — 10/10 pass on iPhone 16 simulator.
  • xcodebuild test -only-testing:ColumbaAppTests (full suite) — 75/75 pass, no regressions.
  • xcodebuild build clean on iPhone 16 simulator (matches CI's pre-test step).
  • Manual smoke (deferred to reviewer / device pass): Settings → Network Interfaces → + → TCP → step 1 with bootstrap/community/custom rows → tap Beleth → Next → step 2 pre-filled → Save → interface appears connecting/connected. Edit existing TCP interface → wizard opens at step 1 with matched server selected. Custom flow + invalid host saves a disconnected entry without crashing.

Implementer notes

  • Issue comment not addressed: Torlando's third comment on Add community tcp server wizard #51 ("amsterdam and between the borders testnets should be removed from the list") refers to RNS Amsterdam (amsterdam.connect.reticulum.network:4965) and RNS BetweenTheBorders (betweentheborders.com:4242) entries in Sources/ColumbaApp/Models/TcpCommunityServer.swift. The approved plan explicitly deferred all TcpCommunityServer.servers edits to a separate issue ("Out of scope: Sync TcpCommunityServer.servers with Android …"), so this PR doesn't touch the list. Worth filing as a small follow-up — the change is two-line and independent of the wizard work.
  • Hotspot warning, fyi: the plan called out InterfaceManagementScreen.swift / InterfaceManagementViewModel.swift having churned through 5+ feature PRs in the last six weeks. This PR adds another wizard-style flow to the same files. The right next refactor (per-type AddInterfaceFlow coordinator splitting these out) is out of scope here but worth queueing.
  • fullScreenCover vs sheet: chose .sheet([.large]) for parity with the existing config-sheet route. RNode uses fullScreenCover only because of BLE-pairing-dialog interference (per InterfaceManagementViewModel.swift:69 comment); TCP has no analogous concern.
  • TCPInterfaceConfigSheet left in place: confirmed via rg "TCPInterfaceConfigSheet" that nothing else routes through it after the change. Left as-is to keep the diff focused per the plan's "leaving the struct in place avoids a file-level refactor in this PR" line. Safe to delete in a follow-up.

🤖 Generated with Claude Code

sectionHeader("Community Servers")
VStack(spacing: 8) {
ForEach(community) { server in
serverRow(server)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@torlando-agent there are incorrect/fictitious tcp servers showing in the list here.

RNS Amsterdam has been decommisisoned
RNS BetweenTheBorders has been decommissioned
RNS Frankfurt doesn't seem to actually exist
i2p should not be listed at all since iOS does not support it
reticulum ireland does not exist
TheHub duckdns does not exist
kosciuszko dosn't exist
reticulum ireland v2 doen'st exist
rns roaming doesn't exist

please find the exact file in columa android and use those tcp servers exactly. up to date servers can be found online at directory.rns.recipes and rmap.world

Comment thread Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift Outdated
torlando-tech pushed a commit that referenced this pull request May 6, 2026
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>
@torlando-agent
Copy link
Copy Markdown
Contributor Author

torlando-agent Bot commented May 6, 2026

Addressed in commit 8ff5967. Replaced the iOS community-server table with the exact list from app/src/main/java/network/columba/app/data/model/TcpCommunityServer.kt (drops the decommissioned / non-existent entries and i2p), and collapsed the bootstrap split in TCPClientWizard into a single Community Servers section since Reticulum-Swift does not yet support bootstrap-interface mode.

If this isn't what you meant, push back and I'll revert.
🤖 — claude-claude-opus-4-7

@torlando-tech torlando-tech marked this pull request as ready for review May 9, 2026 22:41
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 9, 2026

Greptile Summary

Ports the Android TCP client wizard (server selection → review/configure) to iOS, adding TCPClientWizardViewModel, TCPClientWizard, and wiring through InterfaceManagementViewModel/InterfaceManagementScreen. The implementation is well-structured with clean validation, a TCPClientWizardSaveSink protocol for testability, and 10 focused unit tests; all previously raised review concerns (stale editingInterface on swipe-dismiss, canProceed double-evaluation, unused dismiss environment) are addressed in this version.

  • New wizard flow: TCPClientWizardViewModel handles step navigation, community-server pre-fill via selectServer, edit-mode population via loadExisting (host:port match → community selection, otherwise custom mode), port validation clamped to UInt16 > 0, and save(into:) forwarding through the sink protocol.
  • InterfaceManagementViewModel: branches selectInterfaceType(.tcpClient) and showEditInterface to the new wizard; dismissConfigSheet extended to clear showTCPWizard; saveTCPInterface writes through the existing repository + applyChanges pipeline.
  • TcpCommunityServer.swift changed despite being listed as out of scope: the server list is substantially updated (9 removed, 7 added), including two entries with display names \"noDNS1\" / \"noDNS2\" that appear verbatim in the wizard's server picker UI.

Confidence Score: 4/5

The wizard logic and wiring are correct; the only concern is two community-server entries with placeholder-style names that will be shown directly to users in the server picker.

The wizard implementation is solid — navigation, validation, edit pre-population, and the sink protocol all work correctly, and the previously raised issues are addressed. The one blocker is the user-visible server names "noDNS1" and "noDNS2" in the updated community list, which will appear as the primary label in the server picker for real users and look like internal identifiers rather than display-ready names.

Sources/ColumbaApp/Models/TcpCommunityServer.swift — the two entries with "noDNS1"/"noDNS2" display names need real operator names or a descriptive fallback before merging.

Important Files Changed

Filename Overview
Sources/ColumbaApp/Models/TcpCommunityServer.swift Community server list significantly updated (9 servers removed, 7 added); two entries have placeholder names "noDNS1"/"noDNS2" that will surface directly in the wizard UI
Sources/ColumbaApp/ViewModels/TCPClientWizardViewModel.swift New ViewModel: step navigation, server selection, edit pre-population via loadExisting, canProceed validation (UInt16 port range + non-empty host/name), and save forwarding through TCPClientWizardSaveSink. Logic is clean with correct trimming on all fields.
Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift New SwiftUI wizard container with TCPServerSelectionStep and TCPReviewConfigureStep; canProceed is correctly cached as a computed property; onAppear guards re-entrancy with !wizard.isEditing.
Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift Adds showTCPWizard flag, branches selectInterfaceType/.showEditInterface to the wizard for TCP, saveTCPInterface helper that writes through repository and triggers applyChanges, and dismissConfigSheet extended to clear showTCPWizard.
Sources/ColumbaApp/Views/Settings/InterfaceManagementScreen.swift Adds .sheet(isPresented: $viewModel.showTCPWizard, onDismiss: { viewModel.dismissConfigSheet() }) parallel to existing config-sheet and RNode branches; stale editingInterface on swipe-dismiss is correctly handled.
Tests/ColumbaAppTests/TCPClientWizardViewModelTests.swift 10 unit tests covering selectServer pre-fill, enableCustomMode blanking, loadExisting community-match vs custom-mode, canProceed validation across both steps, and save for create/edit/empty-advanced paths.
Columba.xcodeproj/project.pbxproj Adds two new app source files and one test file to the correct groups and build phases.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Sources/ColumbaApp/Models/TcpCommunityServer.swift:41-42
**Placeholder names shipped as user-visible server labels**

`"noDNS1"` and `"noDNS2"` look like internal identifiers rather than display-ready names. Both appear directly in the `TCPServerSelectionStep` server picker as the `Text(server.name)` label, so users will see these literal strings when choosing a community server. If these nodes have recognisable operator names (e.g. from the Android list or directory.rns.recipes), replace them before this lands; if they are intentionally anonymous nodes, a more descriptive label (e.g. `"Anonymous Node 1"` with the IP shown as the address) would communicate the distinction more clearly.

Reviews (4): Last reviewed commit: "fix(TcpCommunityServer): remove unwanted..." | Re-trigger Greptile

Comment thread Sources/ColumbaApp/Views/Settings/InterfaceManagementScreen.swift
Comment thread Sources/ColumbaApp/ViewModels/TCPClientWizardViewModel.swift Outdated
Comment thread Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift
Comment thread Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift Outdated
torlando-agent Bot and others added 3 commits May 9, 2026 20:12
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>
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>
Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com>
@torlando-tech torlando-tech force-pushed the columba-suite/issue-51-add-community-tcp-server-wizard branch from 2646617 to a0cde6f Compare May 10, 2026 00:13
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>
@torlando-tech torlando-tech force-pushed the columba-suite/issue-51-add-community-tcp-server-wizard branch from 1a90298 to bc7b18e Compare May 10, 2026 00:41
@torlando-tech torlando-tech merged commit 371bb71 into main May 10, 2026
2 checks passed
@torlando-tech torlando-tech deleted the columba-suite/issue-51-add-community-tcp-server-wizard branch May 10, 2026 00:42
Comment on lines +41 to +42
TcpCommunityServer(name: "noDNS1", host: "202.61.243.41", port: 4965, isBootstrap: false),
TcpCommunityServer(name: "noDNS2", host: "193.26.158.230", port: 4965, isBootstrap: false),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Placeholder names shipped as user-visible server labels

"noDNS1" and "noDNS2" look like internal identifiers rather than display-ready names. Both appear directly in the TCPServerSelectionStep server picker as the Text(server.name) label, so users will see these literal strings when choosing a community server. If these nodes have recognisable operator names (e.g. from the Android list or directory.rns.recipes), replace them before this lands; if they are intentionally anonymous nodes, a more descriptive label (e.g. "Anonymous Node 1" with the IP shown as the address) would communicate the distinction more clearly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/ColumbaApp/Models/TcpCommunityServer.swift
Line: 41-42

Comment:
**Placeholder names shipped as user-visible server labels**

`"noDNS1"` and `"noDNS2"` look like internal identifiers rather than display-ready names. Both appear directly in the `TCPServerSelectionStep` server picker as the `Text(server.name)` label, so users will see these literal strings when choosing a community server. If these nodes have recognisable operator names (e.g. from the Android list or directory.rns.recipes), replace them before this lands; if they are intentionally anonymous nodes, a more descriptive label (e.g. `"Anonymous Node 1"` with the IP shown as the address) would communicate the distinction more clearly.

How can I resolve this? If you propose a fix, please make it concise.

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