Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2f678909d | |||
| 7fb4f4f711 | |||
| 903e2b8879 | |||
| 2df430ca31 |
@@ -21,6 +21,46 @@ Generated directories are excluded from analysis:
|
||||
- `lib/**/generated/**`
|
||||
- `plugins/**`
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are opt-in and reserved for the few places that genuinely need one. Density is the point: every comment that
|
||||
restates the code devalues the comments that carry real information, until readers skim past all of them. A file with
|
||||
three comments that matter is more readable than one with thirty.
|
||||
|
||||
### Writing Comments
|
||||
|
||||
- Never add a comment on your own initiative. This covers explanatory, narrative, TODO, section-divider, and
|
||||
documentation comments, in Dart, Kotlin, Swift, Go, Rust, YAML, Gradle, and any other file you touch.
|
||||
- Never annotate line by line or statement by statement, and never restate in prose what the code already says. If a
|
||||
block needs a comment per step, the block needs better names or a smaller decomposition instead.
|
||||
- When a change genuinely cannot be understood without a comment, do not write it silently. Explain what is unclear,
|
||||
propose the exact comment text, and wait for the user to approve it before adding it.
|
||||
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
|
||||
that contains them. This does not need approval.
|
||||
- These are not comments and must be preserved: analyzer and linter directives (`// ignore:`, `// ignore_for_file:`,
|
||||
`// coverage:ignore`), license and copyright headers, code-generation markers, and comments inside vendored upstream
|
||||
code such as `lib/widgets/open_container.dart`.
|
||||
|
||||
### Where Knowledge Belongs
|
||||
|
||||
Pick the destination by where the constraint would be violated, not by how important it feels.
|
||||
|
||||
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
|
||||
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
|
||||
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
|
||||
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
|
||||
that.
|
||||
- **A fact that is true only at one call site, and is not visible from that call site, stays a comment there.** Its
|
||||
value is being in the reader's line of sight at the moment of the edit. `lib/common/constant.dart` is the model case:
|
||||
the delay-test concurrency cap is bound to `mBatch` in `core/common.go`, and whoever changes that number must see the
|
||||
constraint on the same screen.
|
||||
|
||||
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
|
||||
leaving a repo-wide policy as a comment reaches only the reader of that one file.
|
||||
|
||||
Before any of the three, prefer encoding the intent in structure and naming — a named mixin, type, or method that makes
|
||||
the invariant hard to break beats prose that asks the next reader not to break it.
|
||||
|
||||
## Core API Safety
|
||||
|
||||
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
|
||||
|
||||
@@ -25,7 +25,11 @@ Read these only when the task touches their area:
|
||||
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
|
||||
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
|
||||
- Do not add code or configuration comments unless the user explicitly asks for comments. This includes explanatory,
|
||||
narrative, TODO, and documentation comments.
|
||||
narrative, TODO, and documentation comments. Never annotate line by line; comments belong only at the few key points
|
||||
that cannot be understood without one, and there you must propose the exact text and wait for approval. Delete
|
||||
commented-out code and stale notes whenever you touch the surrounding code. Put assertable behavior in a test,
|
||||
repository-wide invariants in `.agents/`, and keep a comment only for a fact that is local to one call site.
|
||||
See [.agents/rules.md](.agents/rules.md) for the full policy.
|
||||
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
- Run code generation after modifying models, providers, or database schema.
|
||||
- Do not manually edit generated files.
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
## v0.8.95
|
||||
|
||||
- Optimize core service
|
||||
|
||||
- Optimize Android TV launcher icon
|
||||
|
||||
- Optimize back navigation
|
||||
|
||||
- Optimize more details
|
||||
|
||||
- Fix some issues
|
||||
|
||||
- Optimize app layout
|
||||
|
||||
- Optimize focus control
|
||||
|
||||
- Adjust android process
|
||||
|
||||
## v0.8.94
|
||||
|
||||
- Fix macos performance issue
|
||||
|
||||
@@ -19,9 +19,4 @@ extension ArchiveExt on Archive {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// void addTextFile<T>(String name, T raw) {
|
||||
// final data = json.encode(raw);
|
||||
// addFile(ArchiveFile.string(name, data));
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ String _randomPipeId() {
|
||||
final defaultTextScaleFactor =
|
||||
WidgetsBinding.instance.platformDispatcher.textScaleFactor;
|
||||
const httpTimeoutDuration = Duration(milliseconds: 5000);
|
||||
|
||||
/// Keep at or below the Core's delay-test concurrency (`mBatch` in
|
||||
/// core/common.go). Surplus requests queue inside the Core behind a full wave
|
||||
/// of 5s timeouts, which no RPC timeout can cover.
|
||||
const maxConcurrentDelayTests = 50;
|
||||
const moreDuration = Duration(milliseconds: 100);
|
||||
const animateDuration = Duration(milliseconds: 100);
|
||||
const midDuration = Duration(milliseconds: 200);
|
||||
|
||||
@@ -15,21 +15,6 @@ class BaseNavigator {
|
||||
context,
|
||||
).push<T>(CommonRoute(builder: (context) => child));
|
||||
}
|
||||
|
||||
// static Future<T?> modal<T>(BuildContext context, Widget child) async {
|
||||
// if (globalState.appState.viewMode != ViewMode.mobile) {
|
||||
// return await globalState.showCommonDialog<T>(
|
||||
// child: CommonModal(
|
||||
// child: child,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// return await Navigator.of(context).push<T>(
|
||||
// CommonRoute(
|
||||
// builder: (context) => child,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
const commonSharedXPageTransitions = SharedAxisPageTransitionsBuilder(
|
||||
|
||||
@@ -114,54 +114,6 @@ class NextClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
}
|
||||
}
|
||||
|
||||
// class CacheScrollPositionController extends ScrollController {
|
||||
// final String key;
|
||||
//
|
||||
// CacheScrollPositionController({
|
||||
// required this.key,
|
||||
// double initialScrollOffset = 0.0,
|
||||
// super.keepScrollOffset = true,
|
||||
// super.debugLabel,
|
||||
// super.onAttach,
|
||||
// super.onDetach,
|
||||
// });
|
||||
//
|
||||
// @override
|
||||
// ScrollPosition createScrollPosition(
|
||||
// ScrollPhysics physics,
|
||||
// ScrollContext context,
|
||||
// ScrollPosition? oldPosition,
|
||||
// ) {
|
||||
// return ScrollPositionWithSingleContext(
|
||||
// physics: physics,
|
||||
// context: context,
|
||||
// initialPixels:
|
||||
// globalState.scrollPositionCache[key] ?? initialScrollOffset,
|
||||
// keepScrollOffset: keepScrollOffset,
|
||||
// oldPosition: oldPosition,
|
||||
// debugLabel: debugLabel,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// double? get cacheOffset => globalState.scrollPositionCache[key];
|
||||
//
|
||||
// _handleScroll() {
|
||||
// globalState.scrollPositionCache[key] = position.pixels;
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// void attach(ScrollPosition position) {
|
||||
// super.attach(position);
|
||||
// addListener(_handleScroll);
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// void detach(ScrollPosition position) {
|
||||
// removeListener(_handleScroll);
|
||||
// super.detach(position);
|
||||
// }
|
||||
// }
|
||||
|
||||
class ReverseScrollController extends ScrollController {
|
||||
ReverseScrollController({
|
||||
super.initialScrollOffset,
|
||||
|
||||
@@ -79,10 +79,6 @@ extension StringExtension on String {
|
||||
return md5.convert(bytes).toString();
|
||||
}
|
||||
|
||||
// bool containsToLower(String target) {
|
||||
// return toLowerCase().contains(target);
|
||||
// }
|
||||
|
||||
Future<T> commonToJSON<T>() async {
|
||||
const thresholdLimit = 51200;
|
||||
if (length < thresholdLimit) {
|
||||
|
||||
@@ -364,9 +364,6 @@ Future<MigrationData> _oldToNowTask(
|
||||
configMap['appSettingProps'] = appSettingProps;
|
||||
configMap['proxiesStyleProps'] = configMap['proxiesStyle'];
|
||||
configMap['proxiesStyleProps'] = configMap['proxiesStyle'];
|
||||
// final overwriteMap = configMap['overwrite'] as Map? ?? {};
|
||||
// configMap['overwriteType'] = overwriteMap['type'];
|
||||
// configMap['scriptId'] = overwriteMap['scriptOverwrite'];
|
||||
List rawScripts = configMap['scripts'] as List<dynamic>? ?? [];
|
||||
if (rawScripts.isEmpty) {
|
||||
final scriptPropsJson = configMap['scriptProps'] as Map<String, dynamic>?;
|
||||
|
||||
@@ -184,12 +184,6 @@ class Utils {
|
||||
return build1.compareTo(build2);
|
||||
}
|
||||
|
||||
// String getPinyin(String value) {
|
||||
// return value.isNotEmpty
|
||||
// ? PinyinHelper.getFirstWordPinyin(value.substring(0, 1))
|
||||
// : '';
|
||||
// }
|
||||
|
||||
String? getFileNameForDisposition(String? disposition) {
|
||||
if (disposition == null) return null;
|
||||
final parseValue = HeaderValue.parse(disposition);
|
||||
|
||||
@@ -27,7 +27,6 @@ class Window {
|
||||
protocol.register('flclash');
|
||||
}
|
||||
await windowManager.ensureInitialized();
|
||||
// kDebugMode ? Size(680, 580) :
|
||||
final WindowOptions windowOptions = WindowOptions(
|
||||
size: props.size,
|
||||
minimumSize: const Size(380, 400),
|
||||
|
||||
@@ -6,9 +6,18 @@ import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/event.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import 'transport.dart';
|
||||
|
||||
/// How many timeout windows a request may be extended across while Core keeps
|
||||
/// answering other requests, before it fails regardless.
|
||||
const _maxTimeoutExtension = 3;
|
||||
|
||||
const _maxExtensionGrace = Duration(seconds: 30);
|
||||
|
||||
const _connectTimeout = Duration(seconds: 10);
|
||||
|
||||
abstract interface class CoreRpcChannel {
|
||||
Future<T?> invoke<T>({
|
||||
required CoreMethod method,
|
||||
@@ -21,6 +30,7 @@ abstract interface class CoreRpcChannel {
|
||||
|
||||
final class CoreRpcClient implements CoreRpcChannel {
|
||||
final DesktopCoreTransport transport;
|
||||
final Duration extensionGrace;
|
||||
final Map<String, Completer<Object?>> _pending = {};
|
||||
late final StreamSubscription<Uint8List> _frameSubscription;
|
||||
late final StreamSubscription<DesktopTransportEvent> _eventSubscription;
|
||||
@@ -28,7 +38,17 @@ final class CoreRpcClient implements CoreRpcChannel {
|
||||
int _methodCallId = 0;
|
||||
Future<void>? _closeOperation;
|
||||
|
||||
CoreRpcClient(this.transport) {
|
||||
/// Time since Core last answered any request. A method timeout is a liveness
|
||||
/// guard for a stalled link (a restart mid-flight, a stream that stopped
|
||||
/// carrying data), not a budget for how long a single method may take — so a
|
||||
/// request that is still waiting while Core demonstrably keeps replying to
|
||||
/// its siblings gets its window re-armed instead of being failed.
|
||||
final Stopwatch _sinceLastResponse = Stopwatch()..start();
|
||||
|
||||
CoreRpcClient(
|
||||
this.transport, {
|
||||
@visibleForTesting this.extensionGrace = _maxExtensionGrace,
|
||||
}) {
|
||||
_frameSubscription = transport.frames.listen(
|
||||
_handleFrame,
|
||||
onError: _handleFrameError,
|
||||
@@ -57,23 +77,36 @@ final class CoreRpcClient implements CoreRpcChannel {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
await Future.any<Object?>([
|
||||
transport.waitUntilConnected(const Duration(seconds: 10)),
|
||||
transport.waitUntilConnected(_shorter(_connectTimeout, requestTimeout)),
|
||||
completer.future,
|
||||
]);
|
||||
if (completer.isCompleted) {
|
||||
return await completer.future as T?;
|
||||
}
|
||||
await transport.send(
|
||||
json.encode(
|
||||
CoreMethodCall(id: id, method: method, arguments: arguments),
|
||||
),
|
||||
);
|
||||
final remainingTimeout = requestTimeout - stopwatch.elapsed;
|
||||
if (remainingTimeout <= Duration.zero) {
|
||||
final sendTimeout = requestTimeout - stopwatch.elapsed;
|
||||
if (sendTimeout <= Duration.zero) {
|
||||
throw TimeoutException('Core method ${method.name} timed out');
|
||||
}
|
||||
return await completer.future.timeout(remainingTimeout) as T?;
|
||||
await transport
|
||||
.send(
|
||||
json.encode(
|
||||
CoreMethodCall(id: id, method: method, arguments: arguments),
|
||||
),
|
||||
)
|
||||
.timeout(sendTimeout);
|
||||
return await _awaitResponse(
|
||||
method: method,
|
||||
completer: completer,
|
||||
idleTimeout: requestTimeout,
|
||||
elapsed: stopwatch,
|
||||
)
|
||||
as T?;
|
||||
} on TimeoutException {
|
||||
commonPrint.log(
|
||||
'Core method ${method.name} timed out after '
|
||||
'${stopwatch.elapsedMilliseconds}ms',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
_removePending(id, completer);
|
||||
return null;
|
||||
} on CoreMethodException {
|
||||
@@ -91,6 +124,43 @@ final class CoreRpcClient implements CoreRpcChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for [completer], re-arming the window every time Core proves the
|
||||
/// link is still alive by answering some other request, so a slow method is
|
||||
/// not failed on behalf of a healthy link. Two things still end the wait:
|
||||
/// [idleTimeout] of silence, and a hard deadline set by whichever of
|
||||
/// [_maxTimeoutExtension] windows and [extensionGrace] on top binds first —
|
||||
/// a Core answering everything except this request has to surface as well.
|
||||
Future<Object?> _awaitResponse({
|
||||
required CoreMethod method,
|
||||
required Completer<Object?> completer,
|
||||
required Duration idleTimeout,
|
||||
required Stopwatch elapsed,
|
||||
}) async {
|
||||
final hardDeadline = _shorter(
|
||||
idleTimeout * _maxTimeoutExtension,
|
||||
idleTimeout + extensionGrace,
|
||||
);
|
||||
while (true) {
|
||||
// Silence is measured from the last response or from this request's own
|
||||
// start, whichever is later: an idle app must not shorten a fresh window.
|
||||
final silence = _shorter(_sinceLastResponse.elapsed, elapsed.elapsed);
|
||||
final remaining = _shorter(
|
||||
idleTimeout - silence,
|
||||
hardDeadline - elapsed.elapsed,
|
||||
);
|
||||
if (remaining <= Duration.zero) {
|
||||
throw TimeoutException('Core method ${method.name} timed out');
|
||||
}
|
||||
try {
|
||||
return await completer.future.timeout(remaining);
|
||||
} on TimeoutException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Duration _shorter(Duration a, Duration b) => a < b ? a : b;
|
||||
|
||||
void _removePending(String id, Completer<Object?> completer) {
|
||||
final removed = _pending.remove(id);
|
||||
if (identical(removed, completer) && !completer.isCompleted) {
|
||||
@@ -132,6 +202,11 @@ final class CoreRpcClient implements CoreRpcChannel {
|
||||
}
|
||||
|
||||
void _handleResponse(CoreMethodResponse response) {
|
||||
// Proof of life for every pending request, including a late response whose
|
||||
// own caller already gave up.
|
||||
_sinceLastResponse
|
||||
..reset()
|
||||
..start();
|
||||
final id = response.id;
|
||||
final completer = id == null ? null : _pending.remove(id);
|
||||
if (completer == null || completer.isCompleted) {
|
||||
|
||||
@@ -53,13 +53,6 @@ class Database extends _$Database {
|
||||
await _migrateRules(m);
|
||||
}
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
// final m = Migrator(this);
|
||||
// await m.createTable(iconRecords);
|
||||
// await _migrateRules(m);
|
||||
// await m.deleteTable('proxy_groups');
|
||||
// await m.createTable(proxyGroups);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
|
||||
|
||||
stmt.orderBy([
|
||||
(t) => OrderingTerm.asc(t.scene),
|
||||
//v0.8.92 ordering desc
|
||||
(t) => OrderingTerm.desc(t.order),
|
||||
(t) => OrderingTerm.desc(t.id),
|
||||
]);
|
||||
|
||||
+1
-17
@@ -146,10 +146,6 @@ enum RestoreOption { all, onlyProfiles }
|
||||
enum ChipType { action, delete }
|
||||
|
||||
enum CommonCardType { plain, filled }
|
||||
//
|
||||
// extension CommonCardTypeExt on CommonCardType {
|
||||
// CommonCardType get variant => CommonCardType.plain;
|
||||
// }
|
||||
|
||||
enum ProxiesType { tab, list }
|
||||
|
||||
@@ -438,12 +434,7 @@ extension RuleActionExt on RuleAction {
|
||||
|
||||
enum OverrideRuleType { override, added }
|
||||
|
||||
enum OverwriteType {
|
||||
// none,
|
||||
standard,
|
||||
script,
|
||||
custom,
|
||||
}
|
||||
enum OverwriteType { standard, script, custom }
|
||||
|
||||
enum RuleTarget {
|
||||
DIRECT,
|
||||
@@ -451,13 +442,6 @@ enum RuleTarget {
|
||||
|
||||
static Set<String> get baseTargets =>
|
||||
RuleTarget.values.map((item) => item.name).toSet();
|
||||
|
||||
// static bool isBaseRuleTarget(String? target) {
|
||||
// return RuleTarget.values.indexWhere(
|
||||
// (item) => item.name == target?.toUpperCase(),
|
||||
// ) !=
|
||||
// -1;
|
||||
// }
|
||||
}
|
||||
|
||||
enum RestoreStrategy { compatible, override }
|
||||
|
||||
@@ -140,36 +140,11 @@ class AppSidebarContainer extends ConsumerWidget {
|
||||
|
||||
const AppSidebarContainer({super.key, required this.child});
|
||||
|
||||
// Widget _buildLoading() {
|
||||
// return Consumer(
|
||||
// builder: (_, ref, _) {
|
||||
// final loading = ref.watch(loadingProvider);
|
||||
// final isMobileView = ref.watch(isMobileViewProvider);
|
||||
// return loading && !isMobileView
|
||||
// ? RotatedBox(
|
||||
// quarterTurns: 1,
|
||||
// child: const LinearProgressIndicator(),
|
||||
// )
|
||||
// : Container();
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
Widget _buildBackground({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
}) {
|
||||
return Material(color: context.colorScheme.surfaceContainer, child: child);
|
||||
// if (!system.isMacOS) {
|
||||
// return Material(
|
||||
// color: context.colorScheme.surfaceContainer,
|
||||
// child: child,
|
||||
// );
|
||||
// }
|
||||
// return child;
|
||||
// return TransparentMacOSSidebar(
|
||||
// child: Material(color: Colors.transparent, child: child),
|
||||
// );
|
||||
}
|
||||
|
||||
void _updateSideBarWidth(WidgetRef ref, double contentWidth) {
|
||||
|
||||
@@ -190,7 +190,6 @@ class StatusManagerState extends State<StatusManager> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// LoadingIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -198,64 +197,3 @@ class StatusManagerState extends State<StatusManager> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// class LoadingIndicator extends ConsumerWidget {
|
||||
// const LoadingIndicator({super.key});
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context, ref) {
|
||||
// final loading = ref.watch(loadingProvider);
|
||||
// final isMobileView = ref.watch(isMobileViewProvider);
|
||||
// return AnimatedSwitcher(
|
||||
// switchInCurve: Curves.easeIn,
|
||||
// switchOutCurve: Curves.easeOut,
|
||||
// duration: midDuration,
|
||||
// transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
// return SlideTransition(
|
||||
// position: Tween<Offset>(
|
||||
// begin: const Offset(1, 0),
|
||||
// end: Offset.zero,
|
||||
// ).animate(animation),
|
||||
// child: child,
|
||||
// );
|
||||
// },
|
||||
// child: loading && isMobileView
|
||||
// ? Container(
|
||||
// height: 54,
|
||||
// margin: EdgeInsets.only(top: 8, left: 14, right: 14),
|
||||
// child: Material(
|
||||
// elevation: 3,
|
||||
// color: context.colorScheme.surfaceContainer,
|
||||
// surfaceTintColor: context.colorScheme.surfaceTint,
|
||||
// shape: const RoundedSuperellipseBorder(
|
||||
// borderRadius: BorderRadius.all(Radius.circular(14)),
|
||||
// ),
|
||||
// child: Padding(
|
||||
// padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
// child: Row(
|
||||
// mainAxisSize: MainAxisSize.min,
|
||||
// spacing: 12,
|
||||
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
// children: [
|
||||
// Flexible(
|
||||
// child: Text(
|
||||
// context.appLocalizations.loading,
|
||||
// style: context.textTheme.labelLarge?.copyWith(
|
||||
// color: context.colorScheme.onSurfaceVariant,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// SizedBox(
|
||||
// height: 32,
|
||||
// width: 32,
|
||||
// child: CommonCircleLoading(),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// : SizedBox(),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -42,22 +42,6 @@ class ThemeManager extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// _buildScrollbar(Widget child) {
|
||||
// return Consumer(
|
||||
// builder: (_, ref, child) {
|
||||
// final isMobileView = ref.read(isMobileViewProvider);
|
||||
// if (isMobileView) {
|
||||
// return ScrollConfiguration(
|
||||
// behavior: HiddenBarScrollBehavior(),
|
||||
// child: child!,
|
||||
// );
|
||||
// }
|
||||
// return child!;
|
||||
// },
|
||||
// child: child,
|
||||
// );
|
||||
// }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final textScale = ref.read(
|
||||
|
||||
@@ -233,9 +233,6 @@ class _WindowHeaderState extends State<WindowHeader> {
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
// const SizedBox(
|
||||
// width: 8,
|
||||
// ),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -333,10 +333,6 @@ abstract class Rule with _$Rule {
|
||||
String? order,
|
||||
}) = _Rule;
|
||||
|
||||
// factory Rule.parseString(String? value) {
|
||||
// return Rule.parse(Rule.value(value ?? ''));
|
||||
// }
|
||||
|
||||
factory Rule.init() {
|
||||
return Rule(
|
||||
ruleAction: RuleAction.DOMAIN,
|
||||
@@ -442,39 +438,6 @@ extension RuleExt on Rule {
|
||||
}
|
||||
}
|
||||
|
||||
// @freezed
|
||||
// abstract class Rule with _$Rule {
|
||||
// const factory Rule({required int id, required String value, String? order}) =
|
||||
// _Rule;
|
||||
//
|
||||
// factory Rule.value(String value) {
|
||||
// return Rule(value: value, id: snowflake.id);
|
||||
// }
|
||||
//
|
||||
// factory Rule.fromJson(Map<String, Object?> json) => _$RuleFromJson(json);
|
||||
// }
|
||||
//
|
||||
// extension RulesExt on List<Rule> {
|
||||
// List<Rule> copyAndPut(Rule rule) {
|
||||
// var newList = List<Rule>.from(this);
|
||||
// final index = newList.indexWhere((item) => item.id == rule.id);
|
||||
// if (index != -1) {
|
||||
// rule = newList[index] = rule;
|
||||
// } else {
|
||||
// newList.insert(0, rule);
|
||||
// }
|
||||
// return newList;
|
||||
// }
|
||||
// }
|
||||
|
||||
// @freezed
|
||||
// abstract class SubRule with _$SubRule {
|
||||
// const factory SubRule({required String name}) = _SubRule;
|
||||
//
|
||||
// factory SubRule.fromJson(Map<String, Object?> json) =>
|
||||
// _$SubRuleFromJson(json);
|
||||
// }
|
||||
//
|
||||
List<Rule> _genRules(List<dynamic>? rules) {
|
||||
if (rules == null) {
|
||||
return [];
|
||||
@@ -482,14 +445,6 @@ List<Rule> _genRules(List<dynamic>? rules) {
|
||||
return rules.map((item) => Rule.parse(item)).toList();
|
||||
}
|
||||
|
||||
// List<RuleProvider> _genRuleProviders(Map<String, dynamic> json) {
|
||||
// return json.entries.map((entry) => RuleProvider(name: entry.key)).toList();
|
||||
// }
|
||||
//
|
||||
// List<SubRule> _genSubRules(Map<String, dynamic> json) {
|
||||
// return json.entries.map((entry) => SubRule(name: entry.key)).toList();
|
||||
// }
|
||||
|
||||
List<String> _genList(Map<String, dynamic> json) {
|
||||
return json.entries.map((entry) => entry.key).toList();
|
||||
}
|
||||
|
||||
+1
-10
@@ -140,25 +140,16 @@ String _logDateTime(dynamic _) {
|
||||
return DateTime.now().showFull;
|
||||
}
|
||||
|
||||
// String _logId(_) {
|
||||
// return utils.id;
|
||||
// }
|
||||
|
||||
@freezed
|
||||
abstract class Log with _$Log {
|
||||
const factory Log({
|
||||
// @JsonKey(fromJson: _logId) required String id,
|
||||
@JsonKey(name: 'LogLevel') @Default(LogLevel.info) LogLevel logLevel,
|
||||
@JsonKey(name: 'Payload') @Default('') String payload,
|
||||
@JsonKey(fromJson: _logDateTime) required String dateTime,
|
||||
}) = _Log;
|
||||
|
||||
factory Log.app(String payload) {
|
||||
return Log(
|
||||
payload: payload,
|
||||
dateTime: _logDateTime(null),
|
||||
// id: _logId(null),
|
||||
);
|
||||
return Log(payload: payload, dateTime: _logDateTime(null));
|
||||
}
|
||||
|
||||
factory Log.fromJson(Map<String, Object?> json) => _$LogFromJson(json);
|
||||
|
||||
@@ -93,18 +93,6 @@ extension ProfileRuleLinkExt on ProfileRuleLink {
|
||||
}
|
||||
}
|
||||
|
||||
// @freezed
|
||||
// abstract class Overwrite with _$Overwrite {
|
||||
// const factory Overwrite({
|
||||
// @Default(OverwriteType.standard) OverwriteType type,
|
||||
// @Default(StandardOverwrite()) StandardOverwrite standardOverwrite,
|
||||
// @Default(ScriptOverwrite()) ScriptOverwrite scriptOverwrite,
|
||||
// }) = _Overwrite;
|
||||
//
|
||||
// factory Overwrite.fromJson(Map<String, Object?> json) =>
|
||||
// _$OverwriteFromJson(json);
|
||||
// }
|
||||
|
||||
@freezed
|
||||
abstract class StandardOverwrite with _$StandardOverwrite {
|
||||
const factory StandardOverwrite({
|
||||
@@ -178,19 +166,6 @@ extension ProfileExtension on Profile {
|
||||
return file.create(recursive: true);
|
||||
}
|
||||
return file;
|
||||
// final oldPath = await appPath.getProfilePath(id);
|
||||
// final newPath = await appPath.getProfilePath(fileName);
|
||||
// final oldFile = oldPath == newPath ? null : File(oldPath);
|
||||
// final oldIsExists = await oldFile?.exists() ?? false;
|
||||
// if (oldIsExists) {
|
||||
// return await oldFile!.rename(newPath);
|
||||
// }
|
||||
// final file = File(newPath);
|
||||
// final isExists = await file.exists();
|
||||
// if (!isExists && autoCreate) {
|
||||
// return await file.create(recursive: true);
|
||||
// }
|
||||
// return file;
|
||||
}
|
||||
|
||||
Future<File> get file async {
|
||||
|
||||
@@ -369,42 +369,3 @@ abstract class SetupState with _$SetupState {
|
||||
required Dns dns,
|
||||
}) = _SetupState;
|
||||
}
|
||||
|
||||
// extension SetupStateExt on SetupState {
|
||||
// bool needSetup(SetupState? lastSetupState) {
|
||||
// if (lastSetupState == null) {
|
||||
// return false;
|
||||
// }
|
||||
// if (profileId != lastSetupState.profileId) {
|
||||
// return true;
|
||||
// }
|
||||
// if (profileLastUpdateDate != lastSetupState.profileLastUpdateDate) {
|
||||
// return true;
|
||||
// }
|
||||
// final scriptIsChange = script != lastSetupState.script;
|
||||
// if (overwriteType != lastSetupState.overwriteType) {
|
||||
// if (!ruleListEquality.equals(addedRules, lastSetupState.addedRules) ||
|
||||
// scriptIsChange) {
|
||||
// return true;
|
||||
// }
|
||||
// } else {
|
||||
// if (overwriteType == OverwriteType.script) {
|
||||
// if (scriptIsChange) {
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
// if (overwriteType == OverwriteType.standard) {
|
||||
// if (!ruleListEquality.equals(addedRules, lastSetupState.addedRules)) {
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (overrideDns != lastSetupState.overrideDns) {
|
||||
// return true;
|
||||
// }
|
||||
// if (overrideDns == true && dns != lastSetupState.dns) {
|
||||
// return true;
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
|
||||
+39
-7
@@ -61,14 +61,46 @@ class App {
|
||||
false;
|
||||
}
|
||||
|
||||
Future<ImageProvider?> getPackageIcon(String packageName) async {
|
||||
final path = await methodChannel.invokeMethod<String>('getPackageIcon', {
|
||||
'packageName': packageName,
|
||||
});
|
||||
if (path == null) {
|
||||
return null;
|
||||
final Map<String, ImageProvider?> _packageIcons = {};
|
||||
final Map<String, Future<ImageProvider?>> _packageIconTasks = {};
|
||||
|
||||
bool hasPackageIcon(String packageName) {
|
||||
return _packageIcons.containsKey(packageName);
|
||||
}
|
||||
|
||||
ImageProvider? getCachedPackageIcon(String packageName) {
|
||||
return _packageIcons[packageName];
|
||||
}
|
||||
|
||||
Future<ImageProvider?> getPackageIcon(String packageName) {
|
||||
if (packageName.isEmpty) {
|
||||
return Future.value(null);
|
||||
}
|
||||
return FileImage(File(path));
|
||||
if (_packageIcons.containsKey(packageName)) {
|
||||
return Future.value(_packageIcons[packageName]);
|
||||
}
|
||||
return _packageIconTasks[packageName] ??= _loadPackageIcon(packageName);
|
||||
}
|
||||
|
||||
Future<ImageProvider?> _loadPackageIcon(String packageName) async {
|
||||
ImageProvider? icon;
|
||||
try {
|
||||
final path = await methodChannel.invokeMethod<String>('getPackageIcon', {
|
||||
'packageName': packageName,
|
||||
});
|
||||
icon = path == null ? null : FileImage(File(path));
|
||||
} catch (error) {
|
||||
commonPrint.log('getPackageIcon error: $error');
|
||||
}
|
||||
_packageIcons[packageName] = icon;
|
||||
_packageIconTasks.remove(packageName);
|
||||
return icon;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void clearPackageIconCache() {
|
||||
_packageIcons.clear();
|
||||
_packageIconTasks.clear();
|
||||
}
|
||||
|
||||
Future<bool?> tip(String? message) async {
|
||||
|
||||
@@ -500,9 +500,6 @@ ColorScheme genColorScheme(
|
||||
),
|
||||
);
|
||||
if (color == null && (ignoreConfig == true || vm2.a == null)) {
|
||||
// if (globalState.corePalette != null) {
|
||||
// return globalState.corePalette!.toColorScheme(brightness: brightness);
|
||||
// }
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor:
|
||||
globalState.corePalette
|
||||
|
||||
@@ -199,9 +199,6 @@ class Avatar extends StatelessWidget {
|
||||
Text(contributor.name, style: context.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
// onTap: () {
|
||||
// globalState.openUrl(contributor.link);
|
||||
// },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-19
@@ -454,25 +454,7 @@ class PackageListItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.checkbox(
|
||||
leading: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: FutureBuilder<ImageProvider?>(
|
||||
future: app?.getPackageIcon(package.packageName),
|
||||
builder: (_, snapshot) {
|
||||
if (!snapshot.hasData && snapshot.data == null) {
|
||||
return Container();
|
||||
} else {
|
||||
return Image(
|
||||
image: snapshot.data!,
|
||||
gaplessPlayback: true,
|
||||
width: 48,
|
||||
height: 48,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
leading: PackageIcon(packageName: package.packageName, size: 48),
|
||||
title: Text(
|
||||
package.label,
|
||||
style: const TextStyle(overflow: TextOverflow.ellipsis),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/controller.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
@@ -12,26 +10,30 @@ import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
import 'item.dart';
|
||||
|
||||
class ConnectionsView extends ConsumerStatefulWidget {
|
||||
const ConnectionsView({super.key});
|
||||
final Future<List<TrackerInfo>> Function()? connectionsReader;
|
||||
|
||||
const ConnectionsView({super.key, @visibleForTesting this.connectionsReader});
|
||||
|
||||
@override
|
||||
ConsumerState<ConnectionsView> createState() => _ConnectionsViewState();
|
||||
}
|
||||
|
||||
class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
|
||||
class _ConnectionsViewState extends ConsumerState<ConnectionsView>
|
||||
with WidgetsBindingObserver, ActivePollingMixin<ConnectionsView> {
|
||||
final _connectionsStateNotifier = ValueNotifier<TrackerInfosState>(
|
||||
const TrackerInfosState(),
|
||||
);
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
Timer? timer;
|
||||
@override
|
||||
Duration get pollInterval => const Duration(seconds: 1);
|
||||
|
||||
List<Widget> _buildActions() {
|
||||
return [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
coreController.closeConnections();
|
||||
await _updateConnections();
|
||||
await _refreshConnections();
|
||||
},
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
),
|
||||
@@ -50,46 +52,53 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateConnectionsTask() async {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (mounted) {
|
||||
await _updateConnections();
|
||||
timer = Timer(const Duration(seconds: 1), () async {
|
||||
_updateConnectionsTask();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateConnectionsTask();
|
||||
Future<void> poll(PollGuard isCurrent) async {
|
||||
final trackerInfos = await _readConnections();
|
||||
if (trackerInfos == null || !isCurrent()) {
|
||||
return;
|
||||
}
|
||||
_applyConnections(trackerInfos);
|
||||
}
|
||||
|
||||
Future<void> _updateConnections() async {
|
||||
Future<void> _refreshConnections() async {
|
||||
final trackerInfos = await _readConnections();
|
||||
if (trackerInfos == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
_applyConnections(trackerInfos);
|
||||
}
|
||||
|
||||
Future<List<TrackerInfo>?> _readConnections() async {
|
||||
try {
|
||||
_connectionsStateNotifier.value = _connectionsStateNotifier.value
|
||||
.copyWith(trackerInfos: await coreController.getConnections());
|
||||
final connectionsReader = widget.connectionsReader;
|
||||
return connectionsReader != null
|
||||
? await connectionsReader()
|
||||
: await coreController.getConnections();
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'updateConnections error: $error',
|
||||
logLevel: coreFailureLogLevel(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _applyConnections(List<TrackerInfo> trackerInfos) {
|
||||
_connectionsStateNotifier.value = _connectionsStateNotifier.value.copyWith(
|
||||
trackerInfos: trackerInfos,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleBlockConnection(String id) async {
|
||||
await coreController.closeConnection(id);
|
||||
await _updateConnections();
|
||||
await _refreshConnections();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
_connectionsStateNotifier.dispose();
|
||||
_scrollController.dispose();
|
||||
timer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -111,36 +120,32 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
|
||||
illustration: const ConnectionEmptyIllustration(),
|
||||
);
|
||||
}
|
||||
final items = connections
|
||||
.map<Widget>(
|
||||
(trackerInfo) => TrackerInfoItem(
|
||||
key: Key(trackerInfo.id),
|
||||
trackerInfo: trackerInfo,
|
||||
onClickKeyword: (value) {
|
||||
context.commonScaffoldState?.addKeyword(value);
|
||||
},
|
||||
trailing: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(minimumSize: Size.zero),
|
||||
icon: const Icon(Icons.block),
|
||||
onPressed: () {
|
||||
_handleBlockConnection(trackerInfo.id);
|
||||
},
|
||||
),
|
||||
detailTitle: appLocalizations.details(
|
||||
appLocalizations.connection,
|
||||
),
|
||||
),
|
||||
)
|
||||
.separated(const Divider(height: 0))
|
||||
.toList();
|
||||
return SuperListView.builder(
|
||||
return SuperListView.separated(
|
||||
controller: _scrollController,
|
||||
itemBuilder: (context, index) {
|
||||
return items[index];
|
||||
},
|
||||
itemCount: connections.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 0),
|
||||
itemBuilder: (_, index) {
|
||||
final trackerInfo = connections[index];
|
||||
return TrackerInfoItem(
|
||||
key: Key(trackerInfo.id),
|
||||
trackerInfo: trackerInfo,
|
||||
onClickKeyword: (value) {
|
||||
context.commonScaffoldState?.addKeyword(value);
|
||||
},
|
||||
trailing: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(minimumSize: Size.zero),
|
||||
icon: const Icon(Icons.block),
|
||||
onPressed: () {
|
||||
_handleBlockConnection(trackerInfo.id);
|
||||
},
|
||||
),
|
||||
detailTitle: appLocalizations.details(
|
||||
appLocalizations.connection,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
@@ -26,10 +25,6 @@ class TrackerInfoItem extends ConsumerWidget {
|
||||
return globalState.measure.bodySmallHeight + 20;
|
||||
}
|
||||
|
||||
Future<ImageProvider?> _getPackageIcon(TrackerInfo connection) async {
|
||||
return await app?.getPackageIcon(connection.metadata.process);
|
||||
}
|
||||
|
||||
String _getSourceText(BuildContext context, TrackerInfo trackerInfo) {
|
||||
final progress = trackerInfo.progressText.isNotEmpty
|
||||
? '${trackerInfo.progressText} · '
|
||||
@@ -50,22 +45,6 @@ class TrackerInfoItem extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(trackerInfo.desc, style: context.textTheme.bodyLarge),
|
||||
// Row(
|
||||
// mainAxisSize: MainAxisSize.max,
|
||||
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
// spacing: 8,
|
||||
// children: [
|
||||
// Flexible(
|
||||
// child: Text(trackerInfo.desc, style: context.textTheme.bodyLarge),
|
||||
// ),
|
||||
// Text(
|
||||
// trackerInfo.start.lastUpdateTimeDesc,
|
||||
// style: context.textTheme.bodySmall?.copyWith(
|
||||
// color: context.colorScheme.onSurface.opacity60,
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_getSourceText(context, trackerInfo),
|
||||
@@ -121,20 +100,9 @@ class TrackerInfoItem extends ConsumerWidget {
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
width: 42,
|
||||
height: 42,
|
||||
child: FutureBuilder<ImageProvider?>(
|
||||
future: _getPackageIcon(trackerInfo),
|
||||
builder: (_, snapshot) {
|
||||
if (!snapshot.hasData && snapshot.data == null) {
|
||||
return Container();
|
||||
} else {
|
||||
return Image(
|
||||
image: snapshot.data!,
|
||||
gaplessPlayback: true,
|
||||
width: 42,
|
||||
height: 42,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: PackageIcon(
|
||||
packageName: trackerInfo.metadata.process,
|
||||
size: 42,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -118,21 +118,6 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
|
||||
label: appLocalizations.nullTip(appLocalizations.requests),
|
||||
);
|
||||
}
|
||||
final items = requests
|
||||
.map<Widget>(
|
||||
(trackerInfo) => TrackerInfoItem(
|
||||
key: Key(trackerInfo.id),
|
||||
trackerInfo: trackerInfo,
|
||||
onClickKeyword: (value) {
|
||||
context.commonScaffoldState?.addKeyword(value);
|
||||
},
|
||||
detailTitle: appLocalizations.details(
|
||||
appLocalizations.request,
|
||||
),
|
||||
),
|
||||
)
|
||||
.separated(const Divider(height: 0))
|
||||
.toList();
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: CommonScrollBar(
|
||||
@@ -146,15 +131,26 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
|
||||
_requestsStateNotifier.value = _requestsStateNotifier.value
|
||||
.copyWith(autoScrollToEnd: false);
|
||||
},
|
||||
child: SuperListView.builder(
|
||||
child: SuperListView.separated(
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
physics: const NextClampingScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
itemCount: requests.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 0),
|
||||
itemBuilder: (_, index) {
|
||||
return items[index];
|
||||
final trackerInfo = requests[index];
|
||||
return TrackerInfoItem(
|
||||
key: Key(trackerInfo.id),
|
||||
trackerInfo: trackerInfo,
|
||||
onClickKeyword: (value) {
|
||||
context.commonScaffoldState?.addKeyword(value);
|
||||
},
|
||||
detailTitle: appLocalizations.details(
|
||||
appLocalizations.request,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: items.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/controller.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
@@ -20,85 +20,31 @@ class MemoryInfo extends StatefulWidget {
|
||||
State<MemoryInfo> createState() => _MemoryInfoState();
|
||||
}
|
||||
|
||||
class _MemoryInfoState extends State<MemoryInfo> with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
bool _isForeground = false;
|
||||
bool _isUpdating = false;
|
||||
int _updateGeneration = 0;
|
||||
class _MemoryInfoState extends State<MemoryInfo>
|
||||
with WidgetsBindingObserver, ActivePollingMixin<MemoryInfo> {
|
||||
@override
|
||||
Duration get pollInterval => const Duration(seconds: 2);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_isForeground =
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isForeground) {
|
||||
_startUpdating();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_isForeground = false;
|
||||
_stopUpdating();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
final isForeground = state == AppLifecycleState.resumed;
|
||||
if (_isForeground == isForeground) {
|
||||
return;
|
||||
}
|
||||
_isForeground = isForeground;
|
||||
if (isForeground) {
|
||||
_startUpdating();
|
||||
} else {
|
||||
_stopUpdating();
|
||||
}
|
||||
}
|
||||
|
||||
void _startUpdating() {
|
||||
if (!mounted || !_isForeground || _isUpdating) {
|
||||
return;
|
||||
}
|
||||
_isUpdating = true;
|
||||
final generation = ++_updateGeneration;
|
||||
unawaited(_updateMemory(generation));
|
||||
}
|
||||
|
||||
void _stopUpdating() {
|
||||
_isUpdating = false;
|
||||
_updateGeneration++;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
Future<void> _updateMemory(int generation) async {
|
||||
final memoryReader = widget.memoryReader;
|
||||
final memory = memoryReader != null
|
||||
? await memoryReader()
|
||||
: await _readMemory();
|
||||
if (!mounted ||
|
||||
!_isForeground ||
|
||||
!_isUpdating ||
|
||||
generation != _updateGeneration) {
|
||||
Future<void> poll(PollGuard isCurrent) async {
|
||||
final memory = await _readMemory();
|
||||
if (memory == null || !isCurrent()) {
|
||||
return;
|
||||
}
|
||||
_memoryStateNotifier.value = memory;
|
||||
_timer = Timer(const Duration(seconds: 2), () {
|
||||
_timer = null;
|
||||
if (mounted &&
|
||||
_isForeground &&
|
||||
_isUpdating &&
|
||||
generation == _updateGeneration) {
|
||||
unawaited(_updateMemory(generation));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<num?> _readMemory() async {
|
||||
try {
|
||||
final memoryReader = widget.memoryReader;
|
||||
return memoryReader != null ? await memoryReader() : await _readTotal();
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'updateMemory error: $error',
|
||||
logLevel: coreFailureLogLevel(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -156,7 +102,7 @@ class _MemoryInfoState extends State<MemoryInfo> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<num> _readMemory() async {
|
||||
Future<num> _readTotal() async {
|
||||
final rss = ProcessInfo.currentRss;
|
||||
final coreConnected =
|
||||
globalState.container.read(coreStatusProvider) == CoreStatus.connected;
|
||||
|
||||
@@ -179,18 +179,6 @@ class OutboundModeV2 extends StatelessWidget {
|
||||
height: 8.ap,
|
||||
width: constraints.maxWidth,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
// child: Row(
|
||||
// children: [
|
||||
// Container(
|
||||
// width: (constraints.maxWidth - 32) / 3,
|
||||
// height: 3,
|
||||
// decoration: BoxDecoration(
|
||||
// color: _getTextColor(context, mode),
|
||||
// borderRadius: BorderRadius.circular(2),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -69,15 +69,6 @@ class DeveloperView extends ConsumerWidget {
|
||||
.handleClear();
|
||||
},
|
||||
),
|
||||
// ListItem(
|
||||
// title: Text(appLocalizations.loadTest),
|
||||
// minVerticalPadding: 12,
|
||||
// onTap: () {
|
||||
// ref.read(loadingProvider.notifier).value = !ref.read(
|
||||
// loadingProvider,
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
ListItem(
|
||||
title: Text(appLocalizations.pruneCache),
|
||||
minVerticalPadding: 12,
|
||||
|
||||
@@ -24,22 +24,9 @@ class _ProfilesViewState extends State<ProfilesView> {
|
||||
Function? applyConfigDebounce;
|
||||
bool _isUpdating = false;
|
||||
|
||||
// final GlobalKey _targetKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// final context = _targetKey.currentContext;
|
||||
// if (context == null) {
|
||||
// return;
|
||||
// }
|
||||
// Scrollable.ensureVisible(
|
||||
// context,
|
||||
// duration: commonDuration,
|
||||
// alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
|
||||
// );
|
||||
// });
|
||||
}
|
||||
|
||||
void _handleShowAddExtendPage() {
|
||||
|
||||
@@ -80,7 +80,7 @@ Future<void> proxyDelayTest(Proxy proxy, [String? testUrl]) async {
|
||||
}
|
||||
|
||||
Future<void> delayTest(List<Proxy> proxies, [String? testUrl]) async {
|
||||
final batches = proxies.batch(100);
|
||||
final batches = proxies.batch(maxConcurrentDelayTests);
|
||||
for (final batch in batches) {
|
||||
await Future.wait(
|
||||
batch.map((proxy) async {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/print.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef PollGuard = bool Function();
|
||||
|
||||
mixin ActivePollingMixin<T extends StatefulWidget>
|
||||
on State<T>, WidgetsBindingObserver {
|
||||
Timer? _pollTimer;
|
||||
bool _isForeground = false;
|
||||
bool _isPageActive = true;
|
||||
bool _isPolling = false;
|
||||
int _pollGeneration = 0;
|
||||
|
||||
Duration get pollInterval;
|
||||
|
||||
bool get pollOnStart => true;
|
||||
|
||||
Future<void> poll(PollGuard isCurrent);
|
||||
|
||||
bool get canPoll => mounted && _isForeground && _isPageActive;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
final lifecycleState = WidgetsBinding.instance.lifecycleState;
|
||||
_isForeground =
|
||||
lifecycleState == null || lifecycleState == AppLifecycleState.resumed;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_syncPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final isPageActive = PageActivityScope.isActiveOf(context);
|
||||
if (_isPageActive == isPageActive) {
|
||||
return;
|
||||
}
|
||||
_isPageActive = isPageActive;
|
||||
_syncPolling();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
final isForeground = state == AppLifecycleState.resumed;
|
||||
if (_isForeground == isForeground) {
|
||||
return;
|
||||
}
|
||||
_isForeground = isForeground;
|
||||
_syncPolling();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_isForeground = false;
|
||||
stopPolling();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void startPolling() {
|
||||
if (!canPoll || _isPolling) {
|
||||
return;
|
||||
}
|
||||
_isPolling = true;
|
||||
final generation = ++_pollGeneration;
|
||||
if (pollOnStart) {
|
||||
unawaited(_runPoll(generation));
|
||||
return;
|
||||
}
|
||||
_schedulePoll(generation);
|
||||
}
|
||||
|
||||
void stopPolling() {
|
||||
_isPolling = false;
|
||||
_pollGeneration++;
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
void restartPolling() {
|
||||
stopPolling();
|
||||
_syncPolling();
|
||||
}
|
||||
|
||||
void _syncPolling() {
|
||||
if (canPoll) {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrentPoll(int generation) =>
|
||||
canPoll && _isPolling && generation == _pollGeneration;
|
||||
|
||||
void _schedulePoll(int generation) {
|
||||
_pollTimer = Timer(pollInterval, () {
|
||||
_pollTimer = null;
|
||||
if (_isCurrentPoll(generation)) {
|
||||
unawaited(_runPoll(generation));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runPoll(int generation) async {
|
||||
try {
|
||||
await poll(() => _isCurrentPoll(generation));
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'$runtimeType poll error: $error',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
} finally {
|
||||
if (_isCurrentPoll(generation)) {
|
||||
_schedulePoll(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-24
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/widgets/active_polling.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -16,40 +15,31 @@ class TickBuilder extends StatefulWidget {
|
||||
State<TickBuilder> createState() => _TickBuilderState();
|
||||
}
|
||||
|
||||
class _TickBuilderState extends State<TickBuilder> {
|
||||
Timer? _timer;
|
||||
class _TickBuilderState extends State<TickBuilder>
|
||||
with WidgetsBindingObserver, ActivePollingMixin<TickBuilder> {
|
||||
int _tick = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTimer();
|
||||
Duration get pollInterval => widget.duration;
|
||||
|
||||
@override
|
||||
bool get pollOnStart => false;
|
||||
|
||||
@override
|
||||
Future<void> poll(PollGuard isCurrent) async {
|
||||
setState(() {
|
||||
_tick++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TickBuilder oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.duration != widget.duration) {
|
||||
_startTimer();
|
||||
restartPolling();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(widget.duration, (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tick++;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(context, _tick);
|
||||
|
||||
@@ -154,16 +154,6 @@ class CommonCard extends StatelessWidget {
|
||||
|
||||
Color? _buildBackgroundColor(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
// if (isError) {
|
||||
// if (type == CommonCardType.filled) {
|
||||
// return isSelected
|
||||
// ? colorScheme.errorContainer.opacity80
|
||||
// : colorScheme.errorContainer;
|
||||
// }
|
||||
// return isSelected
|
||||
// ? colorScheme.errorContainer.opacity60
|
||||
// : colorScheme.errorContainer.opacity12;
|
||||
// }
|
||||
if (type == CommonCardType.filled) {
|
||||
if (isSelected) {
|
||||
return colorScheme.secondaryContainer.opacity80;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:fl_clash/common/cache.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/database/database.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
@@ -125,6 +126,72 @@ class _ImageCacheWidgetState extends State<ImageCacheWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class PackageIcon extends StatefulWidget {
|
||||
final String packageName;
|
||||
final double size;
|
||||
|
||||
const PackageIcon({super.key, required this.packageName, required this.size});
|
||||
|
||||
@override
|
||||
State<PackageIcon> createState() => _PackageIconState();
|
||||
}
|
||||
|
||||
class _PackageIconState extends State<PackageIcon> {
|
||||
ImageProvider? _icon;
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadIcon();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PackageIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.packageName != widget.packageName) {
|
||||
_loadIcon();
|
||||
}
|
||||
}
|
||||
|
||||
void _loadIcon() {
|
||||
final generation = ++_generation;
|
||||
final packageName = widget.packageName;
|
||||
final currentApp = app;
|
||||
if (currentApp == null || packageName.isEmpty) {
|
||||
_icon = null;
|
||||
return;
|
||||
}
|
||||
if (currentApp.hasPackageIcon(packageName)) {
|
||||
_icon = currentApp.getCachedPackageIcon(packageName);
|
||||
return;
|
||||
}
|
||||
_icon = null;
|
||||
currentApp.getPackageIcon(packageName).then((icon) {
|
||||
if (!mounted || generation != _generation || icon == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_icon = icon;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = _icon;
|
||||
if (icon == null) {
|
||||
return SizedBox(width: widget.size, height: widget.size);
|
||||
}
|
||||
return Image(
|
||||
image: icon,
|
||||
gaplessPlayback: true,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommonImage extends StatelessWidget {
|
||||
final File data;
|
||||
final bool isSvg;
|
||||
|
||||
@@ -203,12 +203,10 @@ class _ThemeAwareSvg extends StatelessWidget {
|
||||
'#E8DEF8',
|
||||
'#${_colorToHex(colorScheme.secondaryContainer)}',
|
||||
);
|
||||
// primary ??
|
||||
svgString = svgString.replaceAll(
|
||||
'#6750A4',
|
||||
'#${_colorToHex(colorScheme.primary)}',
|
||||
);
|
||||
// surface ??
|
||||
svgString = svgString.replaceAll(
|
||||
'#FDF7FF',
|
||||
'#${_colorToHex(colorScheme.surface)}',
|
||||
|
||||
@@ -628,28 +628,3 @@ Future<T?> showModalSideSheet<T>({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// class ModalAppBar extends StatelessWidget {
|
||||
// final String title;
|
||||
//
|
||||
// const ModalAppBar({
|
||||
// super.key,
|
||||
// required this.title,
|
||||
// });
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return AppBar(
|
||||
// automaticallyImplyLeading: false,
|
||||
// title: Text(title),
|
||||
// centerTitle: false,
|
||||
// actions: const [
|
||||
// SizedBox(
|
||||
// height: kToolbarHeight,
|
||||
// width: kToolbarHeight,
|
||||
// child: CloseButton(),
|
||||
// )
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -130,19 +130,3 @@ class EmojiText extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// class HighlightText extends StatelessWidget {
|
||||
// const HighlightText({super.key});
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return RichText(
|
||||
// textScaler: MediaQuery.of(context).textScaler,
|
||||
// maxLines: maxLines,
|
||||
// overflow: overflow ?? TextOverflow.clip,
|
||||
// text: TextSpan(
|
||||
// children: _buildTextSpans(text),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export 'activate_box.dart';
|
||||
export 'active_polling.dart';
|
||||
export 'animate_grid.dart';
|
||||
export 'animated_visibility.dart';
|
||||
export 'builder.dart';
|
||||
|
||||
@@ -4,11 +4,11 @@ use flutter_rust_bridge::frb;
|
||||
use interprocess::local_socket::prelude::*;
|
||||
use interprocess::local_socket::{GenericFilePath, ListenerNonblockingMode, ListenerOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{self, SyncSender, TrySendError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
@@ -31,12 +31,12 @@ static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
static LIFECYCLE: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct ServerState {
|
||||
tx: Option<SyncSender<Vec<u8>>>,
|
||||
sender: Option<MessageSender>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<ServerState> = Mutex::new(ServerState {
|
||||
tx: None,
|
||||
sender: None,
|
||||
handle: None,
|
||||
});
|
||||
|
||||
@@ -47,9 +47,54 @@ const TYPE_DATA: u8 = 0x03;
|
||||
const TYPE_ERROR: u8 = 0x04;
|
||||
|
||||
const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
|
||||
const MAX_PENDING_MESSAGES: usize = 8;
|
||||
const MAX_PENDING_MESSAGES: usize = 256;
|
||||
// The slot count alone bounds nothing: 256 slots times the frame ceiling is
|
||||
// gigabytes. Real frames are control messages of a few KB, so cap the queue by
|
||||
// bytes too and let the message count stay generous for fan-out bursts.
|
||||
const MAX_PENDING_BYTES: usize = 16 * 1024 * 1024;
|
||||
const IO_POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
// Callers fan out: a group delay test issues one request per proxy at once, so
|
||||
// a full queue means the writer is behind, not that the message is surplus.
|
||||
// Wait it out, but never past this deadline, so a wedged peer still surfaces.
|
||||
const SEND_QUEUE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SEND_RETRY_INTERVAL: Duration = Duration::from_millis(2);
|
||||
const MIN_WRITE_BACKOFF: Duration = Duration::from_millis(1);
|
||||
|
||||
#[frb(ignore)]
|
||||
#[derive(Clone)]
|
||||
struct MessageSender {
|
||||
tx: SyncSender<Vec<u8>>,
|
||||
pending_bytes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl MessageSender {
|
||||
fn channel(capacity: usize) -> (Self, mpsc::Receiver<Vec<u8>>, Arc<AtomicUsize>) {
|
||||
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(capacity);
|
||||
let pending_bytes = Arc::new(AtomicUsize::new(0));
|
||||
let sender = Self {
|
||||
tx,
|
||||
pending_bytes: Arc::clone(&pending_bytes),
|
||||
};
|
||||
(sender, rx, pending_bytes)
|
||||
}
|
||||
|
||||
// An empty queue always accepts, even past the budget: a lone frame may be
|
||||
// larger than the budget, and refusing it would stall forever with nothing
|
||||
// draining ahead of it.
|
||||
fn try_reserve(&self, len: usize) -> bool {
|
||||
self.pending_bytes
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |pending| {
|
||||
let next = pending.checked_add(len)?;
|
||||
(pending == 0 || next <= MAX_PENDING_BYTES).then_some(next)
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn release(&self, len: usize) {
|
||||
self.pending_bytes.fetch_sub(len, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_frame(ty: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut frame = Vec::with_capacity(1 + payload.len());
|
||||
@@ -80,7 +125,7 @@ fn stop_server_thread() -> Result<(), String> {
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
let handle = {
|
||||
let mut state = STATE.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
|
||||
state.tx = None;
|
||||
state.sender = None;
|
||||
state.handle.take()
|
||||
};
|
||||
if let Some(handle) = handle {
|
||||
@@ -125,17 +170,46 @@ pub fn stop_ipc_server() -> Result<(), String> {
|
||||
#[frb]
|
||||
pub fn send_ipc_message(data: Vec<u8>) -> Result<(), String> {
|
||||
validate_frame_len(data.len()).map_err(|e| e.to_string())?;
|
||||
let tx = STATE
|
||||
let sender = STATE
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock poisoned: {e}"))?
|
||||
.tx
|
||||
.sender
|
||||
.clone()
|
||||
.ok_or("IPC client is not connected")?;
|
||||
|
||||
match tx.try_send(data) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(TrySendError::Full(_)) => Err("IPC send queue is full".into()),
|
||||
Err(TrySendError::Disconnected(_)) => Err("IPC client is disconnected".into()),
|
||||
enqueue_message(&sender, data, SEND_QUEUE_TIMEOUT, &RUNNING)
|
||||
}
|
||||
|
||||
fn enqueue_message(
|
||||
sender: &MessageSender,
|
||||
data: Vec<u8>,
|
||||
timeout: Duration,
|
||||
running: &AtomicBool,
|
||||
) -> Result<(), String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let len = data.len();
|
||||
let mut pending = data;
|
||||
loop {
|
||||
if sender.try_reserve(len) {
|
||||
match sender.tx.try_send(pending) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(TrySendError::Disconnected(_)) => {
|
||||
sender.release(len);
|
||||
return Err("IPC client is disconnected".into());
|
||||
}
|
||||
Err(TrySendError::Full(rejected)) => {
|
||||
sender.release(len);
|
||||
pending = rejected;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !running.load(Ordering::SeqCst) {
|
||||
return Err("IPC server is stopped".into());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("IPC send queue is full".into());
|
||||
}
|
||||
thread::sleep(SEND_RETRY_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +237,7 @@ fn write_all_interruptible(
|
||||
mut data: &[u8],
|
||||
connection_running: &AtomicBool,
|
||||
) -> io::Result<()> {
|
||||
let mut backoff = MIN_WRITE_BACKOFF;
|
||||
while !data.is_empty() {
|
||||
if !connection_running.load(Ordering::SeqCst) || !server_active() {
|
||||
return Err(io::Error::new(
|
||||
@@ -175,10 +250,18 @@ fn write_all_interruptible(
|
||||
let result = normalize_windows_pipe_write(result);
|
||||
match result {
|
||||
Ok(0) => return Err(io::ErrorKind::WriteZero.into()),
|
||||
Ok(written) => data = &data[written..],
|
||||
Ok(written) => {
|
||||
data = &data[written..];
|
||||
backoff = MIN_WRITE_BACKOFF;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(IO_POLL_INTERVAL);
|
||||
// A Windows PIPE_NOWAIT handle reports a full buffer through a
|
||||
// zero-byte write, and every frame costs two writes. A flat
|
||||
// 20ms wait there parks the whole queue behind one frame, so
|
||||
// back off from 1ms instead.
|
||||
thread::sleep(backoff);
|
||||
backoff = (backoff * 2).min(IO_POLL_INTERVAL);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
@@ -306,7 +389,7 @@ fn is_expected_disconnect_error(error: &io::Error) -> bool {
|
||||
fn finish_server(name: &str) {
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
if let Ok(mut state) = STATE.lock() {
|
||||
state.tx = None;
|
||||
state.sender = None;
|
||||
}
|
||||
if let Err(e) = cleanup_socket(name) {
|
||||
ipc_debug!("[IPC] cleanup socket failed: {e}");
|
||||
@@ -381,9 +464,9 @@ fn io_loop(name: String, sink: StreamSink<Vec<u8>, SseCodec>) {
|
||||
#[cfg(not(windows))]
|
||||
let connected_payload = Vec::new();
|
||||
|
||||
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(MAX_PENDING_MESSAGES);
|
||||
let (sender, rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
|
||||
match STATE.lock() {
|
||||
Ok(mut state) if server_active() => state.tx = Some(tx),
|
||||
Ok(mut state) if server_active() => state.sender = Some(sender),
|
||||
Ok(_) => break,
|
||||
Err(e) => {
|
||||
report_error(&sink, format!("state lock error: {e}"));
|
||||
@@ -406,6 +489,7 @@ fn io_loop(name: String, sink: StreamSink<Vec<u8>, SseCodec>) {
|
||||
while writer_running.load(Ordering::SeqCst) && server_active() {
|
||||
match rx.recv_timeout(IO_POLL_INTERVAL) {
|
||||
Ok(data) => {
|
||||
pending_bytes.fetch_sub(data.len(), Ordering::SeqCst);
|
||||
if let Err(e) = write_frame(&mut sender, &data, &writer_running) {
|
||||
if e.kind() != io::ErrorKind::Interrupted {
|
||||
error = Some(format!("write error: {e}"));
|
||||
@@ -449,7 +533,7 @@ fn io_loop(name: String, sink: StreamSink<Vec<u8>, SseCodec>) {
|
||||
|
||||
connection_running.store(false, Ordering::SeqCst);
|
||||
if let Ok(mut state) = STATE.lock() {
|
||||
state.tx = None;
|
||||
state.sender = None;
|
||||
}
|
||||
if let Ok(Some(message)) = writer.join() {
|
||||
report_error(&sink, message);
|
||||
@@ -559,6 +643,169 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_message_waits_for_a_draining_writer() {
|
||||
let (sender, rx, _) = MessageSender::channel(1);
|
||||
enqueue_message(
|
||||
&sender,
|
||||
b"first".to_vec(),
|
||||
Duration::from_secs(5),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap();
|
||||
let writer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
(rx.recv().unwrap(), rx.recv().unwrap())
|
||||
});
|
||||
|
||||
enqueue_message(
|
||||
&sender,
|
||||
b"second".to_vec(),
|
||||
Duration::from_secs(5),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
writer.join().unwrap(),
|
||||
(b"first".to_vec(), b"second".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_message_gives_up_on_a_writer_that_never_drains() {
|
||||
let (sender, _rx, _) = MessageSender::channel(1);
|
||||
sender.tx.send(b"first".to_vec()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
enqueue_message(
|
||||
&sender,
|
||||
b"second".to_vec(),
|
||||
Duration::from_millis(20),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap_err(),
|
||||
"IPC send queue is full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_message_stops_waiting_once_the_server_is_stopped() {
|
||||
let (sender, _rx, _) = MessageSender::channel(1);
|
||||
sender.tx.send(b"first".to_vec()).unwrap();
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let stopper = Arc::clone(&running);
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
stopper.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
let error = enqueue_message(
|
||||
&sender,
|
||||
b"second".to_vec(),
|
||||
Duration::from_secs(30),
|
||||
&running,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, "IPC server is stopped");
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_message_reports_a_gone_writer_without_waiting() {
|
||||
let (sender, rx, _) = MessageSender::channel(1);
|
||||
drop(rx);
|
||||
|
||||
assert_eq!(
|
||||
enqueue_message(
|
||||
&sender,
|
||||
b"first".to_vec(),
|
||||
Duration::from_secs(30),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap_err(),
|
||||
"IPC client is disconnected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_message_caps_the_queue_by_bytes_before_the_slot_count() {
|
||||
let (sender, _rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
|
||||
enqueue_message(
|
||||
&sender,
|
||||
vec![0u8; MAX_PENDING_BYTES],
|
||||
Duration::from_secs(5),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pending_bytes.load(Ordering::SeqCst), MAX_PENDING_BYTES);
|
||||
assert_eq!(
|
||||
enqueue_message(
|
||||
&sender,
|
||||
b"overflow".to_vec(),
|
||||
Duration::from_millis(20),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap_err(),
|
||||
"IPC send queue is full"
|
||||
);
|
||||
assert_eq!(pending_bytes.load(Ordering::SeqCst), MAX_PENDING_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_message_admits_a_lone_frame_larger_than_the_budget() {
|
||||
let (sender, rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
|
||||
let len = MAX_PENDING_BYTES + 1;
|
||||
|
||||
enqueue_message(
|
||||
&sender,
|
||||
vec![0u8; len],
|
||||
Duration::from_millis(20),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pending_bytes.load(Ordering::SeqCst), len);
|
||||
assert_eq!(rx.recv().unwrap().len(), len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draining_a_message_frees_its_share_of_the_budget() {
|
||||
let (sender, rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
|
||||
enqueue_message(
|
||||
&sender,
|
||||
vec![0u8; MAX_PENDING_BYTES],
|
||||
Duration::from_secs(5),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let drained = rx.recv().unwrap();
|
||||
pending_bytes.fetch_sub(drained.len(), Ordering::SeqCst);
|
||||
|
||||
assert_eq!(pending_bytes.load(Ordering::SeqCst), 0);
|
||||
enqueue_message(
|
||||
&sender,
|
||||
b"next".to_vec(),
|
||||
Duration::from_millis(20),
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_backoff_starts_short_and_is_capped() {
|
||||
let mut backoff = MIN_WRITE_BACKOFF;
|
||||
assert_eq!(backoff, Duration::from_millis(1));
|
||||
for _ in 0..10 {
|
||||
backoff = (backoff * 2).min(IO_POLL_INTERVAL);
|
||||
}
|
||||
assert_eq!(backoff, IO_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_disconnect_errors_are_not_reported() {
|
||||
for kind in [
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: fl_clash
|
||||
description: A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
|
||||
publish_to: 'none'
|
||||
version: 0.8.95+2026081401
|
||||
version: 0.8.96+2026081701
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
|
||||
@@ -118,6 +118,129 @@ void main() {
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
test('waiting for a connection never outlives the request timeout', () async {
|
||||
final transport = FakeDesktopCoreTransport();
|
||||
final client = CoreRpcClient(transport);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
final result = await client.invoke<bool>(
|
||||
method: CoreMethod.asyncTestDelay,
|
||||
timeout: const Duration(milliseconds: 150),
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
expect(stopwatch.elapsed, lessThan(const Duration(seconds: 5)));
|
||||
expect(transport.sentMessages, isEmpty);
|
||||
expect(client.pendingCount, 0);
|
||||
await client.close();
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
test('a silent link times out the pending request', () async {
|
||||
final transport = FakeDesktopCoreTransport.connected();
|
||||
final client = CoreRpcClient(transport);
|
||||
|
||||
final result = await client.invoke<bool>(
|
||||
method: CoreMethod.getIsInit,
|
||||
timeout: const Duration(milliseconds: 150),
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
expect(client.pendingCount, 0);
|
||||
await client.close();
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
test('a pending request survives while Core answers its siblings', () async {
|
||||
final transport = FakeDesktopCoreTransport.connected();
|
||||
final client = CoreRpcClient(transport);
|
||||
const timeout = Duration(milliseconds: 300);
|
||||
|
||||
final slow = client.invoke<bool>(
|
||||
method: CoreMethod.asyncTestDelay,
|
||||
timeout: timeout,
|
||||
);
|
||||
final sibling = client.invoke<bool>(
|
||||
method: CoreMethod.asyncTestDelay,
|
||||
timeout: timeout,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
final requests = transport.sentMessages
|
||||
.map((message) => jsonDecode(message) as Map<String, Object?>)
|
||||
.toList();
|
||||
expect(requests, hasLength(2));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
transport.addJson({'id': requests[1]['id'], 'result': true});
|
||||
expect(await sibling, isTrue);
|
||||
|
||||
// Past the original window, but the link proved itself 200ms in.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
expect(client.pendingCount, 1);
|
||||
transport.addJson({'id': requests[0]['id'], 'result': true});
|
||||
expect(await slow, isTrue);
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
test('extension is capped so a wedged request still fails', () async {
|
||||
final transport = FakeDesktopCoreTransport.connected();
|
||||
final client = CoreRpcClient(transport);
|
||||
const timeout = Duration(milliseconds: 200);
|
||||
|
||||
final wedged = client.invoke<bool>(
|
||||
method: CoreMethod.asyncTestDelay,
|
||||
timeout: timeout,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
final request = jsonDecode(transport.sentMessages.first) as Map;
|
||||
|
||||
// Core stays chatty about everything except this request.
|
||||
final chatter = Timer.periodic(
|
||||
const Duration(milliseconds: 50),
|
||||
(_) => transport.addJson({'id': 'other', 'result': true}),
|
||||
);
|
||||
addTearDown(chatter.cancel);
|
||||
|
||||
expect(await wedged, isNull);
|
||||
expect(client.pendingCount, 0);
|
||||
expect(request['id'], isNotNull);
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'a long request budget stops extending once the grace is spent',
|
||||
() async {
|
||||
const timeout = Duration(milliseconds: 200);
|
||||
const grace = Duration(milliseconds: 100);
|
||||
expect(timeout + grace, lessThan(timeout * 3));
|
||||
|
||||
final transport = FakeDesktopCoreTransport.connected();
|
||||
final client = CoreRpcClient(transport, extensionGrace: grace);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final wedged = client.invoke<bool>(
|
||||
method: CoreMethod.getIsInit,
|
||||
timeout: timeout,
|
||||
);
|
||||
final chatter = Timer.periodic(
|
||||
const Duration(milliseconds: 30),
|
||||
(_) => transport.addJson({'id': 'other', 'result': true}),
|
||||
);
|
||||
addTearDown(chatter.cancel);
|
||||
|
||||
expect(await wedged, isNull);
|
||||
expect(stopwatch.elapsed, lessThan(const Duration(milliseconds: 500)));
|
||||
expect(client.pendingCount, 0);
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('malformed data does not terminate response processing', () async {
|
||||
final transport = FakeDesktopCoreTransport.connected();
|
||||
final client = CoreRpcClient(transport);
|
||||
|
||||
@@ -8,9 +8,14 @@ void main() {
|
||||
|
||||
const channel = MethodChannel('$packageName/app');
|
||||
|
||||
setUp(() {
|
||||
App().clearPackageIconCache();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
App().clearPackageIconCache();
|
||||
});
|
||||
|
||||
test('reads previous execution crash state from Android', () async {
|
||||
@@ -35,6 +40,71 @@ void main() {
|
||||
expect(await App().didCrashOnPreviousExecution(), isFalse);
|
||||
});
|
||||
|
||||
test('requests every package icon from Android only once', () async {
|
||||
var iconCallCount = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
iconCallCount++;
|
||||
return '/icons/${call.arguments['packageName']}.png';
|
||||
});
|
||||
|
||||
final app = App();
|
||||
final results = await Future.wait([
|
||||
app.getPackageIcon('com.a'),
|
||||
app.getPackageIcon('com.a'),
|
||||
]);
|
||||
final cached = await app.getPackageIcon('com.a');
|
||||
|
||||
expect(iconCallCount, 1);
|
||||
expect(results.first, isNotNull);
|
||||
expect(cached, same(results.first));
|
||||
expect(app.hasPackageIcon('com.a'), isTrue);
|
||||
expect(app.getCachedPackageIcon('com.a'), same(results.first));
|
||||
|
||||
await app.getPackageIcon('com.b');
|
||||
|
||||
expect(iconCallCount, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'caches packages without an icon and skips empty package names',
|
||||
() async {
|
||||
var iconCallCount = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) async {
|
||||
iconCallCount++;
|
||||
return null;
|
||||
});
|
||||
|
||||
final app = App();
|
||||
|
||||
expect(await app.getPackageIcon(''), isNull);
|
||||
expect(iconCallCount, 0);
|
||||
expect(app.hasPackageIcon(''), isFalse);
|
||||
|
||||
expect(await app.getPackageIcon('com.a'), isNull);
|
||||
expect(await app.getPackageIcon('com.a'), isNull);
|
||||
|
||||
expect(iconCallCount, 1);
|
||||
expect(app.hasPackageIcon('com.a'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('caches a failed package icon lookup', () async {
|
||||
var iconCallCount = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) async {
|
||||
iconCallCount++;
|
||||
throw PlatformException(code: 'unavailable');
|
||||
});
|
||||
|
||||
final app = App();
|
||||
|
||||
expect(await app.getPackageIcon('com.a'), isNull);
|
||||
expect(await app.getPackageIcon('com.a'), isNull);
|
||||
expect(iconCallCount, 1);
|
||||
});
|
||||
|
||||
test('uses false when crash detection is unavailable', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) async {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:fl_clash/widgets/builder.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -23,4 +24,62 @@ void main() {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(find.text('tick: 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TickBuilder ticks only while the page is active', (
|
||||
tester,
|
||||
) async {
|
||||
Widget buildApp({required bool isPageActive}) {
|
||||
return MaterialApp(
|
||||
home: PageActivityScope(
|
||||
isActive: isPageActive,
|
||||
child: TickBuilder(
|
||||
duration: const Duration(seconds: 1),
|
||||
builder: (_, tick) => Text('tick: $tick'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(isPageActive: false));
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(find.text('tick: 0'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(buildApp(isPageActive: true));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('tick: 1'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(buildApp(isPageActive: false));
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(find.text('tick: 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TickBuilder stops ticking while the app is paused', (
|
||||
tester,
|
||||
) async {
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: TickBuilder(
|
||||
duration: const Duration(seconds: 1),
|
||||
builder: (_, tick) => Text('tick: $tick'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(find.text('tick: 1'), findsOneWidget);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(find.text('tick: 1'), findsOneWidget);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('tick: 2'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/theme.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/connection/connections.dart';
|
||||
import 'package:fl_clash/views/connection/item.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
container = ProviderContainer();
|
||||
globalState.container = container;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
List<TrackerInfo> buildConnections(int count) {
|
||||
return List.generate(
|
||||
count,
|
||||
(index) => TrackerInfo(
|
||||
id: '$index',
|
||||
start: DateTime(2024),
|
||||
metadata: Metadata(
|
||||
network: 'tcp',
|
||||
host: 'host-$index.com',
|
||||
destinationPort: '443',
|
||||
),
|
||||
chains: const ['proxy-a'],
|
||||
rule: 'MATCH',
|
||||
rulePayload: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpConnections(
|
||||
WidgetTester tester, {
|
||||
required Future<List<TrackerInfo>> Function() connectionsReader,
|
||||
bool isPageActive = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(600, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: _TestApp(
|
||||
child: PageActivityScope(
|
||||
isActive: isPageActive,
|
||||
child: ConnectionsView(connectionsReader: connectionsReader),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('ConnectionsView lazily builds every connection', (tester) async {
|
||||
final connections = buildConnections(100);
|
||||
|
||||
await pumpConnections(tester, connectionsReader: () async => connections);
|
||||
await tester.pump();
|
||||
|
||||
final builtItems = find.byType(TrackerInfoItem).evaluate().length;
|
||||
expect(builtItems, greaterThan(0));
|
||||
expect(builtItems, lessThan(connections.length));
|
||||
expect(find.text('tcp://host-0.com:443'), findsOneWidget);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('tcp://host-99.com:443'),
|
||||
800,
|
||||
scrollable: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Scrollable &&
|
||||
widget.axisDirection == AxisDirection.down &&
|
||||
widget.controller != null,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('tcp://host-99.com:443'), findsOneWidget);
|
||||
expect(tester.takeException(), null);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
testWidgets('ConnectionsView polls only while the page is active', (
|
||||
tester,
|
||||
) async {
|
||||
var readCount = 0;
|
||||
|
||||
Future<List<TrackerInfo>> readConnections() async {
|
||||
readCount++;
|
||||
return const [];
|
||||
}
|
||||
|
||||
await pumpConnections(
|
||||
tester,
|
||||
connectionsReader: readConnections,
|
||||
isPageActive: false,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(readCount, 0);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: _TestApp(
|
||||
child: PageActivityScope(
|
||||
isActive: true,
|
||||
child: ConnectionsView(connectionsReader: readConnections),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 1);
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 2);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: _TestApp(
|
||||
child: PageActivityScope(
|
||||
isActive: false,
|
||||
child: ConnectionsView(connectionsReader: readConnections),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(readCount, 2);
|
||||
expect(tester.takeException(), null);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
testWidgets('ConnectionsView stops polling while the app is paused', (
|
||||
tester,
|
||||
) async {
|
||||
var readCount = 0;
|
||||
|
||||
Future<List<TrackerInfo>> readConnections() async {
|
||||
readCount++;
|
||||
return const [];
|
||||
}
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await pumpConnections(tester, connectionsReader: readConnections);
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 1);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
|
||||
expect(readCount, 1);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 2);
|
||||
expect(tester.takeException(), null);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TestApp({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
navigatorKey: globalState.navigatorKey,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
builder: (context, child) {
|
||||
globalState.measure = Measure.of(context, 1);
|
||||
globalState.theme = CommonTheme.of(context, 1);
|
||||
return child!;
|
||||
},
|
||||
home: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:fl_clash/common/theme.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/dashboard/widgets/memory_info.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -86,6 +87,78 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
testWidgets('MemoryInfo keeps polling after a failed read', (tester) async {
|
||||
var readCount = 0;
|
||||
|
||||
Future<num> readMemory() async {
|
||||
readCount++;
|
||||
if (readCount == 1) {
|
||||
throw StateError('core unavailable');
|
||||
}
|
||||
return readCount;
|
||||
}
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pumpWidget(
|
||||
_TestApp(child: MemoryInfo(memoryReader: readMemory)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 1);
|
||||
expect(tester.takeException(), null);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 2);
|
||||
expect(tester.takeException(), null);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
testWidgets('MemoryInfo refreshes only while the page is active', (
|
||||
tester,
|
||||
) async {
|
||||
var readCount = 0;
|
||||
|
||||
Future<num> readMemory() async {
|
||||
readCount++;
|
||||
return readCount;
|
||||
}
|
||||
|
||||
Widget buildApp({required bool isPageActive}) {
|
||||
return _TestApp(
|
||||
child: PageActivityScope(
|
||||
isActive: isPageActive,
|
||||
child: MemoryInfo(memoryReader: readMemory),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pumpWidget(buildApp(isPageActive: false));
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
|
||||
expect(readCount, 0);
|
||||
|
||||
await tester.pumpWidget(buildApp(isPageActive: true));
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 1);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump();
|
||||
|
||||
expect(readCount, 2);
|
||||
|
||||
await tester.pumpWidget(buildApp(isPageActive: false));
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
|
||||
expect(readCount, 2);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user