From 7ac88f8a7d1555bf0b53a6c8a9e1267877f23813 Mon Sep 17 00:00:00 2001 From: CicerBro <177757655+CicerBro@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:12:01 +0100 Subject: [PATCH 1/2] feat(tvOS): enable engine logs and debug bundle upload Wire Trace Logging into the extension with a writable log path, and add Troubleshoot upload parity with iOS so support can collect bundles. --- .../Source/App/ViewModels/MainViewModel.swift | 21 ++- .../Source/App/Views/TV/TVSettingsView.swift | 155 +++++++++++++++++- .../PacketTunnelProvider.swift | 79 +++++---- NetbirdKit/AppLogger.swift | 5 + NetbirdKit/Preferences.swift | 5 + NetbirdNetworkExtension/NetBirdAdapter.swift | 6 +- 6 files changed, 225 insertions(+), 46 deletions(-) diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 6423d518..d31e4bf3 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -1047,7 +1047,7 @@ class ViewModel: ObservableObject { return .error(message: "Configuration not available") } let cacheDir = Preferences.cacheDirectory() - let logPath = AppLogger.getGoLogFileURL()?.path ?? "" + let logPath = AppLogger.getGoLogFileURL()?.path ?? Preferences.logFilePath() ?? "" guard let client = NetBirdSDKNewClient(configPath, statePath, cacheDir, logPath, Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else { return .error(message: "Failed to initialize client") } @@ -1057,6 +1057,25 @@ class ViewModel: ObservableObject { return .error(message: sdkError.localizedDescription) } return .done(key: key) + #elseif os(tvOS) + let cacheDir = Preferences.cacheDirectory() + let logPath = Preferences.logFilePath() ?? "" + guard let client = NetBirdSDKNewClient("", "", cacheDir, logPath, Device.getName(), Device.getOsVersion(), Device.getOsName(), nil, nil) else { + return .error(message: "Failed to initialize client") + } + if let configJSON = Preferences.loadConfigFromUserDefaults() { + do { + try client.setConfigFromJSON(configJSON) + } catch { + // Continue — a partial bundle is still useful for support. + } + } + var sdkError: NSError? + let key = client.debugBundle(anonymize, anonymizeLevel: NetBirdSDKAnonymizeLevelDefault, error: &sdkError) + if let sdkError { + return .error(message: sdkError.localizedDescription) + } + return .done(key: key) #else return .error(message: "Not supported on this platform") #endif diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index 8630625a..540ceda5 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -18,6 +18,8 @@ struct TVSettingsView: View { @EnvironmentObject var viewModel: ViewModel @State private var showPreSharedKeyAlert = false @State private var showDocsQRCode = false + @State private var showUploadKeyQR = false + @State private var uploadKeyForQR = "" var body: some View { ZStack { @@ -42,13 +44,6 @@ struct TVSettingsView: View { } TVSettingsSection(title: "Advanced") { - TVSettingsToggleRow( - icon: "ant.fill", - title: "Trace Logging", - subtitle: "Enable detailed logs for troubleshooting", - isOn: $viewModel.traceLogsEnabled - ) - TVSettingsToggleRow( icon: "shield.lefthalf.filled", title: "Rosenpass", @@ -79,6 +74,30 @@ struct TVSettingsView: View { ) } + TVSettingsSection(title: "Troubleshoot") { + TVSettingsToggleRow( + icon: "ant.fill", + title: "Trace Logging", + subtitle: "Enable detailed engine logs for troubleshooting", + isOn: $viewModel.traceLogsEnabled + ) + + TVSettingsToggleRow( + icon: "eye.slash.fill", + title: "Anonymize Bundle", + subtitle: "Hide IPs, domains, and private keys in uploads", + isOn: $viewModel.anonymizeDebugBundle + ) + + TVTroubleshootBundleRow( + viewModel: viewModel, + onShowQR: { key in + uploadKeyForQR = key + showUploadKeyQR = true + } + ) + } + TVSettingsSection(title: "Network") { TVSettingsToggleRow( icon: "network", @@ -146,6 +165,10 @@ struct TVSettingsView: View { TVRosenpassChangedAlert(viewModel: viewModel) } + if viewModel.showLogLevelChangedAlert { + TVLogLevelChangedAlert(viewModel: viewModel) + } + } .onAppear { // Load settings from storage to sync UI with actual values @@ -153,6 +176,9 @@ struct TVSettingsView: View { viewModel.loadPreSharedKey() viewModel.loadIPv6Settings() } + .onDisappear { + viewModel.debugBundleUploadState = .idle + } .sheet(isPresented: $showDocsQRCode) { TVQRCodeSheet( url: "https://docs.netbird.io", @@ -160,6 +186,13 @@ struct TVSettingsView: View { subtitle: "Scan this QR code to visit our docs" ) } + .sheet(isPresented: $showUploadKeyQR) { + TVQRCodeSheet( + url: uploadKeyForQR, + title: "Upload Key", + subtitle: "Scan or note this key for NetBird support" + ) + } .fullScreenCover(isPresented: $showPreSharedKeyAlert) { TVPreSharedKeyAlert( viewModel: viewModel, @@ -350,6 +383,114 @@ struct TVSettingsInfoRow: View { } } +/// Upload debug bundle row — mirrors iOS TroubleshootView actions for the remote. +struct TVTroubleshootBundleRow: View { + @ObservedObject var viewModel: ViewModel + let onShowQR: (String) -> Void + + var body: some View { + switch viewModel.debugBundleUploadState { + case .idle: + TVSettingsRow( + icon: "arrow.up.doc.fill", + title: "Upload Debug Bundle", + subtitle: "Send logs to NetBird support", + action: { viewModel.uploadDebugBundle() } + ) + case .uploading: + TVSettingsInfoRow( + icon: "arrow.up.doc.fill", + title: "Uploading…", + subtitle: "Generating debug bundle" + ) + case .done(let key): + VStack(spacing: 4) { + TVSettingsInfoRow( + icon: "checkmark.circle.fill", + title: "Upload Key", + subtitle: key + ) + TVSettingsRow( + icon: "qrcode", + title: "Show Key QR", + subtitle: "Scan with your phone to copy the key", + action: { onShowQR(key) } + ) + TVSettingsRow( + icon: "arrow.clockwise", + title: "Create New Bundle", + subtitle: "Upload another debug bundle", + action: { viewModel.debugBundleUploadState = .idle } + ) + } + case .error(let message): + VStack(spacing: 4) { + TVSettingsInfoRow( + icon: "exclamationmark.triangle.fill", + title: "Upload Failed", + subtitle: message + ) + TVSettingsRow( + icon: "arrow.clockwise", + title: "Try Again", + subtitle: "Retry debug bundle upload", + action: { viewModel.debugBundleUploadState = .idle } + ) + } + } + } +} + +struct TVLogLevelChangedAlert: View { + @ObservedObject var viewModel: ViewModel + + private enum FocusedButton { + case ok + } + + @FocusState private var focusedButton: FocusedButton? + + var body: some View { + ZStack { + Color.black.opacity(0.7) + .ignoresSafeArea() + + VStack(spacing: 40) { + Image(systemName: "ant.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Changing Log Level") + .font(.system(size: 40, weight: .bold)) + .foregroundColor(TVColors.textAlert) + + Text("Changing log level will take effect after next connect.") + .font(.system(size: 24)) + .foregroundColor(TVColors.textAlert) + .multilineTextAlignment(.center) + .frame(maxWidth: 500) + + TVAlertButton( + title: "OK", + style: .filled(Color.accentColor), + isFocused: focusedButton == .ok, + action: { viewModel.showLogLevelChangedAlert = false }, + isSemibold: true + ) + .focused($focusedButton, equals: .ok) + } + .padding(60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill(TVColors.bgSideDrawer) + ) + } + .onAppear { + focusedButton = .ok + } + } +} + /// Reusable button for TV alert dialogs with proper focus styling. /// Text turns dark when focused to remain readable against the light highlight. struct TVAlertButton: View { diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 3b9fc134..d592ad34 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -49,6 +49,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let optionsDesc = options?.description ?? "nil" logger.info("startTunnel: options = \(optionsDesc, privacy: .public)") + let logLevel = (options?["logLevel"] as? String) ?? "INFO" + initializeLogging(loglevel: logLevel) + // On tvOS, config is loaded from UserDefaults directly in NetBirdAdapter.init() // No need to restore to file - the adapter handles this internally. if Preferences.hasConfigInUserDefaults() { @@ -157,6 +160,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { case "ClearConfig": // Clear the extension-local config on logout clearLocalConfig(completionHandler: completionHandler) + case let s where s.hasPrefix("DebugBundle:"): + let anonymize = s.dropFirst("DebugBundle:".count) == "true" + debugBundle(anonymize: anonymize, completionHandler: completionHandler) default: logger.warning("handleAppMessage: Unknown message: \(string)") completionHandler(nil) @@ -671,6 +677,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } + func debugBundle(anonymize: Bool, completionHandler: @escaping (Data?) -> Void) { + guard let adapter = adapter else { + completionHandler("error:adapter not available".data(using: .utf8)) + return + } + DispatchQueue.global(qos: .utility).async { + var error: NSError? + let key = adapter.client.debugBundle(anonymize, anonymizeLevel: NetBirdSDKAnonymizeLevelDefault, error: &error) + if let error = error { + completionHandler("error:\(error.localizedDescription)".data(using: .utf8)) + } else { + completionHandler(key.data(using: .utf8)) + } + } + } + override func sleep(completionHandler: @escaping () -> Void) { completionHandler() } @@ -690,47 +712,32 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } func initializeLogging(loglevel: String) { - let fileManager = FileManager.default - - let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) - let logURL = groupURL?.appendingPathComponent("logfile.log") - - var error: NSError? - var success = false - - let logMessage = "Starting new log file from TV extension" + "\n" - - guard let logURLValid = logURL else { - print("Failed to get the log file URL.") + guard let logPath = Preferences.logFilePath() else { + logger.error("initializeLogging: no writable log path") return } - if fileManager.fileExists(atPath: logURLValid.path) { - if let fileHandle = try? FileHandle(forWritingTo: logURLValid) { - do { - try "".write(to: logURLValid, atomically: true, encoding: .utf8) - } catch { - print("Error handling the log file: \(error)") - } - if let data = logMessage.data(using: .utf8) { - fileHandle.write(data) - } - fileHandle.closeFile() - } else { - print("Failed to open the log file for writing.") - } - } else { - do { - try logMessage.write(to: logURLValid, atomically: true, encoding: .utf8) - } catch { - print("Failed to write to the log file: \(error.localizedDescription)") - } + let fileManager = FileManager.default + let logURL = URL(fileURLWithPath: logPath) + let parent = logURL.deletingLastPathComponent() + do { + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + } catch { + logger.error("initializeLogging: failed to create log directory: \(error.localizedDescription, privacy: .public)") } - if let logPath = logURL?.path { - success = NetBirdSDKInitializeLog(loglevel, logPath, &error) + let logMessage = "Starting new log file from TV extension\n" + do { + try logMessage.write(to: logURL, atomically: true, encoding: .utf8) + } catch { + logger.error("initializeLogging: failed to write log file: \(error.localizedDescription, privacy: .public)") } + + var error: NSError? + let success = NetBirdSDKInitializeLog(loglevel, logPath, &error) if !success, let actualError = error { - print("Failed to initialize log: \(actualError.localizedDescription)") + logger.error("initializeLogging: NetBirdSDKInitializeLog failed: \(actualError.localizedDescription, privacy: .public)") + } else { + logger.info("initializeLogging: level=\(loglevel, privacy: .public) path=\(logPath, privacy: .public)") } -} \ No newline at end of file +} diff --git a/NetbirdKit/AppLogger.swift b/NetbirdKit/AppLogger.swift index 5140b2a1..dbeca5eb 100644 --- a/NetbirdKit/AppLogger.swift +++ b/NetbirdKit/AppLogger.swift @@ -162,6 +162,11 @@ public class AppLogger { public static func getGoLogFileURL() -> URL? { let fileManager = FileManager.default + #if os(tvOS) + if let path = Preferences.logFilePath(), fileManager.fileExists(atPath: path) { + return URL(fileURLWithPath: path) + } + #endif // Try app group first if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) { let url = groupURL.appendingPathComponent("logfile.log") diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index c9efdd6f..49b90888 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -110,10 +110,15 @@ class Preferences { } static func logFilePath() -> String? { + #if os(tvOS) + // App Group container is not writable from the extension on tvOS. + return URL(fileURLWithPath: cacheDirectory()).appendingPathComponent("logfile.log").path + #else return FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName)? .appendingPathComponent("logfile.log") .path + #endif } // MARK: - App-Local UserDefaults Storage diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index 9a6e114a..9bd02c98 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -273,8 +273,10 @@ public class NetBirdAdapter { #if os(tvOS) // On tvOS, the filesystem is blocked for the App Group container. - // Create the client with empty paths and load config from local storage instead. - guard let client = NetBirdSDKNewClient("", "", Preferences.cacheDirectory(), "", deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager) else { + // Create the client with empty config/state paths and load config from local storage instead. + // Log path must be a writable temp/caches file so Trace Logging and debug bundles work. + let logPath = Preferences.logFilePath() ?? "" + guard let client = NetBirdSDKNewClient("", "", Preferences.cacheDirectory(), logPath, deviceName, osVersion, osName, self.networkChangeListener, self.dnsManager) else { adapterLogger.error("init: tvOS - Failed to create NetBird SDK client") return nil } From acf011b53e3ac0a68d2cdb9bae4df2afb5c33d67 Mon Sep 17 00:00:00 2001 From: CicerBro <177757655+CicerBro@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:27:33 +0100 Subject: [PATCH 2/2] feat(tvOS): add Engine Logs toggle and Debug Log viewer Add an on/off switch for engine log file output, GetLog IPC, and a focusable chunk-based log viewer so the Siri Remote can scroll logs. --- .../Source/App/ViewModels/MainViewModel.swift | 28 ++++ .../Source/App/Views/TV/TVSettingsView.swift | 128 +++++++++++++++++- .../PacketTunnelProvider.swift | 48 ++++++- NetbirdKit/NetworkExtensionAdapter.swift | 25 ++++ 4 files changed, 224 insertions(+), 5 deletions(-) diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index d31e4bf3..0c14485e 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -117,6 +117,19 @@ class ViewModel: ObservableObject { } } + /// Master switch for engine log file output. When off, the extension writes + /// no log file at all, so trace sessions are not mixed into always-on logs. + @Published var engineLogsEnabled: Bool { + didSet { + self.showLogLevelChangedAlert = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.showLogLevelChangedAlert = false + } + UserDefaults.standard.set(engineLogsEnabled, forKey: "engineLogsEnabled") + UserDefaults.standard.synchronize() + } + } + // Troubleshoot / Debug Bundle enum DebugBundleUploadState { case idle @@ -131,6 +144,9 @@ class ViewModel: ObservableObject { } } @Published var debugBundleUploadState: DebugBundleUploadState = .idle + #if os(tvOS) + @Published var debugLogText: String = "" + #endif @Published var forceRelayConnection = true @Published var showForceRelayAlert = false @Published var disableIPv6 = false @@ -191,6 +207,7 @@ class ViewModel: ObservableObject { self.networkExtensionAdapter = networkExtensionAdapter let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO" self.traceLogsEnabled = logLevel == "TRACE" + self.engineLogsEnabled = (UserDefaults.standard.object(forKey: "engineLogsEnabled") as? Bool) ?? true self.anonymizeDebugBundle = UserDefaults.standard.bool(forKey: "netbird.anonymizeDebugBundle") self.peerViewModel = PeerViewModel() self.routeViewModel = RoutesViewModel(networkExtensionAdapter: networkExtensionAdapter) @@ -1040,6 +1057,17 @@ class ViewModel: ObservableObject { } } + #if os(tvOS) + func fetchExtensionDebugLog() { + debugLogText = "Loading…" + networkExtensionAdapter.getExtensionLog { [weak self] text in + DispatchQueue.main.async { + self?.debugLogText = text.isEmpty ? "(empty log)" : text + } + } + } + #endif + private static nonisolated func directDebugBundleUpload(anonymize: Bool) -> DebugBundleUploadState { #if os(iOS) guard let configPath = Preferences.configFile(), diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index 540ceda5..850b9930 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -20,6 +20,7 @@ struct TVSettingsView: View { @State private var showDocsQRCode = false @State private var showUploadKeyQR = false @State private var uploadKeyForQR = "" + @State private var showDebugLog = false var body: some View { ZStack { @@ -75,11 +76,19 @@ struct TVSettingsView: View { } TVSettingsSection(title: "Troubleshoot") { + TVSettingsToggleRow( + icon: "doc.plaintext", + title: "Engine Logs", + subtitle: "Write engine logs to a file for the Debug Log viewer", + isOn: $viewModel.engineLogsEnabled + ) + TVSettingsToggleRow( icon: "ant.fill", title: "Trace Logging", subtitle: "Enable detailed engine logs for troubleshooting", - isOn: $viewModel.traceLogsEnabled + isOn: $viewModel.traceLogsEnabled, + isDisabled: !viewModel.engineLogsEnabled ) TVSettingsToggleRow( @@ -96,6 +105,13 @@ struct TVSettingsView: View { showUploadKeyQR = true } ) + + TVSettingsRow( + icon: "doc.text.magnifyingglass", + title: "Debug Log", + subtitle: "View engine logs from the VPN extension", + action: { showDebugLog = true } + ) } TVSettingsSection(title: "Network") { @@ -199,6 +215,9 @@ struct TVSettingsView: View { isPresented: $showPreSharedKeyAlert ) } + .fullScreenCover(isPresented: $showDebugLog) { + TVDebugLogView(viewModel: viewModel) + } } private var appVersion: String { @@ -460,11 +479,11 @@ struct TVLogLevelChangedAlert: View { .font(.system(size: 60)) .foregroundColor(.orange) - Text("Changing Log Level") + Text("Logging Settings Changed") .font(.system(size: 40, weight: .bold)) .foregroundColor(TVColors.textAlert) - Text("Changing log level will take effect after next connect.") + Text("Logging changes will take effect after next connect.") .font(.system(size: 24)) .foregroundColor(TVColors.textAlert) .multilineTextAlignment(.center) @@ -825,6 +844,109 @@ struct TVSettingsView_Previews: PreviewProvider { } } +/// Full-screen engine log viewer. Dismiss with the remote's Menu/Back button. +/// +/// The log is split into focusable chunks inside a ScrollView — the same native +/// focus-driven scrolling every other tvOS screen in this app uses. Pressing +/// down from Refresh enters the log; up/down moves chunk-by-chunk and tvOS +/// automatically scrolls to keep the focused chunk visible. (A UITextView +/// bridged via UIViewRepresentable never receives focus from SwiftUI on tvOS, +/// so it can never scroll — that approach cannot work here.) +struct TVDebugLogView: View { + @ObservedObject var viewModel: ViewModel + @Environment(\.dismiss) private var dismiss + + private static let linesPerChunk = 8 + + private var chunks: [LogChunk] { + let lines = viewModel.debugLogText.split(separator: "\n", omittingEmptySubsequences: false) + var result: [LogChunk] = [] + var start = 0 + while start < lines.count { + let end = min(start + Self.linesPerChunk, lines.count) + result.append(LogChunk(id: result.count, text: lines[start.. Void)?) { + DispatchQueue.global(qos: .utility).async { + let maxBytes = 192 * 1024 + let engineLogsEnabled = (UserDefaults.standard.object(forKey: "engineLogsEnabled") as? Bool) ?? true + guard engineLogsEnabled else { + completionHandler?("Engine logs are disabled. Enable them under Settings → Troubleshoot → Engine Logs, then reconnect.".data(using: .utf8)) + return + } + guard let path = Preferences.logFilePath() else { + completionHandler?("No log path available.".data(using: .utf8)) + return + } + guard let handle = FileHandle(forReadingAtPath: path) else { + completionHandler?("Log file not found. Connect to the VPN first.".data(using: .utf8)) + return + } + defer { try? handle.close() } + let size = (try? handle.seekToEnd()) ?? 0 + if size > UInt64(maxBytes) { + try? handle.seek(toOffset: size - UInt64(maxBytes)) + } else { + try? handle.seek(toOffset: 0) + } + let data = (try? handle.readToEnd()) ?? Data() + let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) + completionHandler?(text.data(using: .utf8)) + } + } + override func sleep(completionHandler: @escaping () -> Void) { completionHandler() } @@ -711,7 +746,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } -func initializeLogging(loglevel: String) { +func initializeLogging(loglevel: String, enabled: Bool = true) { guard let logPath = Preferences.logFilePath() else { logger.error("initializeLogging: no writable log path") return @@ -719,6 +754,15 @@ func initializeLogging(loglevel: String) { let fileManager = FileManager.default let logURL = URL(fileURLWithPath: logPath) + + guard enabled else { + // Engine log output disabled: drop any stale log so the Debug Log view + // and debug bundles don't surface output from an older session. + try? fileManager.removeItem(at: logURL) + logger.info("initializeLogging: engine logs disabled, no log file will be written") + return + } + let parent = logURL.deletingLastPathComponent() do { try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index c676e7e5..3eb2eda4 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -609,8 +609,10 @@ public class NetworkExtensionAdapter: ObservableObject { public func startVPNConnection(loginVerified: Bool = false) { logger.info("startVPNConnection: called (loginVerified=\(loginVerified))") let logLevel = UserDefaults.standard.string(forKey: "logLevel") ?? "INFO" + let engineLogsEnabled = (UserDefaults.standard.object(forKey: "engineLogsEnabled") as? Bool) ?? true logger.info("startVPNConnection: logLevel = \(logLevel)") var options: [String: NSObject] = ["logLevel": logLevel as NSObject] + options["engineLogsEnabled"] = engineLogsEnabled as NSObject #if os(iOS) if loginVerified { options[GlobalConstants.optionLoginVerified] = true as NSObject @@ -1053,6 +1055,29 @@ public class NetworkExtensionAdapter: ObservableObject { } } + /// Fetches the extension Go engine log tail (tvOS Troubleshoot → Debug Log). + func getExtensionLog(completion: @escaping (String) -> Void) { + guard let session = self.session else { + completion("VPN session not available. Connect first.") + return + } + guard let messageData = "GetLog".data(using: .utf8) else { + completion("Failed to encode GetLog message.") + return + } + do { + try session.sendProviderMessage(messageData) { response in + guard let data = response, let text = String(data: data, encoding: .utf8) else { + completion("No response from extension.") + return + } + completion(text) + } + } catch { + completion("Failed to request log: \(error.localizedDescription)") + } + } + func fetchData(completion: @escaping (StatusDetails) -> Void) { guard !isFetchingStatus else { return