Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 226 additions & 20 deletions apps/mod-manager/lib/core/mgr_ffi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import '../library/domain/models.dart';
import '../preflight/domain/models.dart';
import 'core_service.dart';

const _maxImportErrorCandidates = 2;
const _maxImportCandidateRunes = 256;

/// Typed wrappers over the gore-ffi mod-manager commands (`mgr_*`).
///
/// Every command returns the standard envelope `{ok, ...}` /
Expand All @@ -23,7 +26,11 @@ class MgrFfi {
final String code when code.isNotEmpty => code,
_ => 'UNKNOWN',
};
throw MgrFfiException('$cmd: ${e['message'] ?? e}', code: code);
throw MgrFfiException(
'$cmd: ${e['message'] ?? e}',
code: code,
details: _mgrErrorDetails(code, e['details']),
);
}
// Non-map error value (string, number, null, ...): stringify it.
throw MgrFfiException('$cmd: ${e ?? 'unknown error'}');
Expand All @@ -42,22 +49,12 @@ class MgrFfi {
final mods = <ModEntryMetaView>[];
final modIds = <String>{};
for (final raw in rawMods) {
if (raw is! Map ||
raw['id'] is! String ||
raw['kind'] is! String ||
raw['name'] is! String ||
raw['components'] is! List ||
(raw['components']! as List).any(
(component) => component is! Map || component['type'] is! String,
)) {
throw MgrFfiException('mgr_library_list: malformed library entry');
}
final parsed = raw.cast<String, Object?>();
final id = parsed['id']! as String;
final parsed = _libraryEntry(raw, command: 'mgr_library_list');
final id = parsed.id;
if (id.isEmpty || !modIds.add(id)) {
throw MgrFfiException('mgr_library_list: invalid library id set');
}
mods.add(ModEntryMetaView.fromJson(parsed));
mods.add(parsed);
}
final loadoutMap = rawLoadout.cast<String, Object?>();
final format = loadoutMap['format'];
Expand All @@ -81,14 +78,30 @@ class MgrFfi {
return (mods, LoadoutView.fromJson(loadoutMap));
}

/// Import a mod file/folder into the library; returns its library entry.
Future<ModEntryMetaView> import(String path) async {
/// Import a mod file/folder into the library and retain Native's exact
/// disposition and verified match method.
Future<MgrImportOutcome> import(String path) async {
final r = await _call('mgr_import', {'path': path});
final entry = r['entry'];
if (entry is! Map) {
throw MgrFfiException('mgr_import: response is missing entry');
final disposition = MgrImportDisposition.fromWire(r['disposition']);
final matchedBy = MgrImportMatchedBy.fromWire(r['matched_by']);
if (disposition == null ||
matchedBy == null ||
!_validImportOutcome(disposition, matchedBy)) {
throw MgrFfiException(
'mgr_import: malformed import outcome',
code: 'IMPORT_INVALID_RESPONSE',
);
}
return ModEntryMetaView.fromJson(entry.cast<String, Object?>());
return MgrImportOutcome(
entry: _libraryEntry(
entry,
command: 'mgr_import',
errorCode: 'IMPORT_INVALID_RESPONSE',
),
disposition: disposition,
matchedBy: matchedBy,
);
}

/// Remove a mod from the library. True when an entry was actually removed.
Expand Down Expand Up @@ -146,6 +159,15 @@ class MgrFfi {
}
}

bool _validImportOutcome(
MgrImportDisposition disposition,
MgrImportMatchedBy matchedBy,
) => switch (disposition) {
MgrImportDisposition.created => matchedBy == MgrImportMatchedBy.none,
MgrImportDisposition.updated ||
MgrImportDisposition.unchanged => matchedBy != MgrImportMatchedBy.none,
};

/// True for `true` and for positive counts — tolerates the Rust side
/// reporting `removed` as either a bool or a count.
bool _truthy(Object? value) => value == true || (value is num && value > 0);
Expand All @@ -155,8 +177,188 @@ List<Map<String, Object?>> _maps(Object? value) => value is List
? [for (final item in value.whereType<Map>()) item.cast<String, Object?>()]
: const [];

ModEntryMetaView _libraryEntry(
Object? raw, {
required String command,
String errorCode = 'UNKNOWN',
}) {
if (raw is! Map ||
raw.keys.any((key) => key is! String) ||
raw['id'] is! String ||
(raw['id']! as String).isEmpty ||
raw['kind'] is! String ||
raw['name'] is! String ||
raw['components'] is! List ||
(raw['components']! as List).any(
(component) => component is! Map || component['type'] is! String,
)) {
throw MgrFfiException('$command: malformed library entry', code: errorCode);
}
return ModEntryMetaView.fromJson(raw.cast<String, Object?>());
}

MgrFfiErrorDetails? _mgrErrorDetails(String code, Object? raw) {
if (raw is! Map || raw.keys.any((key) => key is! String)) return null;
final details = raw.cast<String, Object?>();
return switch (code) {
'IMPORT_DUPLICATE_AMBIGUOUS' => _duplicateImportDetails(details),
'IMPORT_IDENTITY_CONFLICT' => _identityConflictDetails(details),
_ => null,
};
}

MgrImportDuplicateAmbiguousDetails? _duplicateImportDetails(
Map<String, Object?> details,
) {
final rawCandidates = details['candidate_ids'];
if (rawCandidates is! List ||
rawCandidates.isEmpty ||
rawCandidates.length > _maxImportErrorCandidates) {
return null;
}
final candidates = <MgrImportCandidate>[];
for (final raw in rawCandidates) {
final id = _boundedImportCandidate(raw);
if (id == null) return null;
candidates.add(MgrImportCandidate(id: id));
}
return MgrImportDuplicateAmbiguousDetails(
candidates: List.unmodifiable(candidates),
);
}

MgrImportIdentityConflictDetails? _identityConflictDetails(
Map<String, Object?> details,
) {
final rawCandidates = details['candidates'];
if (rawCandidates is! List ||
rawCandidates.isEmpty ||
rawCandidates.length > _maxImportErrorCandidates) {
return null;
}
final candidates = <MgrImportCandidate>[];
for (final raw in rawCandidates) {
if (raw is! Map || raw.keys.any((key) => key is! String)) return null;
final candidate = raw.cast<String, Object?>();
final id = _boundedImportCandidate(candidate['id']);
final rawMatchedBy = candidate['matched_by'];
if (id == null ||
rawMatchedBy is! List ||
rawMatchedBy.isEmpty ||
rawMatchedBy.length > 3) {
return null;
}
final matchedBy = <MgrImportMatchedBy>[];
for (final role in rawMatchedBy) {
final parsed = MgrImportMatchedBy.fromWire(role);
if (parsed == null ||
parsed == MgrImportMatchedBy.none ||
matchedBy.contains(parsed)) {
return null;
}
matchedBy.add(parsed);
}
candidates.add(
MgrImportCandidate(id: id, matchedBy: List.unmodifiable(matchedBy)),
);
}
return MgrImportIdentityConflictDetails(
candidates: List.unmodifiable(candidates),
);
}

String? _boundedImportCandidate(Object? raw) {
if (raw is! String || raw.isEmpty) return null;
final runes = raw.runes.take(_maxImportCandidateRunes + 1).toList();
if (runes.length > _maxImportCandidateRunes) return null;
return raw;
}

enum MgrImportDisposition {
created,
updated,
unchanged;

static MgrImportDisposition? fromWire(Object? value) => switch (value) {
'created' => created,
'updated' => updated,
'unchanged' => unchanged,
_ => null,
};
}

enum MgrImportMatchedBy {
none,
source,
content,
entryId;

static MgrImportMatchedBy? fromWire(Object? value) => switch (value) {
'none' => none,
'source' => source,
'content' => content,
'entry_id' => entryId,
_ => null,
};

String get wireName => switch (this) {
none => 'none',
source => 'source',
content => 'content',
entryId => 'entry_id',
};
}

class MgrImportOutcome {
const MgrImportOutcome({
required this.entry,
required this.disposition,
required this.matchedBy,
});

final ModEntryMetaView entry;
final MgrImportDisposition disposition;
final MgrImportMatchedBy matchedBy;

MgrImportOutcome withEntry(ModEntryMetaView authoritativeEntry) =>
MgrImportOutcome(
entry: authoritativeEntry,
disposition: disposition,
matchedBy: matchedBy,
);
}

sealed class MgrFfiErrorDetails {
const MgrFfiErrorDetails();
}

sealed class MgrImportRefusalDetails extends MgrFfiErrorDetails {
const MgrImportRefusalDetails(this.candidates);

final List<MgrImportCandidate> candidates;
}

class MgrImportDuplicateAmbiguousDetails extends MgrImportRefusalDetails {
const MgrImportDuplicateAmbiguousDetails({
required List<MgrImportCandidate> candidates,
}) : super(candidates);
}

class MgrImportIdentityConflictDetails extends MgrImportRefusalDetails {
const MgrImportIdentityConflictDetails({
required List<MgrImportCandidate> candidates,
}) : super(candidates);
}

class MgrImportCandidate {
const MgrImportCandidate({this.id = '', this.matchedBy = const []});

final String id;
final List<MgrImportMatchedBy> matchedBy;
}

class MgrFfiException implements Exception {
MgrFfiException(this.message, {this.code = 'UNKNOWN'});
MgrFfiException(this.message, {this.code = 'UNKNOWN', this.details});

final String message;

Expand All @@ -165,6 +367,10 @@ class MgrFfiException implements Exception {
/// STUDIO_DEPLOY_ACTIVE.
final String code;

/// Machine-readable, bounded facts for known failures. Unknown or malformed
/// detail objects stay null; callers never recover facts from [message].
final MgrFfiErrorDetails? details;

@override
String toString() => message;
}
Loading
Loading