h-1.flutter.0/lib/services/mothership_client.dart
2026-03-02 11:06:47 +09:00

154 lines
4.5 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../utils/build_expiry_info.dart';
import 'app_settings_repository.dart';
class MothershipClient {
MothershipClient({AppSettingsRepository? settingsRepository, http.Client? httpClient})
: _settingsRepository = settingsRepository ?? AppSettingsRepository(),
_httpClient = httpClient;
final AppSettingsRepository _settingsRepository;
final http.Client? _httpClient;
static const _clientIdKey = 'mothership_client_id';
static const _hostSettingKey = 'external_host';
static const _apiKeySettingKey = 'external_pass';
Future<void> sendHeartbeat(BuildExpiryInfo expiryInfo) async {
final config = await loadConfig();
if (config == null) {
debugPrint('[Mothership] Heartbeat skipped: config not set');
return;
}
final clientId = await ensureClientId();
final remaining = expiryInfo.remaining?.inSeconds;
final payload = <String, dynamic>{'clientId': clientId};
if (remaining != null) {
payload['remainingLifespanSeconds'] = remaining;
}
await _postJson(
uri: config.heartbeatUri,
apiKey: config.apiKey,
payload: payload,
logLabel: 'heartbeat',
);
}
Future<void> sendHash(String hash) async {
final config = await loadConfig();
if (config == null) {
debugPrint('[Mothership] Hash push skipped: config not set');
return;
}
final clientId = await ensureClientId();
await _postJson(
uri: config.hashUri,
apiKey: config.apiKey,
payload: {
'clientId': clientId,
'hash': hash,
},
logLabel: 'hash',
);
}
Future<MothershipEndpointConfig?> loadConfig() async {
final host = (await _settingsRepository.getString(_hostSettingKey))?.trim();
final apiKey = (await _settingsRepository.getString(_apiKeySettingKey))?.trim();
if (host == null || host.isEmpty || apiKey == null || apiKey.isEmpty) {
return null;
}
try {
final base = _normalizeBaseUri(host);
return MothershipEndpointConfig(
apiKey: apiKey,
heartbeatUri: base.resolve('/sync/heartbeat'),
hashUri: base.resolve('/sync/hash'),
chatSendUri: base.resolve('/chat/send'),
chatPendingUri: base.resolve('/chat/pending'),
chatAckUri: base.resolve('/chat/ack'),
);
} on FormatException catch (err) {
debugPrint('[Mothership] Invalid host "$host": $err');
return null;
}
}
Uri _normalizeBaseUri(String host) {
var normalized = host.trim();
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
normalized = 'http://$normalized';
}
if (!normalized.endsWith('/')) {
normalized = '$normalized/';
}
return Uri.parse(normalized);
}
Future<String> ensureClientId() async {
final prefs = await SharedPreferences.getInstance();
final existing = prefs.getString(_clientIdKey);
if (existing != null && existing.isNotEmpty) {
return existing;
}
final newId = const Uuid().v4();
await prefs.setString(_clientIdKey, newId);
return newId;
}
Future<void> _postJson({
required Uri uri,
required String apiKey,
required Map<String, dynamic> payload,
required String logLabel,
}) async {
final client = _httpClient ?? http.Client();
try {
final response = await client.post(
uri,
headers: {
HttpHeaders.contentTypeHeader: 'application/json',
'x-api-key': apiKey,
},
body: jsonEncode(payload),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
debugPrint('[Mothership] $logLabel OK (${response.statusCode})');
} else {
debugPrint('[Mothership] $logLabel failed: ${response.statusCode} ${response.body}');
}
} catch (err, stack) {
debugPrint('[Mothership] $logLabel error: $err');
debugPrint('$stack');
} finally {
if (_httpClient == null) {
client.close();
}
}
}
}
class MothershipEndpointConfig {
MothershipEndpointConfig({
required this.apiKey,
required this.heartbeatUri,
required this.hashUri,
required this.chatSendUri,
required this.chatPendingUri,
required this.chatAckUri,
});
final String apiKey;
final Uri heartbeatUri;
final Uri hashUri;
final Uri chatSendUri;
final Uri chatPendingUri;
final Uri chatAckUri;
}