From 74acf67fce24e06112802f96759f7e0f0f8fc6d4 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 15:49:14 +0200 Subject: [PATCH 1/3] feat(manager): show owned deployment files --- apps/mod-manager/README.md | 5 + apps/mod-manager/lib/l10n/app_de.arb | 9 + apps/mod-manager/lib/l10n/app_en.arb | 19 + apps/mod-manager/lib/l10n/app_es.arb | 9 + apps/mod-manager/lib/l10n/app_fr.arb | 9 + apps/mod-manager/lib/l10n/app_it.arb | 9 + apps/mod-manager/lib/l10n/app_ja.arb | 9 + .../lib/l10n/app_localizations.dart | 54 +++ .../lib/l10n/app_localizations_de.dart | 33 ++ .../lib/l10n/app_localizations_en.dart | 30 ++ .../lib/l10n/app_localizations_es.dart | 33 ++ .../lib/l10n/app_localizations_fr.dart | 33 ++ .../lib/l10n/app_localizations_it.dart | 32 ++ .../lib/l10n/app_localizations_ja.dart | 30 ++ .../lib/l10n/app_localizations_pl.dart | 32 ++ .../lib/l10n/app_localizations_pt.dart | 68 +++ .../lib/l10n/app_localizations_ru.dart | 33 ++ .../lib/l10n/app_localizations_zh.dart | 60 +++ apps/mod-manager/lib/l10n/app_pl.arb | 9 + apps/mod-manager/lib/l10n/app_pt.arb | 9 + apps/mod-manager/lib/l10n/app_pt_BR.arb | 9 + apps/mod-manager/lib/l10n/app_ru.arb | 9 + apps/mod-manager/lib/l10n/app_zh.arb | 9 + apps/mod-manager/lib/l10n/app_zh_Hans.arb | 9 + .../lib/library/domain/models.dart | 128 ++++- .../lib/status/ui/status_details_dialog.dart | 113 +++++ apps/mod-manager/test/core/mgr_ffi_test.dart | 117 +++++ .../status/status_details_dialog_test.dart | 192 +++++++- crates/gore-ffi/src/lib.rs | 87 +++- crates/gore-mod/src/mgr/status.rs | 448 +++++++++++++++++- crates/gore-mod/src/mgr/store.rs | 9 + docs/guide/mod-manager.md | 18 + 32 files changed, 1636 insertions(+), 37 deletions(-) diff --git a/apps/mod-manager/README.md b/apps/mod-manager/README.md index 204052209..475f9260f 100644 --- a/apps/mod-manager/README.md +++ b/apps/mod-manager/README.md @@ -33,6 +33,11 @@ portable builds are deliberately updater-free. - **Apply** declaratively: full-recompute the modded state from a pristine base and deploy the whole enabled set (backups first), or **undeploy all** to restore. +- **Inspect recorded ownership evidence** in deployment details for an exact + Manager-owned record: replaced files, pristine backups, additive containers, + UE4SS directories, and recovery paths. These bounded, selectable paths are + record evidence only; they do not claim that a path still exists or grant a + cleanup action. - **Take over** a Mod Studio test-deploy so both tools do not fight over the install. diff --git a/apps/mod-manager/lib/l10n/app_de.arb b/apps/mod-manager/lib/l10n/app_de.arb index 3134b7a7a..2563e618d 100644 --- a/apps/mod-manager/lib/l10n/app_de.arb +++ b/apps/mod-manager/lib/l10n/app_de.arb @@ -44,6 +44,15 @@ "recoveryAction": "Wiederherstellen", "recoveryRequiredConfirm": "Unterbrochene Bereitstellung wiederherstellen und alle teilweise bereitgestellten Dateien entfernen?", "statusRecoveryRequired": "Wiederherstellung erforderlich", + "statusDetailsOwnershipTitle": "Aufgezeichnete Besitznachweise", + "statusDetailsOwnershipDescription": "Pfade aus dem Bereitstellungsprotokoll des Managers. Sie belegen nicht, dass diese Pfade noch existieren.", + "statusDetailsOwnershipLive": "Ersetzte Spieldateien", + "statusDetailsOwnershipBackups": "Sicherungen der Originaldateien", + "statusDetailsOwnershipAdditive": "Hinzugefügte Pak- und Containerdateien", + "statusDetailsOwnershipUe4ss": "UE4SS-Mod-Verzeichnisse", + "statusDetailsOwnershipRecovery": "Wiederherstellungsdateien und Ablagen", + "statusDetailsOwnershipEmpty": "Keine Pfade in dieser Gruppe aufgezeichnet.", + "statusDetailsOwnershipShown": "{shown} von {total} aufgezeichneten Pfaden angezeigt.", "appTitle": "GORE Mod Manager", "tabMods": "Mods", "tabSettings": "Einstellungen", diff --git a/apps/mod-manager/lib/l10n/app_en.arb b/apps/mod-manager/lib/l10n/app_en.arb index b47b9afdd..a28ba889a 100644 --- a/apps/mod-manager/lib/l10n/app_en.arb +++ b/apps/mod-manager/lib/l10n/app_en.arb @@ -68,6 +68,25 @@ "recoveryAction": "Recover", "recoveryRequiredConfirm": "Recover the interrupted deployment and remove any partially deployed files?", "statusRecoveryRequired": "Recovery required", + "statusDetailsOwnershipTitle": "Recorded ownership evidence", + "statusDetailsOwnershipDescription": "Paths recorded in the Manager deploy record. They do not prove that those paths still exist.", + "statusDetailsOwnershipLive": "Replaced game files", + "statusDetailsOwnershipBackups": "Pristine backups", + "statusDetailsOwnershipAdditive": "Added pak and container files", + "statusDetailsOwnershipUe4ss": "UE4SS mod directories", + "statusDetailsOwnershipRecovery": "Recovery files and holders", + "statusDetailsOwnershipEmpty": "No paths recorded in this group.", + "statusDetailsOwnershipShown": "{shown} of {total} recorded paths shown.", + "@statusDetailsOwnershipShown": { + "placeholders": { + "shown": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "appTitle": "GORE Mod Manager", "tabMods": "Mods", "tabSettings": "Settings", diff --git a/apps/mod-manager/lib/l10n/app_es.arb b/apps/mod-manager/lib/l10n/app_es.arb index 8f8b00c7b..fdfac02f2 100644 --- a/apps/mod-manager/lib/l10n/app_es.arb +++ b/apps/mod-manager/lib/l10n/app_es.arb @@ -44,6 +44,15 @@ "recoveryAction": "Recuperar", "recoveryRequiredConfirm": "¿Recuperar el despliegue interrumpido y eliminar los archivos desplegados parcialmente?", "statusRecoveryRequired": "Recuperación necesaria", + "statusDetailsOwnershipTitle": "Evidencia de propiedad registrada", + "statusDetailsOwnershipDescription": "Rutas registradas en el registro de despliegue del gestor. No demuestran que esas rutas sigan existiendo.", + "statusDetailsOwnershipLive": "Archivos del juego reemplazados", + "statusDetailsOwnershipBackups": "Copias de seguridad originales", + "statusDetailsOwnershipAdditive": "Archivos pak y contenedores añadidos", + "statusDetailsOwnershipUe4ss": "Directorios de mods UE4SS", + "statusDetailsOwnershipRecovery": "Archivos y ubicaciones de recuperación", + "statusDetailsOwnershipEmpty": "No hay rutas registradas en este grupo.", + "statusDetailsOwnershipShown": "Se muestran {shown} de {total} rutas registradas.", "appTitle": "GORE Mod Manager", "tabMods": "Mods", "tabSettings": "Ajustes", diff --git a/apps/mod-manager/lib/l10n/app_fr.arb b/apps/mod-manager/lib/l10n/app_fr.arb index 030599f63..695ddf3b0 100644 --- a/apps/mod-manager/lib/l10n/app_fr.arb +++ b/apps/mod-manager/lib/l10n/app_fr.arb @@ -44,6 +44,15 @@ "recoveryAction": "Récupérer", "recoveryRequiredConfirm": "Récupérer le déploiement interrompu et supprimer les fichiers partiellement déployés ?", "statusRecoveryRequired": "Récupération requise", + "statusDetailsOwnershipTitle": "Preuves de propriété enregistrées", + "statusDetailsOwnershipDescription": "Chemins enregistrés dans le journal de déploiement du gestionnaire. Ils ne prouvent pas que ces chemins existent encore.", + "statusDetailsOwnershipLive": "Fichiers du jeu remplacés", + "statusDetailsOwnershipBackups": "Sauvegardes d'origine", + "statusDetailsOwnershipAdditive": "Fichiers pak et conteneurs ajoutés", + "statusDetailsOwnershipUe4ss": "Dossiers de mods UE4SS", + "statusDetailsOwnershipRecovery": "Fichiers et emplacements de récupération", + "statusDetailsOwnershipEmpty": "Aucun chemin enregistré dans ce groupe.", + "statusDetailsOwnershipShown": "{shown} chemins enregistrés affichés sur {total}.", "appTitle": "GORE Mod Manager", "tabMods": "Mods", "tabSettings": "Paramètres", diff --git a/apps/mod-manager/lib/l10n/app_it.arb b/apps/mod-manager/lib/l10n/app_it.arb index 223d4e2a0..e36afe252 100644 --- a/apps/mod-manager/lib/l10n/app_it.arb +++ b/apps/mod-manager/lib/l10n/app_it.arb @@ -44,6 +44,15 @@ "recoveryAction": "Ripristina", "recoveryRequiredConfirm": "Ripristinare la distribuzione interrotta e rimuovere i file distribuiti parzialmente?", "statusRecoveryRequired": "Ripristino necessario", + "statusDetailsOwnershipTitle": "Prove di proprietà registrate", + "statusDetailsOwnershipDescription": "Percorsi registrati nel record di distribuzione del gestore. Non provano che tali percorsi esistano ancora.", + "statusDetailsOwnershipLive": "File di gioco sostituiti", + "statusDetailsOwnershipBackups": "Backup originali", + "statusDetailsOwnershipAdditive": "File pak e contenitori aggiunti", + "statusDetailsOwnershipUe4ss": "Cartelle mod UE4SS", + "statusDetailsOwnershipRecovery": "File e posizioni di recupero", + "statusDetailsOwnershipEmpty": "Nessun percorso registrato in questo gruppo.", + "statusDetailsOwnershipShown": "Visualizzati {shown} di {total} percorsi registrati.", "appTitle": "GORE Mod Manager", "tabMods": "Mod", "tabSettings": "Impostazioni", diff --git a/apps/mod-manager/lib/l10n/app_ja.arb b/apps/mod-manager/lib/l10n/app_ja.arb index 8b1aa57a4..d93d90bde 100644 --- a/apps/mod-manager/lib/l10n/app_ja.arb +++ b/apps/mod-manager/lib/l10n/app_ja.arb @@ -44,6 +44,15 @@ "recoveryAction": "復旧", "recoveryRequiredConfirm": "中断されたデプロイを復旧し、部分的にデプロイされたファイルを削除しますか?", "statusRecoveryRequired": "復旧が必要", + "statusDetailsOwnershipTitle": "記録された所有権の証拠", + "statusDetailsOwnershipDescription": "マネージャーのデプロイ記録に保存されたパスです。現在もそのパスが存在することを示すものではありません。", + "statusDetailsOwnershipLive": "置換されたゲームファイル", + "statusDetailsOwnershipBackups": "元ファイルのバックアップ", + "statusDetailsOwnershipAdditive": "追加された pak とコンテナファイル", + "statusDetailsOwnershipUe4ss": "UE4SS Mod ディレクトリ", + "statusDetailsOwnershipRecovery": "復旧ファイルと保持場所", + "statusDetailsOwnershipEmpty": "このグループに記録されたパスはありません。", + "statusDetailsOwnershipShown": "記録された {total} 件のパスのうち {shown} 件を表示しています。", "appTitle": "GORE Mod Manager", "tabMods": "Mod", "tabSettings": "設定", diff --git a/apps/mod-manager/lib/l10n/app_localizations.dart b/apps/mod-manager/lib/l10n/app_localizations.dart index 0b9834f31..a3852fb79 100644 --- a/apps/mod-manager/lib/l10n/app_localizations.dart +++ b/apps/mod-manager/lib/l10n/app_localizations.dart @@ -380,6 +380,60 @@ abstract class AppLocalizations { /// **'Recovery required'** String get statusRecoveryRequired; + /// No description provided for @statusDetailsOwnershipTitle. + /// + /// In en, this message translates to: + /// **'Recorded ownership evidence'** + String get statusDetailsOwnershipTitle; + + /// No description provided for @statusDetailsOwnershipDescription. + /// + /// In en, this message translates to: + /// **'Paths recorded in the Manager deploy record. They do not prove that those paths still exist.'** + String get statusDetailsOwnershipDescription; + + /// No description provided for @statusDetailsOwnershipLive. + /// + /// In en, this message translates to: + /// **'Replaced game files'** + String get statusDetailsOwnershipLive; + + /// No description provided for @statusDetailsOwnershipBackups. + /// + /// In en, this message translates to: + /// **'Pristine backups'** + String get statusDetailsOwnershipBackups; + + /// No description provided for @statusDetailsOwnershipAdditive. + /// + /// In en, this message translates to: + /// **'Added pak and container files'** + String get statusDetailsOwnershipAdditive; + + /// No description provided for @statusDetailsOwnershipUe4ss. + /// + /// In en, this message translates to: + /// **'UE4SS mod directories'** + String get statusDetailsOwnershipUe4ss; + + /// No description provided for @statusDetailsOwnershipRecovery. + /// + /// In en, this message translates to: + /// **'Recovery files and holders'** + String get statusDetailsOwnershipRecovery; + + /// No description provided for @statusDetailsOwnershipEmpty. + /// + /// In en, this message translates to: + /// **'No paths recorded in this group.'** + String get statusDetailsOwnershipEmpty; + + /// No description provided for @statusDetailsOwnershipShown. + /// + /// In en, this message translates to: + /// **'{shown} of {total} recorded paths shown.'** + String statusDetailsOwnershipShown(int shown, int total); + /// No description provided for @appTitle. /// /// In en, this message translates to: diff --git a/apps/mod-manager/lib/l10n/app_localizations_de.dart b/apps/mod-manager/lib/l10n/app_localizations_de.dart index 84283565d..7e78a3b5b 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_de.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_de.dart @@ -166,6 +166,39 @@ class AppLocalizationsDe extends AppLocalizations { @override String get statusRecoveryRequired => 'Wiederherstellung erforderlich'; + @override + String get statusDetailsOwnershipTitle => 'Aufgezeichnete Besitznachweise'; + + @override + String get statusDetailsOwnershipDescription => + 'Pfade aus dem Bereitstellungsprotokoll des Managers. Sie belegen nicht, dass diese Pfade noch existieren.'; + + @override + String get statusDetailsOwnershipLive => 'Ersetzte Spieldateien'; + + @override + String get statusDetailsOwnershipBackups => 'Sicherungen der Originaldateien'; + + @override + String get statusDetailsOwnershipAdditive => + 'Hinzugefügte Pak- und Containerdateien'; + + @override + String get statusDetailsOwnershipUe4ss => 'UE4SS-Mod-Verzeichnisse'; + + @override + String get statusDetailsOwnershipRecovery => + 'Wiederherstellungsdateien und Ablagen'; + + @override + String get statusDetailsOwnershipEmpty => + 'Keine Pfade in dieser Gruppe aufgezeichnet.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return '$shown von $total aufgezeichneten Pfaden angezeigt.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_en.dart b/apps/mod-manager/lib/l10n/app_localizations_en.dart index fe097a992..628298cea 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_en.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_en.dart @@ -165,6 +165,36 @@ class AppLocalizationsEn extends AppLocalizations { @override String get statusRecoveryRequired => 'Recovery required'; + @override + String get statusDetailsOwnershipTitle => 'Recorded ownership evidence'; + + @override + String get statusDetailsOwnershipDescription => + 'Paths recorded in the Manager deploy record. They do not prove that those paths still exist.'; + + @override + String get statusDetailsOwnershipLive => 'Replaced game files'; + + @override + String get statusDetailsOwnershipBackups => 'Pristine backups'; + + @override + String get statusDetailsOwnershipAdditive => 'Added pak and container files'; + + @override + String get statusDetailsOwnershipUe4ss => 'UE4SS mod directories'; + + @override + String get statusDetailsOwnershipRecovery => 'Recovery files and holders'; + + @override + String get statusDetailsOwnershipEmpty => 'No paths recorded in this group.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return '$shown of $total recorded paths shown.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_es.dart b/apps/mod-manager/lib/l10n/app_localizations_es.dart index 94d3d028a..0a8965878 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_es.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_es.dart @@ -166,6 +166,39 @@ class AppLocalizationsEs extends AppLocalizations { @override String get statusRecoveryRequired => 'Recuperación necesaria'; + @override + String get statusDetailsOwnershipTitle => 'Evidencia de propiedad registrada'; + + @override + String get statusDetailsOwnershipDescription => + 'Rutas registradas en el registro de despliegue del gestor. No demuestran que esas rutas sigan existiendo.'; + + @override + String get statusDetailsOwnershipLive => 'Archivos del juego reemplazados'; + + @override + String get statusDetailsOwnershipBackups => 'Copias de seguridad originales'; + + @override + String get statusDetailsOwnershipAdditive => + 'Archivos pak y contenedores añadidos'; + + @override + String get statusDetailsOwnershipUe4ss => 'Directorios de mods UE4SS'; + + @override + String get statusDetailsOwnershipRecovery => + 'Archivos y ubicaciones de recuperación'; + + @override + String get statusDetailsOwnershipEmpty => + 'No hay rutas registradas en este grupo.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return 'Se muestran $shown de $total rutas registradas.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_fr.dart b/apps/mod-manager/lib/l10n/app_localizations_fr.dart index 7d5c2b011..fda2742fc 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_fr.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_fr.dart @@ -167,6 +167,39 @@ class AppLocalizationsFr extends AppLocalizations { @override String get statusRecoveryRequired => 'Récupération requise'; + @override + String get statusDetailsOwnershipTitle => 'Preuves de propriété enregistrées'; + + @override + String get statusDetailsOwnershipDescription => + 'Chemins enregistrés dans le journal de déploiement du gestionnaire. Ils ne prouvent pas que ces chemins existent encore.'; + + @override + String get statusDetailsOwnershipLive => 'Fichiers du jeu remplacés'; + + @override + String get statusDetailsOwnershipBackups => 'Sauvegardes d\'origine'; + + @override + String get statusDetailsOwnershipAdditive => + 'Fichiers pak et conteneurs ajoutés'; + + @override + String get statusDetailsOwnershipUe4ss => 'Dossiers de mods UE4SS'; + + @override + String get statusDetailsOwnershipRecovery => + 'Fichiers et emplacements de récupération'; + + @override + String get statusDetailsOwnershipEmpty => + 'Aucun chemin enregistré dans ce groupe.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return '$shown chemins enregistrés affichés sur $total.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_it.dart b/apps/mod-manager/lib/l10n/app_localizations_it.dart index 91206a91f..6250f26b3 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_it.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_it.dart @@ -168,6 +168,38 @@ class AppLocalizationsIt extends AppLocalizations { @override String get statusRecoveryRequired => 'Ripristino necessario'; + @override + String get statusDetailsOwnershipTitle => 'Prove di proprietà registrate'; + + @override + String get statusDetailsOwnershipDescription => + 'Percorsi registrati nel record di distribuzione del gestore. Non provano che tali percorsi esistano ancora.'; + + @override + String get statusDetailsOwnershipLive => 'File di gioco sostituiti'; + + @override + String get statusDetailsOwnershipBackups => 'Backup originali'; + + @override + String get statusDetailsOwnershipAdditive => + 'File pak e contenitori aggiunti'; + + @override + String get statusDetailsOwnershipUe4ss => 'Cartelle mod UE4SS'; + + @override + String get statusDetailsOwnershipRecovery => 'File e posizioni di recupero'; + + @override + String get statusDetailsOwnershipEmpty => + 'Nessun percorso registrato in questo gruppo.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return 'Visualizzati $shown di $total percorsi registrati.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_ja.dart b/apps/mod-manager/lib/l10n/app_localizations_ja.dart index 75a06ed38..dac64ae3e 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_ja.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_ja.dart @@ -156,6 +156,36 @@ class AppLocalizationsJa extends AppLocalizations { @override String get statusRecoveryRequired => '復旧が必要'; + @override + String get statusDetailsOwnershipTitle => '記録された所有権の証拠'; + + @override + String get statusDetailsOwnershipDescription => + 'マネージャーのデプロイ記録に保存されたパスです。現在もそのパスが存在することを示すものではありません。'; + + @override + String get statusDetailsOwnershipLive => '置換されたゲームファイル'; + + @override + String get statusDetailsOwnershipBackups => '元ファイルのバックアップ'; + + @override + String get statusDetailsOwnershipAdditive => '追加された pak とコンテナファイル'; + + @override + String get statusDetailsOwnershipUe4ss => 'UE4SS Mod ディレクトリ'; + + @override + String get statusDetailsOwnershipRecovery => '復旧ファイルと保持場所'; + + @override + String get statusDetailsOwnershipEmpty => 'このグループに記録されたパスはありません。'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return '記録された $total 件のパスのうち $shown 件を表示しています。'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_pl.dart b/apps/mod-manager/lib/l10n/app_localizations_pl.dart index 7649c29e3..c48e98fae 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_pl.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_pl.dart @@ -165,6 +165,38 @@ class AppLocalizationsPl extends AppLocalizations { @override String get statusRecoveryRequired => 'Wymagane odzyskiwanie'; + @override + String get statusDetailsOwnershipTitle => 'Zapisane dowody własności'; + + @override + String get statusDetailsOwnershipDescription => + 'Ścieżki zapisane w rekordzie wdrożenia Menedżera. Nie potwierdzają, że te ścieżki nadal istnieją.'; + + @override + String get statusDetailsOwnershipLive => 'Zastąpione pliki gry'; + + @override + String get statusDetailsOwnershipBackups => 'Kopie plików oryginalnych'; + + @override + String get statusDetailsOwnershipAdditive => 'Dodane pliki pak i kontenerów'; + + @override + String get statusDetailsOwnershipUe4ss => 'Katalogi modów UE4SS'; + + @override + String get statusDetailsOwnershipRecovery => + 'Pliki i lokalizacje odzyskiwania'; + + @override + String get statusDetailsOwnershipEmpty => + 'Brak zapisanych ścieżek w tej grupie.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return 'Wyświetlono $shown z $total zapisanych ścieżek.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_pt.dart b/apps/mod-manager/lib/l10n/app_localizations_pt.dart index 20f885a6b..0511cac08 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_pt.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_pt.dart @@ -168,6 +168,40 @@ class AppLocalizationsPt extends AppLocalizations { @override String get statusRecoveryRequired => 'Recuperação necessária'; + @override + String get statusDetailsOwnershipTitle => + 'Evidência de propriedade registada'; + + @override + String get statusDetailsOwnershipDescription => + 'Caminhos registados no registo de implementação do gestor. Não provam que esses caminhos ainda existam.'; + + @override + String get statusDetailsOwnershipLive => 'Ficheiros do jogo substituídos'; + + @override + String get statusDetailsOwnershipBackups => 'Cópias de segurança originais'; + + @override + String get statusDetailsOwnershipAdditive => + 'Ficheiros pak e contentores adicionados'; + + @override + String get statusDetailsOwnershipUe4ss => 'Diretórios de mods UE4SS'; + + @override + String get statusDetailsOwnershipRecovery => + 'Ficheiros e locais de recuperação'; + + @override + String get statusDetailsOwnershipEmpty => + 'Nenhum caminho registado neste grupo.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return 'São apresentados $shown de $total caminhos registados.'; + } + @override String get appTitle => 'GORE Mod Manager'; @@ -606,6 +640,40 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get statusRecoveryRequired => 'Recuperação necessária'; + @override + String get statusDetailsOwnershipTitle => + 'Evidência de propriedade registrada'; + + @override + String get statusDetailsOwnershipDescription => + 'Caminhos registrados no registro de implantação do gerenciador. Eles não comprovam que esses caminhos ainda existam.'; + + @override + String get statusDetailsOwnershipLive => 'Arquivos do jogo substituídos'; + + @override + String get statusDetailsOwnershipBackups => 'Backups originais'; + + @override + String get statusDetailsOwnershipAdditive => + 'Arquivos pak e contêineres adicionados'; + + @override + String get statusDetailsOwnershipUe4ss => 'Diretórios de mods UE4SS'; + + @override + String get statusDetailsOwnershipRecovery => + 'Arquivos e locais de recuperação'; + + @override + String get statusDetailsOwnershipEmpty => + 'Nenhum caminho registrado neste grupo.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return 'Exibidos $shown de $total caminhos registrados.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_ru.dart b/apps/mod-manager/lib/l10n/app_localizations_ru.dart index 4b6d544ad..e3e300648 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_ru.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_ru.dart @@ -164,6 +164,39 @@ class AppLocalizationsRu extends AppLocalizations { @override String get statusRecoveryRequired => 'Требуется восстановление'; + @override + String get statusDetailsOwnershipTitle => 'Записанные свидетельства владения'; + + @override + String get statusDetailsOwnershipDescription => + 'Пути из записи развёртывания Менеджера. Они не подтверждают, что эти пути существуют сейчас.'; + + @override + String get statusDetailsOwnershipLive => 'Заменённые файлы игры'; + + @override + String get statusDetailsOwnershipBackups => 'Резервные копии исходных файлов'; + + @override + String get statusDetailsOwnershipAdditive => + 'Добавленные pak-файлы и контейнеры'; + + @override + String get statusDetailsOwnershipUe4ss => 'Каталоги модов UE4SS'; + + @override + String get statusDetailsOwnershipRecovery => + 'Файлы и расположения восстановления'; + + @override + String get statusDetailsOwnershipEmpty => + 'В этой группе нет записанных путей.'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return 'Показано $shown из $total записанных путей.'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_localizations_zh.dart b/apps/mod-manager/lib/l10n/app_localizations_zh.dart index 16afaff77..395583727 100644 --- a/apps/mod-manager/lib/l10n/app_localizations_zh.dart +++ b/apps/mod-manager/lib/l10n/app_localizations_zh.dart @@ -151,6 +151,36 @@ class AppLocalizationsZh extends AppLocalizations { @override String get statusRecoveryRequired => '需要恢复'; + @override + String get statusDetailsOwnershipTitle => '已记录的所有权证据'; + + @override + String get statusDetailsOwnershipDescription => + '管理器部署记录中保存的路径。这不表示这些路径当前仍然存在。'; + + @override + String get statusDetailsOwnershipLive => '已替换的游戏文件'; + + @override + String get statusDetailsOwnershipBackups => '原始文件备份'; + + @override + String get statusDetailsOwnershipAdditive => '新增的 pak 和容器文件'; + + @override + String get statusDetailsOwnershipUe4ss => 'UE4SS 模组目录'; + + @override + String get statusDetailsOwnershipRecovery => '恢复文件和保留位置'; + + @override + String get statusDetailsOwnershipEmpty => '此组中没有记录的路径。'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return '显示了 $total 条已记录路径中的 $shown 条。'; + } + @override String get appTitle => 'GORE Mod Manager'; @@ -561,6 +591,36 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get statusRecoveryRequired => '需要恢复'; + @override + String get statusDetailsOwnershipTitle => '已记录的所有权证据'; + + @override + String get statusDetailsOwnershipDescription => + '管理器部署记录中保存的路径。这不表示这些路径当前仍然存在。'; + + @override + String get statusDetailsOwnershipLive => '已替换的游戏文件'; + + @override + String get statusDetailsOwnershipBackups => '原始文件备份'; + + @override + String get statusDetailsOwnershipAdditive => '新增的 pak 和容器文件'; + + @override + String get statusDetailsOwnershipUe4ss => 'UE4SS 模组目录'; + + @override + String get statusDetailsOwnershipRecovery => '恢复文件和保留位置'; + + @override + String get statusDetailsOwnershipEmpty => '此组中没有记录的路径。'; + + @override + String statusDetailsOwnershipShown(int shown, int total) { + return '显示了 $total 条已记录路径中的 $shown 条。'; + } + @override String get appTitle => 'GORE Mod Manager'; diff --git a/apps/mod-manager/lib/l10n/app_pl.arb b/apps/mod-manager/lib/l10n/app_pl.arb index 18e120e72..3f2c10f71 100644 --- a/apps/mod-manager/lib/l10n/app_pl.arb +++ b/apps/mod-manager/lib/l10n/app_pl.arb @@ -44,6 +44,15 @@ "recoveryAction": "Odzyskaj", "recoveryRequiredConfirm": "Odzyskać przerwane wdrożenie i usunąć częściowo wdrożone pliki?", "statusRecoveryRequired": "Wymagane odzyskiwanie", + "statusDetailsOwnershipTitle": "Zapisane dowody własności", + "statusDetailsOwnershipDescription": "Ścieżki zapisane w rekordzie wdrożenia Menedżera. Nie potwierdzają, że te ścieżki nadal istnieją.", + "statusDetailsOwnershipLive": "Zastąpione pliki gry", + "statusDetailsOwnershipBackups": "Kopie plików oryginalnych", + "statusDetailsOwnershipAdditive": "Dodane pliki pak i kontenerów", + "statusDetailsOwnershipUe4ss": "Katalogi modów UE4SS", + "statusDetailsOwnershipRecovery": "Pliki i lokalizacje odzyskiwania", + "statusDetailsOwnershipEmpty": "Brak zapisanych ścieżek w tej grupie.", + "statusDetailsOwnershipShown": "Wyświetlono {shown} z {total} zapisanych ścieżek.", "appTitle": "GORE Mod Manager", "tabMods": "Mody", "tabSettings": "Ustawienia", diff --git a/apps/mod-manager/lib/l10n/app_pt.arb b/apps/mod-manager/lib/l10n/app_pt.arb index 81e618bea..47b886346 100644 --- a/apps/mod-manager/lib/l10n/app_pt.arb +++ b/apps/mod-manager/lib/l10n/app_pt.arb @@ -44,6 +44,15 @@ "recoveryAction": "Recuperar", "recoveryRequiredConfirm": "Recuperar a implantação interrompida e remover os arquivos parcialmente implantados?", "statusRecoveryRequired": "Recuperação necessária", + "statusDetailsOwnershipTitle": "Evidência de propriedade registada", + "statusDetailsOwnershipDescription": "Caminhos registados no registo de implementação do gestor. Não provam que esses caminhos ainda existam.", + "statusDetailsOwnershipLive": "Ficheiros do jogo substituídos", + "statusDetailsOwnershipBackups": "Cópias de segurança originais", + "statusDetailsOwnershipAdditive": "Ficheiros pak e contentores adicionados", + "statusDetailsOwnershipUe4ss": "Diretórios de mods UE4SS", + "statusDetailsOwnershipRecovery": "Ficheiros e locais de recuperação", + "statusDetailsOwnershipEmpty": "Nenhum caminho registado neste grupo.", + "statusDetailsOwnershipShown": "São apresentados {shown} de {total} caminhos registados.", "appTitle": "GORE Mod Manager", "tabMods": "Mods", "tabSettings": "Definições", diff --git a/apps/mod-manager/lib/l10n/app_pt_BR.arb b/apps/mod-manager/lib/l10n/app_pt_BR.arb index d380acef4..5c615d815 100644 --- a/apps/mod-manager/lib/l10n/app_pt_BR.arb +++ b/apps/mod-manager/lib/l10n/app_pt_BR.arb @@ -44,6 +44,15 @@ "recoveryAction": "Recuperar", "recoveryRequiredConfirm": "Recuperar a implantação interrompida e remover os arquivos parcialmente implantados?", "statusRecoveryRequired": "Recuperação necessária", + "statusDetailsOwnershipTitle": "Evidência de propriedade registrada", + "statusDetailsOwnershipDescription": "Caminhos registrados no registro de implantação do gerenciador. Eles não comprovam que esses caminhos ainda existam.", + "statusDetailsOwnershipLive": "Arquivos do jogo substituídos", + "statusDetailsOwnershipBackups": "Backups originais", + "statusDetailsOwnershipAdditive": "Arquivos pak e contêineres adicionados", + "statusDetailsOwnershipUe4ss": "Diretórios de mods UE4SS", + "statusDetailsOwnershipRecovery": "Arquivos e locais de recuperação", + "statusDetailsOwnershipEmpty": "Nenhum caminho registrado neste grupo.", + "statusDetailsOwnershipShown": "Exibidos {shown} de {total} caminhos registrados.", "appTitle": "GORE Mod Manager", "tabMods": "Mods", "tabSettings": "Configurações", diff --git a/apps/mod-manager/lib/l10n/app_ru.arb b/apps/mod-manager/lib/l10n/app_ru.arb index 0d9c46874..277685f8b 100644 --- a/apps/mod-manager/lib/l10n/app_ru.arb +++ b/apps/mod-manager/lib/l10n/app_ru.arb @@ -44,6 +44,15 @@ "recoveryAction": "Восстановить", "recoveryRequiredConfirm": "Восстановить прерванное развёртывание и удалить частично развёрнутые файлы?", "statusRecoveryRequired": "Требуется восстановление", + "statusDetailsOwnershipTitle": "Записанные свидетельства владения", + "statusDetailsOwnershipDescription": "Пути из записи развёртывания Менеджера. Они не подтверждают, что эти пути существуют сейчас.", + "statusDetailsOwnershipLive": "Заменённые файлы игры", + "statusDetailsOwnershipBackups": "Резервные копии исходных файлов", + "statusDetailsOwnershipAdditive": "Добавленные pak-файлы и контейнеры", + "statusDetailsOwnershipUe4ss": "Каталоги модов UE4SS", + "statusDetailsOwnershipRecovery": "Файлы и расположения восстановления", + "statusDetailsOwnershipEmpty": "В этой группе нет записанных путей.", + "statusDetailsOwnershipShown": "Показано {shown} из {total} записанных путей.", "appTitle": "GORE Mod Manager", "tabMods": "Моды", "tabSettings": "Настройки", diff --git a/apps/mod-manager/lib/l10n/app_zh.arb b/apps/mod-manager/lib/l10n/app_zh.arb index 98bea18f7..478aa5d4b 100644 --- a/apps/mod-manager/lib/l10n/app_zh.arb +++ b/apps/mod-manager/lib/l10n/app_zh.arb @@ -44,6 +44,15 @@ "recoveryAction": "恢复", "recoveryRequiredConfirm": "恢复中断的部署并移除已部分部署的文件吗?", "statusRecoveryRequired": "需要恢复", + "statusDetailsOwnershipTitle": "已记录的所有权证据", + "statusDetailsOwnershipDescription": "管理器部署记录中保存的路径。这不表示这些路径当前仍然存在。", + "statusDetailsOwnershipLive": "已替换的游戏文件", + "statusDetailsOwnershipBackups": "原始文件备份", + "statusDetailsOwnershipAdditive": "新增的 pak 和容器文件", + "statusDetailsOwnershipUe4ss": "UE4SS 模组目录", + "statusDetailsOwnershipRecovery": "恢复文件和保留位置", + "statusDetailsOwnershipEmpty": "此组中没有记录的路径。", + "statusDetailsOwnershipShown": "显示了 {total} 条已记录路径中的 {shown} 条。", "appTitle": "GORE Mod Manager", "tabMods": "模组", "tabSettings": "设置", diff --git a/apps/mod-manager/lib/l10n/app_zh_Hans.arb b/apps/mod-manager/lib/l10n/app_zh_Hans.arb index 75865a42e..bfb968ca0 100644 --- a/apps/mod-manager/lib/l10n/app_zh_Hans.arb +++ b/apps/mod-manager/lib/l10n/app_zh_Hans.arb @@ -44,6 +44,15 @@ "recoveryAction": "恢复", "recoveryRequiredConfirm": "恢复中断的部署并移除已部分部署的文件吗?", "statusRecoveryRequired": "需要恢复", + "statusDetailsOwnershipTitle": "已记录的所有权证据", + "statusDetailsOwnershipDescription": "管理器部署记录中保存的路径。这不表示这些路径当前仍然存在。", + "statusDetailsOwnershipLive": "已替换的游戏文件", + "statusDetailsOwnershipBackups": "原始文件备份", + "statusDetailsOwnershipAdditive": "新增的 pak 和容器文件", + "statusDetailsOwnershipUe4ss": "UE4SS 模组目录", + "statusDetailsOwnershipRecovery": "恢复文件和保留位置", + "statusDetailsOwnershipEmpty": "此组中没有记录的路径。", + "statusDetailsOwnershipShown": "显示了 {total} 条已记录路径中的 {shown} 条。", "appTitle": "GORE Mod Manager", "tabMods": "模组", "tabSettings": "设置", diff --git a/apps/mod-manager/lib/library/domain/models.dart b/apps/mod-manager/lib/library/domain/models.dart index e4f79df88..1da3e0f99 100644 --- a/apps/mod-manager/lib/library/domain/models.dart +++ b/apps/mod-manager/lib/library/domain/models.dart @@ -7,6 +7,12 @@ /// not model every field, the unparsed JSON is kept in `raw`. library; +import 'dart:convert'; + +const _maxManagerOwnedItems = 128; +const _maxManagerOwnedSourceBytes = 64 * 1024; +const _maxManagerOwnedItemBytes = 4 * 1024; + String? _optString(Object? value) => value is String && value.isNotEmpty ? value : null; @@ -35,6 +41,62 @@ LoadoutView? _loadoutOrNull(Object? value) { return null; } +ManagerOwnedPathGroupView? _managerOwnedGroupOrNull(Object? value) { + if (value is! Map) return null; + final rawItems = value['items']; + final total = value['total']; + final truncated = value['truncated']; + if (rawItems is! List || + rawItems.length > _maxManagerOwnedItems || + rawItems.any((item) => item is! String) || + total is! int || + total < rawItems.length || + truncated is! bool || + truncated != (rawItems.length < total)) { + return null; + } + final items = rawItems.cast(); + var sourceBytes = 0; + try { + for (final item in items) { + final itemBytes = utf8.encode(item).length; + if (itemBytes > _maxManagerOwnedItemBytes) return null; + sourceBytes += itemBytes; + if (sourceBytes > _maxManagerOwnedSourceBytes) return null; + } + } on FormatException { + return null; + } + return ManagerOwnedPathGroupView( + items: List.unmodifiable(items), + total: total, + truncated: truncated, + ); +} + +ManagerOwnedDeploymentView? _managerOwnedOrNull(Object? value) { + if (value is! Map) return null; + final live = _managerOwnedGroupOrNull(value['live']); + final backups = _managerOwnedGroupOrNull(value['backups']); + final additive = _managerOwnedGroupOrNull(value['additive']); + final ue4ss = _managerOwnedGroupOrNull(value['ue4ss']); + final recovery = _managerOwnedGroupOrNull(value['recovery']); + if (live == null || + backups == null || + additive == null || + ue4ss == null || + recovery == null) { + return null; + } + return ManagerOwnedDeploymentView( + live: live, + backups: backups, + additive: additive, + ue4ss: ue4ss, + recovery: recovery, + ); +} + /// Library metadata for one installed mod (`ModEntryMeta` on the Rust side). class ModEntryMetaView { const ModEntryMetaView({ @@ -312,6 +374,37 @@ class ApplyReportView { final List warnings; } +/// One bounded group of paths recorded as Manager-owned deployment evidence. +/// Paths are display-only and do not assert that the named object still exists. +class ManagerOwnedPathGroupView { + const ManagerOwnedPathGroupView({ + required this.items, + required this.total, + required this.truncated, + }); + + final List items; + final int total; + final bool truncated; +} + +/// The five fixed path groups projected from one validated Manager deploy record. +class ManagerOwnedDeploymentView { + const ManagerOwnedDeploymentView({ + required this.live, + required this.backups, + required this.additive, + required this.ue4ss, + required this.recovery, + }); + + final ManagerOwnedPathGroupView live; + final ManagerOwnedPathGroupView backups; + final ManagerOwnedPathGroupView additive; + final ManagerOwnedPathGroupView ue4ss; + final ManagerOwnedPathGroupView recovery; +} + /// Deployment status of the game install (`mgr_status`). Sealed hierarchy — /// switch on the subtype. Unknown / future states parse as /// [ManagerStatusUnknown] so a newer DLL never breaks status display; each @@ -323,7 +416,7 @@ sealed class ManagerStatusView { factory ManagerStatusView.fromJson(Map json) { return switch (json['state']) { 'nothing_deployed' => ManagerStatusNothingDeployed(json), - 'recovery_required' => ManagerStatusRecoveryRequired(json), + 'recovery_required' => ManagerStatusRecoveryRequired.fromJson(json), 'studio_deploy_active' => ManagerStatusStudioDeployActive(json), 'in_sync' => ManagerStatusInSync(json), 'changes_pending' => ManagerStatusChangesPending(json), @@ -337,6 +430,11 @@ sealed class ManagerStatusView { /// The raw state tag. String get state; + + /// Optional display-only paths from an exact Manager-owned record. Unknown, + /// Nothing, and Studio states never adopt this field even if malformed or + /// future wire data includes it. + ManagerOwnedDeploymentView? get managerOwned => null; } /// No manager deployment exists in the game install. @@ -350,7 +448,13 @@ class ManagerStatusNothingDeployed extends ManagerStatusView { /// An interrupted deployment must be recovered through undeploy before any new /// deployment is safe. class ManagerStatusRecoveryRequired extends ManagerStatusView { - const ManagerStatusRecoveryRequired(super.raw); + const ManagerStatusRecoveryRequired(super.raw) : managerOwned = null; + + ManagerStatusRecoveryRequired.fromJson(super.raw) + : managerOwned = _managerOwnedOrNull(raw['manager_owned']); + + @override + final ManagerOwnedDeploymentView? managerOwned; @override String get state => 'recovery_required'; @@ -371,11 +475,16 @@ class ManagerStatusStudioDeployActive extends ManagerStatusView { /// The deployed state matches the current loadout. class ManagerStatusInSync extends ManagerStatusView { - ManagerStatusInSync(super.raw) : loadout = _loadoutOrNull(raw['loadout']); + ManagerStatusInSync(super.raw) + : loadout = _loadoutOrNull(raw['loadout']), + managerOwned = _managerOwnedOrNull(raw['manager_owned']); /// The deployed loadout (null when the DLL sent an unexpected shape). final LoadoutView? loadout; + @override + final ManagerOwnedDeploymentView? managerOwned; + @override String get state => 'in_sync'; } @@ -384,23 +493,32 @@ class ManagerStatusInSync extends ManagerStatusView { class ManagerStatusChangesPending extends ManagerStatusView { ManagerStatusChangesPending(super.raw) : deployed = _loadoutOrNull(raw['deployed']), - target = _loadoutOrNull(raw['target']); + target = _loadoutOrNull(raw['target']), + managerOwned = _managerOwnedOrNull(raw['manager_owned']); final LoadoutView? deployed; final LoadoutView? target; + @override + final ManagerOwnedDeploymentView? managerOwned; + @override String get state => 'changes_pending'; } /// A game update overwrote deployed files; a re-apply is needed. class ManagerStatusGameUpdated extends ManagerStatusView { - ManagerStatusGameUpdated(super.raw) : drifted = _stringList(raw['drifted']); + ManagerStatusGameUpdated(super.raw) + : drifted = _stringList(raw['drifted']), + managerOwned = _managerOwnedOrNull(raw['manager_owned']); /// Game-relative paths whose on-disk content drifted from what the manager /// deployed (empty when the DLL reported drift another way — see [raw]). final List drifted; + @override + final ManagerOwnedDeploymentView? managerOwned; + @override String get state => 'game_updated'; } diff --git a/apps/mod-manager/lib/status/ui/status_details_dialog.dart b/apps/mod-manager/lib/status/ui/status_details_dialog.dart index 5f204c7a0..11a6ba30a 100644 --- a/apps/mod-manager/lib/status/ui/status_details_dialog.dart +++ b/apps/mod-manager/lib/status/ui/status_details_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/diagnostic_text.dart'; import '../../l10n/app_localizations.dart'; import '../../library/domain/library_notifier.dart'; import '../../library/domain/models.dart'; @@ -14,6 +15,7 @@ typedef StatusDetailsResult = ({ const _lazyListThreshold = 50; const _lazyListHeight = 240.0; +const _maxOwnedPathRunes = 4096; /// Whether current status authority (or its fail-closed fallback evidence) /// still says Studio owns [currentRoot]. A known non-Studio status always @@ -124,6 +126,14 @@ class StatusDetailsDialog extends StatelessWidget { }); } + final managerOwned = status?.managerOwned; + if (!studioActive && managerOwned != null) { + content.addAll([ + const SizedBox(height: 16), + _managerOwnedExpansion(context, l10n, managerOwned), + ]); + } + final rootBoundError = currentRoot != null && state.gameRoot == currentRoot ? state.error : null; @@ -292,6 +302,109 @@ class StatusDetailsDialog extends StatelessWidget { ); } + Widget _managerOwnedExpansion( + BuildContext context, + AppLocalizations l10n, + ManagerOwnedDeploymentView owned, + ) { + return ExpansionTile( + key: const ValueKey('status-details-manager-owned'), + initiallyExpanded: false, + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsetsDirectional.only( + start: 16, + end: 8, + bottom: 8, + ), + leading: const Icon(Icons.inventory_2_outlined), + title: Text(l10n.statusDetailsOwnershipTitle), + subtitle: Text(l10n.statusDetailsOwnershipDescription), + children: [ + _managerOwnedGroup( + context, + l10n, + keyName: 'owned-live', + title: l10n.statusDetailsOwnershipLive, + group: owned.live, + ), + const SizedBox(height: 16), + _managerOwnedGroup( + context, + l10n, + keyName: 'owned-backups', + title: l10n.statusDetailsOwnershipBackups, + group: owned.backups, + ), + const SizedBox(height: 16), + _managerOwnedGroup( + context, + l10n, + keyName: 'owned-additive', + title: l10n.statusDetailsOwnershipAdditive, + group: owned.additive, + ), + const SizedBox(height: 16), + _managerOwnedGroup( + context, + l10n, + keyName: 'owned-ue4ss', + title: l10n.statusDetailsOwnershipUe4ss, + group: owned.ue4ss, + ), + const SizedBox(height: 16), + _managerOwnedGroup( + context, + l10n, + keyName: 'owned-recovery', + title: l10n.statusDetailsOwnershipRecovery, + group: owned.recovery, + ), + ], + ); + } + + Widget _managerOwnedGroup( + BuildContext context, + AppLocalizations l10n, { + required String keyName, + required String title, + required ManagerOwnedPathGroupView group, + }) { + // Native never truncates a path: it omits paths that exceed its whole-item + // cap. Apply the shared diagnostic sanitizer here and likewise omit any + // impossible over-rune item instead of displaying a shortened path. + final values = []; + for (final path in group.items) { + final sanitized = boundedDiagnosticText(path, _maxOwnedPathRunes); + if (!sanitized.truncated && sanitized.value != null) { + values.add(sanitized.value!); + } + } + final hidden = values.length < group.total; + return Column( + key: ValueKey('status-details-group-$keyName'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _stringListSection( + context, + keyName: keyName, + title: title, + values: values, + emptyText: l10n.statusDetailsOwnershipEmpty, + selectable: true, + ), + if (hidden) ...[ + const SizedBox(height: 8), + Text( + l10n.statusDetailsOwnershipShown(values.length, group.total), + key: ValueKey('status-details-$keyName-truncated'), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ); + } + Widget _loadoutRow( BuildContext context, AppLocalizations l10n, { diff --git a/apps/mod-manager/test/core/mgr_ffi_test.dart b/apps/mod-manager/test/core/mgr_ffi_test.dart index e01e323fa..3d86d9efd 100644 --- a/apps/mod-manager/test/core/mgr_ffi_test.dart +++ b/apps/mod-manager/test/core/mgr_ffi_test.dart @@ -115,6 +115,28 @@ Map _libraryListResponse() => { }, }; +Map _ownedGroup( + List items, { + int? total, + bool? truncated, +}) => { + 'items': items, + 'total': total ?? items.length, + 'truncated': truncated ?? false, +}; + +Map _managerOwnedWire() => { + 'live': _ownedGroup(['C:/game/G1R/Story/VoiceOver/a.zip']), + 'backups': _ownedGroup(['C:/game/G1R/Story/VoiceOver/a.zip.gore-bak']), + 'additive': _ownedGroup(['C:/game/G1R/Content/Paks/~mods/a_P.pak']), + 'ue4ss': _ownedGroup(['C:/game/G1R/Binaries/Win64/ue4ss/Mods/A']), + 'recovery': _ownedGroup( + ['C:/game/gore-mod.deployed.json'], + total: 2, + truncated: true, + ), +}; + void main() { group('canonical native response decoding', () { test('retains one compact exact object', () { @@ -344,8 +366,103 @@ void main() { final s = await parse({'state': 'recovery_required'}); expect(s, isA()); expect(s.state, 'recovery_required'); + expect(s.managerOwned, isNull); + }); + + test('parses all five bounded manager-owned groups additively', () async { + final s = await parse({ + 'state': 'recovery_required', + 'manager_owned': _managerOwnedWire(), + }); + expect(s, isA()); + final owned = s.managerOwned!; + expect(owned.live.items, ['C:/game/G1R/Story/VoiceOver/a.zip']); + expect(owned.backups.total, 1); + expect(owned.additive.truncated, isFalse); + expect(owned.ue4ss.items, hasLength(1)); + expect(owned.recovery.items, ['C:/game/gore-mod.deployed.json']); + expect(owned.recovery.total, 2); + expect(owned.recovery.truncated, isTrue); }); + test( + 'malformed ownership detail is hidden without losing base status', + () async { + final malformed = [ + 'not-an-object', + {..._managerOwnedWire()}..remove('live'), + { + ..._managerOwnedWire(), + 'live': _ownedGroup([7]), + }, + { + ..._managerOwnedWire(), + 'live': _ownedGroup(List.filled(129, 'C:/x')), + }, + { + ..._managerOwnedWire(), + 'live': _ownedGroup([List.filled(4097, 'x').join()]), + }, + { + ..._managerOwnedWire(), + 'live': _ownedGroup([ + for (var i = 0; i < 17; i++) '$i${List.filled(4094, 'x').join()}', + ]), + }, + {..._managerOwnedWire(), 'live': _ownedGroup([], total: -1)}, + { + ..._managerOwnedWire(), + 'live': _ownedGroup(['C:/x'], total: 0), + }, + { + ..._managerOwnedWire(), + 'live': _ownedGroup([], total: 1, truncated: false), + }, + ]; + + for (final managerOwned in malformed) { + final s = await parse({ + 'state': 'in_sync', + 'loadout': [], + 'manager_owned': managerOwned, + }); + expect(s, isA()); + expect(s.managerOwned, isNull, reason: '$managerOwned'); + } + }, + ); + + test('extra ownership fields remain forward compatible', () async { + final s = await parse({ + 'state': 'in_sync', + 'loadout': [], + 'manager_owned': { + ..._managerOwnedWire(), + 'future_group': _ownedGroup([]), + 'live': {..._ownedGroup([]), 'future_fact': 7}, + }, + }); + expect(s.managerOwned, isNotNull); + expect(s.managerOwned!.live.total, 0); + }); + + test( + 'Nothing, Studio, and future states never adopt ownership detail', + () async { + for (final state in [ + 'nothing_deployed', + 'studio_deploy_active', + 'future_state', + ]) { + final s = await parse({ + 'state': state, + 'manager_owned': _managerOwnedWire(), + }); + expect(s.managerOwned, isNull, reason: state); + } + }, + ); + test('parses in_sync with loadout (wire shape: entry ARRAY)', () async { final s = await parse({ 'state': 'in_sync', diff --git a/apps/mod-manager/test/status/status_details_dialog_test.dart b/apps/mod-manager/test/status/status_details_dialog_test.dart index 5c85ee67b..596248a60 100644 --- a/apps/mod-manager/test/status/status_details_dialog_test.dart +++ b/apps/mod-manager/test/status/status_details_dialog_test.dart @@ -37,6 +37,31 @@ StatusState _state( studioActive: status['state'] == 'studio_deploy_active', ); +Map _ownedGroup( + List items, { + int? total, + bool? truncated, +}) => { + 'items': items, + 'total': total ?? items.length, + 'truncated': truncated ?? (total != null && total > items.length), +}; + +Map _ownedEvidence({ + List live = const [], + List backups = const [], + List additive = const [], + List ue4ss = const [], + List recovery = const [], + int? recoveryTotal, +}) => { + 'live': _ownedGroup(live), + 'backups': _ownedGroup(backups), + 'additive': _ownedGroup(additive), + 'ue4ss': _ownedGroup(ue4ss), + 'recovery': _ownedGroup(recovery, total: recoveryTotal), +}; + class _DialogHarness extends StatelessWidget { const _DialogHarness({ required this.state, @@ -189,6 +214,108 @@ void main() { }, ); + testWidgets( + 'recorded ownership evidence is collapsed, complete, selectable, and sanitized', + (tester) async { + await _open( + tester, + _state({ + 'state': 'in_sync', + 'loadout': [], + 'manager_owned': _ownedEvidence( + live: ['C:/game/G1R/safe\u202eevil\u0007.bin'], + backups: ['C:/game/G1R/original.bin.gore-bak'], + recovery: ['C:/game/gore-mod.deployed.json'], + recoveryTotal: 3, + ), + }), + ); + + expect(find.text('Recorded ownership evidence'), findsOneWidget); + expect(find.text('C:/game/G1R/safe evil .bin'), findsNothing); + expect(find.textContaining('\u202e'), findsNothing); + + await tester.tap( + find.byKey(const ValueKey('status-details-manager-owned')), + ); + await tester.pumpAndSettle(); + + for (final heading in [ + 'Replaced game files', + 'Pristine backups', + 'Added pak and container files', + 'UE4SS mod directories', + 'Recovery files and holders', + ]) { + expect(find.text(heading), findsOneWidget); + } + expect(find.text('C:/game/G1R/safe evil .bin'), findsOneWidget); + expect(find.text('C:/game/G1R/original.bin.gore-bak'), findsOneWidget); + expect(find.text('C:/game/gore-mod.deployed.json'), findsOneWidget); + expect(find.text('No paths recorded in this group.'), findsNWidgets(2)); + expect(find.text('1 of 3 recorded paths shown.'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const ValueKey('status-details-owned-live-0')), + matching: find.byType(SelectionArea), + ), + findsOneWidget, + ); + }, + ); + + testWidgets('unauthorized states ignore even syntactically valid evidence', ( + tester, + ) async { + for (final status in [ + { + 'state': 'nothing_deployed', + 'manager_owned': _ownedEvidence(live: ['C:/must-not-show']), + }, + { + 'state': 'studio_deploy_active', + 'mod_name': 'Studio', + 'manager_owned': _ownedEvidence(live: ['C:/must-not-show']), + }, + { + 'state': 'future_state', + 'manager_owned': _ownedEvidence(live: ['C:/must-not-show']), + }, + ]) { + await _open(tester, _state(status), applyEnabled: false); + expect( + find.byKey(const ValueKey('status-details-manager-owned')), + findsNothing, + ); + expect(find.text('C:/must-not-show'), findsNothing); + await tester.tap( + find.byKey(const ValueKey('status-details-action-close')), + ); + await tester.pumpAndSettle(); + } + }); + + test('ownership copy is generated for all supported locales', () async { + for (final locale in AppLocalizations.supportedLocales) { + final l10n = await AppLocalizations.delegate.load(locale); + expect( + [ + l10n.statusDetailsOwnershipTitle, + l10n.statusDetailsOwnershipDescription, + l10n.statusDetailsOwnershipLive, + l10n.statusDetailsOwnershipBackups, + l10n.statusDetailsOwnershipAdditive, + l10n.statusDetailsOwnershipUe4ss, + l10n.statusDetailsOwnershipRecovery, + l10n.statusDetailsOwnershipEmpty, + l10n.statusDetailsOwnershipShown(1, 2), + ].every((value) => value.trim().isNotEmpty), + isTrue, + reason: locale.toLanguageTag(), + ); + } + }); + testWidgets( 'changes pending compares deployed and target and preserves disabled Apply semantics', (tester) async { @@ -296,7 +423,12 @@ void main() { StatusDetailsResult? result; await tester.pumpWidget( _DialogHarness( - state: _state({'state': 'recovery_required'}), + state: _state({ + 'state': 'recovery_required', + 'manager_owned': _ownedEvidence( + recovery: ['C:/game/gore-mod.deployed.json'], + ), + }), currentRoot: _root, applyEnabled: false, operationsBusy: false, @@ -305,6 +437,10 @@ void main() { ); await tester.tap(find.byKey(const ValueKey('open-status-dialog'))); await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('status-details-manager-owned')), + findsOneWidget, + ); await tester.tap( find.byKey(const ValueKey('status-details-action-recover')), ); @@ -426,7 +562,11 @@ void main() { await _open( tester, _state( - {'state': 'in_sync', 'loadout': []}, + { + 'state': 'in_sync', + 'loadout': [], + 'manager_owned': _ownedEvidence(live: ['C:/old-root-owned.bin']), + }, error: 'old-root native detail', report: const ApplyReportView( applied: ['Old root mod'], @@ -440,6 +580,8 @@ void main() { expect(find.text('old-root native detail'), findsNothing); expect(find.text('Old root mod'), findsNothing); expect(find.text('Old root warning'), findsNothing); + expect(find.text('Recorded ownership evidence'), findsNothing); + expect(find.text('C:/old-root-owned.bin'), findsNothing); expect( find.byKey(const ValueKey('status-details-action-refresh')), findsOneWidget, @@ -592,4 +734,50 @@ void main() { findsOneWidget, ); }); + + testWidgets('128 recorded paths stay lazy at compact 200 percent text', ( + tester, + ) async { + tester.view.physicalSize = const Size(700, 460); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final paths = [ + for (var i = 0; i < 128; i++) 'C:/game/G1R/Content/owned-$i.bin', + ]; + + await _open( + tester, + _state({ + 'state': 'in_sync', + 'loadout': [], + 'manager_owned': _ownedEvidence(live: paths), + }), + textScaler: const TextScaler.linear(2), + ); + final expansion = find.byKey( + const ValueKey('status-details-manager-owned'), + ); + await tester.ensureVisible(expansion); + await tester.tap(expansion); + await tester.pumpAndSettle(); + + final listFinder = find.byKey( + const ValueKey('status-details-list-owned-live'), + ); + final list = tester.widget(listFinder); + final delegate = list.childrenDelegate as SliverChildBuilderDelegate; + expect(delegate.estimatedChildCount, 128); + final builtRows = find.byWidgetPredicate((widget) { + final key = widget.key; + return key is ValueKey && + key.value.startsWith('status-details-owned-live-'); + }); + expect(builtRows.evaluate().length, lessThan(100)); + expect( + find.byKey(const ValueKey('status-details-action-close')).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); } diff --git a/crates/gore-ffi/src/lib.rs b/crates/gore-ffi/src/lib.rs index ee45ba2fd..a65fd34c0 100644 --- a/crates/gore-ffi/src/lib.rs +++ b/crates/gore-ffi/src/lib.rs @@ -1803,10 +1803,11 @@ fn mgr_apply(payload: Value) -> Value { } } -/// `{game_root, library_dir?, loadout_path?}` → `{ok, status:ManagerStatus}` — paired Store -/// overrides; diff deployed vs +/// `{game_root, library_dir?, loadout_path?}` → `{ok, status:{state,...,manager_owned?}}` — paired +/// Store overrides; diff deployed vs /// target loadout. `library_dir` lets status fingerprint each enabled mod's current content so a -/// same-id re-import (update) is reported as changes-pending rather than in-sync. +/// same-id re-import (update) is reported as changes-pending rather than in-sync. The optional +/// bounded `manager_owned` display projection comes from that same validated record snapshot. fn mgr_status(payload: Value) -> Value { let Some(game_root) = payload.get("game_root").and_then(Value::as_str) else { return err("BAD_REQUEST", "missing 'game_root'"); @@ -1819,9 +1820,9 @@ fn mgr_status(payload: Value) -> Value { Ok(store) => store, Err(e) => return err("STATUS_FAILED", e.to_string()), }; - match store.status(std::path::Path::new(game_root)) { - Ok(status) => { - json!({"ok": true, "status": serde_json::to_value(&status).unwrap_or(Value::Null)}) + match store.status_report(std::path::Path::new(game_root)) { + Ok(report) => { + json!({"ok": true, "status": serde_json::to_value(&report).unwrap_or(Value::Null)}) } Err(e) => err("STATUS_FAILED", e.to_string()), } @@ -3521,6 +3522,41 @@ mod tests { ); assert_eq!(v["ok"], true, "resp: {v}"); assert_eq!(v["status"]["state"], "nothing_deployed"); + assert!(v["status"].get("manager_owned").is_none(), "resp: {v}"); + } + + #[test] + fn mgr_status_manager_recovery_has_recorded_ownership_evidence() { + let tmp = tempfile::tempdir().unwrap(); + let game = tmp.path().join("game"); + std::fs::create_dir_all(&game).unwrap(); + let lib = tmp.path().join("library"); + let lo = tmp.path().join("loadout.json"); + std::fs::write( + game.join("gore-mod.deployed.json"), + br#"{"mod_name":"manager","ue4ss_mod_dir":null,"backups":[],"owner":"manager","phase":"recovery_required"}"#, + ) + .unwrap(); + + let value = mgr_call( + "mgr_status", + json!({ + "game_root": game.display().to_string(), + "library_dir": lib.display().to_string(), + "loadout_path": lo.display().to_string() + }), + ); + assert_eq!(value["ok"], true, "resp: {value}"); + assert_eq!(value["status"]["state"], "recovery_required"); + assert_eq!(value["status"]["manager_owned"]["recovery"]["total"], 1); + assert_eq!( + value["status"]["manager_owned"]["recovery"]["items"], + json!([std::fs::canonicalize(&game) + .unwrap() + .join("gore-mod.deployed.json") + .display() + .to_string()]) + ); } /// After importing + enabling a mod and applying it, `mgr_status` against the SAME library @@ -3582,6 +3618,31 @@ mod tests { ); assert_eq!(st["ok"], true, "status resp: {st}"); assert_eq!(st["status"]["state"], "in_sync", "resp: {st}"); + let owned = &st["status"]["manager_owned"]; + assert!(owned.is_object(), "status resp: {st}"); + for group in ["live", "backups", "additive", "ue4ss", "recovery"] { + assert!(owned[group]["items"].is_array(), "group {group}: {st}"); + assert!(owned[group]["total"].is_u64(), "group {group}: {st}"); + assert!( + owned[group]["truncated"].is_boolean(), + "group {group}: {st}" + ); + } + assert_eq!(owned["ue4ss"]["total"], 1, "status resp: {st}"); + assert_eq!(owned["recovery"]["total"], 1, "status resp: {st}"); + assert_eq!( + owned["recovery"]["items"], + json!([std::fs::canonicalize(&game) + .unwrap() + .join("gore-mod.deployed.json") + .display() + .to_string()]), + "status resp: {st}" + ); + let wire = serde_json::to_string(owned).unwrap(); + assert!(!wire.contains("sha256")); + assert!(!wire.contains("fingerprint")); + assert!(!wire.contains("mutation.lock")); } #[test] @@ -3615,5 +3676,19 @@ mod tests { assert_eq!(v["error"]["code"], "STUDIO_DEPLOY_ACTIVE"); // The message carries just the blocking studio mod's name. assert_eq!(v["error"]["message"], "SoloMod"); + + let status = mgr_call( + "mgr_status", + json!({ + "game_root": game.display().to_string(), + "library_dir": lib.display().to_string(), + "loadout_path": lo.display().to_string() + }), + ); + assert_eq!(status["status"]["state"], "studio_deploy_active"); + assert!( + status["status"].get("manager_owned").is_none(), + "resp: {status}" + ); } } diff --git a/crates/gore-mod/src/mgr/status.rs b/crates/gore-mod/src/mgr/status.rs index 4332f6967..796f8ed9c 100644 --- a/crates/gore-mod/src/mgr/status.rs +++ b/crates/gore-mod/src/mgr/status.rs @@ -20,7 +20,7 @@ //! reads each enabled mod's library sidecar to fingerprint it; it never writes. use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::Serialize; @@ -38,6 +38,9 @@ const MAX_PREFLIGHT_STATUS_HASH_BYTES: u64 = 64 * 1024 * 1024 * 1024; const MAX_PREFLIGHT_STATUS_HASH_ENTRIES: u64 = 250_000; const MAX_PREFLIGHT_STATUS_TREE_ENTRIES: u64 = 250_000; const MAX_PREFLIGHT_STATUS_PATH_ENTRIES: u64 = 250_000; +const MAX_OWNED_PATH_ITEMS: usize = 128; +const MAX_OWNED_PATH_BYTES: usize = 64 * 1024; +const MAX_OWNED_PATH_ITEM_BYTES: usize = 4 * 1024; /// The manager's deployment state relative to a target loadout. `#[serde(tag = "state")]` so the /// UI can switch on a single discriminant field. @@ -63,6 +66,132 @@ pub enum ManagerStatus { GameUpdated { drifted: Vec }, } +/// Display-only ownership evidence from the same validated deploy-record snapshot as +/// [`ManagerStatus`]. This is deliberately separate from the state enum so existing Rust callers +/// keep exhaustive-match compatibility while additive wire consumers can inspect recorded paths. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ManagerStatusReport { + #[serde(flatten)] + pub status: ManagerStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub manager_owned: Option, +} + +/// The five bounded classes of paths an exact Manager-owned deploy record says it owns. +/// They are historical record evidence only: no member claims that a path currently exists. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ManagerOwnedDeployment { + pub live: ManagerOwnedPathGroup, + pub backups: ManagerOwnedPathGroup, + pub additive: ManagerOwnedPathGroup, + pub ue4ss: ManagerOwnedPathGroup, + pub recovery: ManagerOwnedPathGroup, +} + +/// One stable, per-platform-path-key deduplicated and bounded display group. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ManagerOwnedPathGroup { + pub items: Vec, + /// Unique candidates before the item, aggregate-byte, and per-item display caps. + pub total: usize, + pub truncated: bool, +} + +fn manager_owned_path_group(paths: impl IntoIterator) -> ManagerOwnedPathGroup { + // A BTreeMap both gives stable platform-key order and retains at most one spelling per path. + // If a record contains aliases for one key, choose the lexically first spelling so the result + // is independent of source-field order. + let mut unique = BTreeMap::::new(); + for path in paths { + let key = crate::record_path_key(Path::new(&path)); + unique + .entry(key) + .and_modify(|current| { + if path < *current { + *current = path.clone(); + } + }) + .or_insert(path); + } + + let total = unique.len(); + let mut items = Vec::new(); + let mut source_bytes = 0usize; + for path in unique.into_values() { + let path_bytes = path.len(); + if path_bytes > MAX_OWNED_PATH_ITEM_BYTES + || items.len() == MAX_OWNED_PATH_ITEMS + || source_bytes.saturating_add(path_bytes) > MAX_OWNED_PATH_BYTES + { + continue; + } + source_bytes += path_bytes; + items.push(path); + } + ManagerOwnedPathGroup { + truncated: items.len() < total, + items, + total, + } +} + +fn manager_owned_deployment( + game_root: &Path, + record: &crate::DeployRecord, +) -> Option { + if record.owner != "manager" { + return None; + } + + let live = manager_owned_path_group(record.backups.iter().map(|(live, _, _)| live.clone())); + let backups = + manager_owned_path_group(record.backups.iter().map(|(_, backup, _)| backup.clone())); + let additive = manager_owned_path_group( + record + .texture_triplets + .iter() + .chain(record.managed_paks.iter()) + .cloned(), + ); + let ue4ss = manager_owned_path_group( + record + .ue4ss_mod_dir + .iter() + .chain(record.ue4ss_mod_dirs.iter()) + .chain(record.stale_ue4ss_dirs.iter()) + .cloned(), + ); + + let mut recovery = vec![crate::deploy_record_path(game_root).display().to_string()]; + for (source, holder) in &record.ue4ss_cleanup_claims { + recovery.push(source.clone()); + recovery.push(holder.clone()); + } + for (source, claim) in &record.file_cleanup_claims { + recovery.push(source.clone()); + recovery.push(claim.holder.clone()); + recovery.push( + PathBuf::from(&claim.holder) + .join("claimed") + .display() + .to_string(), + ); + recovery.extend(claim.restore_from.iter().cloned()); + } + recovery.extend(record.trusted_ue4ss_tombstones.iter().cloned()); + recovery.extend(record.trusted_file_tombstones.iter().cloned()); + recovery.extend(record.recovery_file_hashes.keys().cloned()); + recovery.extend(record.recovery_tree_fingerprints.keys().cloned()); + + Some(ManagerOwnedDeployment { + live, + backups, + additive, + ue4ss, + recovery: manager_owned_path_group(recovery), + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InspectionFailurePolicy { TreatAsDrift, @@ -379,7 +508,23 @@ pub fn status( library_dir: &Path, target: &Loadout, ) -> crate::Result { - status_with_failure_policy( + Ok(status_report_with_failure_policy( + game_root, + library_dir, + target, + InspectionFailurePolicy::TreatAsDrift, + )? + .status) +} + +/// The same authoritative status plus optional bounded display-only ownership evidence. +/// `manager_owned` is absent unless the validated record's exact owner is `manager`. +pub fn status_report( + game_root: &Path, + library_dir: &Path, + target: &Loadout, +) -> crate::Result { + status_report_with_failure_policy( game_root, library_dir, target, @@ -394,28 +539,33 @@ pub(super) fn status_for_preflight( library_dir: &Path, target: &Loadout, ) -> crate::Result { - status_with_failure_policy( + Ok(status_report_with_failure_policy( game_root, library_dir, target, InspectionFailurePolicy::Preserve, - ) + )? + .status) } -fn status_with_failure_policy( +fn status_report_with_failure_policy( game_root: &Path, library_dir: &Path, target: &Loadout, failure_policy: InspectionFailurePolicy, -) -> crate::Result { +) -> crate::Result { // Match deploy/undeploy's record location logic so status reads the SAME record they wrote, // regardless of whether the caller passed the install dir or its `G1R` child, or a relative // path from a different cwd. let game_root = crate::abs_root(game_root); let Some(stored) = crate::read_record(&game_root)? else { - return Ok(ManagerStatus::NothingDeployed); + return Ok(ManagerStatusReport { + status: ManagerStatus::NothingDeployed, + manager_owned: None, + }); }; let record = stored.record; + let manager_owned = manager_owned_deployment(&game_root, &record); let recovery_required = record.phase == crate::DeployPhase::RecoveryRequired || !record.file_cleanup_claims.is_empty() @@ -424,14 +574,20 @@ fn status_with_failure_policy( crate::validate_record_identities(&record)?; } if recovery_required { - return Ok(ManagerStatus::RecoveryRequired); + return Ok(ManagerStatusReport { + status: ManagerStatus::RecoveryRequired, + manager_owned, + }); } // A studio (non-manager) deployment is off-limits to the manager: it doesn't own it and can't // meaningfully diff a single hand-built bundle against a loadout. if record.owner != "manager" { - return Ok(ManagerStatus::StudioDeployActive { - mod_name: record.mod_name, + return Ok(ManagerStatusReport { + status: ManagerStatus::StudioDeployActive { + mod_name: record.mod_name, + }, + manager_owned: None, }); } @@ -587,7 +743,10 @@ fn status_with_failure_policy( if !drifted.is_empty() { drifted.sort(); drifted.dedup(); - return Ok(ManagerStatus::GameUpdated { drifted }); + return Ok(ManagerStatusReport { + status: ManagerStatus::GameUpdated { drifted }, + manager_owned, + }); } // Compare the deployed snapshot against the target's ENABLED entries only, order-sensitively @@ -600,9 +759,12 @@ fn status_with_failure_policy( .cloned() .collect(); if record.loadout != target_enabled { - return Ok(ManagerStatus::ChangesPending { - deployed: record.loadout, - target: target_enabled, + return Ok(ManagerStatusReport { + status: ManagerStatus::ChangesPending { + deployed: record.loadout, + target: target_enabled, + }, + manager_owned, }); } @@ -616,9 +778,12 @@ fn status_with_failure_policy( let library = match LibraryRoot::open(library_dir) { Ok(library) => library, Err(_) => { - return Ok(ManagerStatus::ChangesPending { - deployed: record.loadout, - target: target_enabled, + return Ok(ManagerStatusReport { + status: ManagerStatus::ChangesPending { + deployed: record.loadout, + target: target_enabled, + }, + manager_owned, }); } }; @@ -629,14 +794,20 @@ fn status_with_failure_policy( &record.deployed_fingerprints, &mut remaining_meta_bytes, ) { - return Ok(ManagerStatus::ChangesPending { - deployed: record.loadout, - target: target_enabled, + return Ok(ManagerStatusReport { + status: ManagerStatus::ChangesPending { + deployed: record.loadout, + target: target_enabled, + }, + manager_owned, }); } - Ok(ManagerStatus::InSync { - loadout: record.loadout, + Ok(ManagerStatusReport { + status: ManagerStatus::InSync { + loadout: record.loadout, + }, + manager_owned, }) } @@ -686,8 +857,8 @@ mod tests { use super::*; use crate::canonical_tempfile as tempfile; use crate::mgr::model::{ComponentInfo, ModEntryMeta, ModKind, META_FILE}; - use crate::{record_path, DeployPhase, DeployRecord}; - use std::collections::BTreeMap; + use crate::{record_path, DeployPhase, DeployRecord, FileCleanupClaim}; + use std::collections::{BTreeMap, BTreeSet}; #[cfg(unix)] fn make_dir_link(target: &Path, link: &Path) -> bool { @@ -723,6 +894,141 @@ mod tests { std::fs::write(record_path(game), serde_json::to_vec(rec).unwrap()).unwrap(); } + #[test] + fn manager_owned_path_group_deduplicates_and_applies_each_display_cap() { + let stable_group = manager_owned_path_group([ + "C:/game/z.bin".to_owned(), + "C:/game/a.bin".to_owned(), + "C:/game/m.bin".to_owned(), + "C:/game/a.bin".to_owned(), + ]); + assert_eq!( + stable_group.items, + vec![ + "C:/game/a.bin".to_owned(), + "C:/game/m.bin".to_owned(), + "C:/game/z.bin".to_owned(), + ] + ); + assert_eq!(stable_group.total, 3); + assert!(!stable_group.truncated); + + #[cfg(windows)] + { + let aliases = manager_owned_path_group([ + r"C:\Game\G1R\Content\A.bin".to_owned(), + "c:/game/g1r/content/a.bin".to_owned(), + ]); + assert_eq!(aliases.total, 1); + assert_eq!(aliases.items, vec![r"C:\Game\G1R\Content\A.bin"]); + } + + let count_group = manager_owned_path_group( + (0..130) + .map(|index| format!("C:/game/G1R/Content/path-{index:03}.bin")) + .chain(std::iter::once( + "C:/game/G1R/Content/path-000.bin".to_owned(), + )), + ); + assert_eq!(count_group.total, 130); + assert_eq!(count_group.items.len(), MAX_OWNED_PATH_ITEMS); + assert!(count_group.truncated); + + let exact_4096 = "a".repeat(MAX_OWNED_PATH_ITEM_BYTES); + let overlong = "b".repeat(MAX_OWNED_PATH_ITEM_BYTES + 1); + let item_group = manager_owned_path_group([exact_4096.clone(), overlong]); + assert_eq!(item_group.total, 2); + assert_eq!(item_group.items, vec![exact_4096]); + assert!(item_group.truncated); + + let byte_group = manager_owned_path_group((0..17).map(|index| { + let prefix = format!("{index:02}"); + format!( + "{prefix}{}", + "x".repeat(MAX_OWNED_PATH_ITEM_BYTES - prefix.len()) + ) + })); + assert_eq!(byte_group.total, 17); + assert_eq!(byte_group.items.len(), 16); + assert_eq!( + byte_group.items.iter().map(String::len).sum::(), + MAX_OWNED_PATH_BYTES + ); + assert!(byte_group.truncated); + + let unicode_group = manager_owned_path_group([ + "é".repeat(MAX_OWNED_PATH_ITEM_BYTES / 2), + "ê".repeat(MAX_OWNED_PATH_ITEM_BYTES / 2 + 1), + ]); + assert_eq!(unicode_group.total, 2); + assert_eq!(unicode_group.items.len(), 1); + assert_eq!(unicode_group.items[0].len(), MAX_OWNED_PATH_ITEM_BYTES); + assert!(unicode_group.truncated); + } + + #[test] + fn manager_owned_builder_maps_only_recorded_path_evidence() { + let game = Path::new("C:/game"); + let ue4ss_source = "C:/game/G1R/Binaries/Win64/ue4ss/Mods/Old".to_owned(); + let ue4ss_holder = "C:/game/G1R/Binaries/Win64/ue4ss/.gore-ue4ss-delete-old".to_owned(); + let file_source = "C:/game/G1R/Content/Paks/~mods/gm000_A_P.pak".to_owned(); + let file_holder = "C:/game/.gore-mod-cleanup-a".to_owned(); + let restore_source = "C:/game/G1R/Story/VoiceOver/a.zip.gore-bak".to_owned(); + let ue4ss_tombstone = "C:/game/G1R/Binaries/Win64/ue4ss/.gore-ue4ss-delete-tomb".to_owned(); + let file_tombstone = "C:/game/.gore-mod-cleanup-tomb".to_owned(); + let recovery_file = "C:/game/G1R/Story/VoiceOver/a.zip".to_owned(); + let recovery_tree = "C:/game/G1R/Binaries/Win64/ue4ss/Mods/A".to_owned(); + let record = DeployRecord { + owner: "manager".into(), + ue4ss_cleanup_claims: BTreeMap::from([(ue4ss_source.clone(), ue4ss_holder.clone())]), + file_cleanup_claims: BTreeMap::from([( + file_source.clone(), + FileCleanupClaim { + holder: file_holder.clone(), + expected_hashes: vec!["sha256:private-file-identity".into()], + restore_from: Some(restore_source.clone()), + restore_hash: Some("sha256:private-restore-identity".into()), + }, + )]), + trusted_ue4ss_tombstones: vec![ue4ss_tombstone.clone()], + trusted_file_tombstones: vec![file_tombstone.clone()], + recovery_file_hashes: BTreeMap::from([( + recovery_file.clone(), + vec!["private-file-identity".into()], + )]), + recovery_tree_fingerprints: BTreeMap::from([( + recovery_tree.clone(), + vec!["private-tree-identity".into()], + )]), + ..Default::default() + }; + + let owned = manager_owned_deployment(game, &record).unwrap(); + let actual: BTreeSet<_> = owned.recovery.items.iter().cloned().collect(); + let expected = BTreeSet::from([ + crate::deploy_record_path(game).display().to_string(), + ue4ss_source, + ue4ss_holder, + file_source, + file_holder.clone(), + PathBuf::from(file_holder) + .join("claimed") + .display() + .to_string(), + restore_source, + ue4ss_tombstone, + file_tombstone, + recovery_file, + recovery_tree, + ]); + assert_eq!(actual, expected); + assert_eq!(owned.recovery.total, expected.len()); + let wire = serde_json::to_string(&owned).unwrap(); + assert!(!wire.contains("private-")); + assert!(!wire.contains("lock")); + assert!(!wire.contains("exists")); + } + /// Build a `ModEntryMeta` for library id `id` carrying `components`. `imported_at` is a /// parameter so a test can simulate a re-import (bump it and/or the components to change the /// fingerprint) without waiting a real second. @@ -772,6 +1078,10 @@ mod tests { status(&game, &lib, &Loadout::default()).unwrap(), ManagerStatus::NothingDeployed ); + let wire = + serde_json::to_value(status_report(&game, &lib, &Loadout::default()).unwrap()).unwrap(); + assert_eq!(wire["state"], "nothing_deployed"); + assert!(wire.get("manager_owned").is_none()); } #[test] @@ -809,6 +1119,40 @@ mod tests { status(&game, &lib, &Loadout::default()).unwrap(), ManagerStatus::RecoveryRequired ); + let report = status_report(&game, &lib, &Loadout::default()).unwrap(); + let owned = report.manager_owned.unwrap(); + assert_eq!(owned.recovery.total, 1); + assert_eq!( + owned.recovery.items, + vec![record_path(&crate::abs_root(&game)).display().to_string()] + ); + } + + #[test] + fn non_manager_recovery_does_not_publish_manager_owned() { + let tmp = tempfile::tempdir().unwrap(); + let game = tmp.path().join("game"); + let lib = tmp.path().join("lib"); + for owner in ["", "Manager", "manager "] { + write_record( + &game, + &DeployRecord { + mod_name: "Studio recovery".into(), + owner: owner.into(), + phase: DeployPhase::RecoveryRequired, + ..Default::default() + }, + ); + + let wire = + serde_json::to_value(status_report(&game, &lib, &Loadout::default()).unwrap()) + .unwrap(); + assert_eq!(wire["state"], "recovery_required", "owner={owner:?}"); + assert!( + wire.get("manager_owned").is_none(), + "owner={owner:?}: {wire}" + ); + } } #[test] @@ -890,6 +1234,62 @@ mod tests { mod_name: "SoloMod".into() } ); + let wire = + serde_json::to_value(status_report(&game, &lib, &Loadout::default()).unwrap()).unwrap(); + assert_eq!(wire["state"], "studio_deploy_active"); + assert!(wire.get("manager_owned").is_none()); + } + + #[test] + fn manager_status_report_groups_exact_record_path_sources() { + let tmp = tempfile::tempdir().unwrap(); + let game = tmp.path().join("game"); + let lib = tmp.path().join("lib"); + let live = game.join("G1R/Story/VoiceOver/live.zip"); + let backup = crate::bak_path(&live); + let hash_only = game.join("G1R/Story/VoiceOver/hash-only.zip"); + let additive = game.join("G1R/Content/Paks/~mods/gm000_A_P.pak"); + let ue4ss = game.join("G1R/Binaries/Win64/ue4ss/Mods/A"); + let stale = game.join("G1R/Binaries/Win64/ue4ss/.gore-ue4ss-delete-a"); + write_record( + &game, + &DeployRecord { + owner: "manager".into(), + backups: vec![( + live.display().to_string(), + backup.display().to_string(), + true, + )], + deployed_hashes: BTreeMap::from([( + hash_only.display().to_string(), + "0000000000000000".into(), + )]), + texture_triplets: vec![additive.display().to_string()], + managed_paks: vec![additive.display().to_string()], + ue4ss_mod_dir: Some(ue4ss.display().to_string()), + ue4ss_mod_dirs: vec![ue4ss.display().to_string()], + stale_ue4ss_dirs: vec![stale.display().to_string()], + ..Default::default() + }, + ); + + let report = status_report(&game, &lib, &Loadout::default()).unwrap(); + assert!(matches!(report.status, ManagerStatus::GameUpdated { .. })); + let owned = report.manager_owned.unwrap(); + assert_eq!(owned.live.items, vec![live.display().to_string()]); + assert_eq!(owned.backups.items, vec![backup.display().to_string()]); + assert_eq!(owned.additive.items, vec![additive.display().to_string()]); + assert_eq!(owned.additive.total, 1); + assert_eq!(owned.ue4ss.total, 2); + assert_eq!( + owned.ue4ss.items.into_iter().collect::>(), + BTreeSet::from([ue4ss.display().to_string(), stale.display().to_string()]) + ); + assert_eq!( + owned.recovery.items, + vec![record_path(&crate::abs_root(&game)).display().to_string()] + ); + assert!(!owned.live.items.contains(&hash_only.display().to_string())); } #[test] diff --git a/crates/gore-mod/src/mgr/store.rs b/crates/gore-mod/src/mgr/store.rs index 5a2017c9e..c5935f10d 100644 --- a/crates/gore-mod/src/mgr/store.rs +++ b/crates/gore-mod/src/mgr/store.rs @@ -274,6 +274,15 @@ impl StoreSnapshot { super::status::status(game_root, self.library.path(), &self.loadout) } + /// The authoritative status plus bounded display-only Manager ownership evidence from the + /// exact same deploy-record read. Existing Rust callers can keep using [`Self::status`]. + pub fn status_report( + &self, + game_root: &Path, + ) -> crate::Result { + super::status::status_report(game_root, self.library.path(), &self.loadout) + } + /// Persist an explicit whole-loadout edit while the store is locked. Existing entry edits are /// last-writer-wins; concurrently published library ids are still appended disabled. pub fn replace_loadout(&mut self, replacement: Loadout) -> crate::Result<()> { diff --git a/docs/guide/mod-manager.md b/docs/guide/mod-manager.md index 0088df461..f3c2f21e2 100644 --- a/docs/guide/mod-manager.md +++ b/docs/guide/mod-manager.md @@ -239,6 +239,24 @@ disabling a mod in the middle of the order safe. `reset` restores the pristine install. +The Mod Manager app's deployment-details dialog can expand **Recorded ownership +evidence** when the same validated deploy-record snapshot has the exact owner +`manager`. It groups the recorded paths as replaced live files, pristine +backups, additive pak/container files, UE4SS directories, and recovery +files/holders. The section is absent for no deployment, Studio ownership, and +unknown/future status. A Manager recovery record still shows the recovery group, +including the validated deploy-record path, even when it names no other path. + +This is a bounded display projection, not another ownership or cleanup engine. +Each group is stable and platform-path-key deduplicated, contains at most 128 +whole paths, admits at most 64 KiB of source-path UTF-8 bytes in total, and +omits any individual path over 4096 bytes rather than shortening it. `total` +counts unique validated candidates before those display caps, and the UI says +when fewer paths are shown. The projection contains no content hashes, private +identity values, existence checks, shared install-mutation lock, or new action. +It reports what the validated record says; it does not prove that any named +file or directory currently exists. + ### Evidence boundary Applying, reordering, and resetting against an offline synthetic game root can From 0df4163cfff878cdad95b15e948910020d21c785 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 16:47:46 +0200 Subject: [PATCH 2/3] fix(manager): unblock Windows store writes Convert every prepared Manager root to a rename-compatible handle before any LockFileEx wait, so a queued Store snapshot cannot block the active writer's atomic loadout replacement. --- crates/gore-mod/src/mgr/model.rs | 71 ++++++++++++++++++++++++-------- crates/gore-mod/src/mgr/store.rs | 30 ++++++++++++-- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/gore-mod/src/mgr/model.rs b/crates/gore-mod/src/mgr/model.rs index 31ac8c22f..a3c47eb8c 100644 --- a/crates/gore-mod/src/mgr/model.rs +++ b/crates/gore-mod/src/mgr/model.rs @@ -1813,25 +1813,38 @@ pub(crate) fn prepare_existing_manager_root_lock( } #[cfg(windows)] -fn acquire_prepared_manager_root( +#[derive(Debug)] +struct PreparedWindowsManagerRootAcquire { + sentinel: OpenedNode, + root: RenameDirectoryGuard, +} + +#[cfg(windows)] +fn prepare_windows_manager_root_acquire( prepared: PreparedManagerRootLock, +) -> crate::Result { + let sentinel = open_manager_root_sentinel(&prepared.root, prepared.create_lock_file)?; + // Convert every root before any thread or process can block on a sentinel lock. Otherwise a + // waiter that blocks on the physically first root still retains the later Store root without + // DELETE sharing and prevents the current owner from atomically replacing loadout.json. + let root = prepared.root.into_rename_guard("Manager mutation root")?; + Ok(PreparedWindowsManagerRootAcquire { sentinel, root }) +} + +#[cfg(windows)] +fn acquire_prepared_windows_manager_root( + prepared: PreparedWindowsManagerRootAcquire, ) -> crate::Result { use std::os::windows::io::AsRawHandle as _; use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK}; use windows_sys::Win32::System::IO::OVERLAPPED; - let sentinel = open_manager_root_sentinel(&prepared.root, prepared.create_lock_file)?; - // A waiter must not retain the no-DELETE traversal handle while blocking: that would prevent - // the current owner from renaming root children. The sentinel remains identity-bound. - let root = prepared - .root - .into_rename_guard("Manager mutation root")?; let mut overlapped = OVERLAPPED::default(); // SAFETY: `sentinel.file` remains alive in the returned guard. This is synchronous // one-byte lock at offset zero, and `overlapped` is valid for the duration of the call. let locked = unsafe { LockFileEx( - sentinel.file.as_raw_handle(), + prepared.sentinel.file.as_raw_handle(), LOCKFILE_EXCLUSIVE_LOCK, 0, 1, @@ -1845,10 +1858,10 @@ fn acquire_prepared_manager_root( )); } let lock = ManagerRootLock { - sentinel: sentinel.file, - sentinel_identity: sentinel.identity, - sentinel_path: sentinel.final_path, - root, + sentinel: prepared.sentinel.file, + sentinel_identity: prepared.sentinel.identity, + sentinel_path: prepared.sentinel.final_path, + root: prepared.root, }; lock.revalidate_named()?; Ok(lock) @@ -1984,13 +1997,35 @@ pub(crate) fn acquire_manager_root_locks( "manager loadout parent and library root must be different directories".into(), )); } - let mut locked = Vec::with_capacity(prepared.len()); - for root in prepared { - locked.push(acquire_prepared_manager_root(root)?); - if locked.len() == 1 { - run_manager_first_root_lock_hook()?; + #[cfg(windows)] + let locked = { + // Preparation is deliberately completed for the whole ordered set before the first + // blocking LockFileEx. Every retained directory handle is therefore rename-compatible + // while another process owns either root. + let prepared = prepared + .into_iter() + .map(prepare_windows_manager_root_acquire) + .collect::>>()?; + let mut locked = Vec::with_capacity(prepared.len()); + for root in prepared { + locked.push(acquire_prepared_windows_manager_root(root)?); + if locked.len() == 1 { + run_manager_first_root_lock_hook()?; + } } - } + locked + }; + #[cfg(not(windows))] + let locked = { + let mut locked = Vec::with_capacity(prepared.len()); + for root in prepared { + locked.push(acquire_prepared_manager_root(root)?); + if locked.len() == 1 { + run_manager_first_root_lock_hook()?; + } + } + locked + }; for root in &locked { root.revalidate_named()?; } diff --git a/crates/gore-mod/src/mgr/store.rs b/crates/gore-mod/src/mgr/store.rs index c5935f10d..64b0a1394 100644 --- a/crates/gore-mod/src/mgr/store.rs +++ b/crates/gore-mod/src/mgr/store.rs @@ -1641,17 +1641,39 @@ mod tests { #[test] fn cross_process_rmw_preserves_independent_existing_slot_edits() { let temp = tempfile::tempdir().unwrap(); - let library = temp.path().join("library"); - let loadout = temp.path().join("loadout.json"); + let root_a = temp.path().join("root-a"); + let root_b = temp.path().join("root-b"); + fs::create_dir(&root_a).unwrap(); + fs::create_dir(&root_b).unwrap(); + #[cfg(windows)] + let (library, store_parent) = { + let identity_a = + prepare_existing_manager_root_lock(&root_a, "RMW regression root A", false) + .unwrap() + .identity(); + let identity_b = + prepare_existing_manager_root_lock(&root_b, "RMW regression root B", false) + .unwrap() + .identity(); + if identity_a < identity_b { + (root_a, root_b) + } else { + (root_b, root_a) + } + }; + #[cfg(not(windows))] + let (library, store_parent) = (root_a, root_b); + let loadout = store_parent.join("loadout.json"); write_entry(&library, "a"); write_entry(&library, "b"); write_loadout(&loadout, &[("a", false), ("b", false)]); let marker_a = temp.path().join("rmw-a"); let marker_b = temp.path().join("rmw-b"); - let first = spawn_store_child(&library, &loadout, &marker_a, 200, "enable", Some("a")); + let first = spawn_store_child(&library, &loadout, &marker_a, 1_000, "enable", Some("a")); + wait_for_path(&marker_a); let second = spawn_store_child(&library, &loadout, &marker_b, 0, "enable", Some("b")); for child in [first, second] { - let output = child.wait_with_output().unwrap(); + let output = child_output_bounded(child, std::time::Duration::from_secs(10)); assert!( output.status.success(), "child failed: {}", From d30d5af0efdadc4063ef29a94695c4da863c2dd8 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 17:03:38 +0200 Subject: [PATCH 3/3] fix(manager): clarify hidden ownership paths --- .../lib/status/ui/status_details_dialog.dart | 7 +++- .../status/status_details_dialog_test.dart | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/mod-manager/lib/status/ui/status_details_dialog.dart b/apps/mod-manager/lib/status/ui/status_details_dialog.dart index 11a6ba30a..fc8f197a9 100644 --- a/apps/mod-manager/lib/status/ui/status_details_dialog.dart +++ b/apps/mod-manager/lib/status/ui/status_details_dialog.dart @@ -381,6 +381,7 @@ class StatusDetailsDialog extends StatelessWidget { } } final hidden = values.length < group.total; + final allRecordedPathsHidden = values.isEmpty && group.total > 0; return Column( key: ValueKey('status-details-group-$keyName'), crossAxisAlignment: CrossAxisAlignment.start, @@ -390,10 +391,12 @@ class StatusDetailsDialog extends StatelessWidget { keyName: keyName, title: title, values: values, - emptyText: l10n.statusDetailsOwnershipEmpty, + emptyText: allRecordedPathsHidden + ? l10n.statusDetailsOwnershipShown(0, group.total) + : l10n.statusDetailsOwnershipEmpty, selectable: true, ), - if (hidden) ...[ + if (hidden && !allRecordedPathsHidden) ...[ const SizedBox(height: 8), Text( l10n.statusDetailsOwnershipShown(values.length, group.total), diff --git a/apps/mod-manager/test/status/status_details_dialog_test.dart b/apps/mod-manager/test/status/status_details_dialog_test.dart index 596248a60..baed6469e 100644 --- a/apps/mod-manager/test/status/status_details_dialog_test.dart +++ b/apps/mod-manager/test/status/status_details_dialog_test.dart @@ -264,6 +264,47 @@ void main() { }, ); + testWidgets( + 'fully hidden ownership group reports truncation without claiming empty', + (tester) async { + await _open( + tester, + _state({ + 'state': 'in_sync', + 'loadout': [], + 'manager_owned': _ownedEvidence( + recovery: ['\u202e'], + recoveryTotal: 2, + ), + }), + ); + + await tester.tap( + find.byKey(const ValueKey('status-details-manager-owned')), + ); + await tester.pumpAndSettle(); + + final recovery = find.byKey( + const ValueKey('status-details-group-owned-recovery'), + ); + expect( + find.descendant( + of: recovery, + matching: find.text('No paths recorded in this group.'), + ), + findsNothing, + ); + expect( + find.descendant( + of: recovery, + matching: find.text('0 of 2 recorded paths shown.'), + ), + findsOneWidget, + ); + expect(find.textContaining('\u202e'), findsNothing); + }, + ); + testWidgets('unauthorized states ignore even syntactically valid evidence', ( tester, ) async {