mirror of
https://github.com/DefinedNet/mobile_nebula.git
synced 2025-09-07 19:46:06 +00:00
Compare commits
10 commits
0d4eed552b
...
8c799cbda8
Author | SHA1 | Date | |
---|---|---|---|
|
8c799cbda8 | ||
|
fd991b9a02 | ||
|
451aa6ece7 | ||
|
cfc89bf92e | ||
|
bab0c70e24 | ||
|
a0024e4fe8 | ||
|
18e67ed4e0 | ||
|
95fa82662e | ||
|
c3a52697f1 | ||
|
b9d7d29bdb |
14 changed files with 143 additions and 142 deletions
|
@ -23,7 +23,7 @@ extension AppMessageError: LocalizedError {
|
|||
}
|
||||
}
|
||||
|
||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||
private var networkMonitor: NWPathMonitor?
|
||||
|
||||
private var site: Site?
|
||||
|
|
|
@ -6,7 +6,7 @@ import os.log
|
|||
let log = Logger(subsystem: "net.defined.mobileNebula", category: "Site")
|
||||
|
||||
enum SiteError: Error {
|
||||
case nonConforming(site: [String: Any]?)
|
||||
case nonConforming(site: String)
|
||||
case noCertificate
|
||||
case keyLoad
|
||||
case keySave
|
||||
|
@ -22,7 +22,7 @@ extension SiteError: CustomStringConvertible {
|
|||
public var description: String {
|
||||
switch self {
|
||||
case .nonConforming(let site):
|
||||
return String("Non-conforming site \(String(describing: site))")
|
||||
return String("Non-conforming site \(site)")
|
||||
case .noCertificate:
|
||||
return "No certificate found"
|
||||
case .keyLoad:
|
||||
|
@ -150,7 +150,7 @@ let statusString: [NEVPNStatus: String] = [
|
|||
]
|
||||
|
||||
// Represents a site that was pulled out of the system configuration
|
||||
class Site: Codable {
|
||||
class Site: Codable, @unchecked Sendable {
|
||||
// Stored in manager
|
||||
var name: String
|
||||
var id: String
|
||||
|
@ -208,7 +208,7 @@ class Site: Codable {
|
|||
|
||||
let id = dict?["id"] as? String ?? nil
|
||||
if id == nil {
|
||||
throw SiteError.nonConforming(site: dict)
|
||||
throw SiteError.nonConforming(site: String(describing: dict))
|
||||
}
|
||||
|
||||
try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false))
|
||||
|
|
|
@ -563,6 +563,7 @@
|
|||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES;
|
||||
SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES;
|
||||
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
|
||||
|
@ -574,7 +575,7 @@
|
|||
SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES;
|
||||
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
|
||||
SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
|
@ -784,6 +785,7 @@
|
|||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES;
|
||||
SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES;
|
||||
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
|
||||
|
@ -795,7 +797,7 @@
|
|||
SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES;
|
||||
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
|
||||
SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
|
@ -849,6 +851,7 @@
|
|||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES;
|
||||
SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES;
|
||||
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
|
||||
|
@ -860,7 +863,7 @@
|
|||
SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES;
|
||||
SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES;
|
||||
SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import MobileNebula
|
||||
@preconcurrency import MobileNebula
|
||||
|
||||
enum APIClientError: Error {
|
||||
case invalidCredentials
|
||||
}
|
||||
|
||||
class APIClient {
|
||||
struct APIClient: Sendable {
|
||||
let apiClient: MobileNebulaAPIClient
|
||||
let json = JSONDecoder()
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Flutter
|
||||
@preconcurrency import Flutter
|
||||
import MobileNebula
|
||||
import NetworkExtension
|
||||
import SwiftyJSON
|
||||
|
@ -25,17 +25,19 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
|||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
dnUpdater.updateAllLoop { site in
|
||||
// Signal the site has changed in case the current site details screen is active
|
||||
let container = self.sites?.getContainer(id: site.id)
|
||||
if container != nil {
|
||||
// Update references to the site with the new site config
|
||||
container!.site = site
|
||||
container!.updater.update(connected: site.connected ?? false, replaceSite: site)
|
||||
}
|
||||
Task {
|
||||
for await site in dnUpdater.siteUpdates {
|
||||
// Signal the site has changed in case the current site details screen is active
|
||||
let container = self.sites?.getContainer(id: site.id)
|
||||
if container != nil {
|
||||
// Update references to the site with the new site config
|
||||
container!.site = site
|
||||
container!.updater.update(connected: site.connected ?? false, replaceSite: site)
|
||||
}
|
||||
|
||||
// Signal to the main screen to reload
|
||||
self.ui?.invokeMethod("refreshSites", arguments: nil)
|
||||
// Signal to the main screen to reload
|
||||
self.ui?.invokeMethod("refreshSites", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
guard let controller = window?.rootViewController as? FlutterViewController else {
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import Foundation
|
||||
import os.log
|
||||
|
||||
class DNUpdater {
|
||||
class DNUpdater: @unchecked Sendable {
|
||||
private let apiClient = APIClient()
|
||||
private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes
|
||||
private let log = Logger(subsystem: "net.defined.mobileNebula", category: "DNUpdater")
|
||||
|
||||
func updateAll(onUpdate: @escaping (Site) -> Void) {
|
||||
func updateAll(onUpdate: @Sendable @escaping (Site) -> Void) {
|
||||
_ = SiteList { (sites, _) -> Void in
|
||||
// NEVPN seems to force us onto the main thread and we are about to make network calls that
|
||||
// could block for a while. Push ourselves onto another thread to avoid blocking the UI.
|
||||
|
@ -23,21 +23,33 @@ class DNUpdater {
|
|||
}
|
||||
}
|
||||
|
||||
func updateAllLoop(onUpdate: @escaping (Site) -> Void) {
|
||||
func updateAllLoop(onUpdate: @Sendable @escaping (Site) -> Void) {
|
||||
timer.eventHandler = {
|
||||
self.updateAll(onUpdate: onUpdate)
|
||||
}
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> Void) {
|
||||
// Site updates provides an async/await alternative to `.updateAllLoop` that doesn't require a sendable closure.
|
||||
// https://developer.apple.com/documentation/swift/asyncstream
|
||||
var siteUpdates: AsyncStream<Site> {
|
||||
AsyncStream { continuation in
|
||||
self.updateAllLoop(onUpdate: { site in
|
||||
continuation.yield(site)
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func updateSingleLoop(site: Site, onUpdate: @Sendable @escaping (Site) -> Void) {
|
||||
timer.eventHandler = {
|
||||
self.updateSite(site: site, onUpdate: onUpdate)
|
||||
}
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
func updateSite(site: Site, onUpdate: @escaping (Site) -> Void) {
|
||||
func updateSite(site: Site, onUpdate: @Sendable @escaping (Site) -> Void) {
|
||||
do {
|
||||
if !site.managed {
|
||||
return
|
||||
|
|
|
@ -78,14 +78,14 @@ class Sites {
|
|||
}
|
||||
}
|
||||
|
||||
class SiteUpdater: NSObject, FlutterStreamHandler {
|
||||
class SiteUpdater: NSObject, FlutterStreamHandler, @unchecked Sendable {
|
||||
private var eventSink: FlutterEventSink?
|
||||
private var eventChannel: FlutterEventChannel
|
||||
private var site: Site
|
||||
private var notification: Any?
|
||||
public var startFunc: (() -> Void)?
|
||||
private var configFd: Int32? = nil
|
||||
private var configObserver: (any DispatchSourceFileSystemObject)? = nil
|
||||
private var configFd: Int32?
|
||||
private var configObserver: (any DispatchSourceFileSystemObject)?
|
||||
|
||||
init(messenger: any FlutterBinaryMessenger, site: Site) {
|
||||
do {
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||
import 'package:mobile_nebula/models/CIDR.dart';
|
||||
|
||||
import '../services/utils.dart';
|
||||
import 'IPField.dart';
|
||||
|
||||
|
@ -52,57 +53,55 @@ class _CIDRFieldState extends State<CIDRField> {
|
|||
Widget build(BuildContext context) {
|
||||
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
|
||||
|
||||
return Container(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
|
||||
child: IPField(
|
||||
help: widget.ipHelp,
|
||||
ipOnly: true,
|
||||
textPadding: EdgeInsets.all(0),
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlign: TextAlign.end,
|
||||
focusNode: widget.focusNode,
|
||||
nextFocusNode: bitsFocus,
|
||||
onChanged: (val) {
|
||||
if (widget.onChanged == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
cidr.ip = val;
|
||||
widget.onChanged!(cidr);
|
||||
},
|
||||
controller: widget.ipController,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text("/"),
|
||||
Container(
|
||||
width: Utils.textSize("bits", textStyle).width + 12,
|
||||
padding: EdgeInsets.fromLTRB(2, 6, 6, 6),
|
||||
child: SpecialTextField(
|
||||
keyboardType: TextInputType.number,
|
||||
focusNode: bitsFocus,
|
||||
nextFocusNode: widget.nextFocusNode,
|
||||
controller: widget.bitsController,
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
|
||||
child: IPField(
|
||||
help: widget.ipHelp,
|
||||
ipOnly: true,
|
||||
textPadding: EdgeInsets.all(0),
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlign: TextAlign.end,
|
||||
focusNode: widget.focusNode,
|
||||
nextFocusNode: bitsFocus,
|
||||
onChanged: (val) {
|
||||
if (widget.onChanged == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
cidr.bits = int.tryParse(val) ?? 0;
|
||||
cidr.ip = val;
|
||||
widget.onChanged!(cidr);
|
||||
},
|
||||
maxLength: 2,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: widget.textInputAction ?? TextInputAction.done,
|
||||
placeholder: 'bits',
|
||||
controller: widget.ipController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text("/"),
|
||||
Container(
|
||||
width: Utils.textSize("bits", textStyle).width + 12,
|
||||
padding: EdgeInsets.fromLTRB(2, 6, 6, 6),
|
||||
child: SpecialTextField(
|
||||
keyboardType: TextInputType.number,
|
||||
focusNode: bitsFocus,
|
||||
nextFocusNode: widget.nextFocusNode,
|
||||
controller: widget.bitsController,
|
||||
onChanged: (val) {
|
||||
if (widget.onChanged == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
cidr.bits = int.tryParse(val) ?? 0;
|
||||
widget.onChanged!(cidr);
|
||||
},
|
||||
maxLength: 2,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: widget.textInputAction ?? TextInputAction.done,
|
||||
placeholder: 'bits',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||
import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||
|
||||
import '../services/utils.dart';
|
||||
import 'IPField.dart';
|
||||
|
||||
|
@ -58,49 +59,47 @@ class _IPAndPortFieldState extends State<IPAndPortField> {
|
|||
Widget build(BuildContext context) {
|
||||
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
|
||||
|
||||
return Container(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
|
||||
child: IPField(
|
||||
help: widget.ipHelp,
|
||||
ipOnly: widget.ipOnly,
|
||||
nextFocusNode: _portFocus,
|
||||
textPadding: EdgeInsets.all(0),
|
||||
textInputAction: TextInputAction.next,
|
||||
focusNode: widget.focusNode,
|
||||
onChanged: (val) {
|
||||
_ipAndPort.ip = val;
|
||||
widget.onChanged(_ipAndPort);
|
||||
},
|
||||
textAlign: widget.ipTextAlign,
|
||||
controller: widget.ipController,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(":"),
|
||||
Container(
|
||||
width: Utils.textSize("00000", textStyle).width + 12,
|
||||
padding: EdgeInsets.fromLTRB(2, 6, 6, 6),
|
||||
child: SpecialTextField(
|
||||
keyboardType: TextInputType.number,
|
||||
focusNode: _portFocus,
|
||||
nextFocusNode: widget.nextFocusNode,
|
||||
controller: widget.portController,
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
|
||||
child: IPField(
|
||||
help: widget.ipHelp,
|
||||
ipOnly: widget.ipOnly,
|
||||
nextFocusNode: _portFocus,
|
||||
textPadding: EdgeInsets.all(0),
|
||||
textInputAction: TextInputAction.next,
|
||||
focusNode: widget.focusNode,
|
||||
onChanged: (val) {
|
||||
_ipAndPort.port = int.tryParse(val);
|
||||
_ipAndPort.ip = val;
|
||||
widget.onChanged(_ipAndPort);
|
||||
},
|
||||
maxLength: 5,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: TextInputAction.done,
|
||||
placeholder: 'port',
|
||||
textAlign: widget.ipTextAlign,
|
||||
controller: widget.ipController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(":"),
|
||||
Container(
|
||||
width: Utils.textSize("00000", textStyle).width + 12,
|
||||
padding: EdgeInsets.fromLTRB(2, 6, 6, 6),
|
||||
child: SpecialTextField(
|
||||
keyboardType: TextInputType.number,
|
||||
focusNode: _portFocus,
|
||||
nextFocusNode: widget.nextFocusNode,
|
||||
controller: widget.portController,
|
||||
onChanged: (val) {
|
||||
_ipAndPort.port = int.tryParse(val);
|
||||
widget.onChanged(_ipAndPort);
|
||||
},
|
||||
maxLength: 5,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: TextInputAction.done,
|
||||
placeholder: 'port',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ class SiteItem extends StatelessWidget {
|
|||
const SiteItem({super.key, required this.site, this.onPressed});
|
||||
|
||||
final Site site;
|
||||
final onPressed;
|
||||
final void Function()? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
@ -8,7 +8,7 @@ class ConfigButtonItem extends StatelessWidget {
|
|||
const ConfigButtonItem({super.key, this.content, this.onPressed});
|
||||
|
||||
final Widget? content;
|
||||
final onPressed;
|
||||
final void Function()? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
@ -2,7 +2,6 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:mobile_nebula/components/SpecialButton.dart';
|
||||
import 'package:mobile_nebula/services/utils.dart';
|
||||
|
||||
|
@ -21,7 +20,7 @@ class ConfigPageItem extends StatelessWidget {
|
|||
final Widget? content;
|
||||
final double labelWidth;
|
||||
final CrossAxisAlignment crossAxisAlignment;
|
||||
final onPressed;
|
||||
final void Function()? onPressed;
|
||||
final bool disabled;
|
||||
|
||||
@override
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:mobile_nebula/models/HostInfo.dart';
|
||||
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'Certificate.dart';
|
||||
import 'StaticHosts.dart';
|
||||
|
||||
|
@ -53,45 +54,31 @@ class Site {
|
|||
late List<String> errors;
|
||||
|
||||
Site({
|
||||
String name = '',
|
||||
this.name = '',
|
||||
String? id,
|
||||
Map<String, StaticHost>? staticHostmap,
|
||||
List<CertificateInfo>? ca,
|
||||
CertificateInfo? certInfo,
|
||||
int lhDuration = 0,
|
||||
int port = 0,
|
||||
String cipher = "aes",
|
||||
int sortKey = 0,
|
||||
int mtu = 1300,
|
||||
bool connected = false,
|
||||
String status = '',
|
||||
String logFile = '',
|
||||
String logVerbosity = 'info',
|
||||
this.certInfo,
|
||||
this.lhDuration = 0,
|
||||
this.port = 0,
|
||||
this.cipher = "aes",
|
||||
this.sortKey = 0,
|
||||
this.mtu = 1300,
|
||||
this.connected = false,
|
||||
this.status = '',
|
||||
this.logFile = '',
|
||||
this.logVerbosity = 'info',
|
||||
List<String>? errors,
|
||||
List<UnsafeRoute>? unsafeRoutes,
|
||||
bool managed = false,
|
||||
String? rawConfig,
|
||||
DateTime? lastManagedUpdate,
|
||||
this.managed = false,
|
||||
this.rawConfig,
|
||||
this.lastManagedUpdate,
|
||||
}) {
|
||||
this.name = name;
|
||||
this.id = id ?? uuid.v4();
|
||||
this.staticHostmap = staticHostmap ?? {};
|
||||
this.ca = ca ?? [];
|
||||
this.certInfo = certInfo;
|
||||
this.lhDuration = lhDuration;
|
||||
this.port = port;
|
||||
this.cipher = cipher;
|
||||
this.sortKey = sortKey;
|
||||
this.mtu = mtu;
|
||||
this.connected = connected;
|
||||
this.status = status;
|
||||
this.logFile = logFile;
|
||||
this.logVerbosity = logVerbosity;
|
||||
this.errors = errors ?? [];
|
||||
this.unsafeRoutes = unsafeRoutes ?? [];
|
||||
this.managed = managed;
|
||||
this.rawConfig = rawConfig;
|
||||
this.lastManagedUpdate = lastManagedUpdate;
|
||||
|
||||
_updates = EventChannel('net.defined.nebula/${this.id}');
|
||||
_updates.receiveBroadcastStream().listen(
|
||||
|
|
|
@ -5,7 +5,7 @@ bool dnsValidator(str, {requireTld = true, allowUnderscore = false}) {
|
|||
return false;
|
||||
}
|
||||
|
||||
List parts = str.split('.');
|
||||
List<String> parts = str.split('.');
|
||||
if (requireTld) {
|
||||
var tld = parts.removeLast();
|
||||
if (parts.isEmpty || !RegExp(r'^[a-z]{2,}$').hasMatch(tld)) {
|
||||
|
|
Loading…
Add table
Reference in a new issue