feat(app): secrets kept out of screenshots on macOS and Windows too

The reading of the full reading's fixes noted that macOS and Windows can
keep a window out of screen captures, which SecretScreen didn't ask.

PlatformScreenGuard now asks the app's window on Android, macOS and
Windows (asksOn), and both apps' runners answer app.halkyon/screen_guard,
counting what shows: macOS's MainFlutterWindow sets sharingType = .none,
Windows' FlutterWindow WDA_EXCLUDEFROMCAPTURE (Windows 10 2004 on;
before, WDA_MONITOR, black in captures). Neither runner was built here
(Linux): to be tried on each system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 16:40:43 +02:00
commit b9bf9813c5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 218 additions and 19 deletions

View file

@ -87,10 +87,13 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
through Flutter, so clipboard histories and cloud clipboards would keep
it): the recovery phrase and codes are saved or written down, an
authenticator's key scanned, opened or typed; all of them show in a
`SecretScreen`, kept out of screenshots and recents on Android
(`FLAG_SECURE`: the app's `MainActivity` answers `app.halkyon/screen_guard`,
as Learn's and Todo's `ScreenGuard.kt` do) and covered on iOS while the app
isn't in front; keyboards learn nothing typed into a password, a code or
`SecretScreen` (the phrase typed back too), kept out of screenshots,
screen recordings and recents where the app's window answers
`app.halkyon/screen_guard`: Android's `FLAG_SECURE` (as Learn's and Todo's
`ScreenGuard.kt` in their `MainActivity`), macOS's `sharingType = .none`
(`MainFlutterWindow.swift`) and Windows' `WDA_EXCLUDEFROMCAPTURE`
(`flutter_window.cpp`); covered on iOS while the app isn't in front;
nothing on Linux; keyboards learn nothing typed into a password, a code or
the phrase; no state's text (which a debug build's bloc observer logs) holds
a secret, a code or the address; only an `otpauth:` URI is shown as a QR
code or opened; and anything a step didn't expect (bytes of the wrong

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
@ -15,16 +16,29 @@ abstract interface class ScreenGuard() {
Future<void> release();
}
/// [ScreenGuard] through Android's `FLAG_SECURE`, which each app's
/// `MainActivity` sets while any is held (`ScreenGuard.kt`, counting them,
/// as several may show at once); nothing to do elsewhere: iOS has no such
/// flag (its app switcher's picture is covered by [SecretScreen] instead),
/// and desktops none either.
class const PlatformScreenGuard({final bool? android}) implements ScreenGuard {
/// The channel each app's activity answers.
/// [ScreenGuard] through the app's window, which each app's runner keeps
/// out while any is held, counting them, as several may show at once:
/// Android's `FLAG_SECURE` (`ScreenGuard.kt` in its `MainActivity`), macOS's
/// `sharingType = .none` (`MainFlutterWindow.swift`) and Windows'
/// `WDA_EXCLUDEFROMCAPTURE` (`flutter_window.cpp`; from Windows 10 2004 on).
/// Nothing to ask elsewhere: iOS has no such flag (its app switcher's
/// picture is covered by [SecretScreen] instead), and Linux's desktops
/// none either.
class const PlatformScreenGuard({final bool? asks}) implements ScreenGuard {
/// The channel each app's window answers.
static const channel = MethodChannel('app.halkyon/screen_guard');
bool get _android => android ?? Platform.isAndroid;
/// Whether the app's window on [platform] answers [channel].
static bool asksOn(TargetPlatform platform) => switch (platform) {
TargetPlatform.android ||
TargetPlatform.macOS ||
TargetPlatform.windows => true,
TargetPlatform.iOS ||
TargetPlatform.linux ||
TargetPlatform.fuchsia => false,
};
bool get _asks => asks ?? asksOn(defaultTargetPlatform);
@override
Future<void> hold() => _ask('hold');
@ -33,7 +47,7 @@ class const PlatformScreenGuard({final bool? android}) implements ScreenGuard {
Future<void> release() => _ask('release');
Future<void> _ask(String method) async {
if (!_android) {
if (!_asks) {
return;
}
try {

View file

@ -91,9 +91,9 @@ void main() {
}
});
testWidgets("the platform's guard asks Android's activity, counted", (
tester,
) async {
testWidgets("the platform's guard asks the app's window, counted: "
"Android's activity, and macOS's and Windows' windows (the reading of "
'the fixes)', (tester) async {
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
PlatformScreenGuard.channel,
@ -108,14 +108,22 @@ void main() {
null,
),
);
const guard = PlatformScreenGuard(android: true);
const guard = PlatformScreenGuard(asks: true);
await guard.hold();
await guard.release();
expect(calls, ['hold', 'release']);
// Elsewhere, nothing to ask.
// Elsewhere (iOS, Linux), nothing to ask.
calls.clear();
await const PlatformScreenGuard(android: false).hold();
await const PlatformScreenGuard(asks: false).hold();
expect(calls, isEmpty);
expect(
PlatformScreenGuard.asksOn(TargetPlatform.macOS),
isTrue,
);
expect(PlatformScreenGuard.asksOn(TargetPlatform.windows), isTrue);
expect(PlatformScreenGuard.asksOn(TargetPlatform.android), isTrue);
expect(PlatformScreenGuard.asksOn(TargetPlatform.iOS), isFalse);
expect(PlatformScreenGuard.asksOn(TargetPlatform.linux), isFalse);
});
}