feat: app Flutter GameManager (OIDC pocket-id, contrôle serveurs, MOTD live, auto-update) + CI APK signée

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
neckfire
2026-07-13 09:13:55 +02:00
co-authored by Claude Opus 4.8
parent 2c5476b1db
commit 3286640b37
14 changed files with 969 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config.dart';
import '../models.dart';
import 'auth_service.dart';
/// Client de l'API GameManager (Bearer JWT pocket-id).
class ApiService {
ApiService(this._auth);
final AuthService _auth;
Future<String> get baseUrl async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('api_base') ?? Config.defaultApiBase;
}
Future<void> setBaseUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('api_base', url.trim().replaceAll(RegExp(r'/$'), ''));
}
Future<Map<String, String>> _headers() async {
final token = await _auth.validToken();
return {'Authorization': 'Bearer ${token ?? ''}', 'Accept': 'application/json'};
}
Future<List<GameServer>> servers() async {
final base = await baseUrl;
final res = await http.get(Uri.parse('$base/api/servers'), headers: await _headers());
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode != 200) throw ApiException('Erreur ${res.statusCode}');
final list = jsonDecode(res.body) as List;
return list.map((e) => GameServer.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> start(String id) => _action(id, 'start');
Future<void> stop(String id) => _action(id, 'stop');
Future<void> _action(String id, String action) async {
final base = await baseUrl;
final res = await http.post(
Uri.parse('$base/api/servers/$id/$action'),
headers: await _headers(),
);
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode >= 400) throw ApiException('Action refusée (${res.statusCode})');
}
Future<String> iconUrl(String id) async => '${await baseUrl}/api/servers/$id/icon';
}
class UnauthorizedException implements Exception {}
class ApiException implements Exception {
ApiException(this.message);
final String message;
@override
String toString() => message;
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config.dart';
/// Authentification OIDC pocket-id (Authorization Code + PKCE, client public).
/// Le jeton d'identité (JWT) est stocké de façon sécurisée et envoyé à l'API.
class AuthService {
final _appAuth = const FlutterAppAuth();
final _storage = const FlutterSecureStorage();
static const _kIdToken = 'id_token';
static const _kRefresh = 'refresh_token';
static const _kExpiry = 'access_expiry';
Future<String?> get idToken => _storage.read(key: _kIdToken);
Future<bool> isLoggedIn() async => (await idToken) != null;
Future<bool> login() async {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
Config.oidcClientId,
Config.oidcRedirect,
issuer: Config.oidcIssuer,
scopes: Config.oidcScopes,
promptValues: ['login'],
),
);
if (result.idToken == null) return false;
await _storage.write(key: _kIdToken, value: result.idToken);
await _storage.write(key: _kRefresh, value: result.refreshToken);
await _storage.write(
key: _kExpiry,
value: result.accessTokenExpirationDateTime?.toIso8601String(),
);
return true;
}
/// Rafraîchit le jeton si expiré (via refresh_token), renvoie l'id_token courant.
Future<String?> validToken() async {
final expiry = await _storage.read(key: _kExpiry);
final refresh = await _storage.read(key: _kRefresh);
if (expiry != null && refresh != null) {
final exp = DateTime.tryParse(expiry);
if (exp != null && exp.isBefore(DateTime.now().add(const Duration(seconds: 30)))) {
try {
final r = await _appAuth.token(TokenRequest(
Config.oidcClientId,
Config.oidcRedirect,
issuer: Config.oidcIssuer,
refreshToken: refresh,
scopes: Config.oidcScopes,
));
if (r.idToken != null) {
await _storage.write(key: _kIdToken, value: r.idToken);
await _storage.write(key: _kRefresh, value: r.refreshToken ?? refresh);
await _storage.write(
key: _kExpiry,
value: r.accessTokenExpirationDateTime?.toIso8601String(),
);
}
} catch (_) {/* on garde l'ancien token, l'API renverra 401 si mort */}
}
}
return idToken;
}
Future<void> logout() async => _storage.deleteAll();
}
+43
View File
@@ -0,0 +1,43 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:ota_update/ota_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// Vérifie s'il existe une version plus récente (API `/version`) et, si oui,
/// télécharge + installe l'APK (hors Play Store).
class UpdateService {
UpdateService(this._baseUrl);
final Future<String> _baseUrl;
Future<UpdateInfo?> check() async {
try {
final base = await _baseUrl;
final res = await http.get(Uri.parse('$base/version')).timeout(const Duration(seconds: 6));
if (res.statusCode != 200) return null;
final data = jsonDecode(res.body) as Map<String, dynamic>;
final remote = data['version_code'] as int? ?? 0;
final info = await PackageInfo.fromPlatform();
final current = int.tryParse(info.buildNumber) ?? 0;
if (remote > current) {
return UpdateInfo(
version: data['version'] as String? ?? '?',
apkUrl: data['apk_url'] as String? ?? '$base/download/apk',
notes: data['notes'] as String? ?? '',
);
}
} catch (_) {/* pas de réseau / pas d'update → on ignore */}
return null;
}
/// Lance le téléchargement + l'installation ; renvoie le flux de progression.
Stream<OtaEvent> apply(UpdateInfo info) =>
OtaUpdate().execute(info.apkUrl, destinationFilename: 'gamemanager.apk');
}
class UpdateInfo {
UpdateInfo({required this.version, required this.apkUrl, required this.notes});
final String version;
final String apkUrl;
final String notes;
}