refactor(app): rebuild core client, providers, models, database, managers, and window handling

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
This commit is contained in:
chen08209
2026-09-05 20:09:39 +08:00
parent c0fcbc03ee
commit aaf934c2d1
274 changed files with 32613 additions and 8692 deletions
+118
View File
@@ -357,6 +357,27 @@ void main() {
isTrue,
);
const disabled = Rule(
id: 33,
ruleAction: RuleAction.DOMAIN,
content: 'disabled.example',
ruleTarget: 'REJECT',
order: 'd',
);
await database.rulesDao.putProfileDisabledRule(profile.id, disabled);
expect(
(await database.rulesDao.queryProfileDisabledRules(profile.id).get())
.single
.id,
disabled.id,
);
expect(
(await database.rulesDao.queryAddedRules(profile.id).get()).map(
(rule) => rule.id,
),
isNot(contains(disabled.id)),
);
await database.rulesDao.orderGlobalRule(ruleId: global.id, order: '0');
await database.rulesDao.orderProfileAddedRule(
profile.id,
@@ -393,6 +414,103 @@ void main() {
},
);
test(
'a compatible restore keeps records the backup does not carry',
() async {
const keptProfile = Profile(id: 1, autoUpdateDuration: Duration.zero);
const keptRule = Rule(
id: 41,
content: 'kept.example',
ruleTarget: 'DIRECT',
);
const keptLink = ProfileRuleLink(
profileId: 1,
ruleId: 41,
scene: RuleScene.custom,
);
const keptGroup = ProxyGroup(
id: 42,
profileId: 1,
name: 'Kept',
type: GroupType.Selector,
);
await database.restore(
[keptProfile],
const [],
[keptRule],
[keptLink],
[keptGroup],
isOverride: true,
);
const backupProfile = Profile(id: 2, autoUpdateDuration: Duration.zero);
const backupGroup = ProxyGroup(
id: 43,
profileId: 2,
name: 'Backup',
type: GroupType.Selector,
);
await database.restore(
[backupProfile],
const [],
const [],
const [],
[backupGroup],
);
expect(
(await database.rulesDao.queryProfileCustomRules(1).get()).single.id,
keptRule.id,
);
expect(
(await database.proxyGroupsDao.query(1).get()).single.id,
keptGroup.id,
);
expect(
(await database.proxyGroupsDao.query(2).get()).single.id,
backupGroup.id,
);
},
);
test(
'a groups-only backup does not clear the rules of other profiles',
() async {
const profile = Profile(id: 1, autoUpdateDuration: Duration.zero);
const rule = Rule(id: 41, content: 'kept.example', ruleTarget: 'DIRECT');
const link = ProfileRuleLink(
profileId: 1,
ruleId: 41,
scene: RuleScene.custom,
);
await database.restore(
[profile],
const [],
[rule],
[link],
const [],
isOverride: true,
);
const group = ProxyGroup(
id: 42,
profileId: 1,
name: 'Group',
type: GroupType.Selector,
);
await database.restore(const [], const [], const [], const [], [group]);
expect(
(await database.rulesDao.queryProfileCustomRules(1).get()).single.id,
rule.id,
);
expect(
(await database.proxyGroupsDao.query(1).get()).single.id,
group.id,
);
},
);
test('database restore and custom data replace related records', () async {
const profile = Profile(id: 1, autoUpdateDuration: Duration.zero);
final script = Script(
+6 -4
View File
@@ -15,6 +15,7 @@ void main() {
lastUpdateDate: date,
overwriteType: OverwriteType.custom,
scriptId: 2,
matchTarget: 'Proxy',
autoUpdateDurationMillis: 3600000,
subscriptionInfo: const SubscriptionInfo(
upload: 1,
@@ -28,8 +29,8 @@ void main() {
order: 3,
);
expect(profile.toColumns(true), hasLength(13));
expect(profile.toCompanion(true).toColumns(true), hasLength(13));
expect(profile.toColumns(true), hasLength(14));
expect(profile.toCompanion(true).toColumns(true), hasLength(14));
expect(RawProfile.fromJson(profile.toJson()).toJson(), profile.toJson());
expect(profile.copyWith(label: 'Next').label, 'Next');
expect(
@@ -59,7 +60,7 @@ void main() {
unfoldSet: {},
);
expect(emptyProfile.toColumns(true), hasLength(8));
expect(emptyProfile.toColumns(false), hasLength(13));
expect(emptyProfile.toColumns(false), hasLength(14));
expect(emptyProfile.toCompanion(true).toColumns(true), hasLength(8));
final insertedProfile = ProfilesCompanion.insert(
@@ -82,6 +83,7 @@ void main() {
lastUpdateDate: Variable(date),
overwriteType: const Variable('custom'),
scriptId: const Variable(2),
matchTarget: const Variable('Proxy'),
autoUpdateDurationMillis: const Variable(60),
subscriptionInfo: const Variable('{}'),
autoUpdate: const Variable(true),
@@ -89,7 +91,7 @@ void main() {
unfoldSet: const Variable('[]'),
order: const Variable(1),
).toColumns(false),
hasLength(13),
hasLength(14),
);
final script = RawScript(id: 2, label: 'Script', lastUpdateTime: date);
+189
View File
@@ -0,0 +1,189 @@
import 'package:drift/native.dart';
import 'package:fl_clash/database/database.dart' as fl;
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqlite3/sqlite3.dart';
/// Rebuilds [raw] into the shape schema version 1 left behind: no
/// `proxy_groups`, no `icon_records`, and a `rules` table that still stores the
/// whole rule in one `value` column.
///
/// Drift creates the current schema outright on a fresh database, so walking a
/// real database back to v1 and reopening it is the only way to run the real
/// `onUpgrade` against a real SQLite file.
void _downgradeToV1(Database raw) {
_downgradeToV2(raw);
raw.execute('DROP TABLE IF EXISTS proxy_groups');
raw.execute('DROP TABLE IF EXISTS icon_records');
raw.execute('DROP INDEX IF EXISTS idx_rule_target');
raw.execute('DROP TABLE IF EXISTS rules');
raw.execute('''
CREATE TABLE rules (
id INTEGER NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)
''');
raw.execute('PRAGMA user_version = 1');
}
/// Schema version 2 had no `match_target` on `profiles`.
void _downgradeToV2(Database raw) {
raw.execute('ALTER TABLE profiles DROP COLUMN match_target');
raw.execute('PRAGMA user_version = 2');
}
Set<String> _columnsOf(Database raw, String table) => {
for (final row in raw.select('PRAGMA table_info($table)'))
row['name'] as String,
};
bool _hasTable(Database raw, String name) => raw.select(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
[name],
).isNotEmpty;
int _userVersion(Database raw) =>
raw.select('PRAGMA user_version').single['user_version'] as int;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Database raw;
setUp(() async {
raw = sqlite3.openInMemory();
final seed = fl.Database(
NativeDatabase.opened(raw, closeUnderlyingOnClose: false),
);
await seed.customSelect('SELECT 1').get();
await seed.close();
});
tearDown(() => raw.close());
Future<fl.Database> openAndMigrate() async {
final database = fl.Database(
NativeDatabase.opened(raw, closeUnderlyingOnClose: false),
);
addTearDown(database.close);
await database.customSelect('SELECT 1').get();
return database;
}
test('a v1 database is left at the current schema version', () async {
_downgradeToV1(raw);
expect(_userVersion(raw), 1);
await openAndMigrate();
expect(_userVersion(raw), 3);
});
test('the v3 upgrade adds match_target to profiles', () async {
_downgradeToV2(raw);
expect(_columnsOf(raw, 'profiles'), isNot(contains('match_target')));
await openAndMigrate();
expect(_columnsOf(raw, 'profiles'), contains('match_target'));
expect(_userVersion(raw), 3);
});
test(
'a v2 user_version with match_target already present still opens',
() async {
raw.execute('PRAGMA user_version = 2');
expect(_columnsOf(raw, 'profiles'), contains('match_target'));
await openAndMigrate();
expect(_columnsOf(raw, 'profiles'), contains('match_target'));
expect(_userVersion(raw), 3);
},
);
test('the upgrade creates the tables v2 added', () async {
_downgradeToV1(raw);
expect(_hasTable(raw, 'proxy_groups'), isFalse);
expect(_hasTable(raw, 'icon_records'), isFalse);
await openAndMigrate();
expect(_hasTable(raw, 'proxy_groups'), isTrue);
expect(_hasTable(raw, 'icon_records'), isTrue);
});
test('the upgrade splits the rules value column into parsed ones', () async {
_downgradeToV1(raw);
expect(_columnsOf(raw, 'rules'), {'id', 'value'});
await openAndMigrate();
expect(
_columnsOf(raw, 'rules'),
containsAll(<String>[
'rule_action',
'content',
'rule_target',
'rule_provider',
'sub_rule',
'no_resolve',
'src',
]),
);
expect(_columnsOf(raw, 'rules'), isNot(contains('value')));
});
test('every v1 rule row is parsed into the new columns', () async {
_downgradeToV1(raw);
raw.execute(
'INSERT INTO rules (id, value) '
"VALUES (1, 'DOMAIN-SUFFIX,example.com,DIRECT')",
);
raw.execute(
'INSERT INTO rules (id, value) '
"VALUES (2, 'IP-CIDR,10.0.0.0/8,REJECT,no-resolve')",
);
final database = await openAndMigrate();
final rows = await database
.customSelect(
'SELECT id, rule_action, content, rule_target, no_resolve '
'FROM rules ORDER BY id',
)
.get();
expect(rows, hasLength(2));
expect(rows[0].read<String>('rule_action'), RuleAction.DOMAIN_SUFFIX.name);
expect(rows[0].read<String>('content'), 'example.com');
expect(rows[0].read<String>('rule_target'), 'DIRECT');
expect(rows[0].read<int>('no_resolve'), 0);
expect(rows[1].read<String>('rule_action'), RuleAction.IP_CIDR.name);
expect(rows[1].read<String>('content'), '10.0.0.0/8');
expect(rows[1].read<String>('rule_target'), 'REJECT');
expect(
rows[1].read<int>('no_resolve'),
1,
reason: 'the no-resolve modifier has to survive the column split',
);
});
test('an empty v1 rules table still reaches v2', () async {
_downgradeToV1(raw);
final database = await openAndMigrate();
expect(_userVersion(raw), 3);
expect(await database.customSelect('SELECT * FROM rules').get(), isEmpty);
});
test('opening a database already at v2 changes nothing', () async {
final before = _columnsOf(raw, 'rules');
await openAndMigrate();
expect(_columnsOf(raw, 'rules'), before);
expect(_userVersion(raw), 3);
expect(_hasTable(raw, 'proxy_groups'), isTrue);
});
}