import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/ui/color.dart'; import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'models.dart'; part 'generated/config.freezed.dart'; part 'generated/config.g.dart'; const defaultBypassDomain = [ '*zhihu.com', '*zhimg.com', '*jd.com', '100ime-iat-api.xfyun.cn', '*360buyimg.com', 'localhost', '*.local', '127.*', '10.*', '172.16.*', '172.17.*', '172.18.*', '172.19.*', '172.2*', '172.30.*', '172.31.*', '192.168.*', ]; const defaultUserAgents = ['clash-verge/v2.4.2', 'ClashforWindows/0.19.23']; const defaultFilters = [ Filter( label: 'Hong Kong', regex: r'(?i)🇭🇰|港|(? defaultHotKeyActions = system.isWindows ? const [] : [ _defaultHotKeyAction(HotAction.view, PhysicalKeyboardKey.keyV), _defaultHotKeyAction(HotAction.start, PhysicalKeyboardKey.keyS), _defaultHotKeyAction(HotAction.mode, PhysicalKeyboardKey.keyM), _defaultHotKeyAction(HotAction.proxy, PhysicalKeyboardKey.keyP), _defaultHotKeyAction(HotAction.delayTest, PhysicalKeyboardKey.keyD), ]; const List defaultDashboardWidgets = [ DashboardWidget.currentOutbound, DashboardWidget.networkSpeed, DashboardWidget.outboundMode, DashboardWidget.vpnButton, DashboardWidget.networkTakeover, DashboardWidget.intranetIp, DashboardWidget.serviceStatus, DashboardWidget.profiles, DashboardWidget.proxyGroups, DashboardWidget.connectionStats, ]; const _legacyDefaultDashboardWidgets = { 'networkSpeed', 'systemProxyButton', 'tunButton', 'outboundMode', 'networkDetection', 'trafficUsage', 'intranetIp', }; /// The run time has no replacement: it moved onto the current outbound card, /// as did the outbound IP, whose card became the intranet one. const _legacyDashboardWidgets = { 'networkDetection': DashboardWidget.intranetIp, 'outboundModeV2': DashboardWidget.outboundMode, 'systemProxyButton': DashboardWidget.networkTakeover, 'tunButton': DashboardWidget.networkTakeover, 'connections': DashboardWidget.connectionStats, 'requests': DashboardWidget.connectionStats, 'dnsQueries': DashboardWidget.connectionStats, 'trafficUsage': DashboardWidget.networkSpeed, }; const _retiredDashboardWidgets = { 'runTime', 'overrideDnsButton', 'overrideNtpButton', }; /// The current outbound card cannot be removed: a list without it predates it. List dashboardWidgetsFromJson( List? dashboardWidgets, ) { if (dashboardWidgets == null) { return defaultDashboardWidgets; } final byName = DashboardWidget.values.asNameMap(); if (dashboardWidgets.contains(DashboardWidget.currentOutbound.name)) { return dashboardWidgets .map((e) => _legacyDashboardWidgets[e] ?? byName[e]) .nonNulls .toSet() .toList(); } final names = { for (final name in dashboardWidgets) name == 'outboundModeV2' ? DashboardWidget.outboundMode.name : name, }; if (names.length == _legacyDefaultDashboardWidgets.length && names.containsAll(_legacyDefaultDashboardWidgets)) { return defaultDashboardWidgets; } final kept = dashboardWidgets .map((e) => _legacyDashboardWidgets[e] ?? byName[e]) .nonNulls; if (kept.isEmpty && !dashboardWidgets.every(_retiredDashboardWidgets.contains)) { return defaultDashboardWidgets; } return {DashboardWidget.currentOutbound, ...kept}.toList(); } Object? _readSidebarExpanded(Map json, String key) { return json.containsKey(key) ? json[key] : json['showLabel']; } Object? _readTabAnimation(Map json, String key) { if (json.containsKey(key)) { return json[key]; } return json['isAnimateToPage'] == false ? TabAnimation.fade.name : null; } Object? _readUserAgents(Map json, String key) { if (json.containsKey(key)) { return json[key]; } final legacy = json['customUserAgent']; if (legacy is! String) { return null; } final custom = legacy.trim(); if (custom.isEmpty || defaultUserAgents.contains(custom)) { return null; } return [...defaultUserAgents, custom]; } @freezed abstract class Filter with _$Filter { const factory Filter({required String label, required String regex}) = _Filter; factory Filter.fromJson(Map json) => _$FilterFromJson(json); } @freezed abstract class AppSettingProps with _$AppSettingProps { const factory AppSettingProps({ String? locale, @Default(defaultDashboardWidgets) @JsonKey(fromJson: dashboardWidgetsFromJson) List dashboardWidgets, @Default(false) bool onlyStatisticsProxy, @Default(true) bool showNotificationStopAction, @Default(false) bool autoLaunch, @Default(false) bool silentLaunch, @Default(false) bool autoRun, @Default(false) bool openLogs, @Default(true) bool closeConnections, @Default(defaultTestUrl) String testUrl, @Default(TabAnimation.slide) @JsonKey(readValue: _readTabAnimation) TabAnimation tabAnimation, @Default(true) bool floatingNavigationBar, @Default(true) bool autoCheckUpdate, @Default(true) @JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, @Default(0) int acceptedDisclaimerVersion, @Default(true) bool minimizeOnExit, @Default(false) bool hidden, @Default(false) bool developerMode, @Default(RestoreStrategy.compatible) RestoreStrategy restoreStrategy, @Default(true) bool showTrayTitle, @Default(true) bool checkCertificate, @Default(defaultUserAgents) @JsonKey(readValue: _readUserAgents) List userAgents, @Default(defaultFilters) List filters, @Default(false) bool hideIp, @Default(false) bool editorLineWrap, @Default(EditorFontSize.standard) EditorFontSize editorFontSize, @Default([]) List serviceOrder, @Default([]) List disabledServices, @Default({}) Map> tableColumnWidths, }) = _AppSettingProps; factory AppSettingProps.fromJson(Map json) => _$AppSettingPropsFromJson(json); factory AppSettingProps.safeFromJson(Map? json) { if (json == null) { return defaultAppSettingProps; } return decodeSalvaging('app settings', json, AppSettingProps.fromJson); } } @freezed abstract class AccessControlProps with _$AccessControlProps { const factory AccessControlProps({ @Default(false) bool enable, @Default(AccessControlMode.rejectSelected) AccessControlMode mode, @Default([]) List acceptList, @Default([]) List rejectList, @Default(AccessSortType.none) AccessSortType sort, @Default(true) bool isFilterSystemApp, @Default(true) bool isFilterNonInternetApp, }) = _AccessControlProps; factory AccessControlProps.fromJson(Map json) => _$AccessControlPropsFromJson(json); } extension AccessControlPropsExt on AccessControlProps { bool get hasPackages => acceptList.isNotEmpty || rejectList.isNotEmpty; List get currentList => switch (mode) { AccessControlMode.acceptSelected => acceptList, AccessControlMode.rejectSelected => rejectList, }; AccessControlProps copyWithNewList(List value) => switch (mode) { AccessControlMode.acceptSelected => copyWith(acceptList: value), AccessControlMode.rejectSelected => copyWith(rejectList: value), }; } @freezed abstract class WindowProps with _$WindowProps { const factory WindowProps({ @Default(0) double width, @Default(0) double height, double? top, double? left, double? scale, }) = _WindowProps; factory WindowProps.fromJson(Map? json) => json == null ? const WindowProps() : _$WindowPropsFromJson(json); } /// Four dashboard columns at full density beside the expanded sidebar, with /// room for the Windows and Linux title bar. const defaultWindowSize = Size(1280, 880); extension WindowPropsExt on WindowProps { Size? get savedSize { final size = Size(width, height); return size.isEmpty ? null : size; } Size get size => savedSize ?? defaultWindowSize; } @freezed abstract class VpnProps with _$VpnProps { const factory VpnProps({ @Default(true) bool enable, @Default(true) bool systemProxy, @Default(false) bool ipv6, @Default(true) bool allowBypass, @Default(false) bool dnsHijacking, @Default(defaultAccessControlProps) AccessControlProps accessControlProps, }) = _VpnProps; factory VpnProps.fromJson(Map? json) => json == null ? defaultVpnProps : _$VpnPropsFromJson(json); } @freezed abstract class AuthenticationProps with _$AuthenticationProps { const factory AuthenticationProps({ @Default(false) bool enable, @Default('') String username, @Default('') String password, }) = _AuthenticationProps; factory AuthenticationProps.fromJson(Map? json) => json == null ? defaultAuthenticationProps : _$AuthenticationPropsFromJson(json); } extension AuthenticationPropsExt on AuthenticationProps { List get credentials => enable && username.isNotEmpty ? ['$username:$password'] : []; } @freezed abstract class NetworkProps with _$NetworkProps { const factory NetworkProps({ @Default(true) bool systemProxy, @Default(defaultBypassDomain) List bypassDomain, @Default(true) bool bypassPrivateRoute, @Default(true) bool autoSetSystemDns, @Default(false) bool appendSystemDns, @Default(defaultAuthenticationProps) AuthenticationProps authentication, }) = _NetworkProps; factory NetworkProps.fromJson(Map? json) => json == null ? const NetworkProps() : _$NetworkPropsFromJson(json); } /// Reads the styles named `standard`, `icon` and `none` before they became /// [ProxiesIconStyle.filled], [ProxiesIconStyle.plain] and /// [ProxiesIconStyle.hidden]. ProxiesIconStyle proxiesIconStyleSafeFromJson(Object? iconStyle) { return switch (iconStyle) { 'filled' || 'standard' => ProxiesIconStyle.filled, 'plain' || 'icon' => ProxiesIconStyle.plain, 'hidden' || 'none' => ProxiesIconStyle.hidden, _ => ProxiesIconStyle.filled, }; } ProxyCardType proxyCardTypeSafeFromJson(Object? cardType) { return switch (cardType) { 'loose' || 'expand' => ProxyCardType.loose, 'standard' || 'shrink' => ProxyCardType.standard, 'compact' || 'min' => ProxyCardType.compact, _ => ProxyCardType.standard, }; } @freezed abstract class ProxiesStyleProps with _$ProxiesStyleProps { const factory ProxiesStyleProps({ @Default(ProxiesType.list) ProxiesType type, @Default(ProxiesSortType.none) ProxiesSortType sortType, @Default(ProxiesLayout.standard) ProxiesLayout layout, @Default(ProxiesIconStyle.filled) @JsonKey(fromJson: proxiesIconStyleSafeFromJson) ProxiesIconStyle iconStyle, @Default(ProxyCardType.standard) @JsonKey(fromJson: proxyCardTypeSafeFromJson) ProxyCardType cardType, @Default(false) bool hideTimeoutProxies, }) = _ProxiesStyleProps; factory ProxiesStyleProps.fromJson(Map? json) => json == null ? defaultProxiesStyleProps : _$ProxiesStylePropsFromJson(json); } @freezed abstract class TextScale with _$TextScale { const factory TextScale({ @Default(false) bool enable, @Default(1.0) double scale, }) = _TextScale; factory TextScale.fromJson(Map json) => _$TextScaleFromJson(json); } // Older configs name one of Material's nine scheme variants instead. Object? _readColorStyle(Map json, String key) { if (json.containsKey(key)) { return json[key]; } return switch (json['schemeVariant']) { 'monochrome' => ColorStyle.monochrome.name, 'tonalSpot' || 'neutral' => ColorStyle.soft.name, _ => null, }; } // Older configs say whether glass is on. Object? _readLiquidGlass(Map json, String key) { return switch (json[key]) { true => LiquidGlassMode.standard.name, false => LiquidGlassMode.none.name, final value => value, }; } @freezed abstract class ThemeProps with _$ThemeProps { const factory ThemeProps({ int? primaryColor, @Default(defaultPrimaryColors) List primaryColors, @Default(ThemeMode.system) ThemeMode themeMode, @Default(ColorStyle.vivid) @JsonKey(readValue: _readColorStyle) ColorStyle colorStyle, @Default(false) bool pureBlack, @Default(true) bool sidebarBlur, @Default(LiquidGlassMode.none) @JsonKey(readValue: _readLiquidGlass) LiquidGlassMode liquidGlass, @Default(TextScale()) TextScale textScale, // `fontFamily` held the names of a removed enum, which old configs may // still carry. @JsonKey(name: 'systemFontFamily') String? fontFamily, }) = _ThemeProps; factory ThemeProps.fromJson(Map json) => _$ThemePropsFromJson(json); factory ThemeProps.safeFromJson(Map? json) { if (json == null) { return defaultThemeProps; } return decodeSalvaging('theme settings', json, ThemeProps.fromJson); } } @freezed abstract class Config with _$Config { const factory Config({ int? currentProfileId, @Default([]) List hotKeyActions, @JsonKey(fromJson: AppSettingProps.safeFromJson) @Default(defaultAppSettingProps) AppSettingProps appSettingProps, DAVProps? davProps, @Default(defaultNetworkProps) NetworkProps networkProps, @Default(defaultVpnProps) VpnProps vpnProps, @JsonKey(fromJson: ThemeProps.safeFromJson) required ThemeProps themeProps, @Default(defaultProxiesStyleProps) ProxiesStyleProps proxiesStyleProps, // A new key opens every window once at defaultWindowSize. @Default(defaultWindowProps) @JsonKey(name: 'windowGeometry') WindowProps windowProps, @Default(defaultClashConfig) PatchClashConfig patchClashConfig, @Default([]) List excludeSSIDs, }) = _Config; factory Config.fromJson(Map json) => decodeSalvaging('config', json, Config.strictFromJson); factory Config.strictFromJson(Map json) => _withLegacyOverrideDns( _$ConfigFromJson(_withLegacyRouteMode(json)), json, ); factory Config.realFromJson(Map? json) { if (json == null) { return Config( themeProps: defaultThemeProps, hotKeyActions: defaultHotKeyActions, ); } return Config.fromJson(json); } } const _legacyOverrideDnsKey = 'overrideDns'; /// An off `overrideDns` left a profile's own DNS section alone. Config _withLegacyOverrideDns(Config config, Map json) { if (!json.containsKey(_legacyOverrideDnsKey)) { return config; } final keys = json[_legacyOverrideDnsKey] == true ? config.patchClashConfig.dnsOverrideKeys.intersection( DnsOverrideKey.normalProfileKeys, ) : {}; return config.copyWith.patchClashConfig(dnsOverrideKeys: keys); } Map _withLegacyRouteMode(Map json) { final network = json['networkProps']; if (network is! Map || !network.containsKey('routeMode') || network.containsKey('bypassPrivateRoute')) { return json; } final bypassPrivate = network['routeMode'] == 'bypassPrivate'; final clash = json['patchClashConfig']; final tun = clash is Map ? clash['tun'] : null; return { ...json, 'networkProps': { ...Map.from(network), 'bypassPrivateRoute': bypassPrivate, }, if (bypassPrivate && clash is Map && tun is Map) 'patchClashConfig': { ...Map.from(clash), 'tun': {...Map.from(tun), 'route-address': []}, }, }; }