add flutter example app test

This commit is contained in:
mlsmaycon
2026-04-28 03:41:36 +02:00
parent 154b81645a
commit 555f5233cc
88 changed files with 18364 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'src/app_shell.dart';
import 'src/daemon_client.dart';
import 'src/desktop_integration.dart';
Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
final daemonAddr = _readFlag(args, 'daemon-addr') ?? _defaultDaemonAddr();
final fakeDaemon = args.contains('--fake-daemon');
await windowManager.ensureInitialized();
const windowOptions = WindowOptions(
size: Size(900, 640),
minimumSize: Size(720, 520),
center: true,
title: 'NetBird',
);
await windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
final client = fakeDaemon
? FakeDaemonClient(daemonAddr: daemonAddr)
: GrpcDaemonClient(daemonAddr: daemonAddr);
final integration = DesktopIntegration(client: client);
await integration.initialize();
runApp(NetBirdFlutterApp(client: client, integration: integration));
}
String? _readFlag(List<String> args, String name) {
final prefix = '--$name=';
for (final arg in args) {
if (arg.startsWith(prefix)) {
return arg.substring(prefix.length);
}
}
return null;
}
String _defaultDaemonAddr() {
if (Platform.isWindows) {
return 'tcp://127.0.0.1:41731';
}
return 'unix:///var/run/netbird.sock';
}

View File

@@ -0,0 +1,889 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'daemon_client.dart';
import 'debug_screen.dart';
import 'desktop_integration.dart';
import 'models.dart';
import 'platform.dart';
import 'update_progress.dart';
class NetBirdFlutterApp extends StatelessWidget {
const NetBirdFlutterApp({required this.client, this.integration, super.key});
final DaemonClient client;
final DesktopIntegration? integration;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'NetBird',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF008C95),
brightness: Brightness.light,
),
darkTheme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF008C95),
brightness: Brightness.dark,
),
home: AppShell(client: client, integration: integration),
);
}
}
class AppShell extends StatefulWidget {
const AppShell({required this.client, this.integration, super.key});
final DaemonClient client;
final DesktopIntegration? integration;
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
late ClientSnapshot _snapshot;
StreamSubscription<ClientSnapshot>? _subscription;
StreamSubscription<UpdateProgressEvent>? _updateSubscription;
StreamSubscription<int>? _tabSubscription;
int _selectedIndex = 0;
bool _busy = false;
bool _updateDialogOpen = false;
@override
void initState() {
super.initState();
_snapshot = ClientSnapshot.initial(widget.client.daemonAddr);
_subscription = widget.client.watchSnapshot().listen((snapshot) {
if (!mounted) {
return;
}
setState(() => _snapshot = snapshot);
});
_updateSubscription = widget.client.watchUpdateRequests().listen(
_showUpdateDialog,
);
_tabSubscription = widget.integration?.tabRequests.listen((index) {
if (!mounted) {
return;
}
setState(() => _selectedIndex = index);
});
}
@override
void dispose() {
_subscription?.cancel();
_updateSubscription?.cancel();
_tabSubscription?.cancel();
widget.client.dispose();
super.dispose();
}
Future<void> _showUpdateDialog(UpdateProgressEvent event) async {
if (!mounted || _updateDialogOpen) {
return;
}
_updateDialogOpen = true;
try {
await showUpdateProgressDialog(
context: context,
client: widget.client,
event: event,
);
} finally {
if (mounted) {
_updateDialogOpen = false;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
labelType: NavigationRailLabelType.all,
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: _StatusGlyph(status: _snapshot.status),
),
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.hub_outlined),
selectedIcon: Icon(Icons.hub),
label: Text('Status'),
),
NavigationRailDestination(
icon: Icon(Icons.route_outlined),
selectedIcon: Icon(Icons.route),
label: Text('Networks'),
),
NavigationRailDestination(
icon: Icon(Icons.account_circle_outlined),
selectedIcon: Icon(Icons.account_circle),
label: Text('Profiles'),
),
NavigationRailDestination(
icon: Icon(Icons.tune_outlined),
selectedIcon: Icon(Icons.tune),
label: Text('Settings'),
),
NavigationRailDestination(
icon: Icon(Icons.bug_report_outlined),
selectedIcon: Icon(Icons.bug_report),
label: Text('Debug'),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: SafeArea(child: _buildPage(context))),
],
),
);
}
Widget _buildPage(BuildContext context) {
return switch (_selectedIndex) {
0 => _StatusPane(
snapshot: _snapshot,
busy: _busy,
onConnect: () => _run(widget.client.connect),
onDisconnect: () => _run(widget.client.disconnect),
),
1 => _NetworksPane(snapshot: _snapshot, client: widget.client),
2 => _ProfilesPane(snapshot: _snapshot, client: widget.client),
3 => _SettingsPane(snapshot: _snapshot, client: widget.client),
_ => DebugScreen(client: widget.client),
};
}
Future<void> _run(Future<void> Function() action) async {
if (_busy) {
return;
}
setState(() => _busy = true);
try {
await action();
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
}
class _Page extends StatelessWidget {
const _Page({required this.title, required this.child, this.actions});
final String title;
final Widget child;
final List<Widget>? actions;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.headlineSmall,
),
),
if (actions != null) ...actions!,
],
),
const SizedBox(height: 20),
Expanded(child: child),
],
),
);
}
}
class _StatusPane extends StatelessWidget {
const _StatusPane({
required this.snapshot,
required this.busy,
required this.onConnect,
required this.onDisconnect,
});
final ClientSnapshot snapshot;
final bool busy;
final VoidCallback onConnect;
final VoidCallback onDisconnect;
@override
Widget build(BuildContext context) {
final connected = snapshot.status == ConnectionStatus.connected;
final connecting =
snapshot.status == ConnectionStatus.connecting ||
snapshot.status == ConnectionStatus.awaitingLogin;
return _Page(
title: 'Status',
child: ListView(
children: [
_InfoRow(label: 'Connection', value: snapshot.status.label),
_InfoRow(label: 'Daemon', value: snapshot.daemonAddr),
_InfoRow(label: 'Daemon version', value: snapshot.daemonVersion),
if (snapshot.pendingLogin != null) ...[
const SizedBox(height: 16),
_LoginBanner(pending: snapshot.pendingLogin!),
],
if (snapshot.errorMessage != null) ...[
const SizedBox(height: 16),
_ErrorBanner(message: snapshot.errorMessage!),
],
const SizedBox(height: 24),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: busy || connected || connecting ? null : onConnect,
icon: const Icon(Icons.power_settings_new),
label: const Text('Connect'),
),
OutlinedButton.icon(
onPressed: busy || !connected ? null : onDisconnect,
icon: const Icon(Icons.power_off),
label: const Text('Disconnect'),
),
],
),
const SizedBox(height: 32),
_SectionLabel('Active profile'),
_ProfileTile(profile: snapshot.activeProfile),
],
),
);
}
}
class _NetworksPane extends StatefulWidget {
const _NetworksPane({required this.snapshot, required this.client});
final ClientSnapshot snapshot;
final DaemonClient client;
@override
State<_NetworksPane> createState() => _NetworksPaneState();
}
class _NetworksPaneState extends State<_NetworksPane> {
NetworkFilter _filter = NetworkFilter.all;
final Set<String> _busyRoutes = {};
@override
Widget build(BuildContext context) {
final networks = widget.snapshot.networks
.where(_filter.matches)
.toList();
return _Page(
title: 'Networks',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<NetworkFilter>(
segments: const [
ButtonSegment(
value: NetworkFilter.all,
icon: Icon(Icons.all_inclusive),
label: Text('All'),
),
ButtonSegment(
value: NetworkFilter.overlapping,
icon: Icon(Icons.compare_arrows),
label: Text('Overlapping'),
),
ButtonSegment(
value: NetworkFilter.exitNode,
icon: Icon(Icons.public),
label: Text('Exit nodes'),
),
],
selected: {_filter},
onSelectionChanged: (selected) {
setState(() => _filter = selected.single);
},
),
const SizedBox(height: 16),
if (networks.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No networks to show.'),
)
else
Expanded(
child: ListView.separated(
itemCount: networks.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final route = networks[index];
final exitNodeMode = _filter == NetworkFilter.exitNode;
return _NetworkTile(
route: route,
exitNodeMode: exitNodeMode,
busy: _busyRoutes.contains(route.id),
onChanged: (selected) =>
_toggle(route, selected, exitNodeMode),
);
},
),
),
],
),
);
}
Future<void> _toggle(
NetworkRoute route,
bool selected,
bool exitNodeMode,
) async {
if (_busyRoutes.contains(route.id)) {
return;
}
setState(() => _busyRoutes.add(route.id));
try {
if (exitNodeMode) {
await widget.client.setExitNode(selected ? route.id : null);
} else {
await widget.client.setNetworkSelection(route.id, selected);
}
} finally {
if (mounted) {
setState(() => _busyRoutes.remove(route.id));
}
}
}
}
class _ProfilesPane extends StatefulWidget {
const _ProfilesPane({required this.snapshot, required this.client});
final ClientSnapshot snapshot;
final DaemonClient client;
@override
State<_ProfilesPane> createState() => _ProfilesPaneState();
}
class _ProfilesPaneState extends State<_ProfilesPane> {
bool _busy = false;
@override
Widget build(BuildContext context) {
return _Page(
title: 'Profiles',
actions: [
FilledButton.tonalIcon(
onPressed: _busy ? null : _showAddDialog,
icon: const Icon(Icons.add),
label: const Text('Add profile'),
),
],
child: ListView.separated(
itemCount: widget.snapshot.profiles.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final profile = widget.snapshot.profiles[index];
return _ProfileTile(
profile: profile,
onTap: profile.active || _busy ? null : () => _confirmSwitch(profile),
trailing: _profileMenu(profile),
);
},
),
);
}
Widget _profileMenu(ProfileInfo profile) {
return PopupMenuButton<_ProfileAction>(
enabled: !_busy,
onSelected: (action) => _handleAction(action, profile),
itemBuilder: (context) => [
if (profile.active)
const PopupMenuItem(
value: _ProfileAction.logout,
child: ListTile(
leading: Icon(Icons.logout),
title: Text('Logout'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: _ProfileAction.remove,
enabled: !profile.active,
child: const ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Remove'),
contentPadding: EdgeInsets.zero,
),
),
],
);
}
Future<void> _handleAction(
_ProfileAction action,
ProfileInfo profile,
) async {
switch (action) {
case _ProfileAction.logout:
await _confirmAndRun(
title: 'Logout from ${profile.name}?',
message:
'This disconnects the active profile and clears its session.',
run: widget.client.logoutActive,
);
case _ProfileAction.remove:
await _confirmAndRun(
title: 'Remove profile ${profile.name}?',
message: 'This deletes the profile from this device.',
run: () => widget.client.removeProfile(profile.name),
);
}
}
Future<void> _confirmSwitch(ProfileInfo profile) async {
await _confirmAndRun(
title: 'Switch to ${profile.name}?',
message: 'The connection will restart with the new profile.',
run: () => widget.client.switchProfile(profile.name),
);
}
Future<void> _showAddDialog() async {
final controller = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Add profile'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Profile name'),
onSubmitted: (value) => Navigator.of(context).pop(value.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.of(context).pop(controller.text.trim()),
child: const Text('Add'),
),
],
);
},
);
if (name == null || name.isEmpty) {
return;
}
await _runBusy(() => widget.client.addProfile(name));
}
Future<void> _confirmAndRun({
required String title,
required String message,
required Future<void> Function() run,
}) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Confirm'),
),
],
);
},
);
if (confirm != true) {
return;
}
await _runBusy(run);
}
Future<void> _runBusy(Future<void> Function() action) async {
if (_busy) {
return;
}
setState(() => _busy = true);
try {
await action();
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
}
enum _ProfileAction { logout, remove }
class _SettingsPane extends StatefulWidget {
const _SettingsPane({required this.snapshot, required this.client});
final ClientSnapshot snapshot;
final DaemonClient client;
@override
State<_SettingsPane> createState() => _SettingsPaneState();
}
class _SettingsPaneState extends State<_SettingsPane> {
bool _writing = false;
@override
Widget build(BuildContext context) {
final settings = widget.snapshot.settings;
final disabled = _writing;
return _Page(
title: 'Settings',
child: ListView(
children: [
_InfoRow(label: 'Management URL', value: settings.managementUrl),
_InfoRow(label: 'Interface', value: settings.interfaceName),
_InfoRow(label: 'WireGuard port', value: '${settings.wireguardPort}'),
_InfoRow(label: 'MTU', value: '${settings.mtu}'),
const SizedBox(height: 16),
SwitchListTile(
value: settings.autoConnect,
onChanged: disabled
? null
: (value) =>
_apply(settings.copyWith(autoConnect: value)),
title: const Text('Connect on startup'),
),
SwitchListTile(
value: settings.allowSsh,
onChanged: disabled
? null
: (value) => _apply(settings.copyWith(allowSsh: value)),
title: const Text('Allow SSH'),
),
SwitchListTile(
value: settings.quantumResistance,
onChanged: disabled
? null
: (value) =>
_apply(settings.copyWith(quantumResistance: value)),
title: const Text('Quantum resistance'),
),
SwitchListTile(
value: settings.lazyConnection,
onChanged: disabled
? null
: (value) =>
_apply(settings.copyWith(lazyConnection: value)),
title: const Text('Lazy connections'),
),
SwitchListTile(
value: settings.blockInbound,
onChanged: disabled
? null
: (value) =>
_apply(settings.copyWith(blockInbound: value)),
title: const Text('Block inbound'),
),
SwitchListTile(
value: settings.notifications,
onChanged: disabled
? null
: (value) =>
_apply(settings.copyWith(notifications: value)),
title: const Text('Notifications'),
),
],
),
);
}
Future<void> _apply(ClientSettings updated) async {
setState(() => _writing = true);
try {
await widget.client.updateSettings(updated);
} finally {
if (mounted) {
setState(() => _writing = false);
}
}
}
}
class _StatusGlyph extends StatelessWidget {
const _StatusGlyph({required this.status});
final ConnectionStatus status;
@override
Widget build(BuildContext context) {
final color = switch (status) {
ConnectionStatus.connected => Colors.green,
ConnectionStatus.connecting => Colors.amber,
ConnectionStatus.awaitingLogin => Colors.lightBlue,
ConnectionStatus.error => Colors.red,
ConnectionStatus.disconnected => Colors.grey,
};
return Tooltip(
message: status.label,
child: Icon(Icons.circle, color: color, size: 18),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 160,
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
),
Expanded(child: Text(value)),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(text, style: Theme.of(context).textTheme.titleMedium),
);
}
}
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.error_outline, color: colors.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(color: colors.onErrorContainer),
),
),
],
),
),
);
}
}
class _LoginBanner extends StatelessWidget {
const _LoginBanner({required this.pending});
final PendingLogin pending;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: colors.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.onTertiaryContainer,
),
),
const SizedBox(height: 8),
Text(
'A browser window opened to complete sign-in. '
'If it did not, open the URL below.',
style: TextStyle(color: colors.onTertiaryContainer),
),
const SizedBox(height: 12),
SelectableText(
pending.verificationUri,
style: TextStyle(color: colors.onTertiaryContainer),
),
const SizedBox(height: 4),
Text(
'Code: ${pending.userCode}',
style: TextStyle(color: colors.onTertiaryContainer),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: () => _openUrl(pending.verificationUri),
icon: const Icon(Icons.open_in_new),
label: const Text('Open in browser'),
),
OutlinedButton.icon(
onPressed: () => _copy(context, pending.verificationUri),
icon: const Icon(Icons.copy),
label: const Text('Copy URL'),
),
],
),
],
),
),
);
}
Future<void> _openUrl(String url) async {
await openExternalUrl(url);
}
Future<void> _copy(BuildContext context, String url) async {
await Clipboard.setData(ClipboardData(text: url));
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('URL copied')),
);
}
}
class _NetworkTile extends StatelessWidget {
const _NetworkTile({
required this.route,
required this.exitNodeMode,
required this.busy,
required this.onChanged,
});
final NetworkRoute route;
final bool exitNodeMode;
final bool busy;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final subtitle = [
route.range,
if (route.domains.isNotEmpty) route.domains.join(', '),
].join(' ');
Widget leading;
if (busy) {
leading = const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
);
} else if (exitNodeMode) {
leading = IconButton(
icon: Icon(
route.selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
onPressed: () => onChanged(!route.selected),
);
} else {
leading = Checkbox(
value: route.selected,
onChanged: (value) => onChanged(value ?? false),
);
}
return ListTile(
contentPadding: EdgeInsets.zero,
leading: leading,
title: Text(route.id),
subtitle: Text(subtitle),
trailing: route.isExitNode ? const Icon(Icons.public) : null,
onTap: busy ? null : () => onChanged(!route.selected),
);
}
}
class _ProfileTile extends StatelessWidget {
const _ProfileTile({required this.profile, this.onTap, this.trailing});
final ProfileInfo profile;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
profile.active ? Icons.check_circle : Icons.circle_outlined,
),
title: Text(profile.name),
subtitle: profile.email == null ? null : Text(profile.email!),
onTap: onTap,
trailing: trailing,
);
}
}

View File

@@ -0,0 +1,916 @@
import 'dart:async';
import 'dart:io';
import 'package:grpc/grpc.dart';
import 'generated/daemon.pbgrpc.dart' as daemon;
import 'models.dart';
import 'platform.dart';
const _userAgent = 'netbird-desktop-ui/development';
abstract class DaemonClient {
String get daemonAddr;
Stream<ClientSnapshot> watchSnapshot();
Stream<SystemNotification> watchEvents();
Stream<UpdateProgressEvent> watchUpdateRequests();
Future<void> connect();
Future<void> disconnect();
Future<void> bringUp();
Future<void> bringDown();
Future<DebugBundleResult> debugBundle({
required bool anonymize,
required bool systemInfo,
String? uploadUrl,
});
Future<DaemonLogLevel> getLogLevel();
Future<void> setLogLevel(DaemonLogLevel level);
Future<void> setSyncResponsePersistence(bool enabled);
Future<void> startCpuProfile();
Future<void> stopCpuProfile();
Future<TriggerUpdateResult> triggerUpdate();
Future<InstallerResult> getInstallerResult();
Future<void> updateSettings(ClientSettings updated);
Future<void> setNetworkSelection(String routeId, bool selected);
Future<void> setExitNode(String? routeId);
Future<void> switchProfile(String name);
Future<void> addProfile(String name);
Future<void> removeProfile(String name);
Future<void> logoutActive();
void dispose();
}
class GrpcDaemonClient implements DaemonClient {
GrpcDaemonClient({required this.daemonAddr}) {
_snapshot = ClientSnapshot.initial(daemonAddr);
_channel = _createChannel(daemonAddr);
_client = daemon.DaemonServiceClient(_channel);
}
@override
final String daemonAddr;
final _snapshots = StreamController<ClientSnapshot>.broadcast();
final _events = StreamController<SystemNotification>.broadcast();
final _updateRequests = StreamController<UpdateProgressEvent>.broadcast();
final _refreshInterval = const Duration(seconds: 2);
final _callTimeout = const Duration(seconds: 5);
final _ssoLoginTimeout = const Duration(minutes: 5);
final _installerPollTimeout = const Duration(minutes: 15);
late final ClientChannel _channel;
late final daemon.DaemonServiceClient _client;
late ClientSnapshot _snapshot;
Timer? _poller;
StreamSubscription<daemon.SystemEvent>? _eventSubscription;
var _started = false;
@override
Stream<ClientSnapshot> watchSnapshot() {
_start();
scheduleMicrotask(_emit);
return _snapshots.stream;
}
@override
Stream<SystemNotification> watchEvents() {
_start();
return _events.stream;
}
@override
Stream<UpdateProgressEvent> watchUpdateRequests() {
_start();
return _updateRequests.stream;
}
@override
Future<void> connect() async {
_setStatus(ConnectionStatus.connecting, clearError: true);
try {
await _runLoginFlow();
await _client.up(
daemon.UpRequest(username: _username()),
options: _options(timeout: const Duration(seconds: 30)),
);
} catch (error) {
_snapshot = _snapshot.copyWith(
status: ConnectionStatus.error,
errorMessage: _formatError(error),
clearPendingLogin: true,
);
_emit();
return;
} finally {
await _refresh();
}
}
@override
Future<void> disconnect() async {
await _runRpc(() async {
await _client.down(daemon.DownRequest(), options: _options());
});
}
@override
Future<void> bringUp() async {
await _client.up(
daemon.UpRequest(username: _username()),
options: _options(timeout: const Duration(seconds: 30)),
);
}
@override
Future<void> bringDown() async {
await _client.down(
daemon.DownRequest(),
options: _options(timeout: const Duration(seconds: 15)),
);
}
@override
Future<DebugBundleResult> debugBundle({
required bool anonymize,
required bool systemInfo,
String? uploadUrl,
}) async {
final request = daemon.DebugBundleRequest(
anonymize: anonymize,
systemInfo: systemInfo,
uploadURL: uploadUrl ?? '',
);
final response = await _client.debugBundle(
request,
options: _options(timeout: const Duration(minutes: 2)),
);
return DebugBundleResult(
path: response.path,
uploadedKey: response.uploadedKey,
uploadFailureReason: response.uploadFailureReason,
);
}
@override
Future<DaemonLogLevel> getLogLevel() async {
final response = await _client.getLogLevel(
daemon.GetLogLevelRequest(),
options: _options(),
);
return _mapLogLevelFromProto(response.level);
}
@override
Future<void> setLogLevel(DaemonLogLevel level) async {
await _client.setLogLevel(
daemon.SetLogLevelRequest(level: _mapLogLevelToProto(level)),
options: _options(),
);
}
@override
Future<void> setSyncResponsePersistence(bool enabled) async {
await _client.setSyncResponsePersistence(
daemon.SetSyncResponsePersistenceRequest(enabled: enabled),
options: _options(),
);
}
@override
Future<void> startCpuProfile() async {
await _client.startCPUProfile(
daemon.StartCPUProfileRequest(),
options: _options(),
);
}
@override
Future<void> stopCpuProfile() async {
await _client.stopCPUProfile(
daemon.StopCPUProfileRequest(),
options: _options(),
);
}
@override
Future<TriggerUpdateResult> triggerUpdate() async {
final response = await _client.triggerUpdate(
daemon.TriggerUpdateRequest(),
options: _options(timeout: const Duration(seconds: 30)),
);
return TriggerUpdateResult(
success: response.success,
errorMessage: response.errorMsg,
);
}
@override
Future<InstallerResult> getInstallerResult() async {
final response = await _client.getInstallerResult(
daemon.InstallerResultRequest(),
options: _options(timeout: _installerPollTimeout),
);
return InstallerResult(
success: response.success,
errorMessage: response.errorMsg,
);
}
@override
Future<void> updateSettings(ClientSettings updated) async {
await _runRpc(() async {
final activeProfile = _snapshot.activeProfile.name;
await _client.setConfig(
daemon.SetConfigRequest(
username: _username(),
profileName: activeProfile,
managementUrl: updated.managementUrl,
rosenpassEnabled: updated.quantumResistance,
serverSSHAllowed: updated.allowSsh,
disableAutoConnect: !updated.autoConnect,
disableNotifications: !updated.notifications,
lazyConnectionEnabled: updated.lazyConnection,
blockInbound: updated.blockInbound,
),
options: _options(timeout: const Duration(seconds: 10)),
);
});
}
@override
Future<void> setNetworkSelection(String routeId, bool selected) async {
await _runRpc(() async {
final request = daemon.SelectNetworksRequest(networkIDs: [routeId]);
if (selected) {
await _client.selectNetworks(request, options: _options());
} else {
await _client.deselectNetworks(request, options: _options());
}
});
}
@override
Future<void> setExitNode(String? routeId) async {
await _runRpc(() async {
final exitNodeIds = _snapshot.networks
.where((route) => route.isExitNode)
.map((route) => route.id)
.toList();
if (exitNodeIds.isNotEmpty) {
await _client.deselectNetworks(
daemon.SelectNetworksRequest(networkIDs: exitNodeIds),
options: _options(),
);
}
if (routeId != null) {
await _client.selectNetworks(
daemon.SelectNetworksRequest(networkIDs: [routeId]),
options: _options(),
);
}
});
}
@override
Future<void> switchProfile(String name) async {
await _runRpc(() async {
await _client.switchProfile(
daemon.SwitchProfileRequest(profileName: name, username: _username()),
options: _options(),
);
});
}
@override
Future<void> addProfile(String name) async {
await _runRpc(() async {
await _client.addProfile(
daemon.AddProfileRequest(profileName: name, username: _username()),
options: _options(),
);
});
}
@override
Future<void> removeProfile(String name) async {
await _runRpc(() async {
await _client.removeProfile(
daemon.RemoveProfileRequest(profileName: name, username: _username()),
options: _options(),
);
});
}
@override
Future<void> logoutActive() async {
await _runRpc(() async {
final active = _snapshot.activeProfile.name;
await _client.logout(
daemon.LogoutRequest(profileName: active, username: _username()),
options: _options(timeout: const Duration(seconds: 15)),
);
});
}
@override
void dispose() {
_poller?.cancel();
unawaited(_eventSubscription?.cancel() ?? Future<void>.value());
_events.close();
_updateRequests.close();
_snapshots.close();
unawaited(_channel.shutdown());
}
void _start() {
if (_started) {
return;
}
_started = true;
unawaited(_refresh());
_poller = Timer.periodic(_refreshInterval, (_) {
unawaited(_refresh());
});
_eventSubscription = _client
.subscribeEvents(daemon.SubscribeRequest(), options: _options())
.listen(
(event) {
_checkUpdateMetadata(event);
final notification = _mapSystemEvent(event);
if (notification != null && !_events.isClosed) {
_events.add(notification);
}
unawaited(_refresh());
},
onError: (_) {},
);
}
DaemonLogLevel _mapLogLevelFromProto(daemon.LogLevel level) {
return switch (level) {
daemon.LogLevel.PANIC => DaemonLogLevel.panic,
daemon.LogLevel.FATAL => DaemonLogLevel.fatal,
daemon.LogLevel.ERROR => DaemonLogLevel.error,
daemon.LogLevel.WARN => DaemonLogLevel.warn,
daemon.LogLevel.INFO => DaemonLogLevel.info,
daemon.LogLevel.DEBUG => DaemonLogLevel.debug,
daemon.LogLevel.TRACE => DaemonLogLevel.trace,
_ => DaemonLogLevel.unknown,
};
}
daemon.LogLevel _mapLogLevelToProto(DaemonLogLevel level) {
return switch (level) {
DaemonLogLevel.panic => daemon.LogLevel.PANIC,
DaemonLogLevel.fatal => daemon.LogLevel.FATAL,
DaemonLogLevel.error => daemon.LogLevel.ERROR,
DaemonLogLevel.warn => daemon.LogLevel.WARN,
DaemonLogLevel.info => daemon.LogLevel.INFO,
DaemonLogLevel.debug => daemon.LogLevel.DEBUG,
DaemonLogLevel.trace => daemon.LogLevel.TRACE,
DaemonLogLevel.unknown => daemon.LogLevel.UNKNOWN,
};
}
void _checkUpdateMetadata(daemon.SystemEvent event) {
final action = event.metadata['progress_window'];
if (action != 'show') {
return;
}
final version = event.metadata['version'] ?? 'unknown';
if (!_updateRequests.isClosed) {
_updateRequests.add(UpdateProgressEvent(version: version));
}
}
SystemNotification? _mapSystemEvent(daemon.SystemEvent event) {
final severity = switch (event.severity) {
daemon.SystemEvent_Severity.WARNING => NotificationSeverity.warning,
daemon.SystemEvent_Severity.ERROR => NotificationSeverity.error,
daemon.SystemEvent_Severity.CRITICAL => NotificationSeverity.critical,
_ => NotificationSeverity.info,
};
final category = switch (event.category) {
daemon.SystemEvent_Category.NETWORK => NotificationCategory.network,
daemon.SystemEvent_Category.DNS => NotificationCategory.dns,
daemon.SystemEvent_Category.AUTHENTICATION =>
NotificationCategory.authentication,
daemon.SystemEvent_Category.CONNECTIVITY =>
NotificationCategory.connectivity,
daemon.SystemEvent_Category.SYSTEM => NotificationCategory.system,
_ => NotificationCategory.system,
};
return SystemNotification(
severity: severity,
category: category,
message: event.message,
userMessage: event.userMessage,
id: event.metadata['id'],
);
}
Future<void> _runLoginFlow() async {
final loginResponse = await _client.login(
daemon.LoginRequest(
isUnixDesktopClient: Platform.isLinux,
profileName: _snapshot.activeProfile.name,
username: _username(),
hint: _snapshot.activeProfile.email,
),
options: _options(timeout: const Duration(seconds: 30)),
);
if (!loginResponse.needsSSOLogin) {
return;
}
_snapshot = _snapshot.copyWith(
status: ConnectionStatus.awaitingLogin,
pendingLogin: PendingLogin(
verificationUri: loginResponse.verificationURIComplete,
userCode: loginResponse.userCode,
),
);
_emit();
if (loginResponse.verificationURIComplete.isNotEmpty) {
await openExternalUrl(loginResponse.verificationURIComplete);
}
await _client.waitSSOLogin(
daemon.WaitSSOLoginRequest(userCode: loginResponse.userCode),
options: _options(timeout: _ssoLoginTimeout),
);
_snapshot = _snapshot.copyWith(
status: ConnectionStatus.connecting,
clearPendingLogin: true,
);
_emit();
}
Future<void> _runRpc(Future<void> Function() action) async {
try {
_snapshot = _snapshot.copyWith(clearError: true);
_emit();
await action();
} catch (error) {
_snapshot = _snapshot.copyWith(
status: ConnectionStatus.error,
errorMessage: _formatError(error),
);
_emit();
} finally {
await _refresh();
}
}
Future<void> _refresh() async {
try {
final status = await _client.status(
daemon.StatusRequest(),
options: _options(),
);
final activeProfile = await _loadActiveProfile();
final profiles = await _loadProfiles(activeProfile);
final networks = await _loadNetworks();
final settings = await _loadSettings(activeProfile);
final mappedStatus = _mapStatus(status.status);
final preserveAwaiting =
_snapshot.status == ConnectionStatus.awaitingLogin &&
mappedStatus != ConnectionStatus.connected;
_snapshot = ClientSnapshot(
daemonAddr: daemonAddr,
daemonVersion: status.daemonVersion.isEmpty
? 'unknown'
: status.daemonVersion,
status: preserveAwaiting ? ConnectionStatus.awaitingLogin : mappedStatus,
activeProfile: activeProfile,
profiles: profiles,
networks: networks,
settings: settings,
pendingLogin: preserveAwaiting ? _snapshot.pendingLogin : null,
);
} catch (error) {
_snapshot = _snapshot.copyWith(
status: ConnectionStatus.error,
errorMessage: _formatError(error),
);
}
_emit();
}
Future<ProfileInfo> _loadActiveProfile() async {
try {
final active = await _client.getActiveProfile(
daemon.GetActiveProfileRequest(),
options: _options(),
);
if (active.profileName.isNotEmpty) {
return ProfileInfo(
name: active.profileName,
email: _snapshot.activeProfile.email,
active: true,
);
}
} catch (_) {
// Keep the status pane usable even when optional profile RPCs fail.
}
return _snapshot.activeProfile;
}
Future<List<ProfileInfo>> _loadProfiles(ProfileInfo activeProfile) async {
try {
final response = await _client.listProfiles(
daemon.ListProfilesRequest(username: _username()),
options: _options(),
);
final profiles = response.profiles.map((profile) {
return ProfileInfo(name: profile.name, active: profile.isActive);
}).toList();
if (profiles.isNotEmpty) {
return profiles;
}
} catch (_) {
// Profile listing is not required for core connection status.
}
return [activeProfile];
}
Future<List<NetworkRoute>> _loadNetworks() async {
try {
final response = await _client.listNetworks(
daemon.ListNetworksRequest(),
options: _options(),
);
return _mapNetworks(response.routes);
} catch (_) {
return _snapshot.networks;
}
}
Future<ClientSettings> _loadSettings(ProfileInfo activeProfile) async {
try {
final config = await _client.getConfig(
daemon.GetConfigRequest(
profileName: activeProfile.name,
username: _username(),
),
options: _options(),
);
return ClientSettings(
managementUrl: config.managementUrl.isEmpty
? 'https://api.netbird.io'
: config.managementUrl,
interfaceName: config.interfaceName.isEmpty
? 'wt0'
: config.interfaceName,
wireguardPort: config.hasWireguardPort()
? config.wireguardPort.toInt()
: 51820,
mtu: config.hasMtu() ? config.mtu.toInt() : 1280,
autoConnect: !config.disableAutoConnect,
allowSsh: config.serverSSHAllowed,
quantumResistance: config.rosenpassEnabled,
notifications: !config.disableNotifications,
lazyConnection: config.lazyConnectionEnabled,
blockInbound: config.blockInbound,
);
} catch (_) {
return _snapshot.settings;
}
}
List<NetworkRoute> _mapNetworks(Iterable<daemon.Network> routes) {
final rangeCounts = <String, int>{};
for (final route in routes) {
if (route.domains.isEmpty) {
rangeCounts.update(
route.range,
(count) => count + 1,
ifAbsent: () => 1,
);
}
}
return routes.map((route) {
final resolvedIps = route.resolvedIPs.map((domain, ipList) {
return MapEntry(domain, ipList.ips.toList());
});
return NetworkRoute(
id: route.iD,
range: route.range,
selected: route.selected,
domains: route.domains.toList(),
resolvedIps: resolvedIps,
overlapping:
route.domains.isEmpty && (rangeCounts[route.range] ?? 0) > 1,
);
}).toList()
..sort((a, b) => a.id.toLowerCase().compareTo(b.id.toLowerCase()));
}
CallOptions _options({Duration? timeout}) {
return CallOptions(timeout: timeout ?? _callTimeout);
}
void _setStatus(
ConnectionStatus status, {
bool clearError = false,
bool clearPendingLogin = false,
}) {
_snapshot = _snapshot.copyWith(
status: status,
clearError: clearError,
clearPendingLogin: clearPendingLogin,
);
_emit();
}
void _emit() {
if (!_snapshots.isClosed) {
_snapshots.add(_snapshot);
}
}
}
class FakeDaemonClient implements DaemonClient {
FakeDaemonClient({required this.daemonAddr}) {
scheduleMicrotask(_emit);
}
@override
final String daemonAddr;
final _snapshots = StreamController<ClientSnapshot>.broadcast();
late ClientSnapshot _snapshot = ClientSnapshot.initial(daemonAddr).copyWith(
daemonVersion: 'development',
profiles: const [
ProfileInfo(name: 'default', email: 'user@example.com', active: true),
ProfileInfo(name: 'staging', active: false),
],
networks: const [
NetworkRoute(id: 'office', range: '10.10.0.0/16', selected: true),
NetworkRoute(id: 'prod', range: '10.20.0.0/16'),
NetworkRoute(id: 'exit-us', range: '0.0.0.0/0'),
],
);
@override
Stream<ClientSnapshot> watchSnapshot() {
scheduleMicrotask(_emit);
return _snapshots.stream;
}
@override
Stream<SystemNotification> watchEvents() =>
const Stream<SystemNotification>.empty();
@override
Stream<UpdateProgressEvent> watchUpdateRequests() =>
const Stream<UpdateProgressEvent>.empty();
@override
Future<void> connect() async {
_snapshot = _snapshot.copyWith(status: ConnectionStatus.connecting);
_emit();
await Future<void>.delayed(const Duration(milliseconds: 450));
_snapshot = _snapshot.copyWith(status: ConnectionStatus.connected);
_emit();
}
@override
Future<void> disconnect() async {
_snapshot = _snapshot.copyWith(status: ConnectionStatus.disconnected);
_emit();
}
@override
Future<void> bringUp() async {
_snapshot = _snapshot.copyWith(status: ConnectionStatus.connected);
_emit();
}
@override
Future<void> bringDown() async {
_snapshot = _snapshot.copyWith(status: ConnectionStatus.disconnected);
_emit();
}
@override
Future<DebugBundleResult> debugBundle({
required bool anonymize,
required bool systemInfo,
String? uploadUrl,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
return DebugBundleResult(
path: '/tmp/netbird-debug.tar.gz',
uploadedKey: uploadUrl == null ? '' : 'fake-upload-key',
);
}
@override
Future<DaemonLogLevel> getLogLevel() async => DaemonLogLevel.info;
@override
Future<void> setLogLevel(DaemonLogLevel level) async {}
@override
Future<void> setSyncResponsePersistence(bool enabled) async {}
@override
Future<void> startCpuProfile() async {}
@override
Future<void> stopCpuProfile() async {}
@override
Future<TriggerUpdateResult> triggerUpdate() async {
return const TriggerUpdateResult(success: true);
}
@override
Future<InstallerResult> getInstallerResult() async {
await Future<void>.delayed(const Duration(seconds: 2));
return const InstallerResult(success: true);
}
@override
Future<void> updateSettings(ClientSettings updated) async {
_snapshot = _snapshot.copyWith(settings: updated);
_emit();
}
@override
Future<void> setNetworkSelection(String routeId, bool selected) async {
final next = _snapshot.networks.map((route) {
if (route.id != routeId) {
return route;
}
return NetworkRoute(
id: route.id,
range: route.range,
domains: route.domains,
resolvedIps: route.resolvedIps,
overlapping: route.overlapping,
selected: selected,
);
}).toList();
_snapshot = _snapshot.copyWith(networks: next);
_emit();
}
@override
Future<void> setExitNode(String? routeId) async {
final next = _snapshot.networks.map((route) {
if (!route.isExitNode) {
return route;
}
return NetworkRoute(
id: route.id,
range: route.range,
domains: route.domains,
resolvedIps: route.resolvedIps,
overlapping: route.overlapping,
selected: route.id == routeId,
);
}).toList();
_snapshot = _snapshot.copyWith(networks: next);
_emit();
}
@override
Future<void> switchProfile(String name) async {
final profiles = _snapshot.profiles.map((profile) {
return ProfileInfo(
name: profile.name,
email: profile.email,
active: profile.name == name,
);
}).toList();
final active = profiles.firstWhere(
(profile) => profile.active,
orElse: () => _snapshot.activeProfile,
);
_snapshot = _snapshot.copyWith(profiles: profiles, activeProfile: active);
_emit();
}
@override
Future<void> addProfile(String name) async {
final profiles = [
..._snapshot.profiles,
ProfileInfo(name: name, active: false),
];
_snapshot = _snapshot.copyWith(profiles: profiles);
_emit();
}
@override
Future<void> removeProfile(String name) async {
final profiles = _snapshot.profiles
.where((profile) => profile.name != name)
.toList();
_snapshot = _snapshot.copyWith(profiles: profiles);
_emit();
}
@override
Future<void> logoutActive() async {
_snapshot = _snapshot.copyWith(status: ConnectionStatus.disconnected);
_emit();
}
@override
void dispose() {
_snapshots.close();
}
void _emit() {
if (!_snapshots.isClosed) {
_snapshots.add(_snapshot);
}
}
}
ClientChannel _createChannel(String daemonAddr) {
final options = ChannelOptions(
credentials: const ChannelCredentials.insecure(),
userAgent: _userAgent,
connectTimeout: const Duration(seconds: 3),
);
if (daemonAddr.startsWith('unix://')) {
final path = daemonAddr.substring('unix://'.length);
return ClientChannel(
InternetAddress(path, type: InternetAddressType.unix),
port: 0,
options: options,
);
}
final uri = daemonAddr.contains('://')
? Uri.parse(daemonAddr)
: Uri.parse('tcp://$daemonAddr');
final host = uri.host.isEmpty ? '127.0.0.1' : uri.host;
final port = uri.hasPort ? uri.port : 41731;
return ClientChannel(host, port: port, options: options);
}
ConnectionStatus _mapStatus(String status) {
return switch (status) {
'Connected' => ConnectionStatus.connected,
'Connecting' => ConnectionStatus.connecting,
'Idle' || 'SessionExpired' => ConnectionStatus.disconnected,
_ => ConnectionStatus.error,
};
}
String _username() {
if (Platform.isWindows) {
final username = Platform.environment['USERNAME'] ?? '';
final domain = Platform.environment['USERDOMAIN'] ?? '';
if (domain.isNotEmpty && username.isNotEmpty) {
return '$domain\\$username';
}
return username;
}
return Platform.environment['USER'] ?? Platform.environment['LOGNAME'] ?? '';
}
String _formatError(Object error) {
if (error is GrpcError) {
return error.message ?? error.toString();
}
return error.toString();
}

View File

@@ -0,0 +1,460 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'daemon_client.dart';
import 'models.dart';
import 'platform.dart';
const _defaultUploadUrl = 'https://upload.netbird.io/';
class DebugScreen extends StatefulWidget {
const DebugScreen({required this.client, super.key});
final DaemonClient client;
@override
State<DebugScreen> createState() => _DebugScreenState();
}
class _DebugScreenState extends State<DebugScreen> {
final _uploadUrlController =
TextEditingController(text: _defaultUploadUrl);
final _durationController = TextEditingController(text: '1');
bool _anonymize = false;
bool _systemInfo = true;
bool _upload = true;
bool _runWithTrace = true;
bool _busy = false;
String _status = '';
double? _progress;
@override
void dispose() {
_uploadUrlController.dispose();
_durationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Debug', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
Text(
'Create a debug bundle to help troubleshoot issues with NetBird.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
Expanded(
child: ListView(
children: [
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _anonymize,
onChanged: _busy
? null
: (value) => setState(() => _anonymize = value ?? false),
title: const Text(
'Anonymize sensitive information (public IPs, domains, ...)',
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _systemInfo,
onChanged: _busy
? null
: (value) => setState(() => _systemInfo = value ?? false),
title: const Text(
'Include system information (routes, interfaces, ...)',
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _upload,
onChanged: _busy
? null
: (value) => setState(() => _upload = value ?? false),
title: const Text('Upload bundle automatically after creation'),
),
if (_upload)
Padding(
padding: const EdgeInsets.only(left: 32, bottom: 8, top: 4),
child: TextField(
controller: _uploadUrlController,
enabled: !_busy,
decoration: const InputDecoration(
labelText: 'Debug upload URL',
border: OutlineInputBorder(),
),
),
),
const Divider(height: 32),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _runWithTrace,
onChanged: _busy
? null
: (value) =>
setState(() => _runWithTrace = value ?? false),
title: const Text(
'Run with trace logs before creating bundle',
),
),
if (_runWithTrace)
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Row(
children: [
const Text('Run for'),
const SizedBox(width: 12),
SizedBox(
width: 80,
child: TextField(
controller: _durationController,
enabled: !_busy,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
isDense: true,
),
),
),
const SizedBox(width: 8),
Text(_durationLabel()),
],
),
),
if (_runWithTrace)
const Padding(
padding: EdgeInsets.only(left: 32, top: 8),
child: Text(
'Note: NetBird will be brought up and down during collection.',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
const SizedBox(height: 24),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_status),
),
if (_progress != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: LinearProgressIndicator(value: _progress),
),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: _busy ? null : _onCreate,
icon: _busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.archive_outlined),
label: const Text('Create Debug Bundle'),
),
),
],
),
),
],
),
);
}
String _durationLabel() {
final value = int.tryParse(_durationController.text) ?? 0;
return value == 1 ? 'minute' : 'minutes';
}
Future<void> _onCreate() async {
final uploadUrl = _upload ? _uploadUrlController.text.trim() : null;
if (_upload && (uploadUrl == null || uploadUrl.isEmpty)) {
setState(() => _status = 'Upload URL is required when upload is enabled');
return;
}
Duration? traceDuration;
if (_runWithTrace) {
final minutes = int.tryParse(_durationController.text);
if (minutes == null || minutes < 1) {
setState(() => _status = 'Duration must be a number ≥ 1');
return;
}
traceDuration = Duration(minutes: minutes);
}
setState(() {
_busy = true;
_status = '';
_progress = null;
});
try {
DebugBundleResult result;
if (traceDuration != null) {
result = await _runWithTraceLogs(
duration: traceDuration,
uploadUrl: uploadUrl,
);
} else {
setState(() => _status = 'Creating debug bundle...');
result = await widget.client.debugBundle(
anonymize: _anonymize,
systemInfo: _systemInfo,
uploadUrl: uploadUrl,
);
}
if (!mounted) {
return;
}
setState(() => _status = 'Bundle created successfully');
await _showResultDialog(result);
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_status = 'Error: $error';
_progress = null;
});
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
Future<DebugBundleResult> _runWithTraceLogs({
required Duration duration,
required String? uploadUrl,
}) async {
final initialLevel = await widget.client.getLogLevel();
final wasTrace = initialLevel == DaemonLogLevel.trace;
var levelChanged = false;
var persistenceEnabled = false;
var cpuProfileStarted = false;
try {
if (!wasTrace) {
await widget.client.setLogLevel(DaemonLogLevel.trace);
levelChanged = true;
}
try {
await widget.client.bringDown();
} catch (_) {
// Already down is fine; daemon returns OK either way.
}
await Future<void>.delayed(const Duration(seconds: 1));
try {
await widget.client.setSyncResponsePersistence(true);
persistenceEnabled = true;
} catch (_) {
// Persistence is best-effort — the bundle still works without it.
}
await widget.client.bringUp();
await Future<void>.delayed(const Duration(seconds: 3));
try {
await widget.client.startCpuProfile();
cpuProfileStarted = true;
} catch (_) {
// CPU profiling is optional.
}
await _trackProgress(duration);
if (cpuProfileStarted) {
try {
await widget.client.stopCpuProfile();
} catch (_) {}
}
if (!mounted) {
return const DebugBundleResult(path: '');
}
setState(() {
_status = 'Creating debug bundle with collected logs...';
_progress = null;
});
return await widget.client.debugBundle(
anonymize: _anonymize,
systemInfo: _systemInfo,
uploadUrl: uploadUrl,
);
} finally {
if (levelChanged) {
try {
await widget.client.setLogLevel(initialLevel);
} catch (_) {}
}
if (persistenceEnabled) {
try {
await widget.client.setSyncResponsePersistence(false);
} catch (_) {}
}
}
}
Future<void> _trackProgress(Duration total) async {
final start = DateTime.now();
final end = start.add(total);
setState(() {
_progress = 0;
_status = 'Running with trace logs... ${_formatRemaining(total)} remaining';
});
while (DateTime.now().isBefore(end)) {
await Future<void>.delayed(const Duration(milliseconds: 500));
if (!mounted) {
return;
}
final elapsed = DateTime.now().difference(start);
final fraction = (elapsed.inMilliseconds / total.inMilliseconds).clamp(
0.0,
1.0,
);
final remaining = end.difference(DateTime.now());
setState(() {
_progress = fraction;
_status =
'Running with trace logs... ${_formatRemaining(remaining < Duration.zero ? Duration.zero : remaining)} remaining';
});
}
}
String _formatRemaining(Duration d) {
final hours = d.inHours.toString().padLeft(2, '0');
final minutes = (d.inMinutes % 60).toString().padLeft(2, '0');
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
Future<void> _showResultDialog(DebugBundleResult result) async {
if (!mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (context) => _DebugResultDialog(result: result),
);
}
}
class _DebugResultDialog extends StatelessWidget {
const _DebugResultDialog({required this.result});
final DebugBundleResult result;
@override
Widget build(BuildContext context) {
final folder = _parentFolder(result.path);
String title;
Widget body;
if (result.uploadFailed) {
title = 'Upload Failed';
body = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Bundle upload failed:\n${result.uploadFailureReason}'),
const SizedBox(height: 12),
SelectableText('Local copy: ${result.path}'),
],
);
} else if (result.uploaded) {
title = 'Upload Successful';
body = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Bundle uploaded successfully.'),
const SizedBox(height: 12),
const Text('Upload key:'),
SelectableText(result.uploadedKey),
const SizedBox(height: 12),
SelectableText('Local copy: ${result.path}'),
],
);
} else {
title = 'Debug Bundle Created';
body = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Bundle created locally at:\n${result.path}'),
const SizedBox(height: 8),
const Text(
'Administrator privileges may be required to access the file.',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
);
}
return AlertDialog(
title: Text(title),
content: SingleChildScrollView(child: body),
actions: [
if (result.uploaded)
TextButton.icon(
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: result.uploadedKey),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Upload key copied')),
);
}
},
icon: const Icon(Icons.copy),
label: const Text('Copy key'),
),
TextButton.icon(
onPressed: result.path.isEmpty
? null
: () => openExternalUrl(result.path),
icon: const Icon(Icons.description_outlined),
label: const Text('Open file'),
),
TextButton.icon(
onPressed: folder.isEmpty ? null : () => openExternalUrl(folder),
icon: const Icon(Icons.folder_open),
label: const Text('Open folder'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
}
String _parentFolder(String path) {
if (path.isEmpty) {
return '';
}
final lastSlash = path.lastIndexOf(RegExp(r'[/\\]'));
return lastSlash <= 0 ? '' : path.substring(0, lastSlash);
}
}

View File

@@ -0,0 +1,434 @@
import 'dart:async';
import 'dart:io';
import 'package:local_notifier/local_notifier.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';
import 'daemon_client.dart';
import 'models.dart';
import 'platform.dart';
const uiVersion = '0.1.0';
const _githubUrl = 'https://github.com/netbirdio/netbird';
const _downloadUrl = 'https://netbird.io/download/';
class TabIndex {
static const status = 0;
static const networks = 1;
static const profiles = 2;
static const settings = 3;
static const debug = 4;
}
/// Owns native desktop integration: window lifecycle (hide on close), system
/// tray icon and menu, and OS-level notifications driven by daemon events.
class DesktopIntegration with TrayListener, WindowListener {
DesktopIntegration({required this.client});
final DaemonClient client;
final _tabRequests = StreamController<int>.broadcast();
StreamSubscription<ClientSnapshot>? _snapshotSub;
StreamSubscription<SystemNotification>? _eventSub;
ClientSnapshot? _lastSnapshot;
String? _lastMenuKey;
bool _disposed = false;
bool _settingsBusy = false;
Stream<int> get tabRequests => _tabRequests.stream;
static const _trayMenuConnect = 'connect';
static const _trayMenuDisconnect = 'disconnect';
static const _trayMenuShow = 'show';
static const _trayMenuQuit = 'quit';
static const _trayMenuAllowSSH = 'settings.allow_ssh';
static const _trayMenuAutoConnect = 'settings.auto_connect';
static const _trayMenuQuantum = 'settings.quantum';
static const _trayMenuLazy = 'settings.lazy';
static const _trayMenuBlockInbound = 'settings.block_inbound';
static const _trayMenuNotifications = 'settings.notifications';
static const _trayMenuAdvancedSettings = 'open.settings';
static const _trayMenuDebugBundle = 'open.debug';
static const _trayMenuNetworks = 'open.networks';
static const _trayMenuManageProfiles = 'open.profiles';
static const _trayMenuLogout = 'profile.logout';
static const _trayMenuGithub = 'about.github';
static const _trayMenuDownload = 'about.download';
static const _profileSwitchPrefix = 'profile.switch:';
Future<void> initialize() async {
await localNotifier.setup(appName: 'NetBird');
await windowManager.setPreventClose(true);
windowManager.addListener(this);
trayManager.addListener(this);
await _applyTrayIcon(ConnectionStatus.disconnected);
await trayManager.setToolTip('NetBird');
await _refreshTrayMenu(null);
_snapshotSub = client.watchSnapshot().listen(_onSnapshot);
_eventSub = client.watchEvents().listen(_onEvent);
}
Future<void> dispose() async {
if (_disposed) {
return;
}
_disposed = true;
await _snapshotSub?.cancel();
await _eventSub?.cancel();
await _tabRequests.close();
windowManager.removeListener(this);
trayManager.removeListener(this);
await trayManager.destroy();
}
@override
void onWindowClose() {
unawaited(_handleWindowClose());
}
Future<void> _handleWindowClose() async {
final prevent = await windowManager.isPreventClose();
if (prevent) {
await windowManager.hide();
}
}
@override
void onTrayIconMouseDown() {
if (Platform.isMacOS) {
unawaited(trayManager.popUpContextMenu());
} else {
unawaited(_showWindow());
}
}
@override
void onTrayIconRightMouseDown() {
unawaited(trayManager.popUpContextMenu());
}
@override
void onTrayMenuItemClick(MenuItem menuItem) {
final key = menuItem.key;
if (key == null) {
return;
}
if (key.startsWith(_profileSwitchPrefix)) {
final name = key.substring(_profileSwitchPrefix.length);
unawaited(_switchProfile(name));
return;
}
switch (key) {
case _trayMenuConnect:
unawaited(client.connect());
case _trayMenuDisconnect:
unawaited(client.disconnect());
case _trayMenuShow:
unawaited(_showWindow());
case _trayMenuQuit:
unawaited(_quit());
case _trayMenuAllowSSH:
unawaited(_toggleSetting((s) => s.copyWith(allowSsh: !s.allowSsh)));
case _trayMenuAutoConnect:
unawaited(
_toggleSetting((s) => s.copyWith(autoConnect: !s.autoConnect)),
);
case _trayMenuQuantum:
unawaited(
_toggleSetting(
(s) => s.copyWith(quantumResistance: !s.quantumResistance),
),
);
case _trayMenuLazy:
unawaited(
_toggleSetting(
(s) => s.copyWith(lazyConnection: !s.lazyConnection),
),
);
case _trayMenuBlockInbound:
unawaited(
_toggleSetting(
(s) => s.copyWith(blockInbound: !s.blockInbound),
),
);
case _trayMenuNotifications:
unawaited(
_toggleSetting(
(s) => s.copyWith(notifications: !s.notifications),
),
);
case _trayMenuAdvancedSettings:
unawaited(_openTab(TabIndex.settings));
case _trayMenuDebugBundle:
unawaited(_openTab(TabIndex.debug));
case _trayMenuNetworks:
unawaited(_openTab(TabIndex.networks));
case _trayMenuManageProfiles:
unawaited(_openTab(TabIndex.profiles));
case _trayMenuLogout:
unawaited(client.logoutActive());
case _trayMenuGithub:
unawaited(openExternalUrl(_githubUrl));
case _trayMenuDownload:
unawaited(openExternalUrl(_downloadUrl));
}
}
Future<void> _openTab(int index) async {
if (!_tabRequests.isClosed) {
_tabRequests.add(index);
}
await _showWindow();
}
Future<void> _toggleSetting(
ClientSettings Function(ClientSettings) mutate,
) async {
if (_settingsBusy) {
return;
}
final snapshot = _lastSnapshot;
if (snapshot == null) {
return;
}
_settingsBusy = true;
try {
await client.updateSettings(mutate(snapshot.settings));
} finally {
_settingsBusy = false;
}
}
Future<void> _switchProfile(String name) async {
final snapshot = _lastSnapshot;
if (snapshot == null || snapshot.activeProfile.name == name) {
return;
}
await client.switchProfile(name);
}
Future<void> _showWindow() async {
await windowManager.show();
await windowManager.focus();
}
Future<void> _quit() async {
await dispose();
await windowManager.setPreventClose(false);
await windowManager.destroy();
}
void _onSnapshot(ClientSnapshot snapshot) {
final previous = _lastSnapshot;
_lastSnapshot = snapshot;
if (previous == null || previous.status != snapshot.status) {
unawaited(_applyTrayIcon(snapshot.status));
unawaited(trayManager.setToolTip('NetBird — ${snapshot.status.label}'));
}
unawaited(_refreshTrayMenu(snapshot));
}
void _onEvent(SystemNotification event) {
if (event.userMessage.isEmpty) {
return;
}
final notificationsEnabled =
_lastSnapshot?.settings.notifications ?? true;
final critical = event.severity == NotificationSeverity.critical;
if (!notificationsEnabled && !critical) {
return;
}
final title = '${_severityPrefix(event.severity)} [${event.category.label}]';
final body = event.id == null
? event.userMessage
: '${event.userMessage} (id: ${event.id})';
unawaited(
LocalNotification(title: title, body: body).show(),
);
}
Future<void> _applyTrayIcon(ConnectionStatus status) async {
final basename = switch (status) {
ConnectionStatus.connected => 'connected',
ConnectionStatus.connecting ||
ConnectionStatus.awaitingLogin => 'connecting',
ConnectionStatus.error => 'error',
ConnectionStatus.disconnected => 'disconnected',
};
final asset = Platform.isMacOS
? 'assets/tray/$basename-macos.png'
: 'assets/tray/$basename.png';
await trayManager.setIcon(asset, isTemplate: Platform.isMacOS);
}
Future<void> _refreshTrayMenu(ClientSnapshot? snapshot) async {
final key = _menuKey(snapshot);
if (key == _lastMenuKey) {
return;
}
_lastMenuKey = key;
final connected = snapshot?.status == ConnectionStatus.connected;
final connecting = snapshot?.status == ConnectionStatus.connecting ||
snapshot?.status == ConnectionStatus.awaitingLogin;
final statusLabel =
snapshot?.status.label ?? ConnectionStatus.disconnected.label;
final settings = snapshot?.settings ?? const ClientSettings();
final activeName = snapshot?.activeProfile.name ?? 'unknown';
final email = snapshot?.activeProfile.email;
final daemonVersion = snapshot?.daemonVersion ?? 'unknown';
final profileItems = <MenuItem>[];
final profiles = snapshot?.profiles ?? const <ProfileInfo>[];
for (final profile in profiles) {
profileItems.add(
MenuItem.checkbox(
key: '$_profileSwitchPrefix${profile.name}',
label: profile.name,
checked: profile.active,
),
);
}
if (profileItems.isNotEmpty) {
profileItems.add(MenuItem.separator());
}
profileItems
..add(MenuItem(key: _trayMenuManageProfiles, label: 'Manage Profiles'))
..add(
MenuItem(
key: _trayMenuLogout,
label: 'Deregister',
disabled: !connected,
),
);
await trayManager.setContextMenu(
Menu(
items: [
MenuItem(label: statusLabel, disabled: true),
MenuItem.submenu(
label: 'Profile: $activeName',
submenu: Menu(items: profileItems),
),
if (email != null && email.isNotEmpty)
MenuItem(label: '($email)', disabled: true),
MenuItem.separator(),
MenuItem(
key: _trayMenuConnect,
label: 'Connect',
disabled: connected || connecting,
),
MenuItem(
key: _trayMenuDisconnect,
label: 'Disconnect',
disabled: !connected,
),
MenuItem.separator(),
MenuItem.submenu(
label: 'Settings',
submenu: Menu(
items: [
MenuItem.checkbox(
key: _trayMenuAllowSSH,
label: 'Allow SSH',
checked: settings.allowSsh,
),
MenuItem.checkbox(
key: _trayMenuAutoConnect,
label: 'Connect on Startup',
checked: settings.autoConnect,
),
MenuItem.checkbox(
key: _trayMenuQuantum,
label: 'Enable Quantum-Resistance',
checked: settings.quantumResistance,
),
MenuItem.checkbox(
key: _trayMenuLazy,
label: 'Enable Lazy Connections',
checked: settings.lazyConnection,
),
MenuItem.checkbox(
key: _trayMenuBlockInbound,
label: 'Block Inbound Connections',
checked: settings.blockInbound,
),
MenuItem.checkbox(
key: _trayMenuNotifications,
label: 'Notifications',
checked: settings.notifications,
),
MenuItem.separator(),
MenuItem(
key: _trayMenuAdvancedSettings,
label: 'Advanced Settings',
),
MenuItem(
key: _trayMenuDebugBundle,
label: 'Create Debug Bundle',
),
],
),
),
MenuItem(key: _trayMenuNetworks, label: 'Networks'),
MenuItem.separator(),
MenuItem.submenu(
label: 'About',
submenu: Menu(
items: [
MenuItem(key: _trayMenuGithub, label: 'GitHub'),
MenuItem(label: 'GUI: $uiVersion', disabled: true),
MenuItem(label: 'Daemon: $daemonVersion', disabled: true),
MenuItem(
key: _trayMenuDownload,
label: 'Download latest version',
),
],
),
),
MenuItem.separator(),
MenuItem(key: _trayMenuShow, label: 'Show window'),
MenuItem(key: _trayMenuQuit, label: 'Quit'),
],
),
);
}
String _menuKey(ClientSnapshot? snapshot) {
if (snapshot == null) {
return 'null';
}
final s = snapshot.settings;
final profiles = snapshot.profiles
.map((p) => '${p.name}:${p.active}:${p.email ?? ''}')
.join(',');
return [
snapshot.status.name,
snapshot.activeProfile.name,
snapshot.activeProfile.email ?? '',
snapshot.daemonVersion,
profiles,
s.allowSsh,
s.autoConnect,
s.quantumResistance,
s.lazyConnection,
s.blockInbound,
s.notifications,
].join('|');
}
String _severityPrefix(NotificationSeverity severity) {
return switch (severity) {
NotificationSeverity.critical => 'Critical',
NotificationSeverity.error => 'Error',
NotificationSeverity.warning => 'Warning',
NotificationSeverity.info => 'Info',
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
// This is a generated file - do not edit.
//
// Generated from daemon.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
class LogLevel extends $pb.ProtobufEnum {
static const LogLevel UNKNOWN =
LogLevel._(0, _omitEnumNames ? '' : 'UNKNOWN');
static const LogLevel PANIC = LogLevel._(1, _omitEnumNames ? '' : 'PANIC');
static const LogLevel FATAL = LogLevel._(2, _omitEnumNames ? '' : 'FATAL');
static const LogLevel ERROR = LogLevel._(3, _omitEnumNames ? '' : 'ERROR');
static const LogLevel WARN = LogLevel._(4, _omitEnumNames ? '' : 'WARN');
static const LogLevel INFO = LogLevel._(5, _omitEnumNames ? '' : 'INFO');
static const LogLevel DEBUG = LogLevel._(6, _omitEnumNames ? '' : 'DEBUG');
static const LogLevel TRACE = LogLevel._(7, _omitEnumNames ? '' : 'TRACE');
static const $core.List<LogLevel> values = <LogLevel>[
UNKNOWN,
PANIC,
FATAL,
ERROR,
WARN,
INFO,
DEBUG,
TRACE,
];
static final $core.List<LogLevel?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 7);
static LogLevel? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const LogLevel._(super.value, super.name);
}
class ExposeProtocol extends $pb.ProtobufEnum {
static const ExposeProtocol EXPOSE_HTTP =
ExposeProtocol._(0, _omitEnumNames ? '' : 'EXPOSE_HTTP');
static const ExposeProtocol EXPOSE_HTTPS =
ExposeProtocol._(1, _omitEnumNames ? '' : 'EXPOSE_HTTPS');
static const ExposeProtocol EXPOSE_TCP =
ExposeProtocol._(2, _omitEnumNames ? '' : 'EXPOSE_TCP');
static const ExposeProtocol EXPOSE_UDP =
ExposeProtocol._(3, _omitEnumNames ? '' : 'EXPOSE_UDP');
static const ExposeProtocol EXPOSE_TLS =
ExposeProtocol._(4, _omitEnumNames ? '' : 'EXPOSE_TLS');
static const $core.List<ExposeProtocol> values = <ExposeProtocol>[
EXPOSE_HTTP,
EXPOSE_HTTPS,
EXPOSE_TCP,
EXPOSE_UDP,
EXPOSE_TLS,
];
static final $core.List<ExposeProtocol?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 4);
static ExposeProtocol? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const ExposeProtocol._(super.value, super.name);
}
/// avoid collision with loglevel enum
class OSLifecycleRequest_CycleType extends $pb.ProtobufEnum {
static const OSLifecycleRequest_CycleType UNKNOWN =
OSLifecycleRequest_CycleType._(0, _omitEnumNames ? '' : 'UNKNOWN');
static const OSLifecycleRequest_CycleType SLEEP =
OSLifecycleRequest_CycleType._(1, _omitEnumNames ? '' : 'SLEEP');
static const OSLifecycleRequest_CycleType WAKEUP =
OSLifecycleRequest_CycleType._(2, _omitEnumNames ? '' : 'WAKEUP');
static const $core.List<OSLifecycleRequest_CycleType> values =
<OSLifecycleRequest_CycleType>[
UNKNOWN,
SLEEP,
WAKEUP,
];
static final $core.List<OSLifecycleRequest_CycleType?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 2);
static OSLifecycleRequest_CycleType? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const OSLifecycleRequest_CycleType._(super.value, super.name);
}
class SystemEvent_Severity extends $pb.ProtobufEnum {
static const SystemEvent_Severity INFO =
SystemEvent_Severity._(0, _omitEnumNames ? '' : 'INFO');
static const SystemEvent_Severity WARNING =
SystemEvent_Severity._(1, _omitEnumNames ? '' : 'WARNING');
static const SystemEvent_Severity ERROR =
SystemEvent_Severity._(2, _omitEnumNames ? '' : 'ERROR');
static const SystemEvent_Severity CRITICAL =
SystemEvent_Severity._(3, _omitEnumNames ? '' : 'CRITICAL');
static const $core.List<SystemEvent_Severity> values = <SystemEvent_Severity>[
INFO,
WARNING,
ERROR,
CRITICAL,
];
static final $core.List<SystemEvent_Severity?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 3);
static SystemEvent_Severity? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const SystemEvent_Severity._(super.value, super.name);
}
class SystemEvent_Category extends $pb.ProtobufEnum {
static const SystemEvent_Category NETWORK =
SystemEvent_Category._(0, _omitEnumNames ? '' : 'NETWORK');
static const SystemEvent_Category DNS =
SystemEvent_Category._(1, _omitEnumNames ? '' : 'DNS');
static const SystemEvent_Category AUTHENTICATION =
SystemEvent_Category._(2, _omitEnumNames ? '' : 'AUTHENTICATION');
static const SystemEvent_Category CONNECTIVITY =
SystemEvent_Category._(3, _omitEnumNames ? '' : 'CONNECTIVITY');
static const SystemEvent_Category SYSTEM =
SystemEvent_Category._(4, _omitEnumNames ? '' : 'SYSTEM');
static const $core.List<SystemEvent_Category> values = <SystemEvent_Category>[
NETWORK,
DNS,
AUTHENTICATION,
CONNECTIVITY,
SYSTEM,
];
static final $core.List<SystemEvent_Category?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 4);
static SystemEvent_Category? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const SystemEvent_Category._(super.value, super.name);
}
const $core.bool _omitEnumNames =
$core.bool.fromEnvironment('protobuf.omit_enum_names');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,257 @@
enum ConnectionStatus {
disconnected,
connecting,
awaitingLogin,
connected,
error;
String get label {
return switch (this) {
ConnectionStatus.disconnected => 'Disconnected',
ConnectionStatus.connecting => 'Connecting',
ConnectionStatus.awaitingLogin => 'Awaiting login',
ConnectionStatus.connected => 'Connected',
ConnectionStatus.error => 'Error',
};
}
}
enum NetworkFilter {
all,
overlapping,
exitNode;
bool matches(NetworkRoute route) {
return switch (this) {
NetworkFilter.all => true,
NetworkFilter.overlapping => route.overlapping,
NetworkFilter.exitNode => route.isExitNode,
};
}
}
class ClientSnapshot {
const ClientSnapshot({
required this.daemonAddr,
required this.daemonVersion,
required this.status,
required this.activeProfile,
required this.profiles,
required this.networks,
required this.settings,
this.errorMessage,
this.pendingLogin,
});
factory ClientSnapshot.initial(String daemonAddr) {
return ClientSnapshot(
daemonAddr: daemonAddr,
daemonVersion: 'unknown',
status: ConnectionStatus.disconnected,
activeProfile: const ProfileInfo(name: 'default', active: true),
profiles: const [ProfileInfo(name: 'default', active: true)],
networks: const [],
settings: const ClientSettings(),
);
}
final String daemonAddr;
final String daemonVersion;
final ConnectionStatus status;
final ProfileInfo activeProfile;
final List<ProfileInfo> profiles;
final List<NetworkRoute> networks;
final ClientSettings settings;
final String? errorMessage;
final PendingLogin? pendingLogin;
ClientSnapshot copyWith({
String? daemonAddr,
String? daemonVersion,
ConnectionStatus? status,
ProfileInfo? activeProfile,
List<ProfileInfo>? profiles,
List<NetworkRoute>? networks,
ClientSettings? settings,
String? errorMessage,
PendingLogin? pendingLogin,
bool clearError = false,
bool clearPendingLogin = false,
}) {
return ClientSnapshot(
daemonAddr: daemonAddr ?? this.daemonAddr,
daemonVersion: daemonVersion ?? this.daemonVersion,
status: status ?? this.status,
activeProfile: activeProfile ?? this.activeProfile,
profiles: profiles ?? this.profiles,
networks: networks ?? this.networks,
settings: settings ?? this.settings,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
pendingLogin: clearPendingLogin
? null
: pendingLogin ?? this.pendingLogin,
);
}
}
class PendingLogin {
const PendingLogin({
required this.verificationUri,
required this.userCode,
});
final String verificationUri;
final String userCode;
}
class ProfileInfo {
const ProfileInfo({required this.name, required this.active, this.email});
final String name;
final String? email;
final bool active;
}
class NetworkRoute {
const NetworkRoute({
required this.id,
required this.range,
this.domains = const [],
this.resolvedIps = const {},
this.selected = false,
this.overlapping = false,
});
final String id;
final String range;
final List<String> domains;
final Map<String, List<String>> resolvedIps;
final bool selected;
final bool overlapping;
bool get isExitNode => range == '0.0.0.0/0';
}
enum DaemonLogLevel { unknown, panic, fatal, error, warn, info, debug, trace }
class DebugBundleResult {
const DebugBundleResult({
required this.path,
this.uploadedKey = '',
this.uploadFailureReason = '',
});
final String path;
final String uploadedKey;
final String uploadFailureReason;
bool get uploaded => uploadedKey.isNotEmpty && uploadFailureReason.isEmpty;
bool get uploadFailed => uploadFailureReason.isNotEmpty;
}
class TriggerUpdateResult {
const TriggerUpdateResult({required this.success, this.errorMessage = ''});
final bool success;
final String errorMessage;
}
class InstallerResult {
const InstallerResult({required this.success, this.errorMessage = ''});
final bool success;
final String errorMessage;
}
class UpdateProgressEvent {
const UpdateProgressEvent({required this.version});
final String version;
}
enum NotificationSeverity { info, warning, error, critical }
enum NotificationCategory {
network,
dns,
authentication,
connectivity,
system;
String get label {
return switch (this) {
NotificationCategory.network => 'Network',
NotificationCategory.dns => 'DNS',
NotificationCategory.authentication => 'Authentication',
NotificationCategory.connectivity => 'Connectivity',
NotificationCategory.system => 'System',
};
}
}
class SystemNotification {
const SystemNotification({
required this.severity,
required this.category,
required this.message,
required this.userMessage,
this.id,
});
final NotificationSeverity severity;
final NotificationCategory category;
final String message;
final String userMessage;
final String? id;
}
class ClientSettings {
const ClientSettings({
this.managementUrl = 'https://api.netbird.io',
this.interfaceName = 'wt0',
this.wireguardPort = 51820,
this.mtu = 1280,
this.autoConnect = true,
this.allowSsh = false,
this.quantumResistance = false,
this.notifications = true,
this.lazyConnection = false,
this.blockInbound = false,
});
final String managementUrl;
final String interfaceName;
final int wireguardPort;
final int mtu;
final bool autoConnect;
final bool allowSsh;
final bool quantumResistance;
final bool notifications;
final bool lazyConnection;
final bool blockInbound;
ClientSettings copyWith({
String? managementUrl,
String? interfaceName,
int? wireguardPort,
int? mtu,
bool? autoConnect,
bool? allowSsh,
bool? quantumResistance,
bool? notifications,
bool? lazyConnection,
bool? blockInbound,
}) {
return ClientSettings(
managementUrl: managementUrl ?? this.managementUrl,
interfaceName: interfaceName ?? this.interfaceName,
wireguardPort: wireguardPort ?? this.wireguardPort,
mtu: mtu ?? this.mtu,
autoConnect: autoConnect ?? this.autoConnect,
allowSsh: allowSsh ?? this.allowSsh,
quantumResistance: quantumResistance ?? this.quantumResistance,
notifications: notifications ?? this.notifications,
lazyConnection: lazyConnection ?? this.lazyConnection,
blockInbound: blockInbound ?? this.blockInbound,
);
}
}

View File

@@ -0,0 +1,22 @@
import 'dart:io';
/// Opens a URL in the user's default browser. Returns false if the platform
/// helper exits non-zero or is missing. Mirrors the Go UI's `openURL` logic.
Future<bool> openExternalUrl(String url) async {
try {
final ProcessResult result;
if (Platform.isMacOS) {
result = await Process.run('open', [url]);
} else if (Platform.isWindows) {
result = await Process.run('rundll32', [
'url.dll,FileProtocolHandler',
url,
]);
} else {
result = await Process.run('xdg-open', [url]);
}
return result.exitCode == 0;
} catch (_) {
return false;
}
}

View File

@@ -0,0 +1,140 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'daemon_client.dart';
import 'models.dart';
const _allowCloseAfter = Duration(seconds: 10);
const _dotInterval = Duration(seconds: 1);
/// Shows a modal dialog while the daemon installs an update. Polls
/// `GetInstallerResult` and resolves when the daemon finishes or fails.
Future<void> showUpdateProgressDialog({
required BuildContext context,
required DaemonClient client,
required UpdateProgressEvent event,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => _UpdateProgressDialog(client: client, event: event),
);
}
class _UpdateProgressDialog extends StatefulWidget {
const _UpdateProgressDialog({required this.client, required this.event});
final DaemonClient client;
final UpdateProgressEvent event;
@override
State<_UpdateProgressDialog> createState() => _UpdateProgressDialogState();
}
class _UpdateProgressDialogState extends State<_UpdateProgressDialog> {
Timer? _dotTimer;
Timer? _allowCloseTimer;
int _dots = 0;
bool _canClose = false;
String _status = 'Updating';
String? _error;
bool _resolved = false;
@override
void initState() {
super.initState();
_dotTimer = Timer.periodic(_dotInterval, (_) => _tick());
_allowCloseTimer = Timer(_allowCloseAfter, () {
if (mounted) {
setState(() => _canClose = true);
}
});
unawaited(_pollInstaller());
}
@override
void dispose() {
_dotTimer?.cancel();
_allowCloseTimer?.cancel();
super.dispose();
}
void _tick() {
if (!mounted) {
return;
}
setState(() {
_dots = (_dots + 1) % 4;
_status = 'Updating${'.' * _dots}';
});
}
Future<void> _pollInstaller() async {
try {
final result = await widget.client.getInstallerResult();
if (!mounted) {
return;
}
if (result.success) {
Navigator.of(context).pop();
return;
}
setState(() {
_resolved = true;
_canClose = true;
_status = 'Update failed';
_error = result.errorMessage.isEmpty
? 'Unknown error'
: result.errorMessage;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_resolved = true;
_canClose = true;
_status = 'Update timed out';
_error = error.toString();
});
}
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: _canClose,
child: AlertDialog(
title: const Text('Updating client'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Your client version is older than the auto-update version set in '
'Management.\nUpdating client to ${widget.event.version}.',
),
const SizedBox(height: 16),
if (!_resolved) const LinearProgressIndicator(),
const SizedBox(height: 12),
Text(_status),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
actions: [
TextButton(
onPressed: _canClose ? () => Navigator.of(context).pop() : null,
child: const Text('Close'),
),
],
),
);
}
}