feat: 1.新增在 VPN 配置中添加了一条规则,2.修复全局代理无法使用
This commit is contained in:
@@ -15,7 +15,8 @@ enum KRCountry {
|
||||
ru('俄罗斯'),
|
||||
id('印度尼西亚'),
|
||||
tr('土耳其'),
|
||||
br('巴西');
|
||||
br('巴西'),
|
||||
other('全局代理'); // ✅ 新增:全局代理模式(所有流量走代理,不添加国家直连规则)
|
||||
|
||||
final String kr_name;
|
||||
const KRCountry(this.kr_name);
|
||||
@@ -30,7 +31,7 @@ enum KRCountry {
|
||||
static KRCountry? kr_fromCode(String code) {
|
||||
try {
|
||||
return KRCountry.values.firstWhere(
|
||||
(country) => country.kr_code == code.toLowerCase(),
|
||||
(country) => country.kr_code == code.toLowerCase(),
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
@@ -53,7 +54,7 @@ class KRCountryUtil {
|
||||
static Future<void> kr_init() async {
|
||||
try {
|
||||
final String? kr_savedCountry =
|
||||
await _kr_storage.kr_readData(key: _kr_countryKey);
|
||||
await _kr_storage.kr_readData(key: _kr_countryKey);
|
||||
if (kr_savedCountry != null) {
|
||||
final KRCountry? kr_country = KRCountry.kr_fromCode(kr_savedCountry);
|
||||
if (kr_country != null) {
|
||||
@@ -66,7 +67,7 @@ class KRCountryUtil {
|
||||
} else {
|
||||
kr_currentCountry.value = KRCountry.cn;
|
||||
}
|
||||
|
||||
|
||||
} catch (err) {
|
||||
KRLogUtil.kr_e('初始化国家设置失败: $err', tag: 'CountryUtil');
|
||||
kr_currentCountry.value = KRCountry.cn;
|
||||
@@ -106,9 +107,9 @@ class KRCountryUtil {
|
||||
return kr_currentCountry.value.kr_code;
|
||||
}
|
||||
|
||||
static String kr_getCurrentCountryName() {
|
||||
static String kr_getCurrentCountryName() {
|
||||
return kr_getCountryName(kr_currentCountry.value);
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// 获取国家名称
|
||||
@@ -131,12 +132,14 @@ class KRCountryUtil {
|
||||
return AppTranslations.kr_country.tr;
|
||||
case KRCountry.br:
|
||||
return AppTranslations.kr_country.br;
|
||||
case KRCountry.other:
|
||||
return '全局代理'; // ✅ 新增:全局代理
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有支持的国家列表
|
||||
static List<KRCountry> kr_getSupportedCountries() {
|
||||
if (AppConfig().kr_is_daytime == false) {
|
||||
if (AppConfig().kr_is_daytime == false) {
|
||||
return KRCountry.values.where((element) => element != KRCountry.cn).toList();
|
||||
}
|
||||
return KRCountry.values;
|
||||
@@ -146,9 +149,9 @@ class KRCountryUtil {
|
||||
static List<Map<String, String>> kr_getCountryInfoList() {
|
||||
return KRCountry.values
|
||||
.map((country) => {
|
||||
'code': country.kr_code,
|
||||
'name': country.kr_countryName,
|
||||
})
|
||||
'code': country.kr_code,
|
||||
'name': country.kr_countryName,
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import 'dart:io';
|
||||
import 'package:kaer_with_panels/app/utils/kr_log_util.dart';
|
||||
|
||||
/// Windows DNS 管理工具类
|
||||
///
|
||||
/// 用于在 Windows 平台上管理系统 DNS 设置
|
||||
/// 主要功能:
|
||||
/// 1. 备份原始 DNS 设置
|
||||
/// 2. 恢复 DNS 设置
|
||||
/// 3. 兜底设置为国内公共 DNS (223.5.5.5 和 114.114.114.114)
|
||||
class KRWindowsDnsUtil {
|
||||
/// 私有构造函数
|
||||
KRWindowsDnsUtil._();
|
||||
|
||||
/// 单例实例
|
||||
static final KRWindowsDnsUtil _instance = KRWindowsDnsUtil._();
|
||||
|
||||
/// 工厂构造函数
|
||||
factory KRWindowsDnsUtil() => _instance;
|
||||
|
||||
/// 获取实例的静态方法
|
||||
static KRWindowsDnsUtil get instance => _instance;
|
||||
|
||||
/// 原始 DNS 服务器地址(连接前备份)
|
||||
List<String>? _originalDnsServers;
|
||||
|
||||
/// 主网络接口名称
|
||||
String? _primaryInterfaceName;
|
||||
|
||||
/// 备份当前 DNS 设置
|
||||
///
|
||||
/// 在连接 VPN 之前调用,保存原始 DNS 配置
|
||||
/// 返回:true-成功,false-失败
|
||||
Future<bool> kr_backupDnsSettings() async {
|
||||
if (!Platform.isWindows) {
|
||||
KRLogUtil.kr_w('❌ 非 Windows 平台,跳过 DNS 备份', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
KRLogUtil.kr_i('📦 开始备份 Windows DNS 设置...', tag: 'WindowsDNS');
|
||||
|
||||
// 1. 获取主网络接口
|
||||
final interfaceName = await _kr_getPrimaryNetworkInterface();
|
||||
if (interfaceName == null) {
|
||||
KRLogUtil.kr_e('❌ 无法获取主网络接口', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
_primaryInterfaceName = interfaceName;
|
||||
KRLogUtil.kr_i('🔍 主网络接口: $_primaryInterfaceName', tag: 'WindowsDNS');
|
||||
|
||||
// 2. 获取当前 DNS 服务器
|
||||
final dnsServers = await _kr_getCurrentDnsServers(interfaceName);
|
||||
if (dnsServers.isEmpty) {
|
||||
KRLogUtil.kr_w('⚠️ 当前 DNS 为空,可能是自动获取', tag: 'WindowsDNS');
|
||||
_originalDnsServers = []; // 空列表表示 DHCP 自动获取
|
||||
} else {
|
||||
_originalDnsServers = dnsServers;
|
||||
KRLogUtil.kr_i('✅ 已备份 DNS: ${dnsServers.join(", ")}', tag: 'WindowsDNS');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_e('❌ 备份 DNS 设置失败: $e', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复原始 DNS 设置
|
||||
///
|
||||
/// 在断开 VPN 后调用,恢复备份的 DNS 配置
|
||||
/// 如果恢复失败,会自动调用兜底机制设置为公共DNS
|
||||
/// 返回:true-成功,false-失败
|
||||
Future<bool> kr_restoreDnsSettings() async {
|
||||
if (!Platform.isWindows) {
|
||||
KRLogUtil.kr_w('❌ 非 Windows 平台,跳过 DNS 恢复', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
KRLogUtil.kr_i('🔄 开始恢复 Windows DNS 设置...', tag: 'WindowsDNS');
|
||||
|
||||
// 1. 检查是否有备份
|
||||
if (_primaryInterfaceName == null) {
|
||||
KRLogUtil.kr_w('⚠️ 没有备份的网络接口,尝试自动检测', tag: 'WindowsDNS');
|
||||
_primaryInterfaceName = await _kr_getPrimaryNetworkInterface();
|
||||
if (_primaryInterfaceName == null) {
|
||||
KRLogUtil.kr_e('❌ 无法检测网络接口,执行兜底恢复', tag: 'WindowsDNS');
|
||||
return await _kr_fallbackRestoreDns();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 恢复原始 DNS
|
||||
if (_originalDnsServers == null) {
|
||||
KRLogUtil.kr_w('⚠️ 没有备份的 DNS,执行兜底恢复', tag: 'WindowsDNS');
|
||||
return await _kr_fallbackRestoreDns();
|
||||
}
|
||||
|
||||
if (_originalDnsServers!.isEmpty) {
|
||||
// 原本是 DHCP 自动获取
|
||||
KRLogUtil.kr_i('🔄 恢复为 DHCP 自动获取 DNS', tag: 'WindowsDNS');
|
||||
final success = await _kr_setDnsToAuto(_primaryInterfaceName!);
|
||||
if (!success) {
|
||||
KRLogUtil.kr_w('⚠️ 恢复 DHCP 失败,执行兜底恢复', tag: 'WindowsDNS');
|
||||
return await _kr_fallbackRestoreDns();
|
||||
}
|
||||
} else {
|
||||
// 恢复指定的 DNS 服务器
|
||||
KRLogUtil.kr_i('🔄 恢复原始 DNS: ${_originalDnsServers!.join(", ")}', tag: 'WindowsDNS');
|
||||
final success = await _kr_setDnsServers(
|
||||
_primaryInterfaceName!,
|
||||
_originalDnsServers!,
|
||||
);
|
||||
if (!success) {
|
||||
KRLogUtil.kr_w('⚠️ 恢复原始 DNS 失败,执行兜底恢复', tag: 'WindowsDNS');
|
||||
return await _kr_fallbackRestoreDns();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 验证 DNS 是否恢复成功
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final currentDns = await _kr_getCurrentDnsServers(_primaryInterfaceName!);
|
||||
KRLogUtil.kr_i('✅ 当前 DNS: ${currentDns.join(", ")}', tag: 'WindowsDNS');
|
||||
|
||||
// 4. 额外验证:确认 DNS 不再指向 127.0.0.1(sing-box 的本地 DNS)
|
||||
final hasLocalhost = currentDns.any((dns) => dns.startsWith('127.'));
|
||||
if (hasLocalhost) {
|
||||
KRLogUtil.kr_w('⚠️ DNS 仍包含 127.0.0.1,可能未完全恢复', tag: 'WindowsDNS');
|
||||
KRLogUtil.kr_w('⚠️ 执行兜底恢复', tag: 'WindowsDNS');
|
||||
return await _kr_fallbackRestoreDns();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_e('❌ 恢复 DNS 设置失败: $e', tag: 'WindowsDNS');
|
||||
KRLogUtil.kr_w('⚠️ 执行兜底恢复', tag: 'WindowsDNS');
|
||||
return await _kr_fallbackRestoreDns();
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底恢复:强制设置为国内公共 DNS
|
||||
///
|
||||
/// 当正常恢复失败时,设置为安全的公共 DNS 服务器
|
||||
/// - 主 DNS: 223.5.5.5 (阿里云)
|
||||
/// - 备用 DNS: 114.114.114.114 (114DNS)
|
||||
Future<bool> _kr_fallbackRestoreDns() async {
|
||||
try {
|
||||
KRLogUtil.kr_w('🆘 执行 DNS 兜底恢复机制', tag: 'WindowsDNS');
|
||||
KRLogUtil.kr_i('🔧 设置为国内公共 DNS: 223.5.5.5, 114.114.114.114', tag: 'WindowsDNS');
|
||||
|
||||
// 1. 获取主网络接口(如果还没有)
|
||||
if (_primaryInterfaceName == null) {
|
||||
_primaryInterfaceName = await _kr_getPrimaryNetworkInterface();
|
||||
if (_primaryInterfaceName == null) {
|
||||
KRLogUtil.kr_e('❌ 无法检测网络接口,兜底恢复失败', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 设置为公共 DNS
|
||||
final fallbackDns = ['223.5.5.5', '114.114.114.114'];
|
||||
final success = await _kr_setDnsServers(_primaryInterfaceName!, fallbackDns);
|
||||
|
||||
if (success) {
|
||||
KRLogUtil.kr_i('✅ 兜底 DNS 设置成功', tag: 'WindowsDNS');
|
||||
|
||||
// 3. 验证设置
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final currentDns = await _kr_getCurrentDnsServers(_primaryInterfaceName!);
|
||||
KRLogUtil.kr_i('✅ 验证当前 DNS: ${currentDns.join(", ")}', tag: 'WindowsDNS');
|
||||
|
||||
return true;
|
||||
} else {
|
||||
KRLogUtil.kr_e('❌ 兜底 DNS 设置失败', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_e('❌ 兜底恢复失败: $e', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取主网络接口名称
|
||||
///
|
||||
/// 通过 netsh 命令查找处于"已连接"状态的主网络接口
|
||||
/// 返回:接口名称,失败返回 null
|
||||
Future<String?> _kr_getPrimaryNetworkInterface() async {
|
||||
try {
|
||||
// 使用 netsh 获取接口列表
|
||||
final result = await Process.run('netsh', ['interface', 'show', 'interface']);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
KRLogUtil.kr_e('❌ 获取网络接口失败: ${result.stderr}', tag: 'WindowsDNS');
|
||||
return null;
|
||||
}
|
||||
|
||||
final output = result.stdout.toString();
|
||||
final lines = output.split('\n');
|
||||
|
||||
// 收集所有已连接的接口
|
||||
final connectedInterfaces = <String>[];
|
||||
|
||||
// 查找所有"已连接"的接口
|
||||
// 支持中文和英文 Windows 系统
|
||||
for (var line in lines) {
|
||||
// 中文: "已连接", 英文: "Connected", "Enabled"
|
||||
if (line.contains('已连接') ||
|
||||
line.contains('Connected') ||
|
||||
line.toLowerCase().contains('enabled')) {
|
||||
// 跳过表头行
|
||||
if (line.contains('Admin') ||
|
||||
line.contains('管理') ||
|
||||
line.contains('State') ||
|
||||
line.contains('状态')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析接口名称(最后一列)
|
||||
final parts = line.trim().split(RegExp(r'\s{2,}'));
|
||||
if (parts.length >= 4) {
|
||||
final interfaceName = parts.last.trim();
|
||||
// 排除空接口名
|
||||
if (interfaceName.isNotEmpty && interfaceName.length > 1) {
|
||||
connectedInterfaces.add(interfaceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedInterfaces.isEmpty) {
|
||||
KRLogUtil.kr_w('⚠️ 未找到已连接的网络接口', tag: 'WindowsDNS');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🔧 优化:优先选择有线网络(以太网),然后才是 Wi-Fi
|
||||
// 有线网络通常更稳定
|
||||
String? selectedInterface;
|
||||
for (var interface in connectedInterfaces) {
|
||||
final lowerName = interface.toLowerCase();
|
||||
// 优先选择以太网
|
||||
if (lowerName.contains('ethernet') ||
|
||||
lowerName.contains('以太网') ||
|
||||
lowerName.contains('lan') ||
|
||||
lowerName.contains('local')) {
|
||||
selectedInterface = interface;
|
||||
KRLogUtil.kr_i('🔍 选择有线网络接口: $interface', tag: 'WindowsDNS');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有有线网络,选择第一个(通常是 Wi-Fi)
|
||||
selectedInterface ??= connectedInterfaces.first;
|
||||
if (selectedInterface != connectedInterfaces.first) {
|
||||
KRLogUtil.kr_d('🔍 选择网络接口: $selectedInterface', tag: 'WindowsDNS');
|
||||
} else {
|
||||
KRLogUtil.kr_i('🔍 选择网络接口: $selectedInterface', tag: 'WindowsDNS');
|
||||
}
|
||||
|
||||
return selectedInterface;
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_e('❌ 获取网络接口异常: $e', tag: 'WindowsDNS');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定接口的当前 DNS 服务器
|
||||
///
|
||||
/// 参数:
|
||||
/// - interfaceName: 网络接口名称
|
||||
///
|
||||
/// 返回:DNS 服务器列表
|
||||
Future<List<String>> _kr_getCurrentDnsServers(String interfaceName) async {
|
||||
try {
|
||||
final result = await Process.run('netsh', [
|
||||
'interface',
|
||||
'ipv4',
|
||||
'show',
|
||||
'dnsservers',
|
||||
'name="$interfaceName"',
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
KRLogUtil.kr_e('❌ 获取 DNS 失败: ${result.stderr}', tag: 'WindowsDNS');
|
||||
return [];
|
||||
}
|
||||
|
||||
final output = result.stdout.toString();
|
||||
final dnsServers = <String>[];
|
||||
|
||||
// 解析 DNS 服务器地址
|
||||
final lines = output.split('\n');
|
||||
for (var line in lines) {
|
||||
// 查找 IP 地址格式的行
|
||||
final ipMatch = RegExp(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b').firstMatch(line);
|
||||
if (ipMatch != null) {
|
||||
final ip = ipMatch.group(0)!;
|
||||
// 排除本地回环地址
|
||||
if (!ip.startsWith('127.')) {
|
||||
dnsServers.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KRLogUtil.kr_d('🔍 当前 DNS: ${dnsServers.join(", ")}', tag: 'WindowsDNS');
|
||||
return dnsServers;
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_e('❌ 获取 DNS 异常: $e', tag: 'WindowsDNS');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定接口的 DNS 服务器
|
||||
///
|
||||
/// 参数:
|
||||
/// - interfaceName: 网络接口名称
|
||||
/// - dnsServers: DNS 服务器列表
|
||||
///
|
||||
/// 返回:true-成功,false-失败
|
||||
Future<bool> _kr_setDnsServers(String interfaceName, List<String> dnsServers) async {
|
||||
try {
|
||||
if (dnsServers.isEmpty) {
|
||||
KRLogUtil.kr_w('⚠️ DNS 列表为空,无法设置', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 设置主 DNS
|
||||
KRLogUtil.kr_i('🔧 设置主 DNS: ${dnsServers[0]}', tag: 'WindowsDNS');
|
||||
var result = await Process.run('netsh', [
|
||||
'interface',
|
||||
'ipv4',
|
||||
'set',
|
||||
'dnsservers',
|
||||
'name="$interfaceName"',
|
||||
'source=static',
|
||||
'address=${dnsServers[0]}',
|
||||
'validate=no',
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
KRLogUtil.kr_e('❌ 设置主 DNS 失败: ${result.stderr}', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 设置备用 DNS(如果有)
|
||||
if (dnsServers.length > 1) {
|
||||
for (int i = 1; i < dnsServers.length; i++) {
|
||||
KRLogUtil.kr_i('🔧 设置备用 DNS ${i}: ${dnsServers[i]}', tag: 'WindowsDNS');
|
||||
result = await Process.run('netsh', [
|
||||
'interface',
|
||||
'ipv4',
|
||||
'add',
|
||||
'dnsservers',
|
||||
'name="$interfaceName"',
|
||||
'address=${dnsServers[i]}',
|
||||
'index=${i + 1}',
|
||||
'validate=no',
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
KRLogUtil.kr_w('⚠️ 设置备用 DNS $i 失败: ${result.stderr}', tag: 'WindowsDNS');
|
||||
// 继续设置下一个,不中断
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 刷新 DNS 缓存
|
||||
await _kr_flushDnsCache();
|
||||
|
||||
KRLogUtil.kr_i('✅ DNS 服务器设置完成', tag: 'WindowsDNS');
|
||||
return true;
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_e('❌ 设置 DNS 异常: $e', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 DNS 为自动获取(DHCP)
|
||||
///
|
||||
/// 参数:
|
||||
/// - interfaceName: 网络接口名称
|
||||
///
|
||||
/// 返回:true-成功,false-失败
|
||||
Future<bool> _kr_setDnsToAuto(String interfaceName) async {
|
||||
try {
|
||||
KRLogUtil.kr_i('🔧 设置 DNS 为自动获取 (DHCP)', tag: 'WindowsDNS');
|
||||
|
||||
final result = await Process.run('netsh', [
|
||||
'interface',
|
||||
'ipv4',
|
||||
'set',
|
||||
'dnsservers',
|
||||
'name="$interfaceName"',
|
||||
'source=dhcp',
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
KRLogUtil.kr_e('❌ 设置 DHCP 失败: ${result.stderr}', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 刷新 DNS 缓存
|
||||
await _kr_flushDnsCache();
|
||||
|
||||
KRLogUtil.kr_i('✅ DNS 已设置为自动获取', tag: 'WindowsDNS');
|
||||
return true;
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_e('❌ 设置 DHCP 异常: $e', tag: 'WindowsDNS');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新 DNS 缓存
|
||||
///
|
||||
/// 执行 ipconfig /flushdns 命令清空 DNS 解析缓存
|
||||
Future<void> _kr_flushDnsCache() async {
|
||||
try {
|
||||
KRLogUtil.kr_i('🔄 刷新 DNS 缓存...', tag: 'WindowsDNS');
|
||||
|
||||
final result = await Process.run('ipconfig', ['/flushdns']);
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
KRLogUtil.kr_i('✅ DNS 缓存已刷新', tag: 'WindowsDNS');
|
||||
} else {
|
||||
KRLogUtil.kr_w('⚠️ 刷新 DNS 缓存失败: ${result.stderr}', tag: 'WindowsDNS');
|
||||
}
|
||||
} catch (e) {
|
||||
KRLogUtil.kr_w('⚠️ 刷新 DNS 缓存异常: $e', tag: 'WindowsDNS');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除备份数据
|
||||
///
|
||||
/// 在应用退出或不需要时调用
|
||||
void kr_clearBackup() {
|
||||
_originalDnsServers = null;
|
||||
_primaryInterfaceName = null;
|
||||
KRLogUtil.kr_d('🗑️ 已清除 DNS 备份数据', tag: 'WindowsDNS');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user