Skip to content

Multi-profiles#80

Open
evgeniyChepelev wants to merge 6 commits intonetbirdio:mainfrom
evgeniyChepelev:multi-profiles
Open

Multi-profiles#80
evgeniyChepelev wants to merge 6 commits intonetbirdio:mainfrom
evgeniyChepelev:multi-profiles

Conversation

@evgeniyChepelev
Copy link
Copy Markdown
Collaborator

@evgeniyChepelev evgeniyChepelev commented Apr 2, 2026

Summary

  • Added native Swift ProfileManager — manages multiple profiles, each with its own netbird.cfg and state.json, with migration from legacy single-profile layout
  • Added profile switching UI (ProfilesListView, AddProfileSheet, ProfileBadge) with switch/logout/remove actions and confirmation dialogs
  • Fixed profile isolation: passed active config/state paths via startVPNTunnel options and Login IPC so the network extension always reinitializes its SDK adapter for the correct profile on connect and login
  • Fixed continuation leak in performLogin() — completion is now always called on nil response or missing session
  • Used ephemeral ASWebAuthenticationSession for SSO login to prevent cookie sharing between profiles
  • Showed current server URL as footer in the Change Server screen

Summary by CodeRabbit

  • New Features

    • Multi-profile support: create, list, switch, remove, and logout profiles with per-profile storage and UI
    • New profile management UI: add-profile sheet, profiles list, and profile badge component
  • Improvements

    • Per-profile connection caching and profile-aware config/state handling
    • Improved connection state handling to avoid stuck “Connecting...” states during profile switches
    • Updated web authentication to a modern session-based flow
    • Server display/footer and UI refinements in connection/settings views

 Split ProfilesView into ProfilesListView, AddProfileSheet, ProfileBadge, AddProfileViewModel
- Add server URL + setup key fields to Add Profile screen
- Store per-profile connection data (ip/fqdn/managementURL) as typed model in ProfileConnectionCache
- Show cached connection info immediately on profile switch; empty if no prior data
- Fix profile deletion persistence via tombstone in profiles.json
- Fix logout to remove both netbird.cfg and state.json (cfg holds auth tokens)
- Preserve managementURL in UI after logout via cache fallback
- Guard polling from overwriting new profile's data during disconnect/reconnect cycle
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

Adds multi-profile support and UI for iOS: new ProfileManager and ProfileConnectionCache, profile creation/switching views and view models, integration with main view model and settings, network-extension changes to accept profile-specific config/state paths and IPC login payloads, and replaces SFSafariViewController with ASWebAuthenticationSession. Also updates Xcode project file references.

Changes

Cohort / File(s) Summary
Profile management & cache
NetbirdKit/ProfileManager.swift, NetbirdKit/ProfileConnectionCache.swift
New ProfileManager singleton managing profile directories, metadata, add/switch/remove/logout operations, path resolvers, and management-URL resolution. Added ProfileConnectionCache to persist per-profile ip/fqdn/managementURL in UserDefaults.
iOS profile UI & VM
NetBird/Source/App/Views/iOS/AddProfileSheet.swift, NetBird/Source/App/ViewModels/AddProfileViewModel.swift, NetBird/Source/App/Views/iOS/ProfilesListView.swift, NetBird/Source/App/Views/iOS/ProfileBadge.swift
Added AddProfileSheet UI and AddProfileViewModel for creating profiles (validation, async login, rollback). Added ProfilesListView for listing/switching/removing/logging out profiles and ProfileBadge component used in connection UI.
ViewModel & UI integration
NetBird/Source/App/ViewModels/MainViewModel.swift, NetBird/Source/App/Views/iOS/iOSConnectionView.swift, NetBird/Source/App/Views/iOS/iOSSettingsView.swift, NetBird/Source/App/Views/ServerView.swift, NetBird/Source/App/Views/MainView.swift
MainViewModel: profile-aware connection caching, profile-switch guards, navigateToProfilesView and activeProfileName. Connection/settings/server views updated to surface profiles UI and current management server. Minor alert behavior tweak in MainView.
Authentication UI
NetBird/Source/App/Views/Components/SafariView.swift
Replaced SFSafariViewController flow with ASWebAuthenticationSession (ephemeral session, presentation context, completion handling, cancel handling).
Network extension & adapter
NetbirdKit/NetworkExtensionAdapter.swift, NetbirdNetworkExtension/PacketTunnelProvider.swift, NetbirdNetworkExtension/NetBirdAdapter.swift
NetBirdAdapter now accepts optional config/state paths and records initializedConfigPath. PacketTunnelProvider reads configPath/statePath from options and recreates adapter when paths change; handles `"Login:
Preferences & constants
NetbirdKit/Preferences.swift, NetbirdKit/GlobalConstants.swift
Preferences.configFile()/stateFile() on iOS now use ProfileManager active profile paths. Added GlobalConstants.serverURLFileName constant.
Xcode project
NetBird.xcodeproj/project.pbxproj
Updated PBX entries: replaced GoogleService-Info.plist references and added new Swift source files into PBX groups and build phases.

Sequence Diagrams

sequenceDiagram
    actor User
    participant AddProfileSheet
    participant AddProfileVM
    participant ProfileManager
    participant ServerVM
    participant NetworkExtension

    User->>AddProfileSheet: fill form & tap Create
    AddProfileSheet->>AddProfileVM: create(name, serverUrl, setupKey)
    AddProfileVM->>ProfileManager: addProfile(name)
    ProfileManager-->>AddProfileVM: profile created / config path
    AddProfileVM->>ServerVM: init(configPath, deviceName)
    AddProfileVM->>ServerVM: login(setupKey) or changeManagementServer(url)
    ServerVM->>NetworkExtension: perform authentication IPC
    NetworkExtension-->>ServerVM: auth result
    alt success
        ServerVM-->>AddProfileVM: success
        AddProfileVM-->>AddProfileSheet: notify onCreated -> dismiss
    else failure
        ServerVM-->>AddProfileVM: error details
        AddProfileVM->>ProfileManager: removeProfile(name)
        AddProfileVM-->>AddProfileSheet: surface errors
    end
Loading
sequenceDiagram
    actor User
    participant ProfilesListView
    participant ProfileManager
    participant MainVM
    participant ProfileConnectionCache
    participant NetworkExtension

    User->>ProfilesListView: request switch to ProfileB
    ProfilesListView->>ProfileManager: switchProfile("ProfileB")
    ProfileManager-->>ProfilesListView: switched
    ProfilesListView->>MainVM: loadConnectionInfoForProfile("ProfileB")
    MainVM->>ProfileConnectionCache: entry(for: "ProfileB")
    ProfileConnectionCache-->>MainVM: fqdn, ip, managementURL
    MainVM->>MainVM: switchConnectionInfo(to: "ProfileB") (set pending)
    MainVM->>NetworkExtension: observe extension state
    NetworkExtension-->>MainVM: connected
    MainVM->>MainVM: clear pending, update UI
    ProfilesListView-->>User: show active profile updated
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • mlsmaycon
  • doromaraujo

Poem

🐰 Hop, hop — new profiles arrive,

I stash servers so connections thrive,
Switch, create, and cache with cheer,
Multi-profile naps are finally here! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Multi-profiles' accurately summarizes the main objective of the changeset, which introduces native Swift ProfileManager support for multiple profiles with isolation, UI components, and related fixes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
NetbirdKit/NetworkExtensionAdapter.swift (1)

541-586: ⚠️ Potential issue | 🔴 Critical

login(completion:) still leaks the continuation when sendProviderMessage throws.

performLogin() awaits this callback with withCheckedContinuation. If sendProviderMessage throws, the catch path only logs and returns, so the continuation never resumes and the login flow hangs.

💡 Suggested fix
         } catch {
-            print("error when performing network extension action")
+            logger.error("login: Failed to send provider message: \(error.localizedDescription)")
+            completion(nil)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/NetworkExtensionAdapter.swift` around lines 541 - 586, The catch
block in login(completion:) swallows errors from session.sendProviderMessage and
never resumes the awaiting continuation in performLogin(), causing a hang;
modify the catch to call the provided completion (e.g., completion(nil) or
completion with an error string) and log the error so the continuation always
resumes, and also ensure any other early-exit paths (e.g., failure converting
messageString to Data) already call completion—update the catch around try
session.sendProviderMessage and any thrown paths in login(completion:) to always
invoke completion to guarantee performLogin()'s withCheckedContinuation is
resumed.
🧹 Nitpick comments (1)
NetbirdNetworkExtension/PacketTunnelProvider.swift (1)

121-133: Consider adding logging for malformed Login: payloads.

When parts.count != 2 (line 125), the code silently proceeds to login() without logging the malformed message. This could mask IPC format issues or bugs in the sender.

🔧 Suggested improvement
         case let s where s.hasPrefix("Login:"):
             // Format: "Login:<configPath>|<statePath>"
             let payload = String(s.dropFirst("Login:".count))
             let parts = payload.components(separatedBy: "|")
             if parts.count == 2 {
                 let configPath = parts[0]
                 let statePath  = parts[1]
                 if configPath != adapter?.initializedConfigPath {
                     AppLogger.shared.log("handleAppMessage: profile change detected, recreating adapter for \(configPath)")
                     adapter = NetBirdAdapter(with: tunnelManager, configPath: configPath, statePath: statePath)
                 }
+            } else {
+                AppLogger.shared.log("handleAppMessage: malformed Login payload, expected 2 parts but got \(parts.count)")
             }
             login(completionHandler: completionHandler)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdNetworkExtension/PacketTunnelProvider.swift` around lines 121 - 133,
The Login: payload handler currently ignores malformed payloads when parts.count
!= 2 and proceeds to call login(completionHandler:), which hides IPC format
errors; add a log entry (via AppLogger.shared.log or similar) in the Login case
to record the raw payload (s or payload) and indicate a malformed Login message
when parts.count != 2, so you can detect and debug sender/format issues before
calling login(completionHandler:).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@NetBird/Source/App/ViewModels/AddProfileViewModel.swift`:
- Around line 42-43: The code lowercases the entire serverUrl when creating
trimmedUrl and finalUrl, which can alter case-sensitive path/query components;
change the normalization to only lowercase the URL scheme and host using
URLComponents (parse serverUrl into URLComponents, lowercase components.scheme
and components.host, reassemble) and then fall back to
defaultManagementServerUrl if the resulting trimmed/normalized host+scheme are
empty; update references in AddProfileViewModel where trimmedUrl and finalUrl
are computed (serverUrl, trimmedUrl, finalUrl, defaultManagementServerUrl) to
use this URLComponents-based normalization.

In `@NetBird/Source/App/Views/Components/SafariView.swift`:
- Around line 72-78: The presentationAnchor(for session:
ASWebAuthenticationSession) -> ASPresentationAnchor implementation currently
falls back to creating a bare UIWindow() which can cause presentation failures;
change it to first attempt a proper key window or scene window and if none
exists log a warning and in debug build call fatalError (or assert) to surface
the unexpected state, and in release construct a UIWindow with frame =
UIScreen.main.bounds and a minimal rootViewController before returning it so the
ASWebAuthenticationSession has a valid presentation anchor; update any logging
to include context about missing key window.
- Around line 46-48: ASWebAuthenticationSession is being initialized with
callbackURLScheme: "http", which is invalid; change the callback scheme to a
registered custom scheme (e.g., "netbird") or, for iOS 17.4+ localhost/https
redirects, use Callback.https(host:path:) instead. Locate the
ASWebAuthenticationSession creation in SafariView.swift (the session variable
created with parent.url and callbackURLScheme) and replace the hardcoded "http"
with your app's custom URL scheme registered in Info.plist (or construct the
session with Callback.https(host:path:) on supported OS versions), ensuring the
scheme matches the Info.plist entry and handling any availability checks for iOS
17.4+.
- Around line 44-63: The SafariView currently calls parent.didFinish()
unconditionally; change it to surface success vs failure by updating the
didFinish API and the ASWebAuthenticationSession completion handling: modify the
SafariView's parent.didFinish to accept a Bool or Result (e.g.,
didFinish(success: Bool) or didFinish(result: Result<URL, Error>)), then in
SafariView.startSession's ASWebAuthenticationSession completion closure call
parent.didFinish(true) when callbackURL is non-nil and indicates success (or
pass .success(callbackURL)), and call parent.didFinish(false) (or .failure(error
or a cancellation error) ) when error exists or login was cancelled (check for
ASWebAuthenticationSessionError .canceledLogin); only wire iOSConnectionView to
startVPNConnection when didFinish reports success. Ensure to update all call
sites of didFinish accordingly (e.g., SafariView initializer usage in
iOSConnectionView).

In `@NetBird/Source/App/Views/iOS/ProfilesListView.swift`:
- Around line 168-180: The UI is updating the cached connection state before the
filesystem profile switch completes, because switchToProfile calls
viewModel.switchConnectionInfo(to:) (which clears peers and sets
profileSwitchPending) before ProfileManager.shared.switchProfile(...) can throw;
move the UI updates so they only run after a successful switch: call
viewModel.performClose() first, then attempt try
ProfileManager.shared.switchProfile(profile.name), and only on success call
viewModel.switchConnectionInfo(to:), viewModel.reloadConfiguration(), set
viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName(),
update Preferences.saveManagementURL(...) if managementURL exists, and call
loadProfiles(); keep error handling in the catch block unchanged so a failed
switch does not alter the displayed cached connection info.

In `@NetbirdKit/ProfileConnectionCache.swift`:
- Around line 20-56: Add explicit invalidation paths to ProfileConnectionCache:
implement clear(profile:) that removes the profile key from the persisted
dictionary and persists the result, and implement clearConnectionState(for:)
that resets the connection fields (ip and fqdn) for the given profile but
preserves managementURL, then persist; use the existing helpers load() and
persist(...) and mirror how save(...) and saveManagementURL(...) mutate the
stored dictionary. After adding those methods, call clearConnectionState(for:)
from the logout flow (where logoutProfile(_:) is invoked) so connection state is
cleared but managementURL survives, and call clear(profile:) from the profile
removal flow (where ProfileManager.removeProfile(_:) is executed) to delete all
cached data for deleted profiles. Ensure method names match
ProfileConnectionCache.clear(profile:) and
ProfileConnectionCache.clearConnectionState(for:) so callers can locate them.

In `@NetbirdKit/ProfileManager.swift`:
- Around line 285-293: In migrateIfNeeded(), instead of copying
legacyConfig/legacyState into newConfig/newState (which leaves the originals
behind), move the files so the legacy files are removed; replace the
fileManager.copyItem calls for legacyConfig->newConfig and legacyState->newState
with fileManager.moveItem(atPath:toPath:) and log on success. If you want a safe
fallback, perform copy first and only call fileManager.removeItem(atPath:) on
the source after the copy succeeds; reference the migrateIfNeeded() function and
the legacyConfig, legacyState, newConfig, newState symbols when making the
change.

---

Outside diff comments:
In `@NetbirdKit/NetworkExtensionAdapter.swift`:
- Around line 541-586: The catch block in login(completion:) swallows errors
from session.sendProviderMessage and never resumes the awaiting continuation in
performLogin(), causing a hang; modify the catch to call the provided completion
(e.g., completion(nil) or completion with an error string) and log the error so
the continuation always resumes, and also ensure any other early-exit paths
(e.g., failure converting messageString to Data) already call completion—update
the catch around try session.sendProviderMessage and any thrown paths in
login(completion:) to always invoke completion to guarantee performLogin()'s
withCheckedContinuation is resumed.

---

Nitpick comments:
In `@NetbirdNetworkExtension/PacketTunnelProvider.swift`:
- Around line 121-133: The Login: payload handler currently ignores malformed
payloads when parts.count != 2 and proceeds to call login(completionHandler:),
which hides IPC format errors; add a log entry (via AppLogger.shared.log or
similar) in the Login case to record the raw payload (s or payload) and indicate
a malformed Login message when parts.count != 2, so you can detect and debug
sender/format issues before calling login(completionHandler:).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 90b224c4-2c5f-4f47-ae2c-934f1890ec82

📥 Commits

Reviewing files that changed from the base of the PR and between b8eca1d and 39080cf.

📒 Files selected for processing (16)
  • NetBird.xcodeproj/project.pbxproj
  • NetBird/Source/App/ViewModels/AddProfileViewModel.swift
  • NetBird/Source/App/ViewModels/MainViewModel.swift
  • NetBird/Source/App/Views/Components/SafariView.swift
  • NetBird/Source/App/Views/ServerView.swift
  • NetBird/Source/App/Views/iOS/AddProfileSheet.swift
  • NetBird/Source/App/Views/iOS/ProfileBadge.swift
  • NetBird/Source/App/Views/iOS/ProfilesListView.swift
  • NetBird/Source/App/Views/iOS/iOSConnectionView.swift
  • NetBird/Source/App/Views/iOS/iOSSettingsView.swift
  • NetbirdKit/NetworkExtensionAdapter.swift
  • NetbirdKit/Preferences.swift
  • NetbirdKit/ProfileConnectionCache.swift
  • NetbirdKit/ProfileManager.swift
  • NetbirdNetworkExtension/NetBirdAdapter.swift
  • NetbirdNetworkExtension/PacketTunnelProvider.swift

Comment on lines +42 to +43
let trimmedUrl = serverUrl.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let finalUrl = trimmedUrl.isEmpty ? defaultManagementServerUrl : trimmedUrl
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not lowercase the entire management URL.

Lowercasing the full string rewrites path and query components, which can be case-sensitive on self-hosted or reverse-proxied setups. For example, https://example.com/NetBird becomes a different endpoint.

💡 Suggested fix
-        let trimmedUrl = serverUrl.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        let trimmedUrl = serverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
         let finalUrl = trimmedUrl.isEmpty ? defaultManagementServerUrl : trimmedUrl

If you still want normalization, lowercase only the scheme/host via URLComponents.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let trimmedUrl = serverUrl.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let finalUrl = trimmedUrl.isEmpty ? defaultManagementServerUrl : trimmedUrl
let trimmedUrl = serverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
let finalUrl = trimmedUrl.isEmpty ? defaultManagementServerUrl : trimmedUrl
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetBird/Source/App/ViewModels/AddProfileViewModel.swift` around lines 42 -
43, The code lowercases the entire serverUrl when creating trimmedUrl and
finalUrl, which can alter case-sensitive path/query components; change the
normalization to only lowercase the URL scheme and host using URLComponents
(parse serverUrl into URLComponents, lowercase components.scheme and
components.host, reassemble) and then fall back to defaultManagementServerUrl if
the resulting trimmed/normalized host+scheme are empty; update references in
AddProfileViewModel where trimmedUrl and finalUrl are computed (serverUrl,
trimmedUrl, finalUrl, defaultManagementServerUrl) to use this
URLComponents-based normalization.

Comment on lines +44 to 63
func startSession(from viewController: UIViewController) {
// Use "http" callback scheme to intercept the localhost redirect
let session = ASWebAuthenticationSession(
url: parent.url,
callbackURLScheme: "http"
) { [weak self] callbackURL, error in
guard let self = self else { return }

DispatchQueue.main.async {
if let callbackURL = callbackURL {
print("Auth callback URL: \(callbackURL.absoluteString)")
}
if let error = error as? ASWebAuthenticationSessionError,
error.code == .canceledLogin {
print("User cancelled login")
}
self.parent.isPresented = false
self.parent.didFinish()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

didFinish() is invoked unconditionally, causing VPN connection attempts after user cancellation or auth errors.

Per the call-site in iOSConnectionView.swift:165-170, didFinish() triggers startVPNConnection(). Currently, this callback fires regardless of whether authentication succeeded, was cancelled, or errored—meaning a cancelled login will still attempt to start the VPN connection.

Consider either:

  1. Passing auth result to the callback (e.g., didFinish(success: Bool) or didFinish(result: Result<URL, Error>))
  2. Only calling didFinish() on successful authentication
Proposed fix: differentiate success from cancellation/error
 struct SafariView: UIViewControllerRepresentable {
     `@Binding` var isPresented: Bool
     let url: URL
-    let didFinish: () -> Void
+    let didFinish: (Bool) -> Void  // true = success, false = cancelled/error

     // ... in Coordinator.startSession ...
             ) { [weak self] callbackURL, error in
                 guard let self = self else { return }

                 DispatchQueue.main.async {
+                    let success: Bool
                     if let callbackURL = callbackURL {
                         print("Auth callback URL: \(callbackURL.absoluteString)")
+                        success = true
+                    } else {
+                        success = false
                     }
                     if let error = error as? ASWebAuthenticationSessionError,
                        error.code == .canceledLogin {
                         print("User cancelled login")
                     }
                     self.parent.isPresented = false
-                    self.parent.didFinish()
+                    self.parent.didFinish(success)
                 }
             }

Then update the call-site:

SafariView(isPresented: $viewModel.networkExtensionAdapter.showBrowser,
           url: loginURL,
           didFinish: { success in
    print("Finish login, success: \(success)")
    if success {
        viewModel.networkExtensionAdapter.startVPNConnection()
    }
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetBird/Source/App/Views/Components/SafariView.swift` around lines 44 - 63,
The SafariView currently calls parent.didFinish() unconditionally; change it to
surface success vs failure by updating the didFinish API and the
ASWebAuthenticationSession completion handling: modify the SafariView's
parent.didFinish to accept a Bool or Result (e.g., didFinish(success: Bool) or
didFinish(result: Result<URL, Error>)), then in SafariView.startSession's
ASWebAuthenticationSession completion closure call parent.didFinish(true) when
callbackURL is non-nil and indicates success (or pass .success(callbackURL)),
and call parent.didFinish(false) (or .failure(error or a cancellation error) )
when error exists or login was cancelled (check for
ASWebAuthenticationSessionError .canceledLogin); only wire iOSConnectionView to
startVPNConnection when didFinish reports success. Ensure to update all call
sites of didFinish accordingly (e.g., SafariView initializer usage in
iOSConnectionView).

Comment on lines +46 to +48
let session = ASWebAuthenticationSession(
url: parent.url,
callbackURLScheme: "http"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does ASWebAuthenticationSession support using "http" as callbackURLScheme to intercept localhost OAuth redirects on iOS?

💡 Result:

No, ASWebAuthenticationSession does not support using "http" as the callbackURLScheme to intercept localhost OAuth redirects on iOS. The callbackURLScheme parameter requires a custom URL scheme (e.g., "myapp"), not standard schemes like "http". Apple documentation specifies it as "the custom URL scheme that the app requires in the callback URL" and examples always use custom schemes like "exampleauth". Official docs describe Callback.customScheme(_:) for "URLs with the given custom scheme", while https callbacks (iOS 17.4+) are handled separately via Callback.https(host:path:), not callbackURLScheme. Using "http" would fail as it's not a custom scheme registered in Info.plist for app handling, and the session expects interception of custom scheme redirects from the auth server. For localhost OAuth, developers must configure a custom scheme like "myapp://localhost" that their server redirects to, registered in the app's URL Types.

Citations:


Change callbackURLScheme from "http" to a custom scheme or use Callback.https() for iOS 17.4+.

ASWebAuthenticationSession does not support "http" as callbackURLScheme—it requires a custom scheme (e.g., "netbird://") registered in Info.plist. For localhost OAuth redirects on iOS 17.4+, use Callback.https(host:path:) instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetBird/Source/App/Views/Components/SafariView.swift` around lines 46 - 48,
ASWebAuthenticationSession is being initialized with callbackURLScheme: "http",
which is invalid; change the callback scheme to a registered custom scheme
(e.g., "netbird") or, for iOS 17.4+ localhost/https redirects, use
Callback.https(host:path:) instead. Locate the ASWebAuthenticationSession
creation in SafariView.swift (the session variable created with parent.url and
callbackURLScheme) and replace the hardcoded "http" with your app's custom URL
scheme registered in Info.plist (or construct the session with
Callback.https(host:path:) on supported OS versions), ensuring the scheme
matches the Info.plist entry and handling any availability checks for iOS 17.4+.

Comment on lines +72 to 78
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
// Return the key window as the presentation anchor
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first(where: { $0.isKeyWindow }) ?? UIWindow()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fallback to empty UIWindow() may cause presentation failures.

If no key window is found, creating a bare UIWindow() without a root view controller or frame could result in the auth session failing to present or presenting incorrectly.

Consider using a fatalError in debug builds or logging a warning, since a missing key window in a running iOS app is unexpected.

Suggested defensive handling
         func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
-            // Return the key window as the presentation anchor
-            UIApplication.shared.connectedScenes
+            guard let window = UIApplication.shared.connectedScenes
                 .compactMap { $0 as? UIWindowScene }
                 .flatMap { $0.windows }
-                .first(where: { $0.isKeyWindow }) ?? UIWindow()
+                .first(where: { $0.isKeyWindow }) else {
+                assertionFailure("No key window available for ASWebAuthenticationSession")
+                return UIWindow()
+            }
+            return window
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetBird/Source/App/Views/Components/SafariView.swift` around lines 72 - 78,
The presentationAnchor(for session: ASWebAuthenticationSession) ->
ASPresentationAnchor implementation currently falls back to creating a bare
UIWindow() which can cause presentation failures; change it to first attempt a
proper key window or scene window and if none exists log a warning and in debug
build call fatalError (or assert) to surface the unexpected state, and in
release construct a UIWindow with frame = UIScreen.main.bounds and a minimal
rootViewController before returning it so the ASWebAuthenticationSession has a
valid presentation anchor; update any logging to include context about missing
key window.

Comment on lines +168 to +180
private func switchToProfile(_ profile: Profile) {
viewModel.performClose()
viewModel.switchConnectionInfo(to: profile.name)

do {
try ProfileManager.shared.switchProfile(profile.name)
viewModel.reloadConfiguration()
viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName()
if let url = ProfileManager.shared.managementURL(for: profile.name) {
Preferences.saveManagementURL(url)
}
loadProfiles()
} catch {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Only swap the cached connection UI after the profile switch succeeds.

switchConnectionInfo(to:) clears peers and sets profileSwitchPending before switchProfile(_:) can throw. If the filesystem switch fails, the screen still shows the target profile’s cached IP/FQDN even though the active profile never changed.

💡 Suggested adjustment
 private func switchToProfile(_ profile: Profile) {
     viewModel.performClose()
-    viewModel.switchConnectionInfo(to: profile.name)

     do {
         try ProfileManager.shared.switchProfile(profile.name)
+        viewModel.switchConnectionInfo(to: profile.name)
         viewModel.reloadConfiguration()
         viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName()
         if let url = ProfileManager.shared.managementURL(for: profile.name) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func switchToProfile(_ profile: Profile) {
viewModel.performClose()
viewModel.switchConnectionInfo(to: profile.name)
do {
try ProfileManager.shared.switchProfile(profile.name)
viewModel.reloadConfiguration()
viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName()
if let url = ProfileManager.shared.managementURL(for: profile.name) {
Preferences.saveManagementURL(url)
}
loadProfiles()
} catch {
private func switchToProfile(_ profile: Profile) {
viewModel.performClose()
do {
try ProfileManager.shared.switchProfile(profile.name)
viewModel.switchConnectionInfo(to: profile.name)
viewModel.reloadConfiguration()
viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName()
if let url = ProfileManager.shared.managementURL(for: profile.name) {
Preferences.saveManagementURL(url)
}
loadProfiles()
} catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetBird/Source/App/Views/iOS/ProfilesListView.swift` around lines 168 - 180,
The UI is updating the cached connection state before the filesystem profile
switch completes, because switchToProfile calls
viewModel.switchConnectionInfo(to:) (which clears peers and sets
profileSwitchPending) before ProfileManager.shared.switchProfile(...) can throw;
move the UI updates so they only run after a successful switch: call
viewModel.performClose() first, then attempt try
ProfileManager.shared.switchProfile(profile.name), and only on success call
viewModel.switchConnectionInfo(to:), viewModel.reloadConfiguration(), set
viewModel.activeProfileName = ProfileManager.shared.getActiveProfileName(),
update Preferences.saveManagementURL(...) if managementURL exists, and call
loadProfiles(); keep error handling in the catch block unchanged so a failed
switch does not alter the displayed cached connection info.

Comment on lines +20 to +56
struct ProfileConnectionCache {

private static let storageKey = "netbird_profiles_connection_data"
private let defaults: UserDefaults

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

// MARK: - Read

func entry(for profile: String) -> ProfileConnectionEntry? {
return load()[profile]
}

func managementURL(for profile: String) -> String? {
return load()[profile]?.managementURL
}

// MARK: - Write

func save(ip: String, fqdn: String, for profile: String) {
var all = load()
var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil)
entry.ip = ip
entry.fqdn = fqdn
all[profile] = entry
persist(all)
}

func saveManagementURL(_ url: String, for profile: String) {
var all = load()
var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil)
entry.managementURL = url
all[profile] = entry
persist(all)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add an invalidation path for removed or logged-out profiles.

This cache only writes. ProfileManager.removeProfile(_:) / logoutProfile(_:) never clear it, but ViewModel.loadConnectionInfoForProfile(_:) and ProfileManager.managementURL(for:) read it later. A removed profile therefore still leaves data behind, and recreating the same name resurrects stale state.

💡 Suggested API surface
 struct ProfileConnectionCache {
+    func clear(profile: String) {
+        var all = load()
+        all.removeValue(forKey: profile)
+        persist(all)
+    }
+
+    func clearConnectionState(for profile: String) {
+        var all = load()
+        guard var entry = all[profile] else { return }
+        entry.ip = ""
+        entry.fqdn = ""
+        all[profile] = entry
+        persist(all)
+    }

Call clearConnectionState(for:) from logout so managementURL can survive, and clear(profile:) from full profile removal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/ProfileConnectionCache.swift` around lines 20 - 56, Add explicit
invalidation paths to ProfileConnectionCache: implement clear(profile:) that
removes the profile key from the persisted dictionary and persists the result,
and implement clearConnectionState(for:) that resets the connection fields (ip
and fqdn) for the given profile but preserves managementURL, then persist; use
the existing helpers load() and persist(...) and mirror how save(...) and
saveManagementURL(...) mutate the stored dictionary. After adding those methods,
call clearConnectionState(for:) from the logout flow (where logoutProfile(_:) is
invoked) so connection state is cleared but managementURL survives, and call
clear(profile:) from the profile removal flow (where
ProfileManager.removeProfile(_:) is executed) to delete all cached data for
deleted profiles. Ensure method names match
ProfileConnectionCache.clear(profile:) and
ProfileConnectionCache.clearConnectionState(for:) so callers can locate them.

Comment on lines +285 to +293
// Only migrate if legacy files exist and new ones don't
if fileManager.fileExists(atPath: legacyConfig) && !fileManager.fileExists(atPath: newConfig) {
try? fileManager.copyItem(atPath: legacyConfig, toPath: newConfig)
AppLogger.shared.log("ProfileManager: Migrated legacy config to default profile")
}
if fileManager.fileExists(atPath: legacyState) && !fileManager.fileExists(atPath: newState) {
try? fileManager.copyItem(atPath: legacyState, toPath: newState)
AppLogger.shared.log("ProfileManager: Migrated legacy state to default profile")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Move the legacy profile files instead of copying them.

migrateIfNeeded() duplicates netbird.cfg and state.json into profiles/default/ but leaves the old copies at the container root. After migration, logout/remove only touches the profile directory, so migrated users keep an extra copy of profile state on disk indefinitely.

💡 Suggested fix
-        if fileManager.fileExists(atPath: legacyConfig) && !fileManager.fileExists(atPath: newConfig) {
-            try? fileManager.copyItem(atPath: legacyConfig, toPath: newConfig)
+        if fileManager.fileExists(atPath: legacyConfig) && !fileManager.fileExists(atPath: newConfig) {
+            try? fileManager.moveItem(atPath: legacyConfig, toPath: newConfig)
             AppLogger.shared.log("ProfileManager: Migrated legacy config to default profile")
         }
         if fileManager.fileExists(atPath: legacyState) && !fileManager.fileExists(atPath: newState) {
-            try? fileManager.copyItem(atPath: legacyState, toPath: newState)
+            try? fileManager.moveItem(atPath: legacyState, toPath: newState)
             AppLogger.shared.log("ProfileManager: Migrated legacy state to default profile")
         }

If you want a copy fallback, delete the source after a successful copy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/ProfileManager.swift` around lines 285 - 293, In
migrateIfNeeded(), instead of copying legacyConfig/legacyState into
newConfig/newState (which leaves the originals behind), move the files so the
legacy files are removed; replace the fileManager.copyItem calls for
legacyConfig->newConfig and legacyState->newState with
fileManager.moveItem(atPath:toPath:) and log on success. If you want a safe
fallback, perform copy first and only call fileManager.removeItem(atPath:) on
the source after the copy succeeds; reference the migrateIfNeeded() function and
the legacyConfig, legacyState, newConfig, newState symbols when making the
change.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (9)
NetbirdKit/ConfigurationProvider.swift (1)

117-127: Early return preserves stale preferences when config is deleted.

The guard prevents preferences from being recreated when the config file is missing, which avoids overwriting custom server URLs. However, this means preferences may hold stale values from a deleted config until the app restarts.

If a profile is logged out and its config deleted, subsequent reads from configProvider will return outdated values. Consider whether callers need to be aware of this behavior, or whether the preferences object should be invalidated/reset when the config file is known to be removed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/ConfigurationProvider.swift` around lines 117 - 127, The reload()
early-return keeps self.preferences populated when the config file is deleted,
causing stale values; update reload() in ConfigurationProvider so that when
Preferences.configFile() is missing or FileManager reports file not found you
explicitly invalidate or reset self.preferences (e.g., set to nil or a cleared
Preferences instance) instead of returning early; ensure callers of
ConfigurationProvider.reload() and consumers of the preferences property can
handle the cleared state or adjust Preferences.newPreferences()/Preferences to
provide an explicit empty/default instance.
NetbirdKit/NetworkExtensionAdapter.swift (2)

96-142: restoreConfigIfMissing() implementation is sound but duplicated.

This method correctly restores a minimal config with the nested ManagementURL object format expected by the Go SDK. The fallback chain (savedServerURL → ProfileConnectionCache) is appropriate.

However, this logic is duplicated in PacketTunnelProvider.handleAppMessage(). Consider extracting to a shared utility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/NetworkExtensionAdapter.swift` around lines 96 - 142, The
restoreConfigIfMissing() logic is duplicated in
PacketTunnelProvider.handleAppMessage(); extract the URL-to-minimal-config
serialization and file-write steps into a single shared helper (e.g., a new
NetbirdKit utility function like ConfigRestorer.writeMinimalConfig(forProfile:)
or Preferences.restoreConfigIfMissing(profileName:)) that encapsulates parsing
URL, building the Go-style ManagementURL JSON, jsonEscape, and writing to
Preferences.configFile(); then replace both restoreConfigIfMissing() and the
code in PacketTunnelProvider.handleAppMessage() to call that helper and retain
the same logging behavior via the existing logger.

612-625: Consider using a delimiter-safe format for IPC messages.

The format "Login:<configPath>|<statePath>[|<managementURL>]" uses | as a delimiter. While iOS file paths are unlikely to contain this character, using a delimiter-safe format like JSON would be more robust and easier to maintain.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/NetworkExtensionAdapter.swift` around lines 612 - 625, The current
IPC payload built in variable messageString uses a pipe-delimited string
("Login:<configPath>|<statePath>[|<managementURL>]") which is fragile; change
construction to a delimiter-safe JSON object instead (e.g.,
{"action":"Login","configPath":..., "statePath":..., "managementURL":...}) and
serialize it to a string before sending. Locate the code that builds
messageString in NetworkExtensionAdapter (the block using
Preferences.configFile(), Preferences.stateFile(),
ProfileManager.shared.getActiveProfileName(), and
ProfileConnectionCache().managementURL(for:)) and replace the manual
concatenation with creation of a Dictionary or Codable struct representing the
fields and convert it to JSON via JSONSerialization or JSONEncoder; ensure you
only include managementURL when non-empty so receivers that parse JSON still get
the same optional semantics.
NetbirdKit/ProfileManager.swift (3)

66-100: Retry deletion in listProfiles() has no error reporting.

Lines 72-76 attempt to delete directories for profiles in the deletedProfiles tombstone set, but failures are silently ignored with try?. While this is intentional retry logic, consider logging deletion failures to aid debugging when profiles unexpectedly reappear.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/ProfileManager.swift` around lines 66 - 100, In listProfiles(),
the retry deletion loop uses try? so failures are swallowed; change the deletion
logic for each name from a silent try? around fileManager.removeItem(atPath:) to
a do/catch that catches errors and logs them (use AppLogger.shared.log or the
existing logging facility) including the profile name and error; reference the
deletedSet, profileDirectory(for:), fileManager.removeItem(atPath:) and ensure
the log message gives context so failed deletions are visible during
troubleshooting.

43-61: Singleton initialization triggers side effects.

The ProfileManager.shared singleton calls ensureProfilesDirectory() and migrateIfNeeded() during init(). If the App Group container is unavailable (returns nil), these methods silently return without logging, potentially leaving the manager in an inconsistent state where later operations fail unexpectedly.

Consider adding defensive logging or an early-exit check in init() if containerURL() returns nil.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/ProfileManager.swift` around lines 43 - 61, The singleton
initializer ProfileManager.shared currently runs ensureProfilesDirectory() and
migrateIfNeeded() which rely on containerURL() and may silently no-op if the App
Group container is nil; update the private init() in ProfileManager to first
call containerURL() (or a helper that wraps it) and if it returns nil log an
error via the existing logging facility and return early (avoid calling
ensureProfilesDirectory() and migrateIfNeeded()), or alternatively throw/mark
the manager as unavailable so later methods can surface a clear error; ensure
references to containerURL(), ensureProfilesDirectory(), migrateIfNeeded(), and
ProfileManager.shared are used so the check stops the silent failure path.

224-251: managementURL(for:) has side effects on read — consider documenting or separating.

This getter not only reads the URL but also persists it to both a dedicated file and ProfileConnectionCache (lines 241-242). While this ensures durability, it may surprise callers expecting a pure read operation. Consider adding a comment or splitting into explicit readManagementURL and ensureManagementURLPersisted methods.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdKit/ProfileManager.swift` around lines 224 - 251, The
managementURL(for:) function currently has hidden write side-effects (it calls
saveServerURL(_:for:) and ProfileConnectionCache().saveManagementURL(_:for:)
when reading), so refactor to make reads pure: remove the persistence calls from
managementURL(for:) and either 1) add a new explicit method
ensureManagementURLPersisted(_:for:) that performs saveServerURL and
ProfileConnectionCache().saveManagementURL, or 2) document in
managementURL(for:) that callers must invoke a new persist method; update
callers to call ensureManagementURLPersisted(url, for:) after they obtain a URL,
and keep savedServerURL(for:) and ProfileConnectionCache().managementURL(for:)
as the read fallbacks. Ensure the new method and managementURL(for:) are
referenced where appropriate and add a brief comment to managementURL(for:)
explaining it no longer mutates state.
NetbirdNetworkExtension/PacketTunnelProvider.swift (2)

123-160: Duplicated config restoration logic and JSON escaping.

The minimal config restoration logic (lines 136-153) and jsonEscape function (lines 144-147) are duplicated from NetworkExtensionAdapter.restoreConfigIfMissing(). Consider extracting this into a shared utility to avoid divergence.

Additionally, the JSON escaping only handles backslashes and quotes but not other special characters like newlines or control characters. For URLs, this is likely sufficient, but a more robust approach would use JSONSerialization or JSONEncoder.

💡 Consider using JSONSerialization for safer encoding
// Instead of manual string interpolation:
let urlDict: [String: Any] = [
    "ManagementURL": [
        "Scheme": scheme,
        "Host": goHost,
        "Path": path
    ]
]
if let data = try? JSONSerialization.data(withJSONObject: urlDict),
   let minimalConfig = String(data: data, encoding: .utf8) {
    // write minimalConfig
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdNetworkExtension/PacketTunnelProvider.swift` around lines 123 - 160,
Extract the duplicated minimal-config creation and escaping into a shared
utility (e.g., NetworkExtensionAdapter.restoreConfigIfMissing() currently
contains similar logic) by moving the logic that builds the ManagementURL
dictionary into a common helper (name it something like
buildMinimalConfigJSON(managementURL: String) or
restoreConfigIfMissing(at:configPath:managementURL:)) and call it from
PacketTunnelProvider.handleAppMessage and
NetworkExtensionAdapter.restoreConfigIfMissing(); replace the manual jsonEscape
function with JSONSerialization/JSONEncoder to produce the minimalConfig JSON
string (construct a Dictionary
["ManagementURL":["Scheme":scheme,"Host":goHost,"Path":path]] and serialize it
safely before writing to configPath).

155-158: Adapter recreation after config restoration may use stale paths.

When configRestored is true, the adapter is recreated even if configPath == adapter?.initializedConfigPath. This is correct because the config file contents changed. However, the condition configPath != adapter?.initializedConfigPath || configRestored could be simplified for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetbirdNetworkExtension/PacketTunnelProvider.swift` around lines 155 - 158,
The current condition mixes path mismatch and configRestored and is unclear;
change the logic in PacketTunnelProvider so you recreate the adapter when there
is no adapter, when configRestored is true, or when configPath differs from
adapter?.initializedConfigPath. Replace the existing if (configPath !=
adapter?.initializedConfigPath || configRestored) check with a clear check such
as if adapter == nil || configRestored || configPath !=
adapter?.initializedConfigPath, and update the log to indicate whether this is a
restore-driven recreation (use adapter, initializedConfigPath, configRestored,
NetBirdAdapter(with:tunnelManager,configPath:statePath:) to locate where to
change).
NetBird/Source/App/ViewModels/MainViewModel.swift (1)

453-458: Profile switch detection relies on state transition — verify timing.

The guard clears when previousExtensionState != .connected && currentState == .connected. If the extension reconnects very quickly (within one polling interval) without going through a disconnected state, the guard might not clear properly.

This seems unlikely in practice since performClose() is called before switching, but worth noting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@NetBird/Source/App/ViewModels/MainViewModel.swift` around lines 453 - 458,
The current guard that clears profileSwitchPending only on a transition from
non-connected to connected can miss very fast reconnects; to make detection
robust, when initiating a profile switch (e.g., in performClose() or the profile
switch entry point) explicitly set previousExtensionState = .disconnected or
clear profileSwitchPending there, or alternately detect the switch by comparing
the profile identifier rather than relying solely on
previousExtensionState/currentState; update the logic in the methods around
profileSwitchPending, previousExtensionState and performClose() to ensure the
pending flag is cleared deterministically during the switch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@NetBird/Source/App/ViewModels/MainViewModel.swift`:
- Around line 453-458: The current guard that clears profileSwitchPending only
on a transition from non-connected to connected can miss very fast reconnects;
to make detection robust, when initiating a profile switch (e.g., in
performClose() or the profile switch entry point) explicitly set
previousExtensionState = .disconnected or clear profileSwitchPending there, or
alternately detect the switch by comparing the profile identifier rather than
relying solely on previousExtensionState/currentState; update the logic in the
methods around profileSwitchPending, previousExtensionState and performClose()
to ensure the pending flag is cleared deterministically during the switch.

In `@NetbirdKit/ConfigurationProvider.swift`:
- Around line 117-127: The reload() early-return keeps self.preferences
populated when the config file is deleted, causing stale values; update reload()
in ConfigurationProvider so that when Preferences.configFile() is missing or
FileManager reports file not found you explicitly invalidate or reset
self.preferences (e.g., set to nil or a cleared Preferences instance) instead of
returning early; ensure callers of ConfigurationProvider.reload() and consumers
of the preferences property can handle the cleared state or adjust
Preferences.newPreferences()/Preferences to provide an explicit empty/default
instance.

In `@NetbirdKit/NetworkExtensionAdapter.swift`:
- Around line 96-142: The restoreConfigIfMissing() logic is duplicated in
PacketTunnelProvider.handleAppMessage(); extract the URL-to-minimal-config
serialization and file-write steps into a single shared helper (e.g., a new
NetbirdKit utility function like ConfigRestorer.writeMinimalConfig(forProfile:)
or Preferences.restoreConfigIfMissing(profileName:)) that encapsulates parsing
URL, building the Go-style ManagementURL JSON, jsonEscape, and writing to
Preferences.configFile(); then replace both restoreConfigIfMissing() and the
code in PacketTunnelProvider.handleAppMessage() to call that helper and retain
the same logging behavior via the existing logger.
- Around line 612-625: The current IPC payload built in variable messageString
uses a pipe-delimited string
("Login:<configPath>|<statePath>[|<managementURL>]") which is fragile; change
construction to a delimiter-safe JSON object instead (e.g.,
{"action":"Login","configPath":..., "statePath":..., "managementURL":...}) and
serialize it to a string before sending. Locate the code that builds
messageString in NetworkExtensionAdapter (the block using
Preferences.configFile(), Preferences.stateFile(),
ProfileManager.shared.getActiveProfileName(), and
ProfileConnectionCache().managementURL(for:)) and replace the manual
concatenation with creation of a Dictionary or Codable struct representing the
fields and convert it to JSON via JSONSerialization or JSONEncoder; ensure you
only include managementURL when non-empty so receivers that parse JSON still get
the same optional semantics.

In `@NetbirdKit/ProfileManager.swift`:
- Around line 66-100: In listProfiles(), the retry deletion loop uses try? so
failures are swallowed; change the deletion logic for each name from a silent
try? around fileManager.removeItem(atPath:) to a do/catch that catches errors
and logs them (use AppLogger.shared.log or the existing logging facility)
including the profile name and error; reference the deletedSet,
profileDirectory(for:), fileManager.removeItem(atPath:) and ensure the log
message gives context so failed deletions are visible during troubleshooting.
- Around line 43-61: The singleton initializer ProfileManager.shared currently
runs ensureProfilesDirectory() and migrateIfNeeded() which rely on
containerURL() and may silently no-op if the App Group container is nil; update
the private init() in ProfileManager to first call containerURL() (or a helper
that wraps it) and if it returns nil log an error via the existing logging
facility and return early (avoid calling ensureProfilesDirectory() and
migrateIfNeeded()), or alternatively throw/mark the manager as unavailable so
later methods can surface a clear error; ensure references to containerURL(),
ensureProfilesDirectory(), migrateIfNeeded(), and ProfileManager.shared are used
so the check stops the silent failure path.
- Around line 224-251: The managementURL(for:) function currently has hidden
write side-effects (it calls saveServerURL(_:for:) and
ProfileConnectionCache().saveManagementURL(_:for:) when reading), so refactor to
make reads pure: remove the persistence calls from managementURL(for:) and
either 1) add a new explicit method ensureManagementURLPersisted(_:for:) that
performs saveServerURL and ProfileConnectionCache().saveManagementURL, or 2)
document in managementURL(for:) that callers must invoke a new persist method;
update callers to call ensureManagementURLPersisted(url, for:) after they obtain
a URL, and keep savedServerURL(for:) and
ProfileConnectionCache().managementURL(for:) as the read fallbacks. Ensure the
new method and managementURL(for:) are referenced where appropriate and add a
brief comment to managementURL(for:) explaining it no longer mutates state.

In `@NetbirdNetworkExtension/PacketTunnelProvider.swift`:
- Around line 123-160: Extract the duplicated minimal-config creation and
escaping into a shared utility (e.g.,
NetworkExtensionAdapter.restoreConfigIfMissing() currently contains similar
logic) by moving the logic that builds the ManagementURL dictionary into a
common helper (name it something like buildMinimalConfigJSON(managementURL:
String) or restoreConfigIfMissing(at:configPath:managementURL:)) and call it
from PacketTunnelProvider.handleAppMessage and
NetworkExtensionAdapter.restoreConfigIfMissing(); replace the manual jsonEscape
function with JSONSerialization/JSONEncoder to produce the minimalConfig JSON
string (construct a Dictionary
["ManagementURL":["Scheme":scheme,"Host":goHost,"Path":path]] and serialize it
safely before writing to configPath).
- Around line 155-158: The current condition mixes path mismatch and
configRestored and is unclear; change the logic in PacketTunnelProvider so you
recreate the adapter when there is no adapter, when configRestored is true, or
when configPath differs from adapter?.initializedConfigPath. Replace the
existing if (configPath != adapter?.initializedConfigPath || configRestored)
check with a clear check such as if adapter == nil || configRestored ||
configPath != adapter?.initializedConfigPath, and update the log to indicate
whether this is a restore-driven recreation (use adapter, initializedConfigPath,
configRestored, NetBirdAdapter(with:tunnelManager,configPath:statePath:) to
locate where to change).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 936cc0ed-d4dc-4406-b356-4e752d3af431

📥 Commits

Reviewing files that changed from the base of the PR and between 39080cf and 6d82a46.

📒 Files selected for processing (7)
  • NetBird/Source/App/ViewModels/MainViewModel.swift
  • NetBird/Source/App/Views/MainView.swift
  • NetbirdKit/ConfigurationProvider.swift
  • NetbirdKit/GlobalConstants.swift
  • NetbirdKit/NetworkExtensionAdapter.swift
  • NetbirdKit/ProfileManager.swift
  • NetbirdNetworkExtension/PacketTunnelProvider.swift
✅ Files skipped from review due to trivial changes (1)
  • NetbirdKit/GlobalConstants.swift

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