Skip to content

Commit 57c3dcc

Browse files
committed
intent gatekeeper initial
1 parent 5e6cc7a commit 57c3dcc

27 files changed

Lines changed: 1413 additions & 12 deletions

File tree

apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
5353
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
5454
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
5555
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
56+
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
57+
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
58+
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
59+
import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart';
60+
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
5661
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
5762
import 'package:weblibre/features/user/data/models/general_settings.dart';
5863
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
@@ -347,6 +352,37 @@ class _BrowserViewState extends ConsumerState<BrowserView>
347352
}
348353
});
349354

355+
ref.listenManual<AsyncValue<PendingIntentDecision>>(
356+
intentGatekeeperProvider,
357+
(previous, next) async {
358+
final request = next.value;
359+
if (request == null) {
360+
return;
361+
}
362+
363+
final gatekeeper = ref.read(intentGatekeeperProvider.notifier);
364+
if (!context.mounted) {
365+
await gatekeeper.resolve(
366+
id: request.id,
367+
decision: IntentSourcePolicy.block,
368+
);
369+
return;
370+
}
371+
372+
final outcome = await showDialog<DialogOutcome>(
373+
context: context,
374+
builder: (context) => IntentGatekeeperDialog(request: request),
375+
);
376+
377+
await gatekeeper.resolve(
378+
id: request.id,
379+
decision: outcome?.decision ?? IntentSourcePolicy.block,
380+
persist: outcome?.persist ?? false,
381+
packageName: request.packageName,
382+
);
383+
},
384+
);
385+
350386
ref.listenManual(
351387
engineBoundIntentStreamProvider,
352388
(previous, next) {
@@ -443,6 +479,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
443479
},
444480
);
445481

482+
ref.listenManual(
483+
fireImmediately: true,
484+
nativeIntentGatekeeperReplicatorProvider,
485+
(previous, next) {},
486+
onError: (error, stackTrace) {
487+
logger.e(
488+
'Error listening to nativeIntentGatekeeperReplicatorProvider',
489+
error: error,
490+
stackTrace: stackTrace,
491+
);
492+
},
493+
);
494+
446495
ref.listenManual(
447496
fireImmediately: true,
448497
selectionActionServiceProvider,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright (c) 2024-2026 Fabian Freund.
3+
*
4+
* This file is part of WebLibre
5+
* (see https://weblibre.eu).
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License as
9+
* published by the Free Software Foundation, either version 3 of the
10+
* License, or (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
enum IntentSourcePolicy { allow, block }
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright (c) 2024-2026 Fabian Freund.
3+
*
4+
* This file is part of WebLibre
5+
* (see https://weblibre.eu).
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License as
9+
* published by the Free Software Foundation, either version 3 of the
10+
* License, or (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
import 'package:fast_equatable/fast_equatable.dart';
21+
22+
class PendingIntentDecision with FastEquatable {
23+
final int id;
24+
final String packageName;
25+
final String? url;
26+
27+
PendingIntentDecision({
28+
required this.id,
29+
required this.packageName,
30+
required this.url,
31+
});
32+
33+
@override
34+
List<Object?> get hashParameters => [id, packageName, url];
35+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright (c) 2024-2026 Fabian Freund.
3+
*
4+
* This file is part of WebLibre
5+
* (see https://weblibre.eu).
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License as
9+
* published by the Free Software Foundation, either version 3 of the
10+
* License, or (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
import 'dart:async';
21+
22+
import 'package:riverpod_annotation/riverpod_annotation.dart';
23+
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
24+
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
25+
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
26+
import 'package:weblibre/features/user/data/models/general_settings.dart';
27+
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
28+
29+
part 'intent_gatekeeper.g.dart';
30+
31+
const _ownPackageName = 'eu.weblibre.gecko';
32+
33+
@Riverpod(keepAlive: true)
34+
class IntentGatekeeper extends _$IntentGatekeeper {
35+
late StreamController<PendingIntentDecision> _decisionRequests;
36+
final _pending = <int, Completer<bool>>{};
37+
int _nextId = 0;
38+
39+
@override
40+
Stream<PendingIntentDecision> build() {
41+
_decisionRequests = StreamController<PendingIntentDecision>.broadcast();
42+
43+
ref.onDispose(() async {
44+
for (final completer in _pending.values) {
45+
if (!completer.isCompleted) {
46+
completer.complete(false);
47+
}
48+
}
49+
50+
_pending.clear();
51+
52+
await _decisionRequests.close();
53+
});
54+
55+
return _decisionRequests.stream;
56+
}
57+
58+
/// Resolves whether an intent coming from [fromPackageName] targeting [url]
59+
/// should be allowed through. If the user has to decide, this waits for
60+
/// [resolve] to be called with the matching decision id.
61+
Future<bool> shouldAllow({
62+
required String? fromPackageName,
63+
required String? url,
64+
}) async {
65+
final settings = ref.read(generalSettingsWithDefaultsProvider);
66+
67+
if (!settings.blockExternalAppsEnabled) {
68+
return true;
69+
}
70+
71+
// Internal / unknown callers: no package to gate on — let through.
72+
if (fromPackageName == null || fromPackageName == _ownPackageName) {
73+
return true;
74+
}
75+
76+
final existing = settings.externalAppIntentPolicies[fromPackageName];
77+
if (existing == IntentSourcePolicy.allow) {
78+
return true;
79+
}
80+
if (existing == IntentSourcePolicy.block) {
81+
return false;
82+
}
83+
84+
final id = _nextId++;
85+
final completer = Completer<bool>();
86+
_pending[id] = completer;
87+
88+
_decisionRequests.add(
89+
PendingIntentDecision(id: id, packageName: fromPackageName, url: url),
90+
);
91+
92+
return completer.future;
93+
}
94+
95+
Future<void> resolve({
96+
required int id,
97+
required IntentSourcePolicy decision,
98+
bool persist = false,
99+
String? packageName,
100+
}) async {
101+
final completer = _pending.remove(id);
102+
completer?.complete(decision == IntentSourcePolicy.allow);
103+
104+
if (persist && packageName != null) {
105+
await ref
106+
.read(saveGeneralSettingsControllerProvider.notifier)
107+
.save(
108+
(current) => current.copyWith.externalAppIntentPolicies({
109+
...current.externalAppIntentPolicies,
110+
packageName: decision,
111+
}),
112+
);
113+
}
114+
}
115+
}

apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.g.dart

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright (c) 2024-2026 Fabian Freund.
3+
*
4+
* This file is part of WebLibre
5+
* (see https://weblibre.eu).
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License as
9+
* published by the Free Software Foundation, either version 3 of the
10+
* License, or (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
import 'dart:async';
21+
22+
import 'package:collection/collection.dart';
23+
import 'package:fast_equatable/fast_equatable.dart';
24+
import 'package:hooks_riverpod/hooks_riverpod.dart';
25+
import 'package:riverpod_annotation/riverpod_annotation.dart';
26+
import 'package:simple_intent_receiver/simple_intent_receiver.dart';
27+
import 'package:weblibre/core/logger.dart';
28+
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
29+
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
30+
31+
part 'native_gatekeeper_replicator.g.dart';
32+
33+
/// Mirrors the Flutter-side block list to the native side so the
34+
/// `IntentReceiverActivity` can reject intents without launching Flutter.
35+
/// Only blocked packages are replicated — allow/unknown still fall through to
36+
/// the Flutter gatekeeper dialog.
37+
@Riverpod(keepAlive: true)
38+
class NativeIntentGatekeeperReplicator
39+
extends _$NativeIntentGatekeeperReplicator {
40+
final _api = IntentGatekeeperHostApi();
41+
42+
Future<void> _push(
43+
({bool enabled, Map<String, IntentSourcePolicy> policies}) config,
44+
) async {
45+
final blocked = config.policies.entries
46+
.where((entry) => entry.value == IntentSourcePolicy.block)
47+
.map((entry) => entry.key)
48+
.toList();
49+
50+
try {
51+
await _api.setConfig(config.enabled, blocked);
52+
} catch (error, stackTrace) {
53+
logger.e(
54+
'Failed to replicate intent gatekeeper config to native',
55+
error: error,
56+
stackTrace: stackTrace,
57+
);
58+
}
59+
}
60+
61+
@override
62+
void build() {
63+
ref.listen(
64+
generalSettingsWithDefaultsProvider.select(
65+
(settings) => EquatableValue((
66+
enabled: settings.blockExternalAppsEnabled,
67+
policies: settings.externalAppIntentPolicies,
68+
)),
69+
),
70+
fireImmediately: true,
71+
(p, n) {
72+
final previous = p?.value;
73+
final next = n.value;
74+
75+
if (previous != null &&
76+
previous.enabled == next.enabled &&
77+
const DeepCollectionEquality.unordered().equals(
78+
previous.policies,
79+
next.policies,
80+
)) {
81+
return;
82+
}
83+
unawaited(_push(next));
84+
},
85+
);
86+
}
87+
}

0 commit comments

Comments
 (0)