Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../about/internal/application/ServerProfile.dart';
import 'SecretStore.dart';
/// Persists server profiles (with their nested services) locally. Non-secret
/// data goes to shared_preferences; passwords go to encrypted [SecretStore].
class ConfigStore {
static const _key = 'peach.servers.v2';
/// The key used before the app was renamed. Read once and migrated forward,
/// so an existing install keeps its servers.
static const _legacyKey = 'jarpilot.servers.v2';
final _secrets = SecretStore();
Future<List<ServerProfile>> load() async {
final prefs = await SharedPreferences.getInstance();
var raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) {
raw = prefs.getString(_legacyKey);
if (raw != null && raw.isNotEmpty) {
await prefs.setString(_key, raw);
await prefs.remove(_legacyKey);
}
}
if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>;
final servers = list
.map((e) => ServerProfile.fromJson(e as Map<String, dynamic>))
.toList();
// Hydrate passwords from encrypted storage.
for (final s in servers) {
s.password = await _secrets.readPassword(s.id) ?? '';
}
return servers;
}
Future<void> save(List<ServerProfile> servers) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
jsonEncode(servers.map((s) => s.toJson()).toList()),
);
for (final s in servers) {
await _secrets.writePassword(s.id, s.password);
}
}
Future<void> deleteSecret(String serverId) =>
_secrets.deletePassword(serverId);
}

View File

@@ -0,0 +1,24 @@
import '../about/internal/application/DeploymentRecord.dart';
import '../about/internal/application/ServerProfile.dart';
/// Everything Peach persists on this device. Server profiles and their nested
/// services live here; passwords are held separately in the OS keystore and
/// hydrated onto the profiles on read, so a caller never handles the two
/// stores independently.
abstract class ConnectInternalMemory {
Future<List<ServerProfile>> getServers();
Future setServers(List<ServerProfile> servers);
Future deleteServerSecret(String serverId);
Future<List<DeploymentRecord>> getDeployments();
Future<List<DeploymentRecord>> addDeployment(DeploymentRecord record);
Future clearDeployments();
Future<String> getThemeMode();
Future setThemeMode(String mode);
}

View File

@@ -0,0 +1,49 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../about/internal/application/DeploymentRecord.dart';
/// Persists the deployment history so successes/failures can be tracked.
class DeploymentStore {
static const _key = 'peach.deployments.v1';
/// The key used before the app was renamed; migrated forward on first read.
static const _legacyKey = 'jarpilot.deployments.v1';
static const _max = 300;
Future<List<DeploymentRecord>> load() async {
final prefs = await SharedPreferences.getInstance();
var raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) {
raw = prefs.getString(_legacyKey);
if (raw != null && raw.isNotEmpty) {
await prefs.setString(_key, raw);
await prefs.remove(_legacyKey);
}
}
if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>;
final records = list
.map((e) => DeploymentRecord.fromJson(e as Map<String, dynamic>))
.toList();
records.sort((a, b) => b.time.compareTo(a.time));
return records;
}
Future<List<DeploymentRecord>> add(DeploymentRecord record) async {
final records = await load();
records.insert(0, record);
if (records.length > _max) records.removeRange(_max, records.length);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
jsonEncode(records.map((r) => r.toJson()).toList()),
);
return records;
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../about/internal/application/DeploymentRecord.dart';
import '../about/internal/application/ServerProfile.dart';
import 'ConfigStore.dart';
import 'ConnectInternalMemory.dart';
import 'DeploymentStore.dart';
/// The one concrete [ConnectInternalMemory]. Non-secret data goes to
/// shared_preferences via the two stores; passwords go to the OS keystore via
/// [SecretStore] — reads hydrate them back onto the profiles so callers only
/// ever see whole [ServerProfile]s.
class InternalMemory implements ConnectInternalMemory {
static const _themeKey = 'peach.thememode';
final ConfigStore _config = ConfigStore();
final DeploymentStore _deployments = DeploymentStore();
@override
Future<List<ServerProfile>> getServers() => _config.load();
@override
Future setServers(List<ServerProfile> servers) => _config.save(servers);
@override
Future deleteServerSecret(String serverId) => _config.deleteSecret(serverId);
@override
Future<List<DeploymentRecord>> getDeployments() => _deployments.load();
@override
Future<List<DeploymentRecord>> addDeployment(DeploymentRecord record) =>
_deployments.add(record);
@override
Future clearDeployments() => _deployments.clear();
@override
Future<String> getThemeMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_themeKey) ?? 'dark';
}
@override
Future setThemeMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeKey, mode);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Encrypted storage backed by the OS keystore: Keychain (macOS/iOS),
/// Keystore/EncryptedSharedPreferences (Android), DPAPI (Windows) and
/// libsecret (Linux). Server passwords live here; so does anything else the
/// `internal_memory` brick generates with `secure: true`.
class SecretStore {
static const _storage = FlutterSecureStorage(aOptions: AndroidOptions());
static String _passwordKey(String serverId) => 'peach.pw.$serverId';
/// The key this app used before it was renamed. Reads fall back to it once
/// and migrate the value forward, so an existing install doesn't silently
/// lose every stored password on upgrade.
static String _legacyPasswordKey(String serverId) => 'jarpilot.pw.$serverId';
Future<String?> readPassword(String serverId) async {
final current = await _storage.read(key: _passwordKey(serverId));
if (current != null) return current;
final legacy = await _storage.read(key: _legacyPasswordKey(serverId));
if (legacy == null) return null;
await _storage.write(key: _passwordKey(serverId), value: legacy);
await _storage.delete(key: _legacyPasswordKey(serverId));
return legacy;
}
Future<void> writePassword(String serverId, String password) =>
_storage.write(key: _passwordKey(serverId), value: password);
Future<void> deletePassword(String serverId) async {
await _storage.delete(key: _passwordKey(serverId));
await _storage.delete(key: _legacyPasswordKey(serverId));
}
// ── generic access ─────────────────────────────────────────────────────────
// Used by fields the `internal_memory` brick generates as secure.
static Future<String?> read(String key) => _storage.read(key: key);
static Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
static Future<void> delete(String key) => _storage.delete(key: key);
}