aaf934c2d1
Rework the application layer end to end: core IPC client, providers, models, database, managers, and shared utilities, with riverpod 3.4.2 and freezed 4.0.1. Keep a rejected profile selected, push the empty config to Core when a profile fails to build, and fall back to the default activity icon for package icons. Add opt-in proxy authentication that injects credentials into the generated profile and the runtime config, force-clears profile-provided skip-auth-prefixes so loopback stays covered, sends the credentials with the app's own proxied requests, and withholds the Android VPN system proxy while credentials are set. HelperCoreLease treats an unreachable Helper as confirmation once the OS reports the Core pid gone, and the lifecycle reconcile retries a parked stop, so the Core comes back after the Helper service dies. Launch arguments seed LinkManager so a clash:// link raises a silently started window, the access control app list is fetched on every page entry, the desktop header matches a standard title bar height, and the tray icons ship as resolution-aware assets scoped per platform. The version moves to 0.8.97+2026090101. Rule.parse mirrors mihomo's ParseRulePayload: the type is case-insensitive, MATCH rules keep no content field, a two-field rule is payload without a target except for comma-payload types whose lone field is the target, params are matched whole, and the wildcard and REMATCH-NAME types the core accepts get their own actions instead of collapsing into DOMAIN. Window routes show, hide and toggle through one serialized queue and defers the macOS accessory activation switch until a second after the last regular switch, so hotkey bursts no longer leave stray Dock icons. The hotkey manager registers through rust_api instead of hotkey_manager, the window header restyles the caption buttons after the native Windows title bar, and the proxies tab keeps its tab controller alive while the empty state animates in. Bootstrap reads the Android dynamic palette from the plugin channel without the deprecated CorePalette, dynamic_color moves to its material_ui release, navigator_resizable 3.1.0 fixes an assertion when popping mid-transition, reorderable_grid goes, and the SDK floor rises to 3.10. Changelog: Rework the app layer and window handling, and add proxy authentication
323 lines
9.0 KiB
Dart
323 lines
9.0 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:fl_clash/common/string.dart';
|
|
|
|
final class DAVException implements Exception {
|
|
final String method;
|
|
final String path;
|
|
final int? statusCode;
|
|
final String? reasonPhrase;
|
|
|
|
const DAVException({
|
|
required this.method,
|
|
required this.path,
|
|
this.statusCode,
|
|
this.reasonPhrase,
|
|
});
|
|
|
|
@override
|
|
String toString() {
|
|
final reason = reasonPhrase?.trim();
|
|
final detail = [
|
|
if (statusCode != null) '$statusCode',
|
|
if (reason != null && reason.isNotEmpty) reason,
|
|
].join(' ');
|
|
return 'WebDAV $method $path failed: ${detail.isEmpty ? 'no response' : detail}';
|
|
}
|
|
}
|
|
|
|
/// Requests go through `Dio`, so they inherit the app-wide HttpOverrides: the
|
|
/// user's proxy selection and the TLS certificate setting apply here too.
|
|
class DAVTransport {
|
|
DAVTransport({
|
|
required String uri,
|
|
required this.user,
|
|
required this.password,
|
|
Dio? dio,
|
|
}) : _base = Uri.parse(uri.trim()),
|
|
_dio = dio ?? Dio() {
|
|
_dio.options
|
|
..connectTimeout = const Duration(seconds: 8)
|
|
..sendTimeout = const Duration(seconds: 60)
|
|
..receiveTimeout = const Duration(seconds: 60);
|
|
}
|
|
|
|
static const _maxRedirects = 5;
|
|
|
|
final String user;
|
|
final String password;
|
|
final Uri _base;
|
|
final Dio _dio;
|
|
|
|
_DigestChallenge? _digest;
|
|
int _digestNonceCount = 0;
|
|
|
|
Uri resolve(String path, {bool collection = false}) {
|
|
final segments = [
|
|
..._base.pathSegments.where((segment) => segment.isNotEmpty),
|
|
...path.split('/').where((segment) => segment.isNotEmpty),
|
|
];
|
|
return Uri(
|
|
scheme: _base.scheme,
|
|
host: _base.host,
|
|
port: _base.hasPort ? _base.port : null,
|
|
pathSegments: collection ? [...segments, ''] : segments,
|
|
);
|
|
}
|
|
|
|
Future<void> options(String path) async {
|
|
await _send('OPTIONS', path, collection: true, accept: const {200, 204});
|
|
}
|
|
|
|
/// 405 means the collection is already there, which is the common case: the
|
|
/// backup directory only has to be created once per account.
|
|
Future<void> mkcol(String path) async {
|
|
await _send(
|
|
'MKCOL',
|
|
path,
|
|
collection: true,
|
|
accept: const {200, 201, 204, 405},
|
|
);
|
|
}
|
|
|
|
Future<void> put(String path, List<int> bytes) async {
|
|
await _send(
|
|
'PUT',
|
|
path,
|
|
body: bytes,
|
|
headers: {
|
|
Headers.contentTypeHeader: 'application/octet-stream',
|
|
Headers.contentLengthHeader: bytes.length,
|
|
},
|
|
accept: const {200, 201, 204},
|
|
);
|
|
}
|
|
|
|
Future<Uint8List> get(String path) async {
|
|
final response = await _send(
|
|
'GET',
|
|
path,
|
|
responseType: ResponseType.bytes,
|
|
accept: const {200},
|
|
);
|
|
final data = response.data;
|
|
return data is List<int> ? Uint8List.fromList(data) : Uint8List(0);
|
|
}
|
|
|
|
Future<Response<dynamic>> _send(
|
|
String method,
|
|
String path, {
|
|
bool collection = false,
|
|
List<int>? body,
|
|
Map<String, dynamic> headers = const {},
|
|
ResponseType responseType = ResponseType.plain,
|
|
required Set<int> accept,
|
|
}) async {
|
|
final origin = resolve(path, collection: collection);
|
|
var uri = origin;
|
|
var retriedAuth = false;
|
|
for (var redirects = 0; ; redirects++) {
|
|
final response = await _request(
|
|
method,
|
|
uri,
|
|
origin: origin,
|
|
body: body,
|
|
headers: headers,
|
|
responseType: responseType,
|
|
);
|
|
final status = response.statusCode;
|
|
if (status != null && accept.contains(status)) {
|
|
return response;
|
|
}
|
|
if (status == 401 && !retriedAuth && _adoptChallenge(response)) {
|
|
retriedAuth = true;
|
|
continue;
|
|
}
|
|
final location = _redirectTarget(response);
|
|
if (location != null && redirects < _maxRedirects) {
|
|
uri = uri.resolveUri(location);
|
|
continue;
|
|
}
|
|
throw DAVException(
|
|
method: method,
|
|
path: path,
|
|
statusCode: status,
|
|
reasonPhrase: response.statusMessage,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<Response<dynamic>> _request(
|
|
String method,
|
|
Uri uri, {
|
|
required Uri origin,
|
|
List<int>? body,
|
|
required Map<String, dynamic> headers,
|
|
required ResponseType responseType,
|
|
}) {
|
|
return _dio.requestUri<dynamic>(
|
|
uri,
|
|
data: body,
|
|
options: Options(
|
|
method: method,
|
|
headers: {...headers, ..._authorization(method, uri, origin)},
|
|
responseType: responseType,
|
|
// Redirects are followed by hand so that a PUT stays a PUT, and every
|
|
// status reaches the caller instead of becoming a DioException.
|
|
followRedirects: false,
|
|
maxRedirects: 0,
|
|
validateStatus: (_) => true,
|
|
),
|
|
);
|
|
}
|
|
|
|
Uri? _redirectTarget(Response<dynamic> response) {
|
|
const redirects = {301, 302, 303, 307, 308};
|
|
if (!redirects.contains(response.statusCode)) {
|
|
return null;
|
|
}
|
|
final location = response.headers.value('location');
|
|
if (location == null || location.isEmpty) {
|
|
return null;
|
|
}
|
|
return Uri.tryParse(location);
|
|
}
|
|
|
|
bool _adoptChallenge(Response<dynamic> response) {
|
|
// Basic and Digest may each arrive as their own www-authenticate line.
|
|
final headerValues = response.headers[Headers.wwwAuthenticateHeader];
|
|
if (headerValues == null) {
|
|
return false;
|
|
}
|
|
for (final header in headerValues) {
|
|
final challenge = _DigestChallenge.parse(header);
|
|
if (challenge != null) {
|
|
_digest = challenge;
|
|
_digestNonceCount = 0;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool _sameOrigin(Uri a, Uri b) {
|
|
return a.scheme == b.scheme && a.host == b.host && _port(a) == _port(b);
|
|
}
|
|
|
|
static int _port(Uri uri) {
|
|
if (uri.hasPort) return uri.port;
|
|
return uri.scheme == 'https' ? 443 : 80;
|
|
}
|
|
|
|
Map<String, dynamic> _authorization(String method, Uri uri, Uri origin) {
|
|
// Never forward credentials to a redirect target on another origin.
|
|
if (!_sameOrigin(uri, origin)) {
|
|
return const {};
|
|
}
|
|
if (user.isEmpty && password.isEmpty) {
|
|
return const {};
|
|
}
|
|
final digest = _digest;
|
|
if (digest == null) {
|
|
final credentials = base64.encode(utf8.encode('$user:$password'));
|
|
return {'authorization': 'Basic $credentials'};
|
|
}
|
|
return {
|
|
'authorization': digest.authorize(
|
|
user: user,
|
|
password: password,
|
|
method: method,
|
|
uri: uri,
|
|
nonceCount: ++_digestNonceCount,
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
class _DigestChallenge {
|
|
const _DigestChallenge({
|
|
required this.realm,
|
|
required this.nonce,
|
|
this.qop,
|
|
this.opaque,
|
|
this.algorithm,
|
|
});
|
|
|
|
final String realm;
|
|
final String nonce;
|
|
final String? qop;
|
|
final String? opaque;
|
|
final String? algorithm;
|
|
|
|
static _DigestChallenge? parse(String? header) {
|
|
if (header == null) return null;
|
|
final start = header.toLowerCase().indexOf('digest');
|
|
if (start == -1) return null;
|
|
final values = <String, String>{};
|
|
final pattern = RegExp(r'(\w+)\s*=\s*(?:"([^"]*)"|([^\s,]+))');
|
|
for (final match in pattern.allMatches(header.substring(start + 6))) {
|
|
values[match.group(1)!.toLowerCase()] = match.group(2) ?? match.group(3)!;
|
|
}
|
|
final nonce = values['nonce'];
|
|
if (nonce == null) return null;
|
|
final algorithm = values['algorithm'];
|
|
// Only MD5 is implemented; anything else would produce a wrong response
|
|
// hash, and Basic at least has a chance of being accepted.
|
|
if (algorithm != null && algorithm.toUpperCase() != 'MD5') {
|
|
return null;
|
|
}
|
|
return _DigestChallenge(
|
|
realm: values['realm'] ?? '',
|
|
nonce: nonce,
|
|
qop: _pickQop(values['qop']),
|
|
opaque: values['opaque'],
|
|
algorithm: algorithm,
|
|
);
|
|
}
|
|
|
|
static String? _pickQop(String? raw) {
|
|
if (raw == null) return null;
|
|
final options = raw.split(',').map((item) => item.trim().toLowerCase());
|
|
return options.contains('auth') ? 'auth' : null;
|
|
}
|
|
|
|
String authorize({
|
|
required String user,
|
|
required String password,
|
|
required String method,
|
|
required Uri uri,
|
|
required int nonceCount,
|
|
}) {
|
|
final path = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
|
|
final ha1 = '$user:$realm:$password'.toMd5();
|
|
final ha2 = '$method:$path'.toMd5();
|
|
final count = nonceCount.toRadixString(16).padLeft(8, '0');
|
|
final cnonce = _cnonce();
|
|
final response = qop == null
|
|
? '$ha1:$nonce:$ha2'.toMd5()
|
|
: '$ha1:$nonce:$count:$cnonce:$qop:$ha2'.toMd5();
|
|
final parts = [
|
|
'username="$user"',
|
|
'realm="$realm"',
|
|
'nonce="$nonce"',
|
|
'uri="$path"',
|
|
'response="$response"',
|
|
if (algorithm != null) 'algorithm=$algorithm',
|
|
if (opaque != null) 'opaque="$opaque"',
|
|
if (qop != null) 'qop=$qop',
|
|
if (qop != null) 'nc=$count',
|
|
if (qop != null) 'cnonce="$cnonce"',
|
|
];
|
|
return 'Digest ${parts.join(', ')}';
|
|
}
|
|
|
|
static String _cnonce() {
|
|
final random = Random.secure();
|
|
final bytes = List<int>.generate(8, (_) => random.nextInt(256));
|
|
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
|
}
|
|
}
|