Skip to content

Commit 6ae92e2

Browse files
Merge pull request #99 from torlando-tech/feat/ios-nomadnet-over-modelb
feat(nomadnet): browse pages over Model B (NE-side fetch via IPC)
2 parents bc23817 + 1cd514f commit 6ae92e2

7 files changed

Lines changed: 352 additions & 103 deletions

File tree

Columba.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@
195195
PRB001 /* ProxyRnsBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = PRB002 /* ProxyRnsBackend.swift */; };
196196
PXI1B /* ProxyIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = PXIF /* ProxyIPC.swift */; };
197197
PXI2B /* ProxyIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = PXIF /* ProxyIPC.swift */; };
198+
NMF1B /* NomadNetFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMFF /* NomadNetFetch.swift */; };
199+
NMF2B /* NomadNetFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMFF /* NomadNetFetch.swift */; };
198200
SRB001 /* SwiftRNSBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = SRB002 /* SwiftRNSBackend.swift */; };
199201
T003 /* MicronParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT03 /* MicronParserTests.swift */; };
200202
ACT02 /* AnnounceClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACT01 /* AnnounceClassificationTests.swift */; };
@@ -413,6 +415,7 @@
413415
PRB002 /* ProxyRnsBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProxyRnsBackend.swift; path = Sources/RNSBackendProxy/ProxyRnsBackend.swift; sourceTree = SOURCE_ROOT; };
414416
PROD /* ColumbaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ColumbaApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
415417
PXIF /* ProxyIPC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyIPC.swift; sourceTree = "<group>"; };
418+
NMFF /* NomadNetFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NomadNetFetch.swift; sourceTree = "<group>"; };
416419
SRB002 /* SwiftRNSBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftRNSBackend.swift; path = Sources/RNSBackendSwift/SwiftRNSBackend.swift; sourceTree = SOURCE_ROOT; };
417420
TPROD /* ColumbaAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ColumbaAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
418421
/* End PBXFileReference section */
@@ -741,6 +744,7 @@
741744
EDLF /* ExtensionDiagLog.swift */,
742745
AGPF /* AppGroupPaths.swift */,
743746
PXIF /* ProxyIPC.swift */,
747+
NMFF /* NomadNetFetch.swift */,
744748
OBQF /* OutboxQueue.swift */,
745749
43A7FF6A056815712B1D13D9 /* RNodeSeam.swift */,
746750
34E6D26205158570EA4228EF /* AppGroupRNodeSeamWire.swift */,
@@ -1075,6 +1079,7 @@
10751079
AGB2B /* AppGroupBridgeInterface.swift in Sources */,
10761080
NERN2 /* NEReticulumNode.swift in Sources */,
10771081
PXI2B /* ProxyIPC.swift in Sources */,
1082+
NMF2B /* NomadNetFetch.swift in Sources */,
10781083
OBQ2B /* OutboxQueue.swift in Sources */,
10791084
45C9E9AFAC34D61BFB3797AA /* RNodeSeam.swift in Sources */,
10801085
FDE0DB7957C9D877D3E67367 /* AppGroupRNodeSeamWire.swift in Sources */,
@@ -1178,6 +1183,7 @@
11781183
EDL1B /* ExtensionDiagLog.swift in Sources */,
11791184
AGP1B /* AppGroupPaths.swift in Sources */,
11801185
PXI1B /* ProxyIPC.swift in Sources */,
1186+
NMF1B /* NomadNetFetch.swift in Sources */,
11811187
OBQ1B /* OutboxQueue.swift in Sources */,
11821188
077B /* TunnelManager.swift in Sources */,
11831189
BGTB /* BackgroundTransportView.swift in Sources */,

Sources/ColumbaNetworkExtension/NEReticulumNode.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,6 +1409,31 @@ actor NEReticulumNode {
14091409
}
14101410
}
14111411

1412+
/// Fetch a NomadNet page on the NE node (mirrors
1413+
/// `SwiftRNSBackend.fetchNomadNetPage`) using the shared `NomadNetFetch`
1414+
/// helper — in Model B the NE owns transport/identity/pathTable, so the
1415+
/// fetch runs here and the result is marshaled back to the app proxy. Never
1416+
/// throws across the IPC seam: every failure maps to a Foundation-only
1417+
/// `ProxyNomadNetOutcome` the app reconstructs into a `NomadNetFetchResult`.
1418+
func fetchNomadNetPageForIPC(destHashHex: String, path: String, timeoutSeconds: Double, formFields: [String: String]?) async -> ProxyNomadNetOutcome {
1419+
func fail(_ status: String) -> ProxyNomadNetOutcome {
1420+
ProxyNomadNetOutcome(ok: false, status: status, data: Data(), contentType: "")
1421+
}
1422+
guard let transport, let identity, let pathTable else { return fail("not-started") }
1423+
guard let destHash = Self.hexToData(destHashHex), !destHash.isEmpty else { return fail("bad-hash") }
1424+
do {
1425+
let r = try await NomadNetFetch.fetch(
1426+
transport: transport, identity: identity, pathTable: pathTable,
1427+
destHash: destHash, path: path, timeout: timeoutSeconds, formFields: formFields
1428+
)
1429+
return ProxyNomadNetOutcome(ok: r.ok, status: r.status.rawValue, data: r.data, contentType: r.contentType)
1430+
} catch {
1431+
// NomadNetFetch only throws from link.request (all other failures
1432+
// return a Result), so a throw here is a request failure.
1433+
return fail("request-failed")
1434+
}
1435+
}
1436+
14121437
// MARK: - A5b dispatch helpers
14131438

14141439
/// Map the `RNSAPI.LXDeliveryMethod` raw value string to LXMF-swift's enum.

Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
197197
let outcome = await node.sendLxmfForIPC(
198198
destHashHex: destHashHex, content: content, method: method, fieldsData: fieldsData)
199199
return .ok(try? JSONEncoder().encode(outcome))
200+
201+
case .nomadnetFetch(let destHashHex, let path, let timeoutSeconds, let formFields):
202+
let outcome = await node.fetchNomadNetPageForIPC(
203+
destHashHex: destHashHex, path: path, timeoutSeconds: timeoutSeconds, formFields: formFields)
204+
return .ok(try? JSONEncoder().encode(outcome))
200205
}
201206
}
202207

Sources/RNSBackendProxy/ProxyRnsBackend.swift

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,70 @@ public final class ProxyRnsBackend: RnsBackend, @unchecked Sendable {
112112
/// response comes back; returns the `ProxyResponse` (including `.error` /
113113
/// `.unsupported`) otherwise so callers can map those onto their own return
114114
/// shapes.
115-
private func roundTrip(_ request: ProxyRequest, op: String) async throws -> ProxyResponse {
115+
private func roundTrip(_ request: ProxyRequest, op: String, deadline: TimeInterval? = nil) async throws -> ProxyResponse {
116116
let wire: Data
117117
do {
118118
wire = try ProxyIPC.encodeRequest(request)
119119
} catch {
120120
throw BackendError.ipcFailed(operation: op)
121121
}
122-
let reply = await send(wire)
122+
// The seam imposes no timeout of its own — `send` (proxySend) awaits the
123+
// NE's completionHandler indefinitely. For long ops, bound the wait so a
124+
// dropped / wedged / jetsammed NE reply degrades to `ipcFailed` instead
125+
// of hanging forever. Default `nil` keeps every existing caller
126+
// byte-identical.
127+
let reply: Data?
128+
if let deadline {
129+
reply = await sendWithDeadline(wire, deadline: deadline)
130+
} else {
131+
reply = await send(wire)
132+
}
123133
guard let response = ProxyIPC.decodeResponse(reply) else {
124134
throw BackendError.ipcFailed(operation: op)
125135
}
126136
return response
127137
}
128138

139+
/// Send `wire` and await the NE reply, returning `nil` once `deadline`
140+
/// elapses **even if** the underlying `sendProviderMessage` continuation is
141+
/// still suspended (e.g. the NE was jetsammed and iOS hasn't delivered the
142+
/// failure callback yet).
143+
///
144+
/// `withTaskGroup` can't give this guarantee: it awaits ALL child tasks
145+
/// before returning, and `cancelAll()` is only cooperative — a
146+
/// non-cancellable `send` continuation would keep the group suspended past
147+
/// the deadline, defeating the whole point. So this races two unstructured
148+
/// tasks through one continuation and abandons the loser (its later
149+
/// resolution is gated to a no-op). The caller is never blocked past
150+
/// `deadline`.
151+
private func sendWithDeadline(_ wire: Data, deadline: TimeInterval) async -> Data? {
152+
let sendClosure = send
153+
let gate = ResumeOnce()
154+
return await withCheckedContinuation { (cont: CheckedContinuation<Data?, Never>) in
155+
Task {
156+
let reply = await sendClosure(wire)
157+
if gate.claim() { cont.resume(returning: reply) }
158+
}
159+
Task {
160+
try? await Task.sleep(nanoseconds: UInt64(max(1, deadline) * 1_000_000_000))
161+
if gate.claim() { cont.resume(returning: nil) }
162+
}
163+
}
164+
}
165+
166+
/// One-shot latch so exactly one racer resumes the continuation (resuming a
167+
/// `CheckedContinuation` twice traps).
168+
private final class ResumeOnce: @unchecked Sendable {
169+
private let lock = NSLock()
170+
private var fired = false
171+
func claim() -> Bool {
172+
lock.lock(); defer { lock.unlock() }
173+
if fired { return false }
174+
fired = true
175+
return true
176+
}
177+
}
178+
129179
// MARK: - RnsCore
130180

131181
public var localInfo: LocalInfo? {
@@ -456,8 +506,49 @@ public final class ProxyRnsBackend: RnsBackend, @unchecked Sendable {
456506
timeout: TimeInterval,
457507
formFields: [String: String]?
458508
) async throws -> NomadNetFetchResult {
459-
// Model B: runs NE-side / not proxied yet.
460-
NomadNetFetchResult(ok: false, status: .notStarted, data: Data(), contentType: "")
509+
// Model B: the NE owns transport/identity/pathTable, so the fetch runs
510+
// NE-side via the shared `NomadNetFetch` helper and the result is
511+
// marshaled back over the seam. The NE self-bounds every step (path 15s,
512+
// link/request/response timeouts) so it always replies; we add an IPC
513+
// deadline of `timeout + slack` as a backstop against a never-arriving
514+
// reply (the seam has no timeout of its own).
515+
func fail(_ s: NomadNetFetchResult.Status) -> NomadNetFetchResult {
516+
NomadNetFetchResult(ok: false, status: s, data: Data(), contentType: "")
517+
}
518+
// The NE self-bounds the fetch at roughly awaitPath(15) + link(timeout)
519+
// + response(timeout + 2) ≈ 2·timeout + 17s, and it ALWAYS replies. The
520+
// app deadline must comfortably exceed that so it only fires when the NE
521+
// has truly wedged/died — never pre-empting a legitimately slow mesh-path
522+
// fetch (which would surface a false "timeout"). Slack added on top.
523+
let ipcDeadline = 2 * timeout + 30
524+
let response: ProxyResponse
525+
do {
526+
response = try await roundTrip(
527+
.nomadnetFetch(destHashHex: destHashHex, path: path, timeoutSeconds: timeout, formFields: formFields),
528+
op: "nomadnetFetch",
529+
deadline: ipcDeadline
530+
)
531+
} catch {
532+
// IPC failure / deadline hit — the user was actively waiting on this
533+
// page, so surface a timeout rather than a misleading "not started".
534+
return fail(.timeout)
535+
}
536+
switch response {
537+
case .ok(let payload):
538+
guard let payload,
539+
let outcome = try? JSONDecoder().decode(ProxyNomadNetOutcome.self, from: payload) else {
540+
return fail(.unknown)
541+
}
542+
return NomadNetFetchResult(
543+
ok: outcome.ok,
544+
status: NomadNetFetchResult.Status(rawValue: outcome.status) ?? .unknown,
545+
data: outcome.data,
546+
contentType: outcome.contentType
547+
)
548+
case .error, .unsupported:
549+
// NE node not running / op not handled — degrade like a stopped backend.
550+
return fail(.notStarted)
551+
}
461552
}
462553

463554
// MARK: - RnsTelephony

Sources/RNSBackendSwift/SwiftRNSBackend.swift

Lines changed: 10 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -602,105 +602,17 @@ public final class SwiftRNSBackend: RnsBackend, @unchecked Sendable {
602602
guard let transport, let localId = identity, let pathTable else { return fail(.notStarted) }
603603
guard let destHash = Self.hexData(destHashHex), !destHash.isEmpty else { return fail(.badHash) }
604604

605-
// 1. Ensure a path, then recall the node identity from its announce.
606-
if await transport.hasPath(for: destHash) == false {
607-
await transport.requestPath(for: destHash)
608-
if await transport.awaitPath(for: destHash, timeout: 15.0) == false { return fail(.noPath) }
609-
}
610-
guard let entry = await pathTable.lookup(destinationHash: destHash),
611-
entry.publicKeys.count == 64,
612-
let nodeIdentity = try? ReticulumSwift.Identity(publicKeyBytes: entry.publicKeys) else {
613-
return fail(.noPath)
614-
}
615-
616-
// 2. Establish a fresh link to the nomadnetwork.node destination.
617-
let dest = ReticulumSwift.Destination(
618-
identity: nodeIdentity, appName: "nomadnetwork", aspects: ["node"],
619-
type: .single, direction: .out
605+
// The link/fetch algorithm is shared with the Network Extension node
606+
// (Model B) via `NomadNetFetch` so both stacks behave identically.
607+
let r = try await NomadNetFetch.fetch(
608+
transport: transport, identity: localId, pathTable: pathTable,
609+
destHash: destHash, path: path, timeout: timeout, formFields: formFields
610+
)
611+
return NomadNetFetchResult(
612+
ok: r.ok,
613+
status: NomadNetFetchResult.Status(rawValue: r.status.rawValue) ?? .unknown,
614+
data: r.data, contentType: r.contentType
620615
)
621-
let link: ReticulumSwift.Link
622-
do {
623-
link = try await transport.initiateLink(to: dest, identity: localId)
624-
} catch {
625-
return fail(.linkFailed)
626-
}
627-
defer { let l = link; Task { await l.close(reason: .initiatorClosed) } }
628-
guard await Self.awaitLinkEstablished(link, timeout: timeout) else { return fail(.linkFailed) }
629-
630-
// 3. Build the request payload (form fields → msgpack map) and send it.
631-
let requestData: ReticulumSwift.MessagePackValue?
632-
if let formFields, !formFields.isEmpty {
633-
var map: [ReticulumSwift.MessagePackValue: ReticulumSwift.MessagePackValue] = [:]
634-
for (k, v) in formFields { map[.string(k)] = .string(v) }
635-
requestData = .map(map)
636-
} else {
637-
requestData = nil
638-
}
639-
let receipt = try await link.request(path: path, data: requestData, timeout: timeout)
640-
641-
// 4. Await the response, racing the status stream against a timeout.
642-
let (data, status) = await Self.awaitResponse(receipt, timeout: timeout + 2.0)
643-
guard status == .ok, let data else { return fail(status) }
644-
return NomadNetFetchResult(ok: true, status: .ok, data: data, contentType: "")
645-
}
646-
647-
/// Wait for a link to reach an established state, racing against a timeout.
648-
private static func awaitLinkEstablished(_ link: ReticulumSwift.Link, timeout: TimeInterval) async -> Bool {
649-
await withTaskGroup(of: Bool.self) { group in
650-
group.addTask {
651-
for await st in await link.stateUpdates {
652-
if st.isEstablished { return true }
653-
if case .closed = st { return false }
654-
}
655-
return false
656-
}
657-
group.addTask {
658-
try? await Task.sleep(nanoseconds: UInt64(max(1, timeout) * 1_000_000_000))
659-
return false
660-
}
661-
let ok = await group.next() ?? false
662-
group.cancelAll()
663-
return ok
664-
}
665-
}
666-
667-
/// Await an RNS request response, racing the status stream against a timeout.
668-
private static func awaitResponse(_ receipt: ReticulumSwift.RequestReceipt, timeout: TimeInterval) async -> (Data?, NomadNetFetchResult.Status) {
669-
await withTaskGroup(of: (Data?, NomadNetFetchResult.Status).self) { group in
670-
group.addTask {
671-
for await status in await receipt.statusUpdates {
672-
switch status {
673-
case .responseReceived:
674-
let raw = await receipt.responseData
675-
return (raw.map { Self.unwrapResponseData($0) }, .ok)
676-
case .failed: return (nil, .requestFailed)
677-
case .timeout: return (nil, .timeout)
678-
default: continue
679-
}
680-
}
681-
return (nil, .requestFailed)
682-
}
683-
group.addTask {
684-
try? await Task.sleep(nanoseconds: UInt64(max(1, timeout) * 1_000_000_000))
685-
return (nil, .timeout)
686-
}
687-
let result = await group.next() ?? (nil, .unknown)
688-
group.cancelAll()
689-
return result
690-
}
691-
}
692-
693-
/// NomadNet responses come back msgpack-wrapped (binary or string); unwrap to
694-
/// the raw page bytes, falling back to the raw payload if it isn't msgpack.
695-
private static func unwrapResponseData(_ data: Data) -> Data {
696-
if let value = try? ReticulumSwift.unpackMsgPack(data) {
697-
switch value {
698-
case .binary(let bytes): return bytes
699-
case .string(let str): return str.data(using: .utf8) ?? data
700-
default: break
701-
}
702-
}
703-
return data
704616
}
705617

706618
// MARK: - Transport admin (live interface add/remove, no restart)

0 commit comments

Comments
 (0)