Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
lib/Peach/memory/ConfigStore.dart
Normal file
52
lib/Peach/memory/ConfigStore.dart
Normal 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);
|
||||
}
|
||||
24
lib/Peach/memory/ConnectInternalMemory.dart
Normal file
24
lib/Peach/memory/ConnectInternalMemory.dart
Normal 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);
|
||||
}
|
||||
49
lib/Peach/memory/DeploymentStore.dart
Normal file
49
lib/Peach/memory/DeploymentStore.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
49
lib/Peach/memory/InternalMemory.dart
Normal file
49
lib/Peach/memory/InternalMemory.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
45
lib/Peach/memory/SecretStore.dart
Normal file
45
lib/Peach/memory/SecretStore.dart
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user