294 lines
9.4 KiB
Dart
294 lines
9.4 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:mason/mason.dart';
|
|
|
|
/// Adds one persisted field to local storage by injecting the same
|
|
/// getter/setter pair into all three files that have to agree:
|
|
///
|
|
/// ConnectInternalMemory → the contract
|
|
/// InternalMemory → the implementation (+ its storage key)
|
|
/// AppDataManager → the delegation
|
|
///
|
|
/// Doing this by hand drifts: it's easy to add the pair to two of the three and
|
|
/// only find out at compile time, and the secure-key constant should never be
|
|
/// hand-picked.
|
|
Future<void> run(HookContext context) async {
|
|
final logger = context.logger;
|
|
final functionName = (context.vars['function_name'] ?? '') as String;
|
|
final requestObject = (context.vars['request_object'] ?? '') as String;
|
|
final secure = (context.vars['secure'] ?? false) as bool;
|
|
|
|
if (functionName.isEmpty || requestObject.isEmpty) {
|
|
logger.err('Both a field name and a type are required.');
|
|
return;
|
|
}
|
|
|
|
final pName = functionName.pascalCase;
|
|
final keyConst = '_${functionName.camelCase}Key';
|
|
final keyValue = secure
|
|
? _secureKeyValue(functionName)
|
|
: 'peach.${functionName.snakeCase}';
|
|
|
|
final isList = requestObject.startsWith('List<');
|
|
final isString = requestObject.toLowerCase() == 'string';
|
|
final baseType = isList
|
|
? requestObject.substring(5, requestObject.length - 1)
|
|
: requestObject;
|
|
|
|
const memoryDir = 'lib/Peach/memory';
|
|
const connectPath = '$memoryDir/ConnectInternalMemory.dart';
|
|
const memoryPath = '$memoryDir/InternalMemory.dart';
|
|
const managerPath = 'lib/Peach/informatics/AppDataManager.dart';
|
|
|
|
// Model types need importing into all three files; String doesn't.
|
|
String? modelImportFor(String fromFile) =>
|
|
isString ? null : _relativeImport(baseType, fromFile);
|
|
|
|
final duplicate = 'get$pName()';
|
|
|
|
// ── 1. the contract ────────────────────────────────────────────────────────
|
|
final contract = '''
|
|
Future<$requestObject> get$pName();
|
|
|
|
Future set$pName($requestObject data);''';
|
|
|
|
_inject(
|
|
connectPath,
|
|
contract,
|
|
duplicate: duplicate,
|
|
importLine: modelImportFor(connectPath),
|
|
logger: logger,
|
|
);
|
|
|
|
// ── 2. the implementation ──────────────────────────────────────────────────
|
|
final read = secure
|
|
? "await SecretStore.read($keyConst)"
|
|
: "(await SharedPreferences.getInstance()).getString($keyConst)";
|
|
final write = secure
|
|
? (String v) => "await SecretStore.write($keyConst, $v);"
|
|
: (String v) =>
|
|
"await (await SharedPreferences.getInstance()).setString($keyConst, $v);";
|
|
|
|
String getBody;
|
|
String setBody;
|
|
|
|
if (isString) {
|
|
getBody = ' return $read ?? \'\';';
|
|
setBody = ' ${write('data')}';
|
|
} else if (isList) {
|
|
getBody = '''
|
|
final stored = $read;
|
|
if (stored == null || stored.isEmpty) return [];
|
|
return (jsonDecode(stored) as List)
|
|
.map((item) => $baseType.fromJson(item as Map<String, dynamic>))
|
|
.toList();''';
|
|
setBody = '''
|
|
final encoded = jsonEncode(data.map((item) => item.toJson()).toList());
|
|
${write('encoded')}''';
|
|
} else {
|
|
getBody = '''
|
|
final stored = $read;
|
|
if (stored == null || stored.isEmpty) return null;
|
|
return $baseType.fromJson(jsonDecode(stored) as Map<String, dynamic>);''';
|
|
setBody = '''
|
|
${write('jsonEncode(data.toJson())')}''';
|
|
}
|
|
|
|
// A single object can legitimately be absent, so its getter is nullable.
|
|
final returnType = (isString || isList) ? requestObject : '$requestObject?';
|
|
|
|
final implementation = '''
|
|
@override
|
|
Future<$returnType> get$pName() async {
|
|
$getBody
|
|
}
|
|
|
|
@override
|
|
Future set$pName($requestObject data) async {
|
|
$setBody
|
|
}''';
|
|
|
|
_injectImplementation(
|
|
memoryPath,
|
|
keyLine: " static const $keyConst = '$keyValue';",
|
|
methods: implementation,
|
|
duplicate: duplicate,
|
|
importLine: modelImportFor(memoryPath),
|
|
needsConvert: !isString,
|
|
needsSecretStore: secure,
|
|
needsPrefs: !secure,
|
|
logger: logger,
|
|
);
|
|
|
|
// Keep the contract's nullability in step with the implementation.
|
|
if (!isString && !isList) {
|
|
_replaceInFile(
|
|
connectPath,
|
|
'Future<$requestObject> get$pName();',
|
|
'Future<$requestObject?> get$pName();',
|
|
);
|
|
}
|
|
|
|
// ── 3. the delegation ──────────────────────────────────────────────────────
|
|
final delegation = '''
|
|
@override
|
|
Future<$returnType> get$pName() => memory.get$pName();
|
|
|
|
@override
|
|
Future set$pName($requestObject data) => memory.set$pName(data);''';
|
|
|
|
_inject(
|
|
managerPath,
|
|
delegation,
|
|
duplicate: duplicate,
|
|
importLine: modelImportFor(managerPath),
|
|
logger: logger,
|
|
);
|
|
|
|
try {
|
|
await Process.run('dart', ['format', 'lib/Peach/'], runInShell: true);
|
|
} catch (_) {
|
|
// Formatting is a nicety; a missing dart on PATH shouldn't fail the run.
|
|
}
|
|
|
|
logger.success(
|
|
'✨ $pName wired through ConnectInternalMemory, InternalMemory and '
|
|
'AppDataManager.');
|
|
}
|
|
|
|
/// A random-suffixed key, so a stored secret isn't sitting under a guessable
|
|
/// name in the OS keystore.
|
|
String _secureKeyValue(String field) {
|
|
final random = Random.secure();
|
|
final bytes = List<int>.generate(12, (_) => random.nextInt(256));
|
|
final suffix =
|
|
base64Url.encode(bytes).replaceAll('=', '').substring(0, 16);
|
|
return 'peach.${field.snakeCase}.$suffix';
|
|
}
|
|
|
|
/// Finds [className]'s file and builds an import relative to [fromFile] — the
|
|
/// project uses relative imports inside lib/Peach, not package: ones.
|
|
String? _relativeImport(String className, String fromFile) {
|
|
final dir = Directory('lib');
|
|
if (!dir.existsSync()) return null;
|
|
|
|
for (final entity in dir.listSync(recursive: true)) {
|
|
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
|
final content = entity.readAsStringSync();
|
|
// Built from raw parts: in a normal string `\s` would collapse to a plain
|
|
// `s`, quietly turning this into a pattern that never matches.
|
|
final declaration =
|
|
RegExp(r'(class|enum)\s+' + RegExp.escape(className) + r'\b');
|
|
if (!declaration.hasMatch(content)) continue;
|
|
|
|
final target = entity.path.replaceAll(r'\', '/');
|
|
final from = fromFile.replaceAll(r'\', '/');
|
|
return "import '${_relativePath(from, target)}';";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _relativePath(String fromFile, String toFile) {
|
|
final fromParts = fromFile.split('/')..removeLast();
|
|
final toParts = toFile.split('/');
|
|
var shared = 0;
|
|
while (shared < fromParts.length &&
|
|
shared < toParts.length - 1 &&
|
|
fromParts[shared] == toParts[shared]) {
|
|
shared++;
|
|
}
|
|
final up = List.filled(fromParts.length - shared, '..');
|
|
final down = toParts.sublist(shared);
|
|
return [...up, ...down].join('/');
|
|
}
|
|
|
|
/// Appends [content] just before the class's closing brace.
|
|
void _inject(
|
|
String path,
|
|
String content, {
|
|
required String duplicate,
|
|
required Logger logger,
|
|
String? importLine,
|
|
}) {
|
|
final file = File(path);
|
|
if (!file.existsSync()) {
|
|
logger.err('Missing $path — is this being run from the project root?');
|
|
return;
|
|
}
|
|
|
|
var source = file.readAsStringSync();
|
|
if (source.contains(duplicate)) {
|
|
logger.info('$path already has $duplicate — skipped.');
|
|
return;
|
|
}
|
|
|
|
source = _addImport(source, importLine);
|
|
|
|
final close = source.lastIndexOf('}');
|
|
if (close == -1) return;
|
|
file.writeAsStringSync(
|
|
'${source.substring(0, close).trimRight()}\n\n$content\n${source.substring(close)}');
|
|
}
|
|
|
|
/// Like [_inject], but also puts the storage key at the top of the class and
|
|
/// pulls in whatever the generated body needs.
|
|
void _injectImplementation(
|
|
String path, {
|
|
required String keyLine,
|
|
required String methods,
|
|
required String duplicate,
|
|
required bool needsConvert,
|
|
required bool needsSecretStore,
|
|
required bool needsPrefs,
|
|
required Logger logger,
|
|
String? importLine,
|
|
}) {
|
|
final file = File(path);
|
|
if (!file.existsSync()) {
|
|
logger.err('Missing $path — is this being run from the project root?');
|
|
return;
|
|
}
|
|
|
|
var source = file.readAsStringSync();
|
|
if (source.contains(duplicate)) {
|
|
logger.info('$path already has $duplicate — skipped.');
|
|
return;
|
|
}
|
|
|
|
source = _addImport(source, importLine);
|
|
if (needsConvert) source = _addImport(source, "import 'dart:convert';");
|
|
if (needsPrefs) {
|
|
source = _addImport(
|
|
source, "import 'package:shared_preferences/shared_preferences.dart';");
|
|
}
|
|
if (needsSecretStore) {
|
|
source = _addImport(source, "import 'SecretStore.dart';");
|
|
}
|
|
|
|
final classOpen = source.indexOf('{', source.indexOf('class InternalMemory'));
|
|
if (classOpen != -1) {
|
|
source =
|
|
'${source.substring(0, classOpen + 1)}\n$keyLine\n${source.substring(classOpen + 1)}';
|
|
}
|
|
|
|
final close = source.lastIndexOf('}');
|
|
if (close == -1) return;
|
|
file.writeAsStringSync(
|
|
'${source.substring(0, close).trimRight()}\n\n$methods\n${source.substring(close)}');
|
|
}
|
|
|
|
String _addImport(String source, String? importLine) {
|
|
if (importLine == null || source.contains(importLine)) return source;
|
|
return '$importLine\n$source';
|
|
}
|
|
|
|
void _replaceInFile(String path, String from, String to) {
|
|
final file = File(path);
|
|
if (!file.existsSync()) return;
|
|
final source = file.readAsStringSync();
|
|
if (!source.contains(from)) return;
|
|
file.writeAsStringSync(source.replaceFirst(from, to));
|
|
}
|