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:
co-authored by
Claude Opus 4.8
parent
2c5476b1db
commit
3286640b37
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user