mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 18:50:56 +01:00
Allow OSM tiles in the sandbox, log metres
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
parent
1475ab913b
commit
d0bb8d700e
@ -7,7 +7,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:workout_app/services/pc_poke_wire.dart';
|
||||
|
||||
/// Makes every `dart:io` HTTP connection fail before a socket is opened --
|
||||
/// except the workout poke to the configured PC.
|
||||
/// except the workout poke to the configured PC and OSM map tiles.
|
||||
///
|
||||
/// Installed as `HttpOverrides.global` by `main` in the sandbox. Firebase and
|
||||
/// GitHub sync both go through `package:http`, whose IO client is built from
|
||||
@ -19,6 +19,11 @@ import 'package:workout_app/services/pc_poke_wire.dart';
|
||||
/// workout can time the PC round trip. The PC verifies the signed
|
||||
/// `"sandbox": true` and never writes or credits it. Firebase and GitHub are
|
||||
/// https on 443 and can never match.
|
||||
///
|
||||
/// The other exception is `https://tile.openstreetmap.org/{z}/{x}/{y}.png`
|
||||
/// (zoom 0-19) for the run map, under OSM's tile usage policy: the map sends
|
||||
/// a real User-Agent, caches tiles on disk, never prefetches, and nothing else
|
||||
/// in the app GETs that host.
|
||||
class SandboxHttpOverrides extends HttpOverrides {
|
||||
/// The PC host the poke may reach; set by `PcPairing` whenever the host is
|
||||
/// loaded or saved. Null means no exception at all.
|
||||
@ -32,6 +37,19 @@ class SandboxHttpOverrides extends HttpOverrides {
|
||||
uri.port == kPcPokePort &&
|
||||
uri.path == kPcPokePath;
|
||||
|
||||
/// The only tile host the sandbox may reach.
|
||||
static const tileHost = 'tile.openstreetmap.org';
|
||||
|
||||
static final _tilePath = RegExp(r'^/(\d|1[0-9])/\d+/\d+\.png$');
|
||||
|
||||
/// Whether [uri] is an OSM raster tile (https, port 443, zoom <= 19).
|
||||
static bool allowsTile(Uri uri) =>
|
||||
uri.scheme == 'https' &&
|
||||
uri.host == tileHost &&
|
||||
uri.port == 443 &&
|
||||
!uri.hasQuery &&
|
||||
_tilePath.hasMatch(uri.path);
|
||||
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
return super.createHttpClient(context)
|
||||
@ -42,6 +60,10 @@ class SandboxHttpOverrides extends HttpOverrides {
|
||||
);
|
||||
return Socket.startConnect(uri.host, uri.port);
|
||||
}
|
||||
if (proxyHost == null && allowsTile(uri)) {
|
||||
debugPrint('WorkoutSandbox: allowing OSM tile ${uri.path}');
|
||||
return Socket.startConnect(uri.host, uri.port);
|
||||
}
|
||||
debugPrint(
|
||||
'WorkoutSandbox: refused network connection to ${uri.host} — the '
|
||||
'sandbox never talks to Firebase or GitHub.',
|
||||
|
||||
@ -76,7 +76,7 @@ class _SandboxSectionState extends State<_SandboxSection> {
|
||||
const _SectionHeader('SANDBOX'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'This install is the sandbox: its own data, no network (only the '
|
||||
'This install is the sandbox: its own data, no sync (only the '
|
||||
'signed sandbox poke to the PC LINK address, which the PC never '
|
||||
'credits), no LAN server. Nothing here can reach the daily build.',
|
||||
style: TextStyle(
|
||||
|
||||
@ -102,7 +102,7 @@ class RunRecorder extends ChangeNotifier {
|
||||
if (_phase != RunPhase.recording) return;
|
||||
_filter!.breakSegment();
|
||||
_phase = RunPhase.paused;
|
||||
SandboxLog.event('run paused', {'km': distanceM});
|
||||
SandboxLog.event('run paused', {'m': distanceM});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ class RunRecorder extends ChangeNotifier {
|
||||
void resume() {
|
||||
if (_phase != RunPhase.paused) return;
|
||||
_phase = RunPhase.recording;
|
||||
SandboxLog.event('run resumed', {'km': distanceM});
|
||||
SandboxLog.event('run resumed', {'m': distanceM});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -141,7 +141,7 @@ class RunRecorder extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
SandboxLog.event('run finished', {
|
||||
'km': distanceM,
|
||||
'm': distanceM,
|
||||
'secs': _moving,
|
||||
'points': points.length,
|
||||
'saved': track != null,
|
||||
@ -223,7 +223,7 @@ class RunRecorder extends ChangeNotifier {
|
||||
'finish the run to save what was captured.',
|
||||
level: 900,
|
||||
);
|
||||
SandboxLog.event('run stream ended', {'km': distanceM});
|
||||
SandboxLog.event('run stream ended', {'m': distanceM});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -9,7 +9,8 @@ void main() {
|
||||
final printed = <String>[];
|
||||
setUp(() {
|
||||
printed.clear();
|
||||
debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? '');
|
||||
debugPrint = (String? message, {int? wrapWidth}) =>
|
||||
printed.add(message ?? '');
|
||||
});
|
||||
tearDown(() {
|
||||
debugPrint = debugPrintThrottled;
|
||||
@ -24,7 +25,10 @@ void main() {
|
||||
() async => client.getUrl(Uri.parse('http://firebase.example.invalid/x')),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
expect(printed.single, contains('refused network connection to firebase.example.invalid'));
|
||||
expect(
|
||||
printed.single,
|
||||
contains('refused network connection to firebase.example.invalid'),
|
||||
);
|
||||
client.close(force: true);
|
||||
});
|
||||
|
||||
@ -54,9 +58,8 @@ void main() {
|
||||
SandboxHttpOverrides.pokeHost = host;
|
||||
final client = SandboxHttpOverrides().createHttpClient(null);
|
||||
await expectLater(
|
||||
() async => client.postUrl(
|
||||
Uri.parse('https://$host:$kPcPokePort$kPcPokePath'),
|
||||
),
|
||||
() async =>
|
||||
client.postUrl(Uri.parse('https://$host:$kPcPokePort$kPcPokePath')),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
expect(printed.single, contains('refused network connection to $host'));
|
||||
@ -72,7 +75,10 @@ void main() {
|
||||
),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
expect(printed.single, contains('refused network connection to 10.0.0.9'));
|
||||
expect(
|
||||
printed.single,
|
||||
contains('refused network connection to 10.0.0.9'),
|
||||
);
|
||||
client.close(force: true);
|
||||
});
|
||||
|
||||
@ -92,8 +98,98 @@ void main() {
|
||||
if (outcome is SocketException) {
|
||||
expect(outcome.message, isNot(contains('sandbox')));
|
||||
}
|
||||
expect(printed.first, contains('allowing the PC poke to $host:$kPcPokePort'));
|
||||
expect(
|
||||
printed.first,
|
||||
contains('allowing the PC poke to $host:$kPcPokePort'),
|
||||
);
|
||||
client.close(force: true);
|
||||
});
|
||||
});
|
||||
|
||||
group('the OSM tile exception', () {
|
||||
Uri tile([
|
||||
String path = '/17/70123/42650.png',
|
||||
String host = 'tile.openstreetmap.org',
|
||||
]) => Uri.parse('https://$host$path');
|
||||
|
||||
test('allows https tiles on the one host, zoom 0 to 19', () {
|
||||
for (final path in [
|
||||
'/0/0/0.png',
|
||||
'/9/1/2.png',
|
||||
'/17/70123/42650.png',
|
||||
'/19/1/2.png',
|
||||
]) {
|
||||
expect(
|
||||
SandboxHttpOverrides.allowsTile(tile(path)),
|
||||
isTrue,
|
||||
reason: path,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses everything else, including look-alike hosts', () {
|
||||
for (final other in [
|
||||
tile('/20/1/2.png'),
|
||||
tile('/17/1/2.png?x=1'),
|
||||
tile('/17/1/2.jpg'),
|
||||
tile('/'),
|
||||
tile('/api/0.6/map'),
|
||||
tile('/17/1/2.png', 'a.tile.openstreetmap.org'),
|
||||
tile('/17/1/2.png', 'openstreetmap.org'),
|
||||
tile('/17/1/2.png', 'tile.openstreetmap.org.evil.example'),
|
||||
tile('/17/1/2.png').replace(scheme: 'http', port: 80),
|
||||
tile('/17/1/2.png').replace(port: 8443),
|
||||
Uri.parse('https://kuhy-cloud.duckdns.org/x.json'),
|
||||
]) {
|
||||
expect(
|
||||
SandboxHttpOverrides.allowsTile(other),
|
||||
isFalse,
|
||||
reason: '$other',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a tile reaches the socket layer, not the sandbox refusal', () async {
|
||||
final client = SandboxHttpOverrides().createHttpClient(null);
|
||||
Object outcome = 'connected';
|
||||
try {
|
||||
final request = await client.getUrl(tile());
|
||||
request.abort();
|
||||
} on SocketException catch (error) {
|
||||
outcome = error;
|
||||
}
|
||||
if (outcome is SocketException) {
|
||||
expect(outcome.message, isNot(contains('sandbox')));
|
||||
}
|
||||
expect(printed.first, contains('allowing OSM tile /17/70123/42650.png'));
|
||||
client.close(force: true);
|
||||
});
|
||||
|
||||
test('kuhy-cloud and a tile-looking path elsewhere stay refused', () async {
|
||||
final client = SandboxHttpOverrides().createHttpClient(null);
|
||||
for (final uri in [
|
||||
Uri.parse('https://kuhy-cloud.duckdns.org/17/1/2.png'),
|
||||
Uri.parse('https://kuhy-cloud.duckdns.org/x.json'),
|
||||
]) {
|
||||
await expectLater(
|
||||
() => client.getUrl(uri),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
}
|
||||
expect(printed, hasLength(2));
|
||||
expect(
|
||||
printed.first,
|
||||
contains('refused network connection to kuhy-cloud.duckdns.org'),
|
||||
);
|
||||
client.close(force: true);
|
||||
});
|
||||
|
||||
test('the PC poke is unchanged next to the tile exception', () {
|
||||
SandboxHttpOverrides.pokeHost = '127.0.0.1';
|
||||
final poke = Uri.parse('http://127.0.0.1:$kPcPokePort$kPcPokePath');
|
||||
expect(SandboxHttpOverrides.allows(poke), isTrue);
|
||||
expect(SandboxHttpOverrides.allowsTile(poke), isFalse);
|
||||
expect(SandboxHttpOverrides.allows(tile()), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/position_source_scripted.dart';
|
||||
import 'package:workout_app/services/run_recorder.dart';
|
||||
|
||||
@ -51,6 +53,34 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('the sandbox trace labels distance in metres, not km', () async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final printed = <String>[];
|
||||
debugPrint = (message, {wrapWidth}) => printed.add(message ?? '');
|
||||
Sandbox.enabled = true;
|
||||
addTearDown(() {
|
||||
debugPrint = debugPrintThrottled;
|
||||
Sandbox.enabled = false;
|
||||
});
|
||||
final recorder = RunRecorder(
|
||||
sourceFor: (_) => ScriptedPositionSource(straightRun(340)),
|
||||
);
|
||||
await recorder.start();
|
||||
await pumpEventQueue();
|
||||
await recorder.finish();
|
||||
final lines = printed.where(
|
||||
(l) => l.contains('run stream ended') || l.contains('run finished'),
|
||||
);
|
||||
expect(lines, hasLength(2));
|
||||
expect(
|
||||
lines.first,
|
||||
startsWith('WorkoutSandbox: run stream ended {m: 20'),
|
||||
);
|
||||
expect(lines.last, startsWith('WorkoutSandbox: run finished {m: 20'));
|
||||
expect(lines.join(), isNot(contains('km:')));
|
||||
recorder.dispose();
|
||||
});
|
||||
|
||||
test('a stream closing after finish is ignored', () async {
|
||||
final source = ManualPositionSource();
|
||||
final recorder = _recorder(source);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user