51 Commits
Author SHA1 Message Date
Zoltan PappandClaude Opus 5 ecf3b2a82f Add a Troubleshoot toggle for remote debug bundle jobs (#216)
* Add a Troubleshoot toggle for remote debug bundle jobs

The client refuses management-requested remote jobs unless
RemoteJobsAllowed is enabled, and the iOS and tvOS apps had no way to
turn it on. Expose the flag through ConfigurationProvider, add an
"Allow remote debug bundles" toggle to the iOS Troubleshoot screen and
a Troubleshooting section in the tvOS settings, and bump netbird-core to
the commit that adds the SDK preference accessors.

* Update submodule

* Route the iOS remote jobs toggle through the view model setter

The Toggle wrote the published property before onChange invoked
setRemoteJobsAllowed, so on a failed commit the rollback restored the
new value instead of the previous one. Use a Binding whose setter calls
the view model directly, matching the tvOS settings view.

* Lock the remote debug bundle toggle when MDM manages it (#224)

* Lock the remote debug bundle toggle when MDM manages it

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

* Decode the remote jobs snapshot key as allowRemoteJobs

The Go snapshot reports this field under the policy key name,
allowRemoteJobs, not remoteJobsAllowed. The synthesized coding key used the
property name, so decodeIfPresent never found the key and the flag stayed
false — the toggle looked editable under an MDM policy that manages it.

Map the coding key explicitly and keep the property name, which the views
already reference. Bumps netbird-core to pick up the matching Go-side fix.

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

* Bump submodule

* Use allowRemoteJobs key in MDM restriction test fixtures

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Enforce the remote jobs policy before the next poll

setRemoteJobsAllowed validated against the cached MDM snapshot, which is
only refreshed on onAppear or a 30s stale poll. The OS writes managed
configuration from another process, so the in-process change notification
never fires for it and a policy pushed mid-session went unenforced until
the next poll. Refresh the snapshot before validating.

The guard also checked only mdm.remoteJobsAllowed, while both toggles lock
on remoteJobsAllowed || disableUpdateSettings, so the backstop could
disagree with the control the user sees. Extract
remoteJobsForbiddenByPolicy to mirror the lock.

Route the rejection through commitSettings() so an MDM-refused commit
raises the standard alert instead of a silent print. This matters most on
tvOS, where commit() always reports success and the pre-commit guard is
the only backstop.

* Restore the persisted remote jobs setting after the test

testUnmanagedDeviceStillAcceptsTheChange writes through
ConfigurationProvider, which persists into the Go preferences store.
tearDown() only restored the MDM dictionary, so an enabled
remoteJobsAllowed could leak into later tests or a reused test-host
launch. Capture the original value and restore it via defer, which runs
while the device is still unmanaged so the setter is not rejected.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 15:14:26 +02:00
Zoltan Papp 6116ea921c Show exit nodes right after connect without visiting another tab (#232)
* Show exit nodes right after connect without visiting another tab

The exit node selector stayed on "No exit nodes available" after every
connect until the user opened the Peers or Resources tab and came back.

The extension reports the tunnel connected from onConnected, which the core
fires once management and signal are up - before the engine is reachable and
a few hundred milliseconds before the first network map arrives. The app reads
the routes on that .connected transition, the core answers "not connected",
and the extension swallowed that error into an empty route list. The app takes
any answer from the core as the truth about the network map, so it cached zero
routes. Nothing re-read them afterwards: the status poll only fetches status,
and route reads happen on .connected and on a view's onAppear, which is why
switching tabs "fixed" it. The extension log shows the same pair on every
connect, a few milliseconds apart:

  onConnected: state=connected
  Error retrieving or encoding route selection details: not connected

Two changes. getSelectRoutes now answers a core error with no payload, which
the adapter already treats as a failed read that leaves the cache alone - an
error is not zero routes. And the extension now tells the app when the
network map moves: the core's onPeersListChanged fires at the end of every
network map update, after the route manager has applied the routes, and on
every route/peer state change, so the previously empty callback posts a
Darwin notification (the same mechanism the app uses to announce MDM policy
changes to the extension, in the other direction). Android's HomeFragment
relies on the same event for the same reason.

RoutesViewModel observes that notification and re-reads the routes, coalesced
to one read per 300 ms window: the core announces a change per peer and per
route, so a connect with dozens of peers produces dozens of notifications
within seconds, and each read is an IPC round-trip that serialises the whole
route list. One pending work item absorbs every notification until it fires;
a change announced after the fire schedules the next, so nothing is lost.
Optimistic selection writes and clearRoutes() retire in-flight and pending
reads, so a read dispatched before a tap can no longer answer after it with
the pre-tap selection; the mutation's own reconcile read is the one allowed
to answer.

* Drop network map notifications overtaken by a clear or a selection write

scheduleNetworkMapRefresh hops to the main queue before it looks at the
pending refresh, and that hop is a window. A notification still in transit
when clearRoutes() or beginSelectionMutation() ran on main found the pending
slot they had just emptied and scheduled a fresh read after the very
invalidation that should have dropped it. After clearRoutes() that read is not
harmless: the optimistic disconnect and the profile switch clear the routes
while the extension is still winding down and still announcing peer changes,
so the read can succeed and repopulate a list nothing clears again - the
selector enabled over nodes the core can no longer apply.

Capture a lock-guarded epoch when the notification arrives, on whatever
thread the Darwin notify center delivers on, advance it in clearRoutes() and
beginSelectionMutation(), and re-check it once the request reaches main. A
notification that was valid when it arrived and is still valid on main
schedules exactly as before.
2026-09-23 15:09:08 +02:00
evgeniyChepelevandClaude Opus 5 46ba1cb106 Fix exit node (#226)
* Fix exit node

* Bound the route IPC round-trip so its completion always fires

sendProviderMessage only calls its reply handler if the extension answers.
An extension that never does - wedged in a synchronous Go call, gone, or
returning out of handleAppMessage before replying - stranded the completion
that getRoutes/selectRoutes/deselectRoutes promise on every path. That also
hung the DispatchGroup RoutesViewModel.selectRoute balances around the
sibling deselects, so the pending select was never sent and the optimistic
exit node selection was never reverted.

Bound the wait with a DispatchWorkItem and deliver exactly one outcome
through an NSLock-guarded latch, cancelling the timeout when a reply or a
send error completes the request - the same pattern fetchData already uses
for the status round-trip. Adds RouteRequestError.timedOut.

Also promotes sendSelectAndReconcile's comment to a doc comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep the On Demand preference when a re-arm is refused

rearmOnDemandIfNeeded wrote connectOnDemand = false on .failed, guarded only
by `guard self.connectOnDemand`. But true is exactly the value a newer,
successful re-arm - or the user's own Settings toggle - leaves behind, so
that guard cannot tell a stale failure from a fresh success. Nothing pinned
the completion to the request that started it.

The window is wide: applyOnDemandState only reports .failed after
recoverFromFailedOnDemandWrite has run a full loadFromPreferences round-trip.
Two overlapping re-arms are enough to hit it with no user involved -
applyStatusSideEffects(.connected) runs from both applyExtensionStatus and
startActivation, and the very reload that clears configurationStale is what
lets the second attempt succeed. The late failure from the first then
clobbered rules that were genuinely armed.

The write was also wrong in kind. Unlike setConnectOnDemand it makes no
optimistic write of its own, so there is no previous value to roll back to;
and false closes the guard, so a single transient NEVPNError.configurationStale
would discard the user's setting for good instead of letting the next connect
retry. "Preference on, rules not armed yet" is a state the app already creates
on purpose - see checkLoginRequiredFlag().

Log the refusal and leave the intent standing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:26:02 +02:00
evgeniyChepelev 072a881a3e Deliver the MDM policy to the network extension through the App Group (#227)
* Gate tvOS writes and bound the MDM restart retry

tvOS never reached CheckMDMConflicts: commit() there returns true without
consulting anything, and settings go straight to the config JSON, so a managed
field stayed writable while iOS rejected it. The gate belongs in the setters —
by commit() time the write has already happened — and it phrases the refusal
the way Go does, so both platforms say the same thing.

The refusal text was written in three places while the key list Go puts in the
error was thrown away. One constant now, and the wrapped conflicts are parsed
out so the user learns which setting was refused.

pendingMDMRestart was written both synchronously from
stopObservingMDMConfigChanges and from inside a monitorQueue block; it now sits
behind tearDownLock with the rest of the lifecycle state.

The deferred retry rescheduled itself indefinitely. Cap it at 15 attempts —
thirty seconds, the restart pipeline's own timeout — then log and stop, since
waiting longer only hides a wedged pipeline.

* Use one policy snapshot per tvOS write and drop the duplicate start latch

* eliver the MDM policy to the network extension through the App Group

* Update ConfigurationProvider.swift

* Derive the MDM retry budget from the watchdogs it waits on
2026-09-16 16:40:06 +02:00
Zoltan Papp e8dbf9c2fa Fire the adapter's stop completion only after the Go run loop has exited (#223)
* Fire the adapter's stop completion only after the Go run loop has exited

The restart path used the connection listener's onDisconnected callback as
the "stop completed" signal and started the next engine from inside it. The
Go notifier delivers that callback synchronously from the outgoing Run(),
before finishRun() releases the run slot, so the immediate Run() could hit
the already-running guard in startRun(). Run() returns that error without
logging it, the adapter replaced it with a generic message, and the tunnel
stayed installed with no engine behind it. A 0.3.4 debug bundle shows 15
such failed restarts in 13 days; six ended in a device-wide blackhole until
the user toggled the VPN.

- NetBirdAdapter.stop runs the blocking client.stop() on a dedicated serial
  queue and invokes the completion only after it returns; Client.Stop()
  already waits for the run loop to exit. The 15 s fallback and the
  listener-driven notifyStopCompleted are gone, and consecutive stops queue
  up instead of firing each other's handler.
- Without a completion the call stays synchronous, so the tvOS provider is
  unchanged. waitForExit: false uses StopWithoutWait, which stopTunnel now
  passes because iOS gives it only a few seconds.
- The start catch path no longer calls stop(): Stop() cancels whichever run
  is registered, so a late failure of a superseded run could kill the
  healthy one. The real Go error is forwarded instead of a fixed string.
- startTunnel resets adapter.isRestarting alongside isRestartInProgress.

* Keep the tvOS provider off the blocking stop path

restartClient stopped the client synchronously on the path monitor's queue,
so path updates and their debounce could not be delivered for the duration
of the Go teardown. Use the completion-based stop, which waits for the run
loop to exit on the adapter's own queue, and start the client from there.

stopTunnel passes waitForExit: false, as on iOS: the system gives it only a
few seconds and it already delays its completion by two more.

The restart error is logged as public so the forwarded Go error text is
readable in Console.
2026-09-09 18:01:37 +02:00
8fa8d34ff3 Mdm integration (#206)
* MDM policy fetcher

* Bump head of netbird mdm_integration branch

* Bump netbird-core to mdm_integration head

* Enforce MDM policy in the app process and across the UI

The extension already registered the policy fetcher on its Client, but the
main app ran with no policy at all: login and settings read an empty policy
and silently ignored MDM. Register the fetcher on everything that reads or
writes config — Preferences, the Go-backed ProfileManager, and all four
NewAuth call sites — and drive the screens from the enforcement snapshot the
Go layer renders.

MDMPolicyFetcher.swift was in the repository but in no target, so the file
the extension referenced was never compiled. Add it, and the new
MDMRestrictions decoder, to the four targets that need them.

Decode with decodeIfPresent throughout rather than the synthesized
conformance, so a Go layer that adds or drops a key cannot break the parse,
and let any read failure degrade to "nothing managed": a broken policy must
not lock a user out of their own settings.

UI convention: a gated feature (features.*) hides its section outright, while
a managed setting stays visible and locked with "Managed by your
organization". A control that silently vanishes reads as a bug; one that is
visibly locked explains itself.

Replace the Swift-side change detection with hasMDMPolicyChanged(), so the
per-key diff and the dedup that the shared UserDefaults channel requires both
live in Go. Surface a commit() rejection as a managed-setting message instead
of a raw error. Report an applied policy through the same flag-plus-
notification delivery already used for the login-required signal, since an
extension cannot raise a toast itself.

Preferences.getPreSharedKey() is gone from the SDK — the key no longer
crosses the bridge — so ConfigurationProvider exposes a write-only setter
alongside hasPreSharedKey(), and the screens show whether a key is configured
rather than its value.

Tests cover the decoder (tri-state allowServerSSH, missing, unknown and
malformed input) and the Swift↔Go round trip against a pushed managed
configuration. They skip with an explanation when a scheme launch argument
pins a policy, since the argument domain outranks every other and would
otherwise make them report a false result.

* Review findings, all verified against the code before fixing

* Guard every On Demand re-arm with the MDM policy

* Skip the On Demand disconnect prompt while the policy disarms the rules

* Serialize the MDM restart and report the policy only once it is applied

* Bound how long a pushed policy can go unnoticed in the foreground

* Close three paths that mutate state the policy forbids

* Abandon a restart that a teardown overtakes

* Make the teardown latch observable from every queue

* Update PacketTunnelProvider.swift

* Update PacketTunnelProvider.swift

* Bump the core to the MDM branch head and follow its contract changes

* Bump netbird core to the main

---------

Co-authored-by: riccardom <riccardomanfrin@gmail.com>
Co-authored-by: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:45:20 +02:00
Dominic Vonk 59839d0c7e fix: harden iOS handoff recovery and resource lifecycle (#212)
* fix: tighten iOS resource and reconnect lifecycle

Use the tunnel provider's long-lived path state during disconnect callbacks, keep restart ownership in the restart transaction, break the foreground polling retain cycle, deliver status updates outside locks on the main queue, clean up observers, and batch log synchronization to reduce I/O wakeups.

* fix: harden iOS network handoff recovery

Serialize restart transactions, queue path changes that arrive mid-restart, reject stale callbacks, avoid starting on an unavailable path, and retry non-auth startup failures with bounded exponential backoff. Also reduce foreground polling frequency and skip malformed optional Firebase configuration instead of aborting at launch.

* fix: address reconnect review feedback

* fix: resolve signed flavor tunnel provider

* fix: address follow-up review findings

* fix: show disconnect intent immediately

* docs: document VPN lifecycle changes

* fix: clear stale disconnect intent after status refresh
2026-09-09 10:59:53 +02:00
Zoltan Papp 7b382045b8 Stop the tunnel from restarting itself during a normal connect (#220)
* Stop the tunnel from restarting itself during a normal connect

The extension monitored an unfiltered network path, which also covers the
utun interface the provider itself creates. A single connect applies the
tunnel settings several times — the engine pushes the interface address, the
routes and the DNS config separately — and each apply flapped that path.
handleNetworkChange read every flap as an outage followed by a recovery and
answered it with a full client restart, which applied the settings again and
flapped the path once more: a loop that kept the tunnel cycling between
connected and disconnected until the timing happened to miss the detection
window. The main app's 3s status poll surfaced the cycle as an unreliable,
drawn-out connect.

Break the loop and the conditions that widened it:

- Exclude the provider's own tunnel interface from the path monitor, so
  applying tunnel settings is no longer visible to it. This matches what the
  Android client already does by requiring NET_CAPABILITY_NOT_VPN.
- Keep only one monitor live. startMonitoringNetworkChanges overwrote
  pathMonitor without cancelling it, so a second startTunnel in the same
  extension process left both monitors handling every path update. Tear the
  monitor down on the queue that starts it and delivers its updates, rather
  than from the main thread, so cancellation cannot race an in-flight update.
- Require the path to stay unsatisfied for 2s before declaring an outage. A
  shorter gap is a blip and must produce neither the UI's network warning nor
  a restart.
- Hold off network-change restarts until the initial connect reports its
  outcome, so a restart cannot tear down a connection that is still coming
  up. A bounded guard keeps a start that never reports from blocking
  restarts for the rest of the tunnel's life.
- Skip the On Demand preferences write when the requested state and rules
  already match. saveToPreferences on a configured manager makes NE emit a
  transient .disconnecting, and applyExtensionStatus arms On Demand on every
  transition to .connected, so an unconditional write added a spurious
  disconnect to every connect.
- Report the startTunnel outcome to NE exactly once. The Go notifier
  re-fires OnConnected on any management/signal reconnect within one run
  (peer/notifier.go dedupes only consecutive identical states), so the
  adapter's start callback announced a fresh tunnel start for every
  mid-session reconnect.

* Do not trust the manager's On Demand state after a failed save

Skipping the write when the requested state already matched assumed the
retained manager reflects the system preferences. A failed saveToPreferences
breaks that: the assigned isOnDemandEnabled and onDemandRules stay on the
manager while the system still holds the old values, so the next identical
request satisfied the equality check and reported .applied without writing
anything. closeWithOnDemandDisabled waits for that .applied before dropping
the tunnel, and would have disconnected with the rules still in force — which
has the system bring the tunnel straight back.

Reload the manager after a failed save so the values that were never
persisted come off it, and report the failure only once that reload has
settled. Reloading is also the documented recovery from
NEVPNError.configurationStale, the case where another process wrote the
configuration in between.

The reload can fail too, leaving the manager untrustworthy, so also latch the
failure: while it is set the no-write path is closed and every request writes.
The latch clears on the next successful save and is guarded by the lock that
already protects the neighbouring requested-state property, since these
requests arrive both from the main queue and from the async login path.

* Report the start outcome when a stop interrupts the tunnel startup

The start outcome is delivered from the connection listener's onConnected, so
it is bound to the connection succeeding rather than to the start attempt
ending. A stop that lands before the engine ever connects just makes the run
loop return — client.run does not throw, and onDisconnected only completes the
stop — so nothing reported the outcome and NE was left waiting on startTunnel
until it timed out.

Complete it from stopTunnel with a cancellation error. The latch makes this
safe: a start that already reported is untouched, so an ordinary disconnect
from a connected tunnel and the login-required path both stay as they were.

Also fix the logging the latch introduced. Its "outcome already reported"
line would have been written on every ordinary disconnect and read as a
failure. completeTunnelStart now reports whether it delivered, and each call
site logs the case that is actually worth reading: a repeated onConnected from
an engine reconnect at the start site, and a stop before connect at the stop
site.
2026-09-08 16:02:21 +02:00
Zoltan Papp 6d3407f970 Capture Go panics in the main app and report them to Crashlytics (#215)
* Capture Go panics in the main app and report them to Crashlytics

A Go panic aborts the process with SIGABRT, and both the Apple crash
report and Crashlytics stop at runtime.asmcgocall: the Go stack switch
breaks the unwinder, so the panicking Go frames are never shown. The
panic message and goroutine dump only go to stderr.

The network extension already redirected stderr into netbird.err in the
app group container, but the main app did not, so a panic in the Go code
the app itself calls (login, preferences, profile management) left no
trace anywhere.

Move the redirect into a shared GoCrashCapture in NetbirdKit and call it
from both processes before any Go SDK call. On launch, after Firebase is
configured, the app forwards whatever netbird.err gained since the last
report to Crashlytics: the full dump via log() and a non-fatal whose
headline is the panic line. The file itself is never truncated, so the
debug bundle keeps picking it up unchanged.

* Keep netbird.err shared across processes and report backlogs one dump at a time

Truncate the file in place when it passes the size cap instead of
unlinking it: the other process may hold fd 2 on the same inode, and an
unlink would send its later panic output into an orphan nobody reads.

Advance the reported offset only past the chunk actually returned. The
read is capped at 48 KB, so jumping to the end of the file skipped any
second dump that landed behind the first one between two app launches.
2026-09-03 09:37:22 +02:00
Zoltan Papp eedd8b6fc1 Remove connection state indicator from resource list cards (#214)
* fix: remove connection state indicator from resource list cards

* fix: drop stale statusIndicatorColor mention from route status comment
2026-09-02 15:37:57 +02:00
evgeniyChepelevandClaude Opus 5 37de9d9065 fix(ios): repeated 2FA prompt during SSO re-login (iOS) (#175)
* fix(ios): present the SSO login browser as a sheet

* fix(ios): address review findings in the SSO login flow

 don't log the loopback redirect URL — its query carries the OAuth
  authorization code; log only the fact and host
- rearm the login-sheet completion flag in onDismiss instead of the
  content's onAppear, and dismiss-and-cancel defensively if the sheet
  is ever presented without a valid login URL
- tag each login attempt with a token and check it in the 20 s
  post-dismissal fallback, so a stale timer can't cancel a newer
  attempt (including the automatic identity-reset retry)
- harden the ownership-conflict self-heal: skip the retry when the
  identity reset itself fails (try? previously hid the error and the
  retry re-presented the same conflicting key) and gate it on the
  browser phase having started, so it can't race the IPC fallback
- document why navigation-failure handlers act only after the loopback
  redirect: pre-success failures include benign NSURLErrorCancelled
  from superseded navigations, and reacting to them would abort
  healthy logins

* fix(ios): run SSO login in the system auth session instead of a webview

* fix(ios): reuse the browser session on re-login instead of clearing it

* fix(ios): serialize the loopback probe and clarify the callback design

* fix(ios): bind the account to the profile instead of the cookie jar

* Update submodule and fix conflicts

* fix(ios): keep the authorize URL out of the log and profile lookups out of body

* Point the submodule back at upstream netbird

The fork override was temporary: it carried the login_hint support this branch
needs while it was unmerged. That support is upstream now — the account-email
layer lives in client/mobile and the submodule already points at an upstream
commit — so the override has nothing left to carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Name the browser-session key what it is: a profile ID

It has held the ID since the move onto the Go profile manager, but the
parameter was still called `name`, which is exactly the ambiguity a reviewer
asked about: a display name would break under rename, an ID does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let the Go SDK decide the login account

The branch rewrote the authorize URL in Swift to append prompt=login whenever
it judged the browser session might resolve to the wrong account. The Go SDK
already does this, better: pkce_flow.go sets prompt=login (or max_age=0) from
the management server's login flag, and skips it entirely for IdPs marked
DisablePromptLogin — a flag the Swift rewriting could not see, so it forced a
prompt on exactly the providers that break under one.

It also could not work as intended. The core sends prompt=login by default, so
the Swift path found a prompt already present and bailed out; the account
chooser it was supposed to force never fired on a default deployment.

Account targeting now comes from the login_hint the SDK sends, which removes
the reason for the browser-session bookkeeping too: which profile last held the
session was only ever an input to this decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the comment left behind by the removed browser-session key

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* CI: build the core from netbird#7193 while it is unmerged

This branch needs the iOS login_hint wiring that netbirdio/netbird#7193 adds:
without it the SDK sends no hint and a re-login is resolved by whatever the
shared browser session holds, which is the bug this branch is fixing.

Fetch the PR head from netbirdio/netbird rather than repointing .gitmodules at
a fork: the submodule URL and pin stay upstream, so a plain clone still works
and only the SDK build uses the pending code.

The step runs before the core-sha step so the cache key follows the commit that
is actually built — keying it on the pinned commit would serve a cached
framework built without the fix.

Remove both steps and re-pin the submodule once #7193 lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ios): resume the login continuation on one queue, publish on the main one

Two races in performLogin(), both on the failure paths.

The continuation could be resumed twice. `resumed` is a plain captured var with
an unsynchronised check-then-set: onOpen resumes from a main-queue block, while
the error callback resumed directly on the SDK's goroutine thread. The Go side
runs the whole flow on one goroutine — urlOpener.Open() only schedules the main
queue block, it does not run it — so a login that fails right after handing over
the browser URL (unreachable management server, an immediately rejected token
exchange) reaches the error callback while that block is still pending. Both see
resumed == false and resume, which traps: resuming a checked continuation twice
is a fatal error, not a recoverable one.

Move the resume into the error callback's existing main-queue block so both
call sites are serialised on the same queue. It goes in a defer rather than at
the end of the block: the two guards above it return early — a deallocated self,
and an error before the browser phase, which is the path that falls through to
the IPC fallback — and neither may leave the await hanging.

The IPC fallback then published loginURL and showBrowser straight from
performLogin()'s body. That body runs on the cooperative pool: performLogin() is
nonisolated, and a nonisolated async function does not inherit its caller's
actor, so being called from @MainActor start() does not put it on the main
thread. Publishing off the main thread is undefined behaviour in SwiftUI. Hop to
the main actor for both, and await the hop — loginIfRequired() reads showBrowser
as soon as performLogin() returns, so a fire-and-forget dispatch would reopen
the same ordering bug the main-app path documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:09:17 +02:00
CicerBro 4e08732d80 fix(tvOS): reliable device-code login and VPN profile selection (#192)
* Fix tvOS onChange deprecation warnings

Update TVMainView to use the tvOS 17-compatible SwiftUI onChange closure forms while preserving the existing state handling behavior.

* fix(tvOS): park tunnel start during device authentication

IPC for LoginTV only reaches a running extension, but startTunnel used
to fail immediately when login was required. Park the start until auth
completes, boot the extension from performLogin, and persist the
post-login config into the extension-local client.

* fix: match VPN configs by provider bundle ID

Matching only on localizedDescription can pick up stale VPN profiles
from another install. Prefer the current providerBundleIdentifier and
remove leftover configs that point at a missing provider.

* fix(tvOS): poll login status with a single IPC round-trip

TVAuthView's 2s poll timer sent two identical IsLoginComplete messages
per tick: checkLoginError decoded LoginDiagnostics for the error, then
checkLoginComplete re-fetched the same payload for completion. Replace
both adapter methods with a single checkLoginDiagnostics call and move
the friendly-error mapping to a LoginDiagnostics.friendlyError property,
so error and completion are derived from one atomic response.

* fix(tvOS): harden parked login flow

* fix(tvOS): address parked login review feedback

* fix(tvOS): make auth completion one-shot
2026-08-27 09:17:56 +02:00
Zoltan Papp fef31c66fd Drop the dedicated server URL file (#202)
* refactor(ios): own profile management in the Go core

Replace the parallel Swift profile manager with a thin wrapper over the
core's ID-based profilemanager.ServiceManager (via the new NetBirdSDK iOS
binding), eliminating the duplicate implementation. Profiles are now
ID-keyed (on-disk filename = id, display name lives in the config); the
default profile stays "default" at the container root and never becomes a
hex id, matching the Go/desktop semantics.

- ProfileManager: thin wrapper over NetBirdSDKProfileManager on iOS; tvOS
  keeps its single-default, container-root behavior (no Go profile manager)
- ProfileLayoutMigration: one-time, idempotent, file-coordinated migration
  from the legacy profiles/<name>/ directory layout to the Go layout;
  preserves auth tokens (copies unparseable configs verbatim), keeps the
  active selection and logged-out profiles, and only marks itself done on
  full success so a failure retries on the next launch
- ProfileConnectionCache and all callers re-keyed by profile id
- Settings/server changes already target the active profile through the
  profile manager (Preferences.configFile -> activeConfigPath)
- Tests: NetBirdTests/ProfileMigrationTests covers the migration, reads it
  back through the Go lib, and round-trips a setting via NetBirdSDKPreferences
  (the test target now links NetBirdSDK)
- Bump netbird-core submodule to include the iOS profile manager binding

* ci: re-enable Test workflow on push and pull_request

Run the iOS test suite on PRs again to check whether the previously reported test crash still reproduces.

* fix(ios): call gomobile path getters with NSErrorPointer

The non-null String path getters (getActiveConfigPath/getActiveStateFilePath/getConfigPath/getStateFilePath) are generated by gomobile as value-returning calls with an explicit NSErrorPointer parameter, not throwing methods. Wrap them via a goPath helper in ProfileManager (and matching helpers in the tests), fixing the iOS app build failure (missing argument for parameter).

* fix(ios): raise NetBirdTests deployment target to iOS 15.0

The NetBirdTests target was iOS 14.0 while the NetBird app module requires iOS 15.0, so `@testable import NetBird` failed to build ("Compiling for iOS 14.0, but module 'NetBird' has a minimum deployment target of iOS 15.0"). Match the test target to the app so the test bundle compiles and the suite can run.

* ci(ios): use valid GOOGLE_APP_ID in dummy Firebase plist

The dummy GoogleService-Info.plist used a GOOGLE_APP_ID of "dummy",
which fails Firebase's app-ID format validation. At launch the host app
calls FirebaseApp.configure(options:), and +[FIRApp addAppToAppDictionary:]
raised an uncaught NSException, aborting the host before the test runner
could establish a connection ("Test crashed with signal abrt before
establishing connection"). This blocked all hosted tests, not just the
new profile-migration ones.

Give the dummy app ID a well-formed value so configure() succeeds.

* fix(ios): skip Firebase configuration under unit tests

The host app calls FirebaseApp.configure() at launch. When launched purely
to host unit tests, this aborted the process before the test runner could
connect because FirebaseApp.configure() raises an uncaught ObjC exception on
an invalid app ID (the CI dummy plist).

Gate both the iOS and tvOS configure paths on the XCTestConfigurationFilePath
environment variable so Firebase is never initialized in the test host, and
fold the duplicated configure logic into a single helper.

* test(ios): write ManagementURL as Go-style object in migration fixtures

The legacy netbird.cfg is written by the Go SDK, which serializes
Config.ManagementURL (a url.URL) as a nested {Scheme,Host,Path} object,
not a plain string. The settings round-trip test drives NetBirdSDKPreferences,
which fully unmarshals Config and rejected the string fixture with
"cannot unmarshal string into Go struct field Config.ManagementURL".
Normalize the fixture to the object form the SDK actually produces.

* fix(ios): narrow NetBirdTests FRAMEWORK_SEARCH_PATHS to project dir

The test target used a recursive "$(PROJECT_DIR)/**" framework search path,
scanning every subdirectory (DerivedData, netbird-core, package checkouts) and
risking unexpected framework resolution. NetBirdSDK.xcframework lives at the
project root, so point the search path at "$(PROJECT_DIR)" directly.

* chore(ios): bump netbird-core to preserve profile name on auth save

* feat(ios): exclude profile storage from device backups

The profile configs carry the WireGuard and SSH private keys, and the
WireGuard key is the peer's identity: restoring a backup onto another
device would produce two devices connecting with the same key. Mark the
profiles/ directory and the container-root profile files (netbird.cfg,
state.json, active_profile.json, netbird_server_url) as excluded from
iCloud/iTunes backups.

The exclusion is re-applied on every launch from the ProfileManager init:
the Go core rewrites some of these files via temp-file+rename, which drops
per-file attributes. The profiles/ directory is excluded as a whole (and
created up front if missing), so its contents stay covered regardless of
how they are rewritten. After a restore the user re-logs in, matching how
a profile behaves when moved to a new machine on desktop.

* fix(ios): back the profile connection cache with the App Group suite

The cache was backed by UserDefaults.standard, which is per-process: the
network extension is the process that learns the peer's ip/fqdn and runs
the login, so everything it wrote was invisible to the main app and vice
versa. Back it with the App Group suite instead, so both processes see the
same entries.

Entries written by an earlier version are moved over on first use. A suite
that already holds data is never overwritten, so a stale app-local copy
cannot clobber what the extension has since written.

* fix(ios): drop the dedicated server URL file

The file was a fourth copy of the management URL, written next to the
profile config. It existed because Swift code used to rebuild configs and
could drop the URL, leaving login to fall back to the default cloud server
and write that back — so the URL had to be recoverable from somewhere else.

Both reasons are gone. In the ID-based layout the config survives logout
(only the keys are cleared), and the core's DirectUpdateOrCreateConfig
merges into the existing config instead of rebuilding it, so no write can
lose the URL. The connection cache covers the window before the config can
be read back, and now that it lives in the App Group suite it does so
across processes too — which was the file's last unique property.

managementURL(forID:) is therefore config -> connection cache.
saveServerURL(_:forID:) stays as the callers' entry point but records into
the cache. Profiles migrated from the legacy layout keep their URL:
ProfileLayoutMigration already seeds the cache from the legacy file.

The file name stays in the backup exclusion list — earlier versions left
files behind, and those should not reach a backup either.

* fix(ios): resolve the login IPC URL via ProfileManager

login() read the management URL straight from the connection cache when
building the "Login:<configPath>|<statePath>[|<managementURL>]" message,
bypassing ProfileManager.managementURL(forID:), which resolves from the
profile config first and falls back to the cache.

With an empty cache but a live config the URL was omitted from the IPC
message. The practical impact is small — when the config exists the
extension reads the URL from it anyway — but this was the last call site
reading the cache directly for the URL. Going through ProfileManager
keeps both login paths consistent (performLogin already resolves this
way), re-seeds the cache from the config as a side effect, and keeps the
URL in the message even if a cache entry is ever lost.

* fix(ios): merge the connection cache migration instead of clobbering

The standard → App Group suite migration dropped the legacy copy whenever
the suite already held data, and removed it even when the suite write
never landed. Both lose data on the first launch after the upgrade:

- On-demand VPN can start the extension before the app runs, seeding the
  suite with URL-only entries; the app's entire legacy cache (ip/fqdn and
  URLs for every profile) was then deleted without a merge.
- With a missing App Group entitlement the suite write silently no-ops,
  and the standard-domain copy — the last remaining one — was deleted
  anyway.

Merge instead: suite values win field by field (the suite is strictly
newer than the legacy copy, which is frozen at upgrade time) and legacy
values fill the fields the suite holds no value for. The standard copy is
removed only after a verified read-back of the merged data — compared as
decoded values, since JSON key order is not stable — so a write that did
not land leaves the copy in place and the migration retries on the next
init.

The migration source is now injectable (defaulting to .standard), and
ProfileConnectionCacheTests covers the merge semantics, the failed-write
path, and idempotency. The migration still inherits the unsynchronized
cross-process read-modify-write on the shared blob; that is addressed
separately.

* fix(ios): store the connection cache under per-profile keys

Every cache write was load → mutate → persist on a single JSON blob.
With the cache in the App Group suite that is a cross-process
read-modify-write with no coordination: the app writing ip/fqdn while
the extension seeds a URL means one side's update is lost to the last
writer.

Split the storage so the schema matches the write patterns: one key per
profile for the connection data (ip/fqdn/ipv6, always written together
by the status updates) and one for the management URL (written by the
login paths). Every write becomes a blind single-key set with no prior
read, so concurrent writers cannot clobber each other's fields and no
cross-process locking is needed — file coordination is unsafe from an
extension, where the other process can be suspended while holding a
claim.

The blob is exploded into the per-profile keys on first use: existing
keys are never overwritten (they are strictly newer than the blob), so
concurrent migration from both processes stays benign, and the blob is
removed only after every entry verifiably landed. The standard → suite
merge keeps writing the blob and this second stage explodes it, so the
full upgrade chain is standard blob → suite blob → per-profile keys.

The public API is unchanged. clearConnectionData now removes the
connection-data key instead of writing empty fields — same observable
behavior, one blind delete. remove clears both keys; a process death
between the two can orphan a URL key, which is harmless and cleaned up
by the next remove for that ID.
2026-08-27 09:15:50 +02:00
Zoltan Papp 2768bf2d67 Refactor own profile management in the Go core (#147)
* refactor(ios): own profile management in the Go core

Replace the parallel Swift profile manager with a thin wrapper over the
core's ID-based profilemanager.ServiceManager (via the new NetBirdSDK iOS
binding), eliminating the duplicate implementation. Profiles are now
ID-keyed (on-disk filename = id, display name lives in the config); the
default profile stays "default" at the container root and never becomes a
hex id, matching the Go/desktop semantics.

- ProfileManager: thin wrapper over NetBirdSDKProfileManager on iOS; tvOS
  keeps its single-default, container-root behavior (no Go profile manager)
- ProfileLayoutMigration: one-time, idempotent, file-coordinated migration
  from the legacy profiles/<name>/ directory layout to the Go layout;
  preserves auth tokens (copies unparseable configs verbatim), keeps the
  active selection and logged-out profiles, and only marks itself done on
  full success so a failure retries on the next launch
- ProfileConnectionCache and all callers re-keyed by profile id
- Settings/server changes already target the active profile through the
  profile manager (Preferences.configFile -> activeConfigPath)
- Tests: NetBirdTests/ProfileMigrationTests covers the migration, reads it
  back through the Go lib, and round-trips a setting via NetBirdSDKPreferences
  (the test target now links NetBirdSDK)
- Bump netbird-core submodule to include the iOS profile manager binding

* ci: re-enable Test workflow on push and pull_request

Run the iOS test suite on PRs again to check whether the previously reported test crash still reproduces.

* fix(ios): call gomobile path getters with NSErrorPointer

The non-null String path getters (getActiveConfigPath/getActiveStateFilePath/getConfigPath/getStateFilePath) are generated by gomobile as value-returning calls with an explicit NSErrorPointer parameter, not throwing methods. Wrap them via a goPath helper in ProfileManager (and matching helpers in the tests), fixing the iOS app build failure (missing argument for parameter).

* fix(ios): raise NetBirdTests deployment target to iOS 15.0

The NetBirdTests target was iOS 14.0 while the NetBird app module requires iOS 15.0, so `@testable import NetBird` failed to build ("Compiling for iOS 14.0, but module 'NetBird' has a minimum deployment target of iOS 15.0"). Match the test target to the app so the test bundle compiles and the suite can run.

* ci(ios): use valid GOOGLE_APP_ID in dummy Firebase plist

The dummy GoogleService-Info.plist used a GOOGLE_APP_ID of "dummy",
which fails Firebase's app-ID format validation. At launch the host app
calls FirebaseApp.configure(options:), and +[FIRApp addAppToAppDictionary:]
raised an uncaught NSException, aborting the host before the test runner
could establish a connection ("Test crashed with signal abrt before
establishing connection"). This blocked all hosted tests, not just the
new profile-migration ones.

Give the dummy app ID a well-formed value so configure() succeeds.

* fix(ios): skip Firebase configuration under unit tests

The host app calls FirebaseApp.configure() at launch. When launched purely
to host unit tests, this aborted the process before the test runner could
connect because FirebaseApp.configure() raises an uncaught ObjC exception on
an invalid app ID (the CI dummy plist).

Gate both the iOS and tvOS configure paths on the XCTestConfigurationFilePath
environment variable so Firebase is never initialized in the test host, and
fold the duplicated configure logic into a single helper.

* test(ios): write ManagementURL as Go-style object in migration fixtures

The legacy netbird.cfg is written by the Go SDK, which serializes
Config.ManagementURL (a url.URL) as a nested {Scheme,Host,Path} object,
not a plain string. The settings round-trip test drives NetBirdSDKPreferences,
which fully unmarshals Config and rejected the string fixture with
"cannot unmarshal string into Go struct field Config.ManagementURL".
Normalize the fixture to the object form the SDK actually produces.

* fix(ios): narrow NetBirdTests FRAMEWORK_SEARCH_PATHS to project dir

The test target used a recursive "$(PROJECT_DIR)/**" framework search path,
scanning every subdirectory (DerivedData, netbird-core, package checkouts) and
risking unexpected framework resolution. NetBirdSDK.xcframework lives at the
project root, so point the search path at "$(PROJECT_DIR)" directly.

* chore(ios): bump netbird-core to preserve profile name on auth save

* feat(ios): exclude profile storage from device backups

The profile configs carry the WireGuard and SSH private keys, and the
WireGuard key is the peer's identity: restoring a backup onto another
device would produce two devices connecting with the same key. Mark the
profiles/ directory and the container-root profile files (netbird.cfg,
state.json, active_profile.json, netbird_server_url) as excluded from
iCloud/iTunes backups.

The exclusion is re-applied on every launch from the ProfileManager init:
the Go core rewrites some of these files via temp-file+rename, which drops
per-file attributes. The profiles/ directory is excluded as a whole (and
created up front if missing), so its contents stay covered regardless of
how they are rewritten. After a restore the user re-logs in, matching how
a profile behaves when moved to a new machine on desktop.

* Bump netbird-core submodule to main (ed7d4de99)
2026-08-26 11:54:21 +02:00
CicerBro 813bf79255 fix(tvOS): use writable local paths for prefs, auth config, and Go state (#191)
* fix(tvOS): use process-local defaults and writable cache paths

App-group UserDefaults are non-functional on tvOS, so shared preferences
and the cached device name silently failed. Prefer UserDefaults.standard
and a writable Caches fallback in DEBUG.

* fix(tvOS): use writable scratch path for auth config file

NetBirdSDKNewAuth creates or updates netbird.cfg at the given path
before the SSO check runs. On tvOS the path came from the App Group
container, which is not writable, so changing the management server
failed with EPERM before the UserDefaults fallbacks could run. Point
configFile() at the caches directory on tvOS instead, mirroring the
stateFile() treatment — persistence on tvOS goes through UserDefaults
and IPC, not this file.

* fix(tvOS): use writable path for Go state file

Empty state paths made the state manager write temp files into the
extension CWD, which is read-only and failed with EPERM every 10s.

* fix(tvOS): prefer caches and validate state path
2026-08-25 23:32:23 +02:00
evgeniyChepelev c50ff9f24a TVos - vpn on demand (#198)
* feat(tvos): connect on demand after reboot and network changes

* fix(on-demand): serialize state changes and report failures to callers
2026-08-22 21:17:29 +02:00
c6a81e0b41 Fix/ios reduce login requests (#177)
* cut redundant Login RPCs before Sync

Every "is login required?" check is a full Login gRPC call — IsLoginRequired runs
doMgmLogin and classifies the error, so there is no cheaper probe. iOS made ~7 per
fresh connect against Android's ~3: three layers each verified the auth state
before the engine performed the login that actually matters.

Removed four duplicates:

- client.go Run(): dropped the LoginSync() pre-flight (IsLoginRequired + Login).
  The engine's loginToManagement is authoritative and runs immediately before the
  first Sync. Auth failures still surface: PermissionDenied marks the shared status
  recorder and fires ClientStop → onDisconnected, where needsLoginCached() reports
  login-required and the provider tears the tunnel down.
- login.go: added LoginInteractive, skipping the IsLoginRequired pre-flight for
  callers that already know login is needed. LoginWithDeviceName keeps the check
  for tvOS, where the state may be unknown.
- PacketTunnelProvider.startTunnel: skips needsLogin() when the main app passes
  loginVerified in the start options. Starts the main app did not initiate
  (On Demand, widget) carry no flag and still verify.
- ConnectionListener.onDisconnected and restartClient: use the network-free
  needsLoginCached() instead of the blocking needsLogin(), which cost an RPC on
  every ordinary disconnect and every network change.

Interactive login now issues 3 Login RPCs, a reconnect 2, a network-change restart 1.

Go changes are confined to client/ios/NetBirdSDK (ios build tag) — Android and the
shared core are untouched. Requires a netbird-core submodule bump and a rebuilt
NetBirdSDK.xcframework via ./build-go-lib.sh.

Docs: verification scenarios, test-scenario catalog and UI test plan under docs/.

* Update .gitmodules

* Remove docs

* Bump netbird-core to fix/ios-redundant-login-rpcs

Points the submodule at the fork branch carrying LoginInteractive and the
removed LoginSync pre-flight, which the Swift changes in this branch require.

Temporary: revert to the upstream URL and an upstream commit once the core
change lands in netbirdio/netbird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* signal login-required when a restarted engine fails auth

The connection listener suppresses its login-required signalling while
adapter.isRestarting is true. Normally the stop-phase onDisconnected resets
the flag before the restart's engine start begins, so an auth failure there
still signals. But when the stop phase never fires onDisconnected (engine
already dead) and the stop completion arrives via the 15s fallback timeout
instead, isRestarting stays true into the new start and a PermissionDenied
failure there died silently: no re-auth signal, no teardown, dead tunnel
left black-holing traffic.

Re-check the recorder in the start error handler — the engine marks it with
PermissionDenied before Run() returns, so the cached check answers without a
network call — and signal + cancel the tunnel. Also removes the reliance on
the listener's reset ordering.

Addresses the CodeRabbit finding on the restart path.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zoltán Papp <zoltan.pmail@gmail.com>
2026-08-07 18:32:07 +02:00
Zoltan Papp 85f865d179 fix: preserve peer identity on re-login (#163)
* fix(ios): preserve peer identity on re-login, stop creating duplicate peers

Every interactive re-authentication registered a brand-new peer on the
management server (named after the fallback hostname) instead of re-using the
existing one. Root cause was in the netbird-core iOS SDK: NewAuth built a fresh
in-memory config that generated a new WireGuard private key, and performLogin()
wrote that config (with the new key) back to netbird.cfg on login success —
replacing the peer's persisted identity. Regression from the login flow moving
into the main-app process (#138), where OAuth switched from the config-reading
IPC path to NewAuth.

- Bump netbird-core to fix/ios-relogin: NewAuth now loads the existing config
  (preserving the private key) when a config file is present, only generating a
  fresh key for first-time login / after logout when no file exists.
- Update the stale performLogin() comment that claimed NewAuth does not read the
  config file.

* fix(ios): log when re-login falls back to the default cloud server

managementURL(for:) already recovers the server URL from the config file, the
logout-surviving server URL file, and the connection cache in turn, so a nil
result means no URL is persisted anywhere. Passing "" to NewAuth is harmless for
a re-login now that the SDK reads the existing config (its apply() keeps the
config URL unless a non-empty one is provided), but a nil URL on an own-server
profile that lost every URL source would silently log in against the default
cloud. Log that case so it is visible in diagnostics; keep the "" fallback so a
genuine first-time login still works.

* fix(ios): register peer under device name, not hostname, on interactive login

The interactive OAuth login called the SDK's plain login() path, which passes an
empty device name; the management server then registers the peer under the
machine hostname fallback instead of the user's device name (UIDevice.current.name).
Every other iOS registration path (extension Run/LoginForMobile, setup-key login)
already passes Device.getName(), so the same device could appear under two
different names depending on the entry point.

Switch both interactive login call sites to login(withDeviceName:) with
Device.getName():
- NetworkExtensionAdapter.performLogin() (main-app OAuth flow)
- NetBirdAdapter.loginAsync() iOS branch (was tvOS-only; now unified since both
  branches pass the device name)

Uses the existing LoginWithDeviceName SDK binding — no netbird-core change needed.
Affects first-time registration; a re-login reuses the persisted identity.

* Update submodule to the release branch

* Fix submodule version

* Update submodule to the release branch

* Update submodule
2026-07-15 15:41:48 +02:00
Zoltan Papp 76177e3e5c Backport single exit node selection (#151)
* Bump netbird-core: backport single exit-node enforcement

Points the submodule at backport/single-exit-node (844881f5c), which
carries the exit-node exclusivity reconcile onto v0.74.0 so at most one
exit node stays selected.

* Enforce single exit-node selection in the routes UI

Activating an exit node now deselects every other selected exit node
(mirroring the desktop/Wails toggleExitNode behaviour), so 0.0.0.0/0 can't
stay pinned to the previously selected peer while the UI shows only the new
one. Siblings are deselected before the new node is selected so the core
drops the old exit node before adding the new one. Non-exit routes are
untouched.

* Bump netbird-core: atomic exit-node reconciliation

Points the submodule at 415eb59ef, which moves the single exit-node
reconcile into one locked RouteSelector method so a concurrent deselect-all
can no longer interleave and get undone.

* fix(ios): sequence exit-node switch and reconcile route selection

selectRoute fired the new selectRoutes before the sibling deselectRoutes
round-trips completed. Since both go through independent async
sendProviderMessage calls, the select could race the deselects and the
core would drop the just-added exit node, leaving 0.0.0.0/0 pinned to the
old peer.

Deselect every selected exit-node sibling first, track them in a
DispatchGroup, and only send the select once all deselects complete. After
the select, reconcile the optimistic UI with the core's real state via
GetRoutes so a rejected change reverts the toggle instead of leaving a
stale selection (Select/Deselect messages don't report their applied
result).

* fix(ios): guarantee deselectRoutes completion and refresh sibling toggle

deselectRoutes returned without calling its completion on the nil-session,
send-throw, and UTF-8 branches. selectRoute balances a DispatchGroup around
each sibling deselect, so a missed completion stranded the group and the
follow-up select/reconcile for the new exit node never ran. Invoke the
completion on every exit path.

RoutesSelectionInfo.selected is a plain (non-@Published) property, so mutating
a sibling emitted no change and its RouteCard toggle stayed on until the next
getRoutes reconcile. Send objectWillChange before deselecting the sibling so
the toggle flips off immediately, matching the mutually-exclusive exit-node UX.

* Update submodule
2026-07-03 18:05:05 +02:00
Zoltan Papp 43165697a7 Clear stale interfaceIPv6 on new engine session (#152)
The settings manager outlives individual engine sessions, and the engine
only pushes setInterfaceIPv6 when a session has a v6 address. After IPv6 is
disabled or a v4-only profile is selected, a stale v6 address kept
createTunnelSettings on the dual-stack branch and skipped the ::/0
blackhole, leaking IPv6 past a selected exit node. Reset interfaceIPv6 at
the setInterfaceIP session boundary so a v4-only session can't reuse it.
2026-07-03 10:52:36 +02:00
Zoltan PappandClaude Opus 4.8 5f9df4f391 fix(ios): prevent extension crash from late Go callbacks during network flapping (#142)
The packet tunnel extension crash-loops (EXC_BAD_ACCESS, KERN_INVALID_ADDRESS
at 0x28) on wifi<->cellular path flapping: it connects briefly, then the
extension is killed and relaunched every ~6-12s, so no IP/device name renders
and iOS never activates the VPN (T-808).

Root cause (code-derived; not yet confirmed against a symbolicated build-7
crash because no dSYM was available): the Go engine retains NetworkChangeListener
and DNSManager for the lifetime of the SDK client they were created with. When
the adapter is swapped/torn down (profile switch, or a restart triggered by
flapping) the old client can still fire a late onNetworkChanged/applyDns into
the now-stale listener, which dereferences a tunnel manager whose owning
provider has already been deallocated -> use-after-free.

Fix: make the Go-held listeners null-safe and explicitly detachable.
- NetworkChangeListener / DNSManager: tunnelManager is now optional; every Go
  callback runs on a serial queue behind a guard (isValid + non-nil
  tunnelManager). Late/in-flight callbacks become no-ops instead of crashing.
  Added invalidate() to detach, serialized on the same queue.
- NetBirdAdapter: added invalidateListeners(). Deliberately NOT called from
  stop()/restart (the same adapter is reused there and must keep delivering
  callbacks) -- only when the adapter is actually discarded.
- PacketTunnelProvider: invalidate the outgoing adapter's listeners before
  replacing it at the two reconfigure sites (startTunnel, handleAppMessage).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:17:10 +02:00
Zoltan Papp b3721c2a71 Fix login flow: wrong server after login, premature auth alert, parallel login (#144)
* Fix login flow: wrong server after login, premature auth alert, parallel login

Three distinct bugs in the iOS login flow:

1. Login ran against (and persisted) the default cloud server instead of the
   user's own management server. performLogin() called NetBirdSDKNewAuth(path, "", nil)
   with an empty management URL. The Go SDK's NewAuth does not read the config file at
   the given path — it builds a fresh in-memory config from the URL argument, and an
   empty URL falls back to DefaultManagementURL (api.netbird.io). Login then ran against
   the wrong server and DirectWriteOutConfig wrote that wrong server back into the
   profile's netbird.cfg. Regression from #138, which moved OAuth into the main-app
   process but passed "" as the URL. Fix: pass the active profile's real management URL
   (ProfileManager.managementURL) and also persist it to the dedicated, logout-surviving
   server URL file on login success.

2. The "Login required" alert popped immediately on Connect even for a first-time
   login. A stale keyLoginRequired flag from a previous extension run was read by the
   polling timer's checkLoginRequiredFlag() right after connect. Fix: clear the flag (and
   any pending auth alert) at the start of performConnect(); a genuinely required re-auth
   is re-signalled by the extension after this connect attempt.

3. The browser login and a parallel VPN start raced. onOpen runs on a background
   goroutine and resumed the continuation before showBrowser was committed on the main
   queue, so loginIfRequired() could observe showBrowser == false and call
   startVPNConnection() — launching the extension, tripping its needsLogin path, and
   popping the auth alert alongside the browser. Fix: set showBrowser and resume the
   continuation in the same main-queue block.

Also persist the chosen server to the dedicated server URL file on iOS when the user
changes the management server in ServerView (previously tvOS-only), so the user's
server cannot fall back to the default cloud.

* Don't signal login-required when user cancels the login browser

Closing the OAuth browser without logging in popped a spurious "Login required"
alert and notification. The browser-finished handler called startVPNConnection()
unconditionally, which launched the extension, tripped its needs-login path, and
signalled login-required — even though the user simply chose not to log in (not a
failed active session).

The ASWebAuthenticationSession callbackURL cannot distinguish success from
cancellation here: NetBird's PKCE flow uses an http://localhost loopback redirect
that the Go HTTP server consumes, so the session completes with a nil callbackURL
even on success. Determine success from the SDK's onLoginSuccess callback instead:

- NetworkExtensionAdapter.loginSucceeded, set by onSuccess (on the main queue) and
  reset at the start of each performLogin().
- SafariView.didFinish starts the VPN only when loginSucceeded; otherwise it calls
  the new ViewModel.cancelPendingLogin(), which resets the Connecting state to
  disconnected and clears the login-required flag and any pending auth alert.

* Abort interactive login when the user dismisses the browser

Closing the OAuth browser without logging in left the SDK's PKCE loopback HTTP
server bound to its redirect port (the iOS login runs in the main-app process and
nothing cancelled it). The next connect then stalled in "Connecting…" while the
new login flow tried to bind the same still-occupied port, eventually failed, and
only later managed to reopen the browser.

cancelPendingLogin() now calls the new NetworkExtensionAdapter.cancelLogin(), which
stops the SDK auth (Auth.Stop, added in the netbird-core submodule). Stopping cancels
the auth context, unblocking WaitToken and shutting down its loopback server so the
port is freed at once. Mirrors Android, where stopEngine() aborts login via the
engine context.

Bumps netbird-core submodule to fix/ios-auth-stop-cancel for Auth.Stop().

NOTE: requires regenerating NetBirdSDK.xcframework on a machine with Xcode
(./build-go-lib.sh) so the Swift binding exposes Auth.stop(); the binding cannot be
built on Linux.
2026-06-29 14:16:52 +02:00
evgeniyChepelev 3ccb66a821 Replace Lottie connect button with toggle switch (#140)
* feat(ios): replace Lottie connect button with toggle switch

- Add VPNToggleView: custom pill toggle with spring animation and pulse
  during connecting/disconnecting states
- Optimistic UI: thumb moves instantly on tap, pulse starts before OS confirms
- Simplify iOSConnectionView: monotone BgMenu background, centered layout,
  SECURED/NOT SECURED status label, hostname/IP grouped under toggle

* Update iOSConnectionView.swift

* Update iOSConnectionView.swift

* feat(ios): redesign connection screen with toggle switch and expandable IP details

* Update main screen UI

* Fix padding and tap area for main screen
2026-06-28 15:36:13 +02:00
evgeniyChepelev 07e90dc209 Notify user when VPN disconnects due to re-authentication (#106)
* feat: notify user when VPN disconnects due to re-authentication

* Fix double notification

* Update project.pbxproj

* Update netbird-core
2026-06-18 18:03:36 +02:00
Zoltan Papp a448f5ea6e Propagate exit-node deselect to synthesized v6 (::/0) route (#128)
* Update submodule

* Update submodule

* Update submodule

* [ui] fix exit-node route indicator stuck on yellow

A merged exit-node route card exposes its network as a comma-joined string
("0.0.0.0/0, ::/0"), while peer.routes lists each prefix as a separate entry.
statusIndicatorColor compared the whole joined string with connectedPeerRoutes
.contains(...), which can never equal a single entry, so the exit node always
showed yellow even when the peer was connected. Non-merged routes (a single
prefix) matched fine, which is why only the exit node looked wrong.

Split route.network on "," and match any component, so the indicator turns
green when the peer is connected on either the v4 or v6 default route.

* [ui] use core-computed route status for the indicator

Read the new Status field from the bridge and color the route indicator from
it (Connected -> green, else yellow), instead of inferring status by matching
the network string against peer routes. This fixes the merged exit node always
showing yellow, because its network value is the joined "0.0.0.0/0, ::/0" which
never equaled a single peer-route entry.

Threads status through RoutesSelectionInfo and the PacketTunnelProvider mapping.
Keeps the legacy network-matching path (now split-aware) as a fallback for cores
that don't yet provide a status.

* [ui] drop legacy network-matching from route indicator

Now that the core always provides a per-route connection status, the route
indicator no longer needs to infer it from peer routes. Remove the fallback
network-string matching (and the resolved-IP check), so statusIndicatorColor
simply reflects route.status. Also drops the now-unused peerViewModel
dependency from RouteCard and its call site.

* [deps] bump netbird-core to exit-node v6 deselect + route status fixes

Points the submodule at the netbird-core commits that fix the orphaned v6 exit
selection (leaking ::/0 after the v4 exit node was disabled) and compute the
per-route connection status in the iOS bridge.

* [ui] fix stale comment on RoutesSelectionInfo.status

The status property no longer has a legacy network-matching fallback (removed
when the indicator switched to reading route.status directly). Update the doc
comment to describe the current behavior: consumers use the core-provided status
directly, and a nil/empty value is treated as not-connected (yellow indicator).

* Update submodule

* Update submodule

* Update submodule

* Update submodule
2026-06-16 17:41:40 +02:00