diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8165a9ab2e..632b69bcad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -488,6 +488,7 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.test.manifest) diff --git a/app/schemas/app.gamenative.db.PluviaDatabase/27.json b/app/schemas/app.gamenative.db.PluviaDatabase/27.json new file mode 100644 index 0000000000..cffda1ec9f --- /dev/null +++ b/app/schemas/app.gamenative.db.PluviaDatabase/27.json @@ -0,0 +1,1887 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "e06fa9178e730cbe25e5afadf33e22f5", + "entities": [ + { + "tableName": "app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_downloaded` INTEGER NOT NULL, `downloaded_depots` TEXT NOT NULL, `dlc_depots` TEXT NOT NULL, `branch` TEXT NOT NULL DEFAULT 'public', `recovered_install_size_bytes` INTEGER NOT NULL DEFAULT 0, `custom_install_path` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "is_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedDepots", + "columnName": "downloaded_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dlcDepots", + "columnName": "dlc_depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branch", + "columnName": "branch", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'public'" + }, + { + "fieldPath": "recoveredInstallSizeBytes", + "columnName": "recovered_install_size_bytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customInstallPath", + "columnName": "custom_install_path", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `license_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseJson", + "columnName": "license_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_change_numbers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `changeNumber` INTEGER, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "changeNumber", + "columnName": "changeNumber", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "encrypted_app_ticket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER NOT NULL, `result` INTEGER NOT NULL, `ticket_version_no` INTEGER NOT NULL, `crc_encrypted_ticket` INTEGER NOT NULL, `cb_encrypted_user_data` INTEGER NOT NULL, `cb_encrypted_app_ownership_ticket` INTEGER NOT NULL, `encrypted_ticket` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ticketVersionNo", + "columnName": "ticket_version_no", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "crcEncryptedTicket", + "columnName": "crc_encrypted_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedUserData", + "columnName": "cb_encrypted_user_data", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cbEncryptedAppOwnershipTicket", + "columnName": "cb_encrypted_app_ownership_ticket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedTicket", + "columnName": "encrypted_ticket", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "app_file_change_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER, `userFileInfo` TEXT NOT NULL, PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER" + }, + { + "fieldPath": "userFileInfo", + "columnName": "userFileInfo", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "library_play_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` TEXT NOT NULL, `last_played` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`app_id`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_id" + ] + } + }, + { + "tableName": "steam_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `package_id` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `license_flags` INTEGER NOT NULL, `received_pics` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `ufs_parse_version` INTEGER NOT NULL DEFAULT 0, `depots` TEXT NOT NULL, `branches` TEXT NOT NULL, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `os_list` INTEGER NOT NULL, `release_state` INTEGER NOT NULL, `release_date` INTEGER NOT NULL, `metacritic_score` INTEGER NOT NULL, `metacritic_full_url` TEXT NOT NULL, `logo_hash` TEXT NOT NULL, `logo_small_hash` TEXT NOT NULL, `icon_hash` TEXT NOT NULL, `client_icon_hash` TEXT NOT NULL, `client_tga_hash` TEXT NOT NULL, `small_capsule` TEXT NOT NULL, `header_image` TEXT NOT NULL, `library_assets` TEXT NOT NULL, `primary_genre` INTEGER NOT NULL, `review_score` INTEGER NOT NULL, `review_percentage` INTEGER NOT NULL, `controller_support` INTEGER NOT NULL, `demo_of_app_id` INTEGER NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `homepage_url` TEXT NOT NULL, `game_manual_url` TEXT NOT NULL, `load_all_before_launch` INTEGER NOT NULL, `dlc_app_ids` TEXT NOT NULL, `is_free_app` INTEGER NOT NULL, `dlc_for_app_id` INTEGER NOT NULL, `must_own_app_to_purchase` INTEGER NOT NULL, `dlc_available_on_store` INTEGER NOT NULL, `optional_dlc` INTEGER NOT NULL, `game_dir` TEXT NOT NULL, `install_script` TEXT NOT NULL, `no_servers` INTEGER NOT NULL, `order` INTEGER NOT NULL, `primary_cache` INTEGER NOT NULL, `valid_os_list` INTEGER NOT NULL, `third_party_cd_key` INTEGER NOT NULL, `visible_only_when_installed` INTEGER NOT NULL, `visible_only_when_subscribed` INTEGER NOT NULL, `launch_eula_url` TEXT NOT NULL, `require_default_install_folder` INTEGER NOT NULL, `content_type` INTEGER NOT NULL, `install_dir` TEXT NOT NULL, `use_launch_cmd_line` INTEGER NOT NULL, `launch_without_workshop_updates` INTEGER NOT NULL, `use_mms` INTEGER NOT NULL, `install_script_signature` TEXT NOT NULL, `install_script_override` INTEGER NOT NULL, `config` TEXT NOT NULL, `ufs` TEXT NOT NULL, `workshop_mods` INTEGER NOT NULL DEFAULT 0, `enabled_workshop_item_ids` TEXT NOT NULL DEFAULT '', `workshop_download_pending` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedPICS", + "columnName": "received_pics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ufsParseVersion", + "columnName": "ufs_parse_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "depots", + "columnName": "depots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branches", + "columnName": "branches", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "osList", + "columnName": "os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseState", + "columnName": "release_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticScore", + "columnName": "metacritic_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metacriticFullUrl", + "columnName": "metacritic_full_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoHash", + "columnName": "logo_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "logoSmallHash", + "columnName": "logo_small_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconHash", + "columnName": "icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIconHash", + "columnName": "client_icon_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTgaHash", + "columnName": "client_tga_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallCapsule", + "columnName": "small_capsule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImage", + "columnName": "header_image", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryAssets", + "columnName": "library_assets", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryGenre", + "columnName": "primary_genre", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewScore", + "columnName": "review_score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewPercentage", + "columnName": "review_percentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "controllerSupport", + "columnName": "controller_support", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "demoOfAppId", + "columnName": "demo_of_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homepageUrl", + "columnName": "homepage_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gameManualUrl", + "columnName": "game_manual_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loadAllBeforeLaunch", + "columnName": "load_all_before_launch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlc_app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeApp", + "columnName": "is_free_app", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcForAppId", + "columnName": "dlc_for_app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mustOwnAppToPurchase", + "columnName": "must_own_app_to_purchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAvailableOnStore", + "columnName": "dlc_available_on_store", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "optionalDlc", + "columnName": "optional_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameDir", + "columnName": "game_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScript", + "columnName": "install_script", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noServers", + "columnName": "no_servers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryCache", + "columnName": "primary_cache", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validOSList", + "columnName": "valid_os_list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thirdPartyCdKey", + "columnName": "third_party_cd_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenInstalled", + "columnName": "visible_only_when_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibleOnlyWhenSubscribed", + "columnName": "visible_only_when_subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchEulaUrl", + "columnName": "launch_eula_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requireDefaultInstallFolder", + "columnName": "require_default_install_folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installDir", + "columnName": "install_dir", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useLaunchCmdLine", + "columnName": "use_launch_cmd_line", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchWithoutWorkshopUpdates", + "columnName": "launch_without_workshop_updates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useMms", + "columnName": "use_mms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installScriptSignature", + "columnName": "install_script_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installScriptOverride", + "columnName": "install_script_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ufs", + "columnName": "ufs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workshopMods", + "columnName": "workshop_mods", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabledWorkshopItemIds", + "columnName": "enabled_workshop_item_ids", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "workshopDownloadPending", + "columnName": "workshop_download_pending", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "steam_file_hash_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `absPath` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `mtimeMillis` INTEGER NOT NULL, `sha` BLOB NOT NULL, PRIMARY KEY(`appId`, `absPath`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "absPath", + "columnName": "absPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtimeMillis", + "columnName": "mtimeMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sha", + "columnName": "sha", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId", + "absPath" + ] + } + }, + { + "tableName": "steam_license", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageId` INTEGER NOT NULL, `last_change_number` INTEGER NOT NULL, `time_created` INTEGER NOT NULL, `time_next_process` INTEGER NOT NULL, `minute_limit` INTEGER NOT NULL, `minutes_used` INTEGER NOT NULL, `payment_method` INTEGER NOT NULL, `license_flags` INTEGER NOT NULL, `purchase_code` TEXT NOT NULL, `license_type` INTEGER NOT NULL, `territory_code` INTEGER NOT NULL, `access_token` INTEGER NOT NULL, `owner_account_id` TEXT NOT NULL, `master_package_id` INTEGER NOT NULL, `app_ids` TEXT NOT NULL, `depot_ids` TEXT NOT NULL, PRIMARY KEY(`packageId`))", + "fields": [ + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChangeNumber", + "columnName": "last_change_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeCreated", + "columnName": "time_created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeNextProcess", + "columnName": "time_next_process", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minuteLimit", + "columnName": "minute_limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesUsed", + "columnName": "minutes_used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentMethod", + "columnName": "payment_method", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "licenseFlags", + "columnName": "license_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseCode", + "columnName": "purchase_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "licenseType", + "columnName": "license_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "territoryCode", + "columnName": "territory_code", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerAccountId", + "columnName": "owner_account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "masterPackageID", + "columnName": "master_package_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appIds", + "columnName": "app_ids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depotIds", + "columnName": "depot_ids", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageId" + ] + } + }, + { + "tableName": "gog_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `slug` TEXT NOT NULL, `download_size` INTEGER NOT NULL, `install_size` INTEGER NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `image_url` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `background_url` TEXT NOT NULL DEFAULT '', `vertical_cover_url` TEXT NOT NULL DEFAULT '', `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `genres` TEXT NOT NULL, `languages` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `exclude` INTEGER NOT NULL DEFAULT 0, `hidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundUrl", + "columnName": "background_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "verticalCoverUrl", + "columnName": "vertical_cover_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "languages", + "columnName": "languages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "play_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exclude", + "columnName": "exclude", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "epic_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `catalog_id` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT NOT NULL, `namespace` TEXT NOT NULL, `developer` TEXT NOT NULL, `publisher` TEXT NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `platform` TEXT NOT NULL, `version` TEXT NOT NULL, `executable` TEXT NOT NULL, `install_size` INTEGER NOT NULL, `download_size` INTEGER NOT NULL, `art_cover` TEXT NOT NULL, `art_square` TEXT NOT NULL, `art_logo` TEXT NOT NULL, `art_portrait` TEXT NOT NULL, `can_run_offline` INTEGER NOT NULL, `requires_ot` INTEGER NOT NULL, `cloud_save_enabled` INTEGER NOT NULL, `save_folder` TEXT NOT NULL, `third_party_managed_app` TEXT NOT NULL, `is_ea_managed` INTEGER NOT NULL, `is_dlc` INTEGER NOT NULL, `base_game_app_name` TEXT NOT NULL, `description` TEXT NOT NULL, `release_date` TEXT NOT NULL, `genres` TEXT NOT NULL, `tags` TEXT NOT NULL, `last_played` INTEGER NOT NULL, `play_time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `eos_catalog_item_id` TEXT NOT NULL, `eos_app_id` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "catalogId", + "columnName": "catalog_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "app_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "executable", + "columnName": "executable", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artCover", + "columnName": "art_cover", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artSquare", + "columnName": "art_square", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artLogo", + "columnName": "art_logo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artPortrait", + "columnName": "art_portrait", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canRunOffline", + "columnName": "can_run_offline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresOT", + "columnName": "requires_ot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cloudSaveEnabled", + "columnName": "cloud_save_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saveFolder", + "columnName": "save_folder", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thirdPartyManagedApp", + "columnName": "third_party_managed_app", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEAManaged", + "columnName": "is_ea_managed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDLC", + "columnName": "is_dlc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseGameAppName", + "columnName": "base_game_app_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genres", + "columnName": "genres", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "play_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eosCatalogItemId", + "columnName": "eos_catalog_item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eosAppId", + "columnName": "eos_app_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "amazon_games", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `product_id` TEXT NOT NULL, `entitlement_id` TEXT NOT NULL DEFAULT '', `title` TEXT NOT NULL, `is_installed` INTEGER NOT NULL, `install_path` TEXT NOT NULL, `art_url` TEXT NOT NULL, `hero_url` TEXT NOT NULL DEFAULT '', `purchased_date` TEXT NOT NULL, `developer` TEXT NOT NULL DEFAULT '', `publisher` TEXT NOT NULL DEFAULT '', `release_date` TEXT NOT NULL DEFAULT '', `download_size` INTEGER NOT NULL DEFAULT 0, `install_size` INTEGER NOT NULL DEFAULT 0, `version_id` TEXT NOT NULL DEFAULT '', `product_sku` TEXT NOT NULL DEFAULT '', `last_played` INTEGER NOT NULL DEFAULT 0, `play_time_minutes` INTEGER NOT NULL DEFAULT 0, `product_json` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "product_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entitlementId", + "columnName": "entitlement_id", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "is_installed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installPath", + "columnName": "install_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artUrl", + "columnName": "art_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heroUrl", + "columnName": "hero_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "purchasedDate", + "columnName": "purchased_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "developer", + "columnName": "developer", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "downloadSize", + "columnName": "download_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "installSize", + "columnName": "install_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "versionId", + "columnName": "version_id", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "lastPlayed", + "columnName": "last_played", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "playTimeMinutes", + "columnName": "play_time_minutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "productJson", + "columnName": "product_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "app_id" + ] + }, + "indices": [ + { + "name": "index_amazon_games_product_id", + "unique": false, + "columnNames": [ + "product_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_amazon_games_product_id` ON `${TABLE_NAME}` (`product_id`)" + } + ] + }, + { + "tableName": "downloading_app_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `dlcAppIds` TEXT NOT NULL, `branch` TEXT NOT NULL DEFAULT 'public', PRIMARY KEY(`appId`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dlcAppIds", + "columnName": "dlcAppIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branch", + "columnName": "branch", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'public'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId" + ] + } + }, + { + "tableName": "steam_unlocked_branch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appId` INTEGER NOT NULL, `branchName` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`appId`, `branchName`))", + "fields": [ + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "branchName", + "columnName": "branchName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appId", + "branchName" + ] + } + }, + { + "tableName": "mod_install", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`install_id` TEXT NOT NULL, `app_id` TEXT NOT NULL, `source` TEXT NOT NULL, `nexus_game_domain` TEXT, `nexus_mod_id` INTEGER, `nexus_file_id` INTEGER, `mod_name` TEXT NOT NULL, `file_name` TEXT NOT NULL, `version` TEXT NOT NULL, `size_bytes` INTEGER NOT NULL, `archive_path` TEXT NOT NULL, `extracted_path` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `status` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `downloaded_at` INTEGER NOT NULL, `metadata_json` TEXT NOT NULL, `archive_sha256` TEXT NOT NULL, PRIMARY KEY(`install_id`))", + "fields": [ + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nexusGameDomain", + "columnName": "nexus_game_domain", + "affinity": "TEXT" + }, + { + "fieldPath": "nexusModId", + "columnName": "nexus_mod_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "nexusFileId", + "columnName": "nexus_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "modName", + "columnName": "mod_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archivePath", + "columnName": "archive_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extractedPath", + "columnName": "extracted_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloaded_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadataJson", + "columnName": "metadata_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archiveSha256", + "columnName": "archive_sha256", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "install_id" + ] + }, + "indices": [ + { + "name": "index_mod_install_app_id", + "unique": false, + "columnNames": [ + "app_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_install_app_id` ON `${TABLE_NAME}` (`app_id`)" + }, + { + "name": "index_mod_install_app_id_source_archive_sha256", + "unique": false, + "columnNames": [ + "app_id", + "source", + "archive_sha256" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_install_app_id_source_archive_sha256` ON `${TABLE_NAME}` (`app_id`, `source`, `archive_sha256`)" + }, + { + "name": "index_mod_install_app_id_source_nexus_game_domain_nexus_mod_id_nexus_file_id", + "unique": true, + "columnNames": [ + "app_id", + "source", + "nexus_game_domain", + "nexus_mod_id", + "nexus_file_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_mod_install_app_id_source_nexus_game_domain_nexus_mod_id_nexus_file_id` ON `${TABLE_NAME}` (`app_id`, `source`, `nexus_game_domain`, `nexus_mod_id`, `nexus_file_id`)" + } + ] + }, + { + "tableName": "mod_profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` TEXT NOT NULL, `app_id` TEXT NOT NULL, `name` TEXT NOT NULL, `active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`profile_id`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profile_id" + ] + }, + "indices": [ + { + "name": "index_mod_profile_app_id", + "unique": false, + "columnNames": [ + "app_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_app_id` ON `${TABLE_NAME}` (`app_id`)" + }, + { + "name": "index_mod_profile_app_id_name", + "unique": true, + "columnNames": [ + "app_id", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_mod_profile_app_id_name` ON `${TABLE_NAME}` (`app_id`, `name`)" + } + ] + }, + { + "tableName": "mod_profile_install_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profile_id` TEXT NOT NULL, `install_id` TEXT NOT NULL, `app_id` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`profile_id`, `install_id`), FOREIGN KEY(`profile_id`) REFERENCES `mod_profile`(`profile_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`install_id`) REFERENCES `mod_install`(`install_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profile_id", + "install_id" + ] + }, + "indices": [ + { + "name": "index_mod_profile_install_state_app_id", + "unique": false, + "columnNames": [ + "app_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_install_state_app_id` ON `${TABLE_NAME}` (`app_id`)" + }, + { + "name": "index_mod_profile_install_state_install_id", + "unique": false, + "columnNames": [ + "install_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_install_state_install_id` ON `${TABLE_NAME}` (`install_id`)" + }, + { + "name": "index_mod_profile_install_state_app_id_profile_id_priority", + "unique": false, + "columnNames": [ + "app_id", + "profile_id", + "priority" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_profile_install_state_app_id_profile_id_priority` ON `${TABLE_NAME}` (`app_id`, `profile_id`, `priority`)" + } + ], + "foreignKeys": [ + { + "table": "mod_profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profile_id" + ], + "referencedColumns": [ + "profile_id" + ] + }, + { + "table": "mod_install", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "install_id" + ], + "referencedColumns": [ + "install_id" + ] + } + ] + }, + { + "tableName": "mod_placement_recipe", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recipe_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `install_id` TEXT NOT NULL, `source_subpath` TEXT NOT NULL, `target_root` TEXT NOT NULL, `target_relative_path` TEXT NOT NULL, `target_file_name` TEXT NOT NULL, `mode` TEXT NOT NULL, `strip_prefix_segments` INTEGER NOT NULL, `include_source_directory` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, FOREIGN KEY(`install_id`) REFERENCES `mod_install`(`install_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recipeId", + "columnName": "recipe_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceSubpath", + "columnName": "source_subpath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetRoot", + "columnName": "target_root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetRelativePath", + "columnName": "target_relative_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileName", + "columnName": "target_file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stripPrefixSegments", + "columnName": "strip_prefix_segments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeSourceDirectory", + "columnName": "include_source_directory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "recipe_id" + ] + }, + "indices": [ + { + "name": "index_mod_placement_recipe_install_id", + "unique": false, + "columnNames": [ + "install_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_placement_recipe_install_id` ON `${TABLE_NAME}` (`install_id`)" + } + ], + "foreignKeys": [ + { + "table": "mod_install", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "install_id" + ], + "referencedColumns": [ + "install_id" + ] + } + ] + }, + { + "tableName": "mod_overwrite_manifest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manifest_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `install_id` TEXT NOT NULL, `target_path` TEXT NOT NULL, `backup_path` TEXT NOT NULL, `original_hash` TEXT NOT NULL, `original_size` INTEGER NOT NULL, `original_mtime` INTEGER NOT NULL, `installed_hash` TEXT NOT NULL, `installed_size` INTEGER NOT NULL, `installed_mtime` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`install_id`) REFERENCES `mod_install`(`install_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "manifestId", + "columnName": "manifest_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetPath", + "columnName": "target_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backupPath", + "columnName": "backup_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalHash", + "columnName": "original_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalSize", + "columnName": "original_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalMtime", + "columnName": "original_mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedHash", + "columnName": "installed_hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedSize", + "columnName": "installed_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedMtime", + "columnName": "installed_mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "manifest_id" + ] + }, + "indices": [ + { + "name": "index_mod_overwrite_manifest_install_id", + "unique": false, + "columnNames": [ + "install_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_overwrite_manifest_install_id` ON `${TABLE_NAME}` (`install_id`)" + }, + { + "name": "index_mod_overwrite_manifest_target_path", + "unique": false, + "columnNames": [ + "target_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_mod_overwrite_manifest_target_path` ON `${TABLE_NAME}` (`target_path`)" + } + ], + "foreignKeys": [ + { + "table": "mod_install", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "install_id" + ], + "referencedColumns": [ + "install_id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e06fa9178e730cbe25e5afadf33e22f5')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/app/gamenative/db/ModPlacementMigrationAndroidTest.kt b/app/src/androidTest/java/app/gamenative/db/ModPlacementMigrationAndroidTest.kt new file mode 100644 index 0000000000..f4b8fb0809 --- /dev/null +++ b/app/src/androidTest/java/app/gamenative/db/ModPlacementMigrationAndroidTest.kt @@ -0,0 +1,95 @@ +package app.gamenative.db + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import app.gamenative.db.migration.ROOM_MIGRATION_V26_to_V27 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ModPlacementMigrationAndroidTest { + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + PluviaDatabase::class.java, + ) + + @Test + fun migrate26To27_preservesMasterInstallAndRecipe() { + helper.createDatabase(MASTER_SCHEMA_DATABASE_NAME, 26).apply { + execSQL( + """ + INSERT INTO mod_install ( + install_id, app_id, source, nexus_game_domain, nexus_mod_id, nexus_file_id, + mod_name, file_name, version, size_bytes, archive_path, extracted_path, + enabled, status, created_at, updated_at, downloaded_at, metadata_json, archive_sha256 + ) VALUES ( + 'install-1', 'steam:489830', 'LOCAL_ARCHIVE', NULL, NULL, NULL, + 'Historical mod', 'historical.zip', '1.0', 42, '/archive.zip', '/extracted', + 1, 'APPLIED', 1, 2, 3, '{}', 'archive-hash' + ) + """.trimIndent(), + ) + execSQL( + """ + INSERT INTO mod_placement_recipe ( + install_id, source_subpath, target_root, target_relative_path, mode, + strip_prefix_segments, include_source_directory, enabled + ) VALUES ( + 'install-1', 'Scripts', 'GAME_DIR', 'Data', 'OVERWRITE_COPY', 0, 1, 1 + ) + """.trimIndent(), + ) + close() + } + + helper.runMigrationsAndValidate( + MASTER_SCHEMA_DATABASE_NAME, + 27, + true, + ROOM_MIGRATION_V26_to_V27, + ).use { database -> + database.query( + """ + SELECT i.mod_name, r.source_subpath, r.target_relative_path, r.target_file_name + FROM mod_install i + JOIN mod_placement_recipe r ON r.install_id = i.install_id + """.trimIndent(), + ).use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("Historical mod", cursor.getString(0)) + assertEquals("Scripts", cursor.getString(1)) + assertEquals("Data", cursor.getString(2)) + assertEquals("", cursor.getString(3)) + assertEquals(false, cursor.moveToNext()) + } + } + } + + @Test + fun migrate26To27_reconcilesPlacementSchema() { + helper.createDatabase(PLACEMENT_SCHEMA_DATABASE_NAME, 25).apply { + execSQL( + "ALTER TABLE mod_placement_recipe ADD COLUMN target_file_name TEXT NOT NULL DEFAULT ''", + ) + execSQL("PRAGMA user_version = 26") + execSQL("UPDATE room_master_table SET identity_hash = '7e163d4af9b2107253274fe6d3f84665' WHERE id = 42") + close() + } + + helper.runMigrationsAndValidate( + PLACEMENT_SCHEMA_DATABASE_NAME, + 27, + true, + ROOM_MIGRATION_V26_to_V27, + ).close() + } + + private companion object { + const val MASTER_SCHEMA_DATABASE_NAME = "mod-placement-v26-master" + const val PLACEMENT_SCHEMA_DATABASE_NAME = "mod-placement-v26-placement" + } +} diff --git a/app/src/androidTest/java/app/gamenative/ui/component/dialog/PlacementReviewAndroidTest.kt b/app/src/androidTest/java/app/gamenative/ui/component/dialog/PlacementReviewAndroidTest.kt new file mode 100644 index 0000000000..fae3ca2e87 --- /dev/null +++ b/app/src/androidTest/java/app/gamenative/ui/component/dialog/PlacementReviewAndroidTest.kt @@ -0,0 +1,101 @@ +package app.gamenative.ui.component.dialog + +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import app.gamenative.R +import app.gamenative.data.ModInstall +import app.gamenative.data.ModTargetRoot +import app.gamenative.mods.AutomaticPlacementPlanner +import app.gamenative.mods.FomodEnvironmentSnapshot +import app.gamenative.mods.ModArchiveEntry +import app.gamenative.mods.ResolvedModTargetRoot +import java.io.File +import org.junit.Rule +import org.junit.Test + +class PlacementReviewAndroidTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun completeAutomaticPlan_exposesReviewAndExportAction() { + val entries = listOf( + ModArchiveEntry("Sounds/voice.wav", directory = false, sizeBytes = 1), + ModArchiveEntry("Scripts/menu.pex", directory = false, sizeBytes = 1), + ModArchiveEntry("SKSE/Plugins/Example.dll", directory = false, sizeBytes = 1), + ModArchiveEntry("Example.esp", directory = false, sizeBytes = 1), + ModArchiveEntry("Example.bsa", directory = false, sizeBytes = 1), + ) + val automatic = AutomaticPlacementPlanner.plan("Skyrim Special Edition", entries) + val recommended = checkNotNull(automatic.recommended) + val drafts = recommended.drafts.map { draft -> + RecipeDraft( + sourceSubpath = draft.sourceSubpath, + targetRelativePath = draft.targetRelativePath, + mode = draft.mode, + includeSourceDirectory = draft.includeSourceDirectory, + ) + } + val gameRoot = File(compose.activity.cacheDir, "placement-review-game").apply { mkdirs() } + + compose.setContent { + MaterialTheme { + PlacementSection( + install = ModInstall( + installId = "install-1", + appId = "steam:489830", + modName = "Example mod", + fileName = "example.zip", + archivePath = "/archive.zip", + extractedPath = "/extracted", + ), + entries = entries, + fomodInstaller = null, + fomodEnvironment = FomodEnvironmentSnapshot(), + fomodBaseDraft = RecipeDraft(targetRelativePath = "Data"), + roots = listOf(ResolvedModTargetRoot(ModTargetRoot.GAME_DIR, "Game folder", gameRoot)), + drafts = drafts, + presetOptions = emptyList(), + automaticPlacement = automatic, + automaticPlanLoading = false, + selectedAutomaticOptions = emptyMap(), + onAutomaticOptionSelected = { _, _ -> }, + riskyAutomaticPlanApproved = false, + onRiskyAutomaticPlanApprovalChange = {}, + reviewedPlan = null, + initialFomodSelections = emptyMap(), + onFomodSelectionsChanged = {}, + previousOwnership = null, + canRestorePrevious = true, + onRestorePrevious = {}, + placementChoice = PlacementChoice.AUTOMATIC, + canUseLastPlacement = false, + onPlacementChoiceChange = {}, + onUseLastPlacement = {}, + onPresetSelected = {}, + onUpdateDraft = { _, _ -> }, + onAddDraft = {}, + onRemoveDraft = {}, + onFomodRecipes = { _, _, _ -> }, + applyStatusMessage = null, + onExportPlan = {}, + onSaveAndApply = {}, + ) + } + } + + compose.onNodeWithText(compose.activity.getString(R.string.nexus_plan_review_title)).assertExists() + compose.onNodeWithText(compose.activity.getString(R.string.nexus_plan_export)) + .assertExists() + .assertHasClickAction() + compose.onNodeWithText(compose.activity.getString(R.string.nexus_review_all_files, recommended.plan.files.size)) + .assertExists() + .assertHasClickAction() + compose.onNodeWithText(compose.activity.getString(R.string.nexus_restore_previous_deployment)) + .assertExists() + .assertHasClickAction() + } +} diff --git a/app/src/main/java/app/gamenative/data/ModInstall.kt b/app/src/main/java/app/gamenative/data/ModInstall.kt index 08e32c391c..bedee8c485 100644 --- a/app/src/main/java/app/gamenative/data/ModInstall.kt +++ b/app/src/main/java/app/gamenative/data/ModInstall.kt @@ -223,6 +223,9 @@ data class ModPlacementRecipe( @ColumnInfo(name = "target_relative_path") val targetRelativePath: String = "", + @ColumnInfo(name = "target_file_name") + val targetFileName: String = "", + @ColumnInfo(name = "mode") val mode: String = ModPlacementMode.SYMLINK.name, diff --git a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt index 9b7bd08b8e..4f84821a6a 100644 --- a/app/src/main/java/app/gamenative/db/PluviaDatabase.kt +++ b/app/src/main/java/app/gamenative/db/PluviaDatabase.kt @@ -69,7 +69,7 @@ const val DATABASE_NAME = "pluvia.db" ModPlacementRecipe::class, ModOverwriteManifest::class, ], - version = 26, + version = 27, // For db migration, visit https://developer.android.com/training/data-storage/room/migrating-db-versions for more information exportSchema = true, // It is better to handle db changes carefully, as GN is getting much more users. autoMigrations = [ @@ -85,7 +85,7 @@ const val DATABASE_NAME = "pluvia.db" // AutoMigration(from = 16, to = 17), // Disabled auto-migration due to duplicated column in previous version (upstream PR #1048) // duplicate column name: ufs_parse_version (code 1 SQLITE_ERROR) - // v16 users will fallback to destructive migration (only cached Steam data, re-fetched on login) + // Version 16 cannot upgrade automatically; no destructive fallback is configured. AutoMigration(from = 17, to = 18), // Added workshop_mods, enabled_workshop_item_ids, workshop_download_pending to steam_app AutoMigration(from = 18, to = 19), // Added recovered_install_size_bytes to app_info AutoMigration(from = 19, to = 20), // Added custom_install_path to app_info diff --git a/app/src/main/java/app/gamenative/db/dao/ModDao.kt b/app/src/main/java/app/gamenative/db/dao/ModDao.kt index da772463ef..176b53b7f4 100644 --- a/app/src/main/java/app/gamenative/db/dao/ModDao.kt +++ b/app/src/main/java/app/gamenative/db/dao/ModDao.kt @@ -82,7 +82,7 @@ interface ModDao { @Query("SELECT * FROM mod_profile WHERE app_id = :appId AND active = 1 LIMIT 1") suspend fun getActiveProfileForApp(appId: String): ModProfile? - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Upsert suspend fun upsertProfile(profile: ModProfile) @Query("UPDATE mod_profile SET name = :name, updated_at = :updatedAt WHERE profile_id = :profileId") diff --git a/app/src/main/java/app/gamenative/db/migration/RoomMigration.kt b/app/src/main/java/app/gamenative/db/migration/RoomMigration.kt index ff24584a5c..94ebd957ac 100644 --- a/app/src/main/java/app/gamenative/db/migration/RoomMigration.kt +++ b/app/src/main/java/app/gamenative/db/migration/RoomMigration.kt @@ -27,6 +27,38 @@ internal val ROOM_MIGRATION_V24_to_V25 = object : Migration(24, 25) { } } +/** + * Reconciles the two version-26 schemas that were published by parallel workstreams. + * One schema added GOG's hidden flag while the other added per-file placement names. + */ +internal val ROOM_MIGRATION_V26_to_V27 = object : Migration(26, 27) { + override fun migrate(connection: SQLiteConnection) { + if (!connection.hasColumn("gog_games", "hidden")) { + connection.execSQL( + "ALTER TABLE `gog_games` ADD COLUMN `hidden` INTEGER NOT NULL DEFAULT 0", + ) + } + if (!connection.hasColumn("mod_placement_recipe", "target_file_name")) { + connection.execSQL( + "ALTER TABLE `mod_placement_recipe` ADD COLUMN `target_file_name` TEXT NOT NULL DEFAULT ''", + ) + } + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_mod_install_app_id_source_archive_sha256` " + + "ON `mod_install` (`app_id`, `source`, `archive_sha256`)", + ) + } +} + +private fun SQLiteConnection.hasColumn(tableName: String, columnName: String): Boolean { + prepare("PRAGMA table_info(`$tableName`)").use { statement -> + while (statement.step()) { + if (statement.getText(1) == columnName) return true + } + } + return false +} + private fun migrateManagedModSourcesToV25(connection: SQLiteConnection) { connection.execSQL( """ diff --git a/app/src/main/java/app/gamenative/di/DatabaseModule.kt b/app/src/main/java/app/gamenative/di/DatabaseModule.kt index b0d71cedec..402f66fb4d 100644 --- a/app/src/main/java/app/gamenative/di/DatabaseModule.kt +++ b/app/src/main/java/app/gamenative/di/DatabaseModule.kt @@ -14,6 +14,7 @@ import app.gamenative.db.dao.ModDao import app.gamenative.db.dao.SteamUnlockedBranchDao import app.gamenative.db.migration.ROOM_MIGRATION_V23_to_V24 import app.gamenative.db.migration.ROOM_MIGRATION_V24_to_V25 +import app.gamenative.db.migration.ROOM_MIGRATION_V26_to_V27 import app.gamenative.db.migration.ROOM_MIGRATION_V7_to_V8 import dagger.Module import dagger.Provides @@ -29,15 +30,13 @@ class DatabaseModule { @Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): PluviaDatabase { - // The db will be considered unstable during development. - // Once stable we should add a (room) db migration return Room.databaseBuilder(context, PluviaDatabase::class.java, DATABASE_NAME) .addMigrations( ROOM_MIGRATION_V7_to_V8, ROOM_MIGRATION_V23_to_V24, ROOM_MIGRATION_V24_to_V25, + ROOM_MIGRATION_V26_to_V27, ) - .fallbackToDestructiveMigration(true) .build() } diff --git a/app/src/main/java/app/gamenative/mods/AutomaticPlacementPlanner.kt b/app/src/main/java/app/gamenative/mods/AutomaticPlacementPlanner.kt new file mode 100644 index 0000000000..844cb08834 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/AutomaticPlacementPlanner.kt @@ -0,0 +1,575 @@ +package app.gamenative.mods + +import app.gamenative.data.ModPlacementMode +import app.gamenative.data.ModTargetRoot +import java.util.Locale + +data class AutomaticPlacementCandidate( + val id: String, + val label: String, + val description: String, + val drafts: List, + val plan: ModInstallPlan, + val score: Int, + val evidence: List, +) + +data class AutomaticPlacementResult( + val candidates: List, + val recommended: AutomaticPlacementCandidate?, + val optionGroups: List = emptyList(), +) + +data class AutomaticPlacementContext( + val defaultTargetRoot: String = ModTargetRoot.GAME_DIR.name, + val defaultTargetRelativePath: String = "", + val defaultTargetIsProven: Boolean = false, + val existingGameDirectories: Set = emptySet(), +) + +object AutomaticPlacementPlanner { + private val bethesdaRule = ModPlacementRulePacks.bethesda + + fun plan( + gameName: String, + entries: List, + selectedOptions: Map = emptyMap(), + context: AutomaticPlacementContext = AutomaticPlacementContext(), + ): AutomaticPlacementResult { + val fullIndex = ModArchiveIndex.build(entries) + val optionGroups = GenericOptionSetDetector.detect(fullIndex) + val validSelections = optionGroups.mapNotNull { group -> + val saved = selectedOptions[group.stableId] ?: selectedOptions.values.singleOrNull { saved -> + group.choices.any { it.sourceDirectory.equals(saved, ignoreCase = true) } + } + group.choices.firstOrNull { it.sourceDirectory.equals(saved, ignoreCase = true) } + ?.let { group.stableId to it.sourceDirectory } + }.toMap() + val excludedOptionRoots = optionGroups.flatMap { group -> + val selected = validSelections[group.stableId] + if (selected == null) emptyList() else group.choices.map { it.sourceDirectory }.filterNot { it == selected } + } + val effectiveEntries = if (excludedOptionRoots.isEmpty()) { + entries + } else { + entries.filterNot { entry -> excludedOptionRoots.any { root -> entry.path.isUnderArchiveRoot(root) } } + } + val index = if (effectiveEntries === entries) fullIndex else ModArchiveIndex.build(effectiveEntries) + val legacy = ModPlacementPresetDetector.detect(gameName, effectiveEntries).map { preset -> + candidateFromDrafts( + id = "legacy:${preset.id}", + label = preset.label, + description = preset.description, + drafts = preset.drafts, + index = index, + origin = PlacementOrigin.LEGACY_PRESET, + evidence = listOf("Existing placement preset ${preset.id}"), + ) + } + val generated = buildList { + bethesdaCandidate(gameName, index)?.let(::add) + frameworkCandidates(gameName, index).let(::addAll) + existingGameLayoutCandidate(index, context)?.let(::add) + provenPackageDirectoryCandidate(index, optionGroups, validSelections, context)?.let(::add) + } + val combined = generated.takeIf { candidates -> candidates.size > 1 } + ?.flatMap { it.drafts } + ?.distinct() + ?.let { drafts -> + candidateFromDrafts( + id = "rules:combined-v1", + label = "Complete mixed-layout plan", + description = "Combines compatible built-in game and framework rules.", + drafts = drafts, + index = index, + origin = PlacementOrigin.GAME_RULE, + evidence = generated.flatMap { it.evidence }.distinct(), + ) + } + val generatedCandidates = if (combined == null) generated else generated + combined + val ranked = (generatedCandidates + legacy) + .distinctBy { candidate -> candidate.plan.digest } + .sortedWith( + compareByDescending { it.plan.isComplete } + .thenByDescending { it.score } + .thenBy { it.id }, + ) + val baseline = legacy.firstOrNull() + val bestGenerated = generatedCandidates.maxWithOrNull( + compareBy { it.plan.isComplete } + .thenBy { it.score } + .thenByDescending { it.id }, + ) + val recommendedBase = when { + bestGenerated == null -> baseline + baseline == null -> bestGenerated + PlacementPlanRegressionPolicy.canReplace(baseline.plan, bestGenerated.plan) && + preservesExistingDestinations(baseline.plan, bestGenerated.plan) -> bestGenerated + else -> baseline + } + val optionMessage = optionGroups.firstOrNull { it.stableId !in validSelections }?.let { group -> + "Choose one package variant: ${group.choices.joinToString { it.sourceDirectory }}" + } + val withExcluded = if (excludedOptionRoots.isEmpty()) { + ranked + } else { + val excludedFiles = fullIndex.files.filter { file -> + excludedOptionRoots.any { root -> file.displayPath.isUnderArchiveRoot(root) } + } + ranked.map { candidate -> + candidate.copy( + plan = candidate.plan.copy( + files = candidate.plan.files + excludedFiles.map { file -> + PlannedModFile( + sourceRelativePath = file.displayPath, + status = PlannedFileStatus.INTENTIONALLY_IGNORED, + origin = PlacementOrigin.GAME_RULE, + sizeBytes = file.sizeBytes, + reason = "Unselected package variant", + ) + }, + ), + ) + } + } + val reviewed = if (optionMessage == null) { + withExcluded + } else { + withExcluded.map { candidate -> + candidate.copy( + plan = candidate.plan.copy(blockingIssues = (candidate.plan.blockingIssues + optionMessage).distinct()), + evidence = candidate.evidence + optionMessage, + ) + } + } + val recommendedIndex = recommendedBase?.let { base -> + ranked.indexOfFirst { candidate -> + candidate.id == base.id || candidate.plan.digest == base.plan.digest + } + } ?: -1 + val recommended = reviewed.getOrNull(recommendedIndex) + return AutomaticPlacementResult(reviewed, recommended, optionGroups) + } + + fun inferIncludeSourceDirectory( + selectedPaths: Collection, + entries: List, + targetRelativePath: String, + ): Boolean { + val selectedDirectories = selectedPaths.filter { selectedPath -> + val selectedKey = normalizedArchiveKey(selectedPath) ?: return@filter false + val childPrefix = "$selectedKey/" + entries.any { entry -> + val entryKey = normalizedArchiveKey(entry.path) + entryKey == selectedKey && entry.directory || entryKey?.startsWith(childPrefix) == true + } + } + if (selectedDirectories.isEmpty()) return false + val targetName = normalizeArchiveDisplayPath(targetRelativePath).substringAfterLast('/') + return selectedDirectories.any { source -> + !source.substringAfterLast('/').equals(targetName, ignoreCase = true) + } + } + + private fun bethesdaCandidate(gameName: String, index: ModArchiveIndex): AutomaticPlacementCandidate? { + val game = BethesdaPluginManager.detectGame(gameName) ?: return null + if (index.hasFomod) return null + val dataNodes = index.nodes.filter { node -> node.displayPath.substringAfterLast('/').equals("Data", ignoreCase = true) } + val bestData = dataNodes.maxWithOrNull( + compareBy { it.descendantFileCount } + .thenByDescending { it.displayPath.count { char -> char == '/' } } + .thenBy { it.normalizedKey }, + ) + val drafts = if (bestData != null) { + dataNodes.sortedBy { it.normalizedKey }.map { dataNode -> + ModPlacementPresetDraft( + sourceSubpath = dataNode.displayPath, + targetRelativePath = game.dataDirName, + mode = ModPlacementMode.OVERWRITE_COPY.name, + includeSourceDirectory = false, + ) + } + riskyGameRootDrafts(index) + } else { + val sources = index.files.mapNotNull(::bethesdaSourceForFile).distinctBy { it.lowercase(Locale.ROOT) } + if (sources.isEmpty()) return null + listOf( + ModPlacementPresetDraft( + sourceSubpath = ModPlacementSources.encode(sources), + targetRelativePath = game.dataDirName, + mode = ModPlacementMode.OVERWRITE_COPY.name, + includeSourceDirectory = true, + ), + ) + } + val evidence = buildList { + if (bestData != null) { + add("Found ${dataNodes.size} compatible Data container(s): ${dataNodes.take(3).joinToString { it.displayPath }}") + } + val anchors = index.nodes.flatMapTo(mutableSetOf()) { it.semanticAnchors }.sorted() + if (anchors.isNotEmpty()) add("Recognized Data content: ${anchors.joinToString()}") + val loose = index.files.count { + it.displayPath.substringAfterLast('.').lowercase(Locale.ROOT) in bethesdaRule.looseExtensions + } + if (loose > 0) add("Found $loose Bethesda plugin/archive file(s)") + } + return candidateFromDrafts( + id = "rules:${bethesdaRule.stableId}-v${bethesdaRule.version}", + label = "Complete Bethesda Data plan", + description = "Maps recognized Data content and loose plugins while preserving content folders.", + drafts = drafts, + index = index, + origin = PlacementOrigin.GAME_RULE, + evidence = evidence, + ) + } + + private fun riskyGameRootDrafts(index: ModArchiveIndex): List = + index.files.filter { it.role == ArchiveContentRole.RISKY_ROOT && '/' !in it.displayPath } + .map { file -> + ModPlacementPresetDraft( + sourceSubpath = file.displayPath, + targetRelativePath = "", + mode = ModPlacementMode.OVERWRITE_COPY.name, + includeSourceDirectory = false, + ) + } + + private fun frameworkCandidates(gameName: String, index: ModArchiveIndex): List { + if (index.hasFomod) return emptyList() + return listOf( + Triple(ModPlacementRulePacks.bepInEx, "BepInEx framework plan", "Recognized BepInEx package layout"), + Triple(ModPlacementRulePacks.melonLoader, "MelonLoader framework plan", "Recognized MelonLoader package layout"), + Triple(ModPlacementRulePacks.unreal, "Unreal Engine package plan", "Recognized Unreal Paks package layout"), + Triple(ModPlacementRulePacks.redmod, "REDmod package plan", "Recognized Cyberpunk/REDmod package layout"), + ).mapNotNull { (rule, label, evidence) -> + if (rule.gameNameTokens.isNotEmpty()) { + val normalizedGame = gameName.lowercase(Locale.ROOT) + if (rule.gameNameTokens.none(normalizedGame::contains)) return@mapNotNull null + } + val drafts = rule.directoryTargets.mapNotNull { (sourcePath, targetPath) -> + index.nodes + .filter { node -> + val sourceKey = sourcePath.lowercase(Locale.ROOT) + val suffixMatch = node.normalizedKey.endsWith("/$sourceKey") + val parentName = node.normalizedKey.substringBeforeLast('/', "").substringAfterLast('/') + node.normalizedKey == sourceKey || + ( + suffixMatch && + parentName !in setOf("bepinex", "skse", "f4se", "sfse", "content", "bin", "x64") + ) + } + .maxByOrNull { it.descendantFileCount } + ?.let { node -> + ModPlacementPresetDraft( + sourceSubpath = node.displayPath, + targetRelativePath = targetPath, + mode = ModPlacementMode.OVERWRITE_COPY.name, + includeSourceDirectory = false, + ) + } + }.toMutableList() + if (rule == ModPlacementRulePacks.unreal) { + val loose = index.files.filter { file -> + '/' !in file.displayPath && + file.displayPath.substringAfterLast('.', "").lowercase(Locale.ROOT) in rule.looseExtensions + }.map { it.displayPath } + if (loose.isNotEmpty()) { + drafts += ModPlacementPresetDraft( + sourceSubpath = ModPlacementSources.encode(loose), + targetRelativePath = "Content/Paks", + mode = ModPlacementMode.OVERWRITE_COPY.name, + ) + } + } + if (drafts.isEmpty()) return@mapNotNull null + candidateFromDrafts( + id = "rules:${rule.stableId}-v${rule.version}", + label = label, + description = evidence, + drafts = drafts, + index = index, + origin = PlacementOrigin.GAME_RULE, + evidence = listOf(evidence, "Rule ${rule.stableId}@${rule.version}"), + ) + } + } + + private fun existingGameLayoutCandidate( + index: ModArchiveIndex, + context: AutomaticPlacementContext, + ): AutomaticPlacementCandidate? { + if (index.hasFomod || context.existingGameDirectories.isEmpty()) return null + val existingByKey = context.existingGameDirectories + .associateBy { it.lowercase(Locale.ROOT) } + val matchingRoots = index.nodes + .asSequence() + .filter { node -> '/' !in node.displayPath && node.descendantFileCount > 0 } + .filter { node -> node.normalizedKey in existingByKey } + .filter { node -> index.filesUnder(node.displayPath).any { it.role == ArchiveContentRole.INSTALLABLE } } + .sortedBy { it.normalizedKey } + .toList() + if (matchingRoots.isEmpty()) return null + val drafts = matchingRoots.map { node -> + ModPlacementPresetDraft( + sourceSubpath = node.displayPath, + targetRelativePath = "", + targetRoot = ModTargetRoot.GAME_DIR.name, + mode = ModPlacementMode.OVERWRITE_COPY.name, + includeSourceDirectory = true, + ) + } + return candidateFromDrafts( + id = "rules:existing-game-layout-v1", + label = "Match existing game folders", + description = "Keeps archive folders whose names match folders already used by the game.", + drafts = drafts, + index = index, + origin = PlacementOrigin.GAME_RULE, + evidence = listOf("Matched existing game folders: ${matchingRoots.joinToString { it.displayPath }}"), + ) + } + + private fun provenPackageDirectoryCandidate( + index: ModArchiveIndex, + optionGroups: List, + selectedOptions: Map, + context: AutomaticPlacementContext, + ): AutomaticPlacementCandidate? { + if ( + index.hasFomod || + !context.defaultTargetIsProven || + context.defaultTargetRelativePath.isBlank() + ) { + return null + } + val installableFiles = index.files.filter { it.role == ArchiveContentRole.INSTALLABLE } + val topLevelRoots = installableFiles.mapNotNull { file -> + file.displayPath.substringBefore('/', "").takeIf(String::isNotBlank) + }.distinctBy { it.lowercase(Locale.ROOT) } + if (topLevelRoots.size != 1 || installableFiles.any { '/' !in it.displayPath }) return null + val packageRoot = topLevelRoots.single() + val packageName = packageRoot.substringAfterLast('/') + val sourceIsTargetContainer = context.defaultTargetRelativePath.substringAfterLast('/') + .equals(packageName, ignoreCase = true) + val packageTarget = if (sourceIsTargetContainer) { + context.defaultTargetRelativePath + } else { + listOf(context.defaultTargetRelativePath, packageName).filter(String::isNotBlank).joinToString("/") + } + val packageGroups = optionGroups.filter { group -> + group.choices.all { choice -> + choice.sourceDirectory.substringBeforeLast('/', "").equals(packageRoot, ignoreCase = true) + } + } + val selectedPackageGroups = packageGroups.mapNotNull { group -> + selectedOptions[group.stableId]?.let { selected -> group to selected } + } + val drafts = if (selectedPackageGroups.isEmpty()) { + listOf( + ModPlacementPresetDraft( + sourceSubpath = packageRoot, + targetRelativePath = context.defaultTargetRelativePath, + targetRoot = context.defaultTargetRoot, + mode = ModPlacementMode.OVERWRITE_COPY.name, + includeSourceDirectory = !sourceIsTargetContainer, + ), + ) + } else { + buildList { + selectedPackageGroups.forEach { (_, selected) -> + add( + ModPlacementPresetDraft( + sourceSubpath = selected, + targetRelativePath = packageTarget, + targetRoot = context.defaultTargetRoot, + mode = ModPlacementMode.OVERWRITE_COPY.name, + ), + ) + } + packageGroups.flatMap { it.commonSourceDirectories } + .distinctBy { it.lowercase(Locale.ROOT) } + .forEach { common -> + add( + ModPlacementPresetDraft( + sourceSubpath = common, + targetRelativePath = packageTarget, + targetRoot = context.defaultTargetRoot, + mode = ModPlacementMode.OVERWRITE_COPY.name, + includeSourceDirectory = true, + ), + ) + } + val directFiles = index.filesUnder(packageRoot).filter { file -> + file.displayPath.removePrefixCaseInsensitive("$packageRoot/").let { '/' !in it } + }.map { it.displayPath } + if (directFiles.isNotEmpty()) { + add( + ModPlacementPresetDraft( + sourceSubpath = ModPlacementSources.encode(directFiles), + targetRelativePath = packageTarget, + targetRoot = context.defaultTargetRoot, + mode = ModPlacementMode.OVERWRITE_COPY.name, + ), + ) + } + } + } + return candidateFromDrafts( + id = "rules:proven-package-directory-v1", + label = "Install package into ${context.defaultTargetRelativePath}", + description = "Preserves the package folder while applying only the selected package variant.", + drafts = drafts, + index = index, + origin = PlacementOrigin.GAME_RULE, + evidence = listOf( + "High-confidence mod directory: ${context.defaultTargetRelativePath}", + "Single package folder: $packageRoot", + ), + ) + } + + private fun bethesdaSourceForFile(file: IndexedArchiveFile): String? { + if (file.role != ArchiveContentRole.INSTALLABLE) return null + val segments = file.displayPath.split('/') + val anchorIndex = segments.indexOfFirst { it.lowercase(Locale.ROOT) in bethesdaRule.directoryTargets } + if (anchorIndex >= 0) return segments.take(anchorIndex + 1).joinToString("/") + if (file.displayPath.substringAfterLast('.', "").lowercase(Locale.ROOT) in bethesdaRule.looseExtensions) { + return file.displayPath + } + return null + } + + private fun candidateFromDrafts( + id: String, + label: String, + description: String, + drafts: List, + index: ModArchiveIndex, + origin: PlacementOrigin, + evidence: List, + ): AutomaticPlacementCandidate { + val placedBySource = linkedMapOf() + val conflictingSourceKeys = mutableSetOf() + drafts.forEach { draft -> + ModPlacementSources.decode(draft.sourceSubpath).ifEmpty { listOf("") }.forEach { source -> + val sourceIsDirectory = source.isBlank() || index.isDirectory(source) + index.filesUnder(source).forEach fileLoop@ { file -> + if (!file.role.participatesInAutomaticPlacement() && file.role != ArchiveContentRole.INVALID) { + return@fileLoop + } + val relative = when { + source.isBlank() -> file.displayPath + !sourceIsDirectory -> file.displayPath.substringAfterLast('/') + else -> file.displayPath.removePrefixCaseInsensitive("$source/") + } + val targetPath = listOfNotNull( + draft.targetRelativePath.takeIf(String::isNotBlank), + source.substringAfterLast('/').takeIf { sourceIsDirectory && source.isNotBlank() && draft.includeSourceDirectory }, + relative.takeIf(String::isNotBlank), + ).joinToString("/") + val targetKey = ModTargetResolver.normalizedTargetKey(draft.targetRoot, targetPath) + val planned = PlannedModFile( + sourceRelativePath = file.displayPath, + targetRoot = draft.targetRoot, + targetRelativePath = targetPath, + normalizedTargetKey = targetKey, + status = if (targetKey == null) PlannedFileStatus.UNSUPPORTED else PlannedFileStatus.PLACED, + origin = origin, + mode = draft.mode, + sizeBytes = file.sizeBytes, + reason = evidence.firstOrNull() ?: description, + evidence = evidence, + risk = if (file.role == ArchiveContentRole.RISKY_ROOT) PlacementRisk.UNSAFE else PlacementRisk.SAFE, + ) + val previous = placedBySource[file.normalizedKey] + when { + file.normalizedKey in conflictingSourceKeys -> Unit + previous != null && previous.normalizedTargetKey != planned.normalizedTargetKey -> { + conflictingSourceKeys += file.normalizedKey + placedBySource[file.normalizedKey] = previous.copy( + status = PlannedFileStatus.CONFLICTED, + reason = "One archive file maps to multiple destinations", + evidence = (previous.evidence + planned.evidence).distinct(), + risk = if ( + previous.risk == PlacementRisk.UNSAFE || planned.risk == PlacementRisk.UNSAFE + ) { + PlacementRisk.UNSAFE + } else { + PlacementRisk.REVIEW + }, + ) + } + else -> placedBySource[file.normalizedKey] = planned + } + } + } + } + val classified = index.files.map { file -> + placedBySource[file.normalizedKey] ?: when (file.role) { + ArchiveContentRole.DOCUMENTATION, + ArchiveContentRole.METADATA, + ArchiveContentRole.INSTALLER_SUPPORT, + -> PlannedModFile( + sourceRelativePath = file.displayPath, + status = PlannedFileStatus.INTENTIONALLY_IGNORED, + origin = origin, + sizeBytes = file.sizeBytes, + reason = "Known non-installable ${file.role.name.lowercase(Locale.ROOT).replace('_', ' ')}", + ) + else -> PlannedModFile( + sourceRelativePath = file.displayPath, + status = PlannedFileStatus.UNSUPPORTED, + origin = origin, + sizeBytes = file.sizeBytes, + reason = "No supported destination was proven", + risk = if (file.role == ArchiveContentRole.RISKY_ROOT) PlacementRisk.UNSAFE else PlacementRisk.REVIEW, + ) + } + }.toMutableList() + val duplicateTargets = classified.filter { it.status == PlannedFileStatus.PLACED } + .groupBy { it.normalizedTargetKey } + .filterKeys { it != null } + .filterValues { files -> files.map { it.sourceRelativePath.lowercase(Locale.ROOT) }.distinct().size > 1 } + if (duplicateTargets.isNotEmpty()) { + duplicateTargets.values.flatten().forEach { duplicate -> + val indexOfFile = classified.indexOf(duplicate) + classified[indexOfFile] = duplicate.copy( + status = PlannedFileStatus.CONFLICTED, + reason = "Multiple archive files target one Windows path", + risk = PlacementRisk.REVIEW, + ) + } + } + val blockers = buildList { + if (index.caseCollisions.isNotEmpty()) add("Archive contains case-colliding file paths") + if (classified.any { it.status == PlannedFileStatus.UNSUPPORTED }) add("Some installable files have no proven destination") + if (conflictingSourceKeys.isNotEmpty()) add("One or more archive files map to multiple destinations") + if (duplicateTargets.isNotEmpty()) add("Multiple files target the same Windows path") + if (classified.any { it.risk == PlacementRisk.UNSAFE }) add(ModInstallPlan.RISKY_ROOT_REVIEW_BLOCKER) + } + val plan = ModInstallPlan( + files = classified, + blockingIssues = blockers, + producerId = id, + producerVersion = id.substringAfterLast("-v", "1").toIntOrNull() ?: 1, + ) + val score = (plan.coverage * 1_000).toInt() + evidence.size * 25 - blockers.size * 250 + return AutomaticPlacementCandidate(id, label, description, drafts, plan, score, evidence) + } + + private fun preservesExistingDestinations(baseline: ModInstallPlan, candidate: ModInstallPlan): Boolean { + val candidateTargets = candidate.files.associate { it.sourceRelativePath.lowercase(Locale.ROOT) to it.normalizedTargetKey } + return baseline.files.filter { it.status == PlannedFileStatus.PLACED }.all { old -> + candidateTargets[old.sourceRelativePath.lowercase(Locale.ROOT)] == old.normalizedTargetKey + } + } + + private fun String.removePrefixCaseInsensitive(prefix: String): String = + if (startsWith(prefix, ignoreCase = true)) substring(prefix.length) else this + + private fun String.isUnderArchiveRoot(root: String): Boolean { + val path = normalizeArchiveDisplayPath(this) + val normalizedRoot = normalizeArchiveDisplayPath(root) + return path.equals(normalizedRoot, ignoreCase = true) || path.startsWith("$normalizedRoot/", ignoreCase = true) + } +} diff --git a/app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt b/app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt index 4f67382b49..13490bad36 100644 --- a/app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt +++ b/app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt @@ -1,5 +1,6 @@ package app.gamenative.mods +import app.gamenative.data.GameSource import app.gamenative.data.ModInstall import app.gamenative.data.ModInstallStatus import app.gamenative.data.ModPlacementRecipe @@ -25,6 +26,11 @@ enum class BethesdaGame( STARFIELD("Starfield", "Starfield"), } +data class BethesdaPluginFiles( + val targetFile: File, + val stateFile: File, +) + data class BethesdaPlugin( val fileName: String, val installId: String?, @@ -78,6 +84,7 @@ object BethesdaPluginManager { gameRootDir: File?, winePrefix: String, pluginsFile: File?, + ownershipByInstallId: Map = emptyMap(), defaultEnabled: Boolean = false, ): List = withContext(Dispatchers.IO) { val game = gameFromPluginsFile(pluginsFile) @@ -88,21 +95,40 @@ object BethesdaPluginManager { .toMap() installs.filter { it.status == ModInstallStatus.APPLIED.name }.flatMap { install -> val recipes = recipesByInstallId[install.installId].orEmpty() + val ownership = ownershipByInstallId[install.installId] + ?.takeIf { it.state == ModOwnershipState.ACTIVE } runCatching { - ModMaterializer.plannedEntries(install, recipes, gameRootDir, winePrefix) - .flatMap { entry -> entry.toPluginFiles() } - .map { plugin -> - BethesdaPlugin( - fileName = plugin.target.name, - installId = install.installId, - modName = install.modName, - deployedPath = plugin.target.absolutePath, - enabled = existingByPlugin[plugin.target.name.lowercase()]?.enabled ?: defaultEnabled, - priority = prioritiesByInstallId[install.installId] ?: 0, - orderIndex = orderByPlugin[plugin.target.name.lowercase()] ?: Int.MAX_VALUE, - sourcePath = plugin.source.absolutePath, - ) - } + val pluginFiles = if (ownership != null) { + ownership.files + .asSequence() + .filter { it.active && File(it.targetPath).extension.lowercase() in pluginExtensions } + .map { file -> File(install.extractedPath, file.sourceRelativePath) to File(file.targetPath) } + .toList() + } else { + val plan = ModMaterializer.materializationPlan( + install, + recipes, + gameRootDir, + winePrefix, + captureTargetHashes = false, + ) + check(plan.isComplete) { plan.errors.values.joinToString() } + plan.files + .filter { file -> file.target.extension.lowercase() in pluginExtensions } + .map { file -> file.source to file.target } + } + pluginFiles.map { (source, target) -> + BethesdaPlugin( + fileName = target.name, + installId = install.installId, + modName = install.modName, + deployedPath = target.absolutePath, + enabled = existingByPlugin[target.name.lowercase()]?.enabled ?: defaultEnabled, + priority = prioritiesByInstallId[install.installId] ?: 0, + orderIndex = orderByPlugin[target.name.lowercase()] ?: Int.MAX_VALUE, + sourcePath = source.absolutePath, + ) + } }.getOrElse { error -> Timber.w(error, "Skipping Bethesda plugin detection for Nexus install %s", install.installId) emptyList() @@ -185,7 +211,10 @@ object BethesdaPluginManager { val missingFiles = buildList { if (!targetPlugin.isFile) add(plugin.fileName) sourcePluginSidecars(sourcePlugin).forEach { sourceSidecar -> - val expectedTarget = File(targetPlugin.parentFile ?: return@forEach, sourceSidecar.name) + val expectedTarget = ModTargetResolver.resolveWithin( + targetPlugin.parentFile ?: return@forEach, + sourceSidecar.name, + ) ?: return@forEach if (!expectedTarget.isFile) add(sourceSidecar.name) } } @@ -215,10 +244,30 @@ object BethesdaPluginManager { }.getOrDefault(emptyList()) } - fun pluginsFile(winePrefix: String, game: BethesdaGame): File? { + fun pluginFiles( + winePrefix: String, + game: BethesdaGame, + gameSource: GameSource, + ): BethesdaPluginFiles? { if (winePrefix.isBlank()) return null val userHome = ModContainerResolver.getWineUserHome(winePrefix) - return File(userHome, "AppData/Local/${game.localAppDataDir}/plugins.txt") + val isGogSkyrimSpecialEdition = + game == BethesdaGame.SKYRIM_SPECIAL_EDITION && gameSource == GameSource.GOG + val localAppDataDir = if (isGogSkyrimSpecialEdition) { + "Skyrim Special Edition GOG" + } else { + game.localAppDataDir + } + val targetFile = File(userHome, "AppData/Local/$localAppDataDir/plugins.txt") + val legacyFile = if (isGogSkyrimSpecialEdition) { + File(userHome, "AppData/Local/${game.localAppDataDir}/plugins.txt") + } else { + null + } + val stateFile = legacyFile + ?.takeIf { shouldUseLegacyPluginState(targetFile, it) } + ?: targetFile + return BethesdaPluginFiles(targetFile = targetFile, stateFile = stateFile) } fun updateManagedPluginsTxt( @@ -226,13 +275,14 @@ object BethesdaPluginManager { managedPlugins: List, game: BethesdaGame? = null, gameRootDir: File? = null, + migrationSourceFile: File? = null, ) { file.parentFile?.mkdirs() val managedByName = managedPlugins.associateBy { it.fileName.lowercase() } val usesMarkers = usesAsteriskEnabledMarkers(game) val basePlugins = gameRootDir?.let { root -> baseGamePlugins(game, root, managedByName.keys) }.orEmpty() val baseByName = basePlugins.map { it.lowercase() }.toSet() - val retainedLines = pluginFileVariants(file) + val targetRetainedLines = pluginFileVariants(file) .flatMap { existingFile -> if (existingFile.isFile) existingFile.readLines() else emptyList() } .filter { line -> val entry = parsePluginLine(line, game) ?: return@filter true @@ -240,6 +290,21 @@ object BethesdaPluginManager { } .map { if (usesMarkers) it else normalizePluginLineForNoMarkerGame(it) } .distinctPluginLines(game) + val targetRetainedNames = targetRetainedLines + .mapNotNull { parsePluginLine(it, game)?.fileName?.lowercase() } + .toSet() + val migratedRetainedLines = migrationSourceFile + ?.takeUnless { it.absolutePath.equals(file.absolutePath, ignoreCase = true) } + ?.let { source -> readPluginEntries(source, game) } + .orEmpty() + .filter { entry -> + val key = entry.fileName.lowercase() + key !in targetRetainedNames && key !in managedByName && key !in baseByName + } + .map { entry -> + if (entry.enabled) enabledPluginLine(entry.fileName, usesMarkers) else entry.fileName + } + val retainedLines = (targetRetainedLines + migratedRetainedLines).distinctPluginLines(game) val baseLines = basePlugins.map { enabledPluginLine(it, usesMarkers) } val managedLines = managedPlugins .distinctBy { it.fileName.lowercase() } @@ -275,6 +340,13 @@ object BethesdaPluginManager { .mapNotNull { parsePluginLine(it, game) } .distinctBy { it.fileName.lowercase() } + private fun shouldUseLegacyPluginState(targetFile: File, legacyFile: File): Boolean { + if (!legacyFile.isFile) return false + val targetLoadOrder = File(targetFile.parentFile, "loadorder.txt") + val legacyLoadOrder = File(legacyFile.parentFile, "loadorder.txt") + return !targetLoadOrder.isFile && legacyLoadOrder.isFile + } + private fun parsePluginLine(line: String, game: BethesdaGame?): PluginEntry? { val trimmed = line.trim() if (trimmed.isBlank() || trimmed.startsWith("#")) return null @@ -284,27 +356,6 @@ object BethesdaPluginManager { return PluginEntry(fileName = fileName, enabled = enabled) } - private data class PlannedPluginFile(val source: File, val target: File) - - private fun ModPlannedEntry.toPluginFiles(): List { - if (source.isFile) { - return if (source.extension.lowercase() in pluginExtensions) { - listOf(PlannedPluginFile(source, target)) - } else { - emptyList() - } - } - if (!source.isDirectory) return emptyList() - val sourceRoot = source.canonicalFile - return source.walkTopDown() - .filter { it.isFile && it.extension.lowercase() in pluginExtensions } - .mapNotNull { file -> - val relative = file.canonicalFile.relativeToOrNull(sourceRoot)?.path ?: return@mapNotNull null - PlannedPluginFile(file, File(target, relative)) - } - .toList() - } - private fun pluginTypeRank(name: String): Int = when (name.substringAfterLast('.', "").lowercase()) { "esm" -> 0 "esl" -> 1 diff --git a/app/src/main/java/app/gamenative/mods/DurableFileReplacement.kt b/app/src/main/java/app/gamenative/mods/DurableFileReplacement.kt new file mode 100644 index 0000000000..4ea67bd3fa --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/DurableFileReplacement.kt @@ -0,0 +1,23 @@ +package app.gamenative.mods + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +internal fun replaceWithPreparedFile(source: File, target: File) { + runCatching { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + }.getOrElse { atomicFailure -> + runCatching { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + }.getOrElse { replacementFailure -> + replacementFailure.addSuppressed(atomicFailure) + throw replacementFailure + } + } +} diff --git a/app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt b/app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt index 6955184f5d..160791696a 100644 --- a/app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt +++ b/app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt @@ -16,11 +16,22 @@ object FomodAutoSelector { installer: FomodInstaller, targetRoot: String = ModTargetRoot.GAME_DIR.name, targetRelativePath: String = "Data", + environment: FomodEnvironmentSnapshot = FomodEnvironmentSnapshot(), ): FomodAutoSelectionResult? { if (installer.unsupportedWarnings.isNotEmpty()) return null - if (installer.steps.any { step -> step.groups.any { group -> group.plugins.any { it.typePatterns.isNotEmpty() } } }) { + val hasDynamicPluginTypes = installer.steps.any { step -> + step.groups.any { group -> + group.plugins.any { plugin -> plugin.typePatterns.isNotEmpty() } + } + } + if (hasDynamicPluginTypes) { return null } + if (installer.moduleDependencies.evaluate(emptyMap(), environment) != FomodFactState.TRUE) return null + val hasUnknownConditionalFiles = installer.conditionalFileInstalls.any { conditional -> + conditional.dependencies.evaluate(emptyMap(), environment) == FomodFactState.UNKNOWN + } + if (hasUnknownConditionalFiles) return null val selectedKeys = linkedSetOf() val selectedLabels = mutableListOf() @@ -65,8 +76,8 @@ object FomodAutoSelector { targetRoot = targetRoot, targetRelativePath = targetRelativePath, mode = ModPlacementMode.OVERWRITE_COPY.name, + environment = environment, ) - if (result.unsupportedMappings.isNotEmpty()) return null if (result.recipes.isEmpty()) return null return FomodAutoSelectionResult( diff --git a/app/src/main/java/app/gamenative/mods/FomodEnvironment.kt b/app/src/main/java/app/gamenative/mods/FomodEnvironment.kt new file mode 100644 index 0000000000..036139ad72 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/FomodEnvironment.kt @@ -0,0 +1,208 @@ +package app.gamenative.mods + +import java.io.File +import java.io.RandomAccessFile +import java.util.Locale + +enum class NativeBinaryArchitecture { + X86, + X64, + ARM64, + UNKNOWN, +} + +data class ScriptExtenderFact( + val id: String, + val present: Boolean, + val version: String? = null, + val path: String = "", +) + +data class FomodEnvironmentSnapshot( + val gameName: String = "", + val gameVersion: String? = null, + val fileFacts: Map = emptyMap(), + val pluginStateKnown: Boolean = false, + val presentPlugins: Set = emptySet(), + val activePlugins: Set = emptySet(), + val pluginMasters: Map> = emptyMap(), + val scriptExtenders: Map = emptyMap(), + val nativeDllArchitectures: Map = emptyMap(), + val unsupportedRequirements: List = emptyList(), +) { + fun evaluate(dependency: FomodFileDependency): FomodFactState { + val key = dependency.file.normalizedFactKey() + val present = fileFacts[key] ?: return FomodFactState.UNKNOWN + val pluginName = dependency.file.substringAfterLast('/').lowercase(Locale.ROOT) + val isPlugin = pluginName.substringAfterLast('.', "") in setOf("esp", "esm", "esl") + val active = pluginName in activePlugins + return requiredState(dependency.state, present, active, isPlugin) + } + + fun evaluate(dependency: FomodPluginDependency): FomodFactState { + val key = dependency.plugin.substringAfterLast('/').lowercase(Locale.ROOT) + val present = when { + key in activePlugins -> true + key in presentPlugins -> true + pluginStateKnown -> false + else -> return FomodFactState.UNKNOWN + } + return requiredState(dependency.state, present, key in activePlugins, isPlugin = true) + } + + fun evaluate(dependency: FomodGameDependency): FomodFactState { + val current = gameVersion ?: return FomodFactState.UNKNOWN + return if (compareVersions(current, dependency.version) >= 0) FomodFactState.TRUE else FomodFactState.FALSE + } + + private fun requiredState( + state: FomodRequiredFileState, + present: Boolean, + active: Boolean, + isPlugin: Boolean, + ): FomodFactState = + when (state) { + FomodRequiredFileState.ACTIVE -> if (present && (!isPlugin || active)) FomodFactState.TRUE else FomodFactState.FALSE + FomodRequiredFileState.INACTIVE -> if (present && isPlugin && !active) FomodFactState.TRUE else FomodFactState.FALSE + FomodRequiredFileState.MISSING -> if (!present) FomodFactState.TRUE else FomodFactState.FALSE + } + + private fun compareVersions(left: String, right: String): Int { + val leftParts = left.versionParts() + val rightParts = right.versionParts() + repeat(maxOf(leftParts.size, rightParts.size)) { index -> + val comparison = (leftParts.getOrElse(index) { 0 }).compareTo(rightParts.getOrElse(index) { 0 }) + if (comparison != 0) return comparison + } + return 0 + } + + private fun String.versionParts(): List = + split(Regex("[^0-9]+")).filter(String::isNotBlank).mapNotNull(String::toIntOrNull) +} + +object FomodEnvironmentSnapshotBuilder { + fun build( + installer: FomodInstaller, + gameName: String, + gameRootDir: File?, + pluginsFile: File? = null, + gameVersion: String? = null, + ): FomodEnvironmentSnapshot { + val requestedFiles = installer.dependencyExpressions() + .flatMap { it.fileDependencies } + .map { it.file } + .distinctBy { it.normalizedFactKey() } + val requestedPlugins = installer.dependencyExpressions() + .flatMap { it.pluginDependencies } + .map { it.plugin } + .distinctBy { it.lowercase(Locale.ROOT) } + val fileFacts = if (gameRootDir == null) { + emptyMap() + } else { + requestedFiles.associate { requested -> + requested.normalizedFactKey() to resolveRequestedFile(gameRootDir, requested).isFile + } + } + val presentPlugins = (requestedFiles + requestedPlugins).asSequence() + .filter { it.substringAfterLast('.').lowercase(Locale.ROOT) in setOf("esp", "esm", "esl") } + .filter { requested -> + fileFacts[requested.normalizedFactKey()] == true || + (gameRootDir != null && resolveRequestedFile(gameRootDir, requested).isFile) + } + .mapTo(mutableSetOf()) { it.substringAfterLast('/').lowercase(Locale.ROOT) } + val pluginLines = pluginsFile?.takeIf(File::isFile)?.readLines().orEmpty() + .map { it.substringBefore('#').trim() } + .filter(String::isNotBlank) + val usesEnabledMarkers = pluginLines.any { it.startsWith('*') } + val activePlugins = pluginLines.asSequence() + .filter { !usesEnabledMarkers || it.startsWith('*') } + .map { it.removePrefix("*").trim().lowercase(Locale.ROOT) } + .filterTo(mutableSetOf(), String::isNotBlank) + val pluginMasters = presentPlugins.associateWith { plugin -> + val file = resolveRequestedFile(gameRootDir, plugin) + if (file.isFile) BethesdaPluginManager.readPluginMasters(file) else emptyList() + } + val scriptExtenders = discoverScriptExtenders(gameRootDir) + val nativeDllArchitectures = requestedFiles.asSequence() + .filter { it.endsWith(".dll", ignoreCase = true) } + .mapNotNull { requested -> + resolveRequestedFile(gameRootDir, requested).takeIf(File::isFile)?.let { file -> + requested.normalizedFactKey() to readPeArchitecture(file) + } + } + .toMap() + return FomodEnvironmentSnapshot( + gameName = gameName, + gameVersion = gameVersion, + fileFacts = fileFacts, + pluginStateKnown = gameRootDir != null, + presentPlugins = presentPlugins, + activePlugins = activePlugins, + pluginMasters = pluginMasters, + scriptExtenders = scriptExtenders, + nativeDllArchitectures = nativeDllArchitectures, + unsupportedRequirements = installer.unsupportedWarnings, + ) + } + + private fun resolveRequestedFile(gameRootDir: File?, requested: String): File { + val root = gameRootDir ?: return File("") + val relative = normalizeArchiveDisplayPath(requested) + return listOf(relative, "Data/$relative") + .mapNotNull { candidate -> ModTargetResolver.resolveWithin(root, candidate) } + .firstOrNull { it.exists() } + ?: File(root, relative) + } + + private fun discoverScriptExtenders(gameRootDir: File?): Map { + val root = gameRootDir ?: return emptyMap() + val definitions = mapOf( + "skse" to listOf("skse_loader.exe", "skse64_loader.exe", "Data/SKSE"), + "f4se" to listOf("f4se_loader.exe", "Data/F4SE"), + "sfse" to listOf("sfse_loader.exe", "Data/SFSE"), + "nvse" to listOf("nvse_loader.exe", "Data/NVSE"), + "obse" to listOf("obse_loader.exe", "Data/OBSE"), + ) + return definitions.mapValues { (id, candidates) -> + val present = candidates.mapNotNull { ModTargetResolver.resolveWithin(root, it) }.firstOrNull(File::exists) + val version = root.listFiles().orEmpty().asSequence() + .filter { it.isFile && it.name.startsWith(id, ignoreCase = true) && it.extension.equals("dll", true) } + .mapNotNull { file -> + Regex("(?:^|_)(\\d+)[_.-](\\d+)[_.-](\\d+)(?:[_.-](\\d+))?", RegexOption.IGNORE_CASE) + .find(file.nameWithoutExtension) + ?.groupValues + ?.drop(1) + ?.filter(String::isNotBlank) + ?.map { part -> part.toIntOrNull()?.toString() ?: part } + ?.joinToString(".") + } + .firstOrNull() + ScriptExtenderFact(id, present != null, version, present?.absolutePath.orEmpty()) + } + } + + internal fun readPeArchitecture(file: File): NativeBinaryArchitecture = runCatching { + RandomAccessFile(file, "r").use { input -> + if (input.length() < 64L || input.readUnsignedShort() != 0x4d5a) return@use NativeBinaryArchitecture.UNKNOWN + input.seek(0x3c) + val peOffset = Integer.reverseBytes(input.readInt()).toLong() and 0xffffffffL + if (peOffset + 6 > input.length()) return@use NativeBinaryArchitecture.UNKNOWN + input.seek(peOffset) + if (Integer.reverseBytes(input.readInt()) != 0x00004550) return@use NativeBinaryArchitecture.UNKNOWN + when (java.lang.Short.toUnsignedInt(java.lang.Short.reverseBytes(input.readShort()))) { + 0x014c -> NativeBinaryArchitecture.X86 + 0x8664 -> NativeBinaryArchitecture.X64 + 0xaa64 -> NativeBinaryArchitecture.ARM64 + else -> NativeBinaryArchitecture.UNKNOWN + } + } + }.getOrDefault(NativeBinaryArchitecture.UNKNOWN) +} + +private fun FomodInstaller.dependencyExpressions(): List = + listOf(moduleDependencies) + conditionalFileInstalls.map { it.dependencies } + + steps.flatMap { it.groups }.flatMap { it.plugins }.flatMap { it.typePatterns }.map { it.dependencies } + +private fun String.normalizedFactKey(): String = + normalizeArchiveDisplayPath(this).lowercase(Locale.ROOT) diff --git a/app/src/main/java/app/gamenative/mods/FomodInstallPlanner.kt b/app/src/main/java/app/gamenative/mods/FomodInstallPlanner.kt new file mode 100644 index 0000000000..ec6d5d3e59 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/FomodInstallPlanner.kt @@ -0,0 +1,238 @@ +package app.gamenative.mods + +import app.gamenative.data.ModTargetRoot +import app.gamenative.data.ModPlacementMode +import java.io.File +import java.util.Locale + +data class FomodExpectedMapping( + val mapping: FomodFileMapping, + val origin: PlacementOrigin, + val ordinal: Int, +) + +data class FomodSelectionEvaluation( + val mappings: List, + val flags: Map, + val warnings: List, + val blockingIssues: List, +) + +object FomodSelectionEvaluator { + fun evaluate( + installer: FomodInstaller, + selectedPluginKeys: Set, + environment: FomodEnvironmentSnapshot = FomodEnvironmentSnapshot(), + ): FomodSelectionEvaluation { + val selectedPlugins = FomodRecipeGenerator.selectedPluginsForKeys(installer, selectedPluginKeys, environment) + val flags = linkedMapOf() + selectedPlugins.forEach { plugin -> plugin.conditionFlags.forEach { (name, value) -> flags[name] = value } } + val conditionalStates = installer.conditionalFileInstalls.map { conditional -> + conditional to conditional.dependencies.evaluate(flags, environment) + } + var ordinal = 0 + val expected = buildList { + installer.requiredFiles.forEach { mapping -> + add(FomodExpectedMapping(mapping, PlacementOrigin.FOMOD_REQUIRED, ordinal++)) + } + selectedPlugins.forEach { plugin -> + plugin.files.forEach { mapping -> + add(FomodExpectedMapping(mapping, PlacementOrigin.FOMOD_OPTION, ordinal++)) + } + } + conditionalStates.forEach { (conditional, state) -> + if (state == FomodFactState.TRUE) { + conditional.files.forEach { mapping -> + add(FomodExpectedMapping(mapping, PlacementOrigin.FOMOD_CONDITIONAL, ordinal++)) + } + } + } + } + val moduleDependencyState = installer.moduleDependencies.evaluate(flags, environment) + val warnings = buildList { + if (moduleDependencyState == FomodFactState.UNKNOWN && installer.moduleDependencies.hasFacts()) { + add("FOMOD game requirements could not be verified; the selected package version will be used") + } + if ( + installer.steps.flatMap { it.groups }.flatMap { it.plugins }.flatMap { it.typePatterns } + .any { it.dependencies.evaluate(flags, environment) == FomodFactState.UNKNOWN } + ) { + add("FOMOD option availability could not be verified; your explicit choices will be used") + } + } + val blockers = buildList { + addAll(installer.unsupportedWarnings) + when (moduleDependencyState) { + FomodFactState.FALSE -> add("The installed game does not satisfy this FOMOD's requirements") + FomodFactState.UNKNOWN -> Unit + FomodFactState.TRUE -> Unit + } + if (conditionalStates.any { (_, state) -> state == FomodFactState.UNKNOWN }) { + add("A selected FOMOD conditional depends on unknown game facts") + } + if (installer.conditionalFileInstalls.any { it.dependencies.unsupportedCount() > 0 }) { + add("A selected FOMOD conditional uses unsupported dependencies") + } + if ( + installer.steps.flatMap { it.groups }.flatMap { it.plugins }.flatMap { it.typePatterns } + .any { it.dependencies.unsupportedCount() > 0 } + ) { + add("FOMOD option availability depends on unsupported game facts") + } + } + return FomodSelectionEvaluation(expected, flags, warnings.distinct(), blockers.distinct()) + } + + private fun FomodDependencyExpression.hasFacts(): Boolean = + flagDependencies.isNotEmpty() || + fileDependencies.isNotEmpty() || + pluginDependencies.isNotEmpty() || + gameDependencies.isNotEmpty() || + childGroups.isNotEmpty() || + unsupportedDependencyCount > 0 +} + +object FomodPlanExpander { + fun expand( + installer: FomodInstaller, + evaluation: FomodSelectionEvaluation, + extractedRoot: File, + targetRoot: String = ModTargetRoot.GAME_DIR.name, + targetRelativePath: String = "Data", + mode: String = ModPlacementMode.OVERWRITE_COPY.name, + ): ModInstallPlan { + val root = extractedRoot.canonicalFile + val sourceResolver = CaseInsensitiveSourceResolver(root) + val expanded = mutableListOf() + val missing = mutableListOf() + + evaluation.mappings.forEach { expected -> + val sourcePath = joinPath(installer.basePath, expected.mapping.source) + val source = sourceResolver.resolve(sourcePath) + when { + source == null || !source.exists() -> missing += expected.missingFile(sourcePath) + expected.mapping.directory && !source.isDirectory -> missing += expected.missingFile(sourcePath) + !expected.mapping.directory && !source.isFile -> missing += expected.missingFile(sourcePath) + expected.mapping.directory -> { + val sourceRoot = source.canonicalFile + source.walkTopDown() + .filter { it.isFile } + .sortedBy { file -> file.relativeTo(sourceRoot).path.lowercase(Locale.ROOT) } + .forEach { file -> + val relative = file.canonicalFile.relativeTo(sourceRoot).path.replace(File.separatorChar, '/') + val destination = joinPath(targetRelativePath, expected.mapping.destination, relative) + expanded += expected.expanded(file, root, targetRoot, destination, mode) + } + } + else -> { + val destination = if (expected.mapping.destination.isBlank()) { + joinPath(targetRelativePath, source.name) + } else { + joinPath(targetRelativePath, expected.mapping.destination) + } + expanded += expected.expanded(source, root, targetRoot, destination, mode) + } + } + } + + val planned = expanded.map { it.file }.toMutableList() + expanded.groupBy { it.file.normalizedTargetKey }.filterKeys { it != null }.values.forEach { contenders -> + if (contenders.size < 2) return@forEach + val winner = contenders.maxWithOrNull( + compareBy { it.file.priority }.thenBy { it.ordinal }, + ) ?: return@forEach + contenders.filter { it !== winner }.forEach { loser -> + val index = planned.indexOf(loser.file) + planned[index] = loser.file.copy( + status = PlannedFileStatus.INTENTIONALLY_IGNORED, + reason = "Replaced by selected FOMOD file ${winner.file.sourceRelativePath}", + ) + } + } + planned += missing + return PlacementRiskPolicy.enforce(ModInstallPlan( + files = planned.sortedWith( + compareBy { it.normalizedTargetKey.orEmpty() } + .thenBy { it.sourceRelativePath.lowercase(Locale.ROOT) } + .thenByDescending { it.priority }, + ), + warnings = evaluation.warnings, + blockingIssues = evaluation.blockingIssues, + producerId = "fomod", + producerVersion = 1, + )) + } + + private data class ExpandedFomodFile( + val file: PlannedModFile, + val ordinal: Int, + ) + + private fun FomodExpectedMapping.expanded( + source: File, + extractedRoot: File, + targetRoot: String, + destination: String, + mode: String, + ): ExpandedFomodFile { + val targetKey = ModTargetResolver.normalizedTargetKey(targetRoot, destination) + return ExpandedFomodFile( + file = PlannedModFile( + sourceRelativePath = source.canonicalFile.relativeTo(extractedRoot).path.replace(File.separatorChar, '/'), + targetRoot = targetRoot, + targetRelativePath = destination, + normalizedTargetKey = targetKey, + status = if (targetKey == null) PlannedFileStatus.UNSUPPORTED else PlannedFileStatus.PLACED, + origin = origin, + priority = mapping.priority, + mode = mode, + sizeBytes = source.length(), + reason = "Selected FOMOD ${origin.name.lowercase(Locale.ROOT).replace('_', ' ')} mapping", + risk = if (targetKey == null) PlacementRisk.REVIEW else PlacementRisk.SAFE, + ), + ordinal = ordinal, + ) + } + + private fun FomodExpectedMapping.missingFile(sourcePath: String): PlannedModFile = PlannedModFile( + sourceRelativePath = sourcePath, + status = PlannedFileStatus.MISSING, + origin = origin, + priority = mapping.priority, + reason = "Selected FOMOD source is missing or has the wrong file type", + risk = PlacementRisk.REVIEW, + ) + + private class CaseInsensitiveSourceResolver( + private val root: File, + ) { + private val directoryListings = mutableMapOf>>() + private val resolvedPaths = mutableMapOf() + + fun resolve(relativePath: String): File? { + val key = normalizedArchiveKey(relativePath) ?: return null + if (resolvedPaths.containsKey(key)) return resolvedPaths[key] + val displaySegments = normalizeArchiveDisplayPath(relativePath).split('/').filter(String::isNotBlank) + if (key.split('/').size != displaySegments.size) return null + var current = root + displaySegments.forEach { segment -> + val children = directoryListings.getOrPut(current.path) { + current.listFiles().orEmpty().groupBy { it.name.lowercase(Locale.ROOT) } + } + val matches = children[segment.lowercase(Locale.ROOT)].orEmpty() + if (matches.size != 1) { + resolvedPaths[key] = null + return null + } + current = matches.single() + } + val candidate = runCatching { current.canonicalFile }.getOrNull() + ?.takeIf { it == root || it.path.startsWith(root.path + File.separator) } + resolvedPaths[key] = candidate + return candidate + } + } + + private fun joinPath(vararg paths: String): String = + paths.map(::normalizeArchiveDisplayPath).filter(String::isNotBlank).joinToString("/") +} diff --git a/app/src/main/java/app/gamenative/mods/FomodInstaller.kt b/app/src/main/java/app/gamenative/mods/FomodInstaller.kt index b389f9e5e3..0db3d05773 100644 --- a/app/src/main/java/app/gamenative/mods/FomodInstaller.kt +++ b/app/src/main/java/app/gamenative/mods/FomodInstaller.kt @@ -8,6 +8,7 @@ import org.w3c.dom.Node import timber.log.Timber import java.io.File import java.io.IOException +import java.util.Locale import javax.xml.parsers.DocumentBuilderFactory data class FomodInstaller( @@ -15,6 +16,7 @@ data class FomodInstaller( val requiredFiles: List, val steps: List, val conditionalFileInstalls: List = emptyList(), + val moduleDependencies: FomodDependencyExpression = FomodDependencyExpression(), val unsupportedWarnings: List = emptyList(), val basePath: String = "", ) @@ -60,18 +62,40 @@ data class FomodTypePattern( data class FomodDependencyExpression( val operator: FomodDependencyOperator = FomodDependencyOperator.AND, val flagDependencies: List = emptyList(), + val fileDependencies: List = emptyList(), + val pluginDependencies: List = emptyList(), + val gameDependencies: List = emptyList(), val childGroups: List = emptyList(), val unsupportedDependencyCount: Int = 0, ) { - fun matches(flags: Map): Boolean { + fun matches(flags: Map): Boolean = + evaluate(flags, FomodEnvironmentSnapshot()) == FomodFactState.TRUE + + fun evaluate(flags: Map, environment: FomodEnvironmentSnapshot): FomodFactState { val results = flagDependencies.map { dependency -> - flags[dependency.flag]?.equals(dependency.value, ignoreCase = true) == true - } + childGroups.map { it.matches(flags) } + if (flags[dependency.flag]?.equals(dependency.value, ignoreCase = true) == true) { + FomodFactState.TRUE + } else { + FomodFactState.FALSE + } + } + fileDependencies.map(environment::evaluate) + + pluginDependencies.map(environment::evaluate) + + gameDependencies.map(environment::evaluate) + + childGroups.map { it.evaluate(flags, environment) } + + List(unsupportedDependencyCount) { FomodFactState.UNKNOWN } - if (results.isEmpty()) return unsupportedDependencyCount == 0 + if (results.isEmpty()) return FomodFactState.TRUE return when (operator) { - FomodDependencyOperator.AND -> unsupportedDependencyCount == 0 && results.all { it } - FomodDependencyOperator.OR -> results.any { it } + FomodDependencyOperator.AND -> when { + FomodFactState.FALSE in results -> FomodFactState.FALSE + FomodFactState.UNKNOWN in results -> FomodFactState.UNKNOWN + else -> FomodFactState.TRUE + } + FomodDependencyOperator.OR -> when { + FomodFactState.TRUE in results -> FomodFactState.TRUE + FomodFactState.UNKNOWN in results -> FomodFactState.UNKNOWN + else -> FomodFactState.FALSE + } } } @@ -104,14 +128,29 @@ enum class FomodPluginType { COULD_BE_USABLE, } -fun FomodPlugin.effectiveType(flags: Map): FomodPluginType = - typePatterns.firstOrNull { it.dependencies.matches(flags) }?.type ?: type +fun FomodPlugin.effectiveType( + flags: Map, + environment: FomodEnvironmentSnapshot = FomodEnvironmentSnapshot(), +): FomodPluginType = typePatterns.firstOrNull { + it.dependencies.evaluate(flags, environment) == FomodFactState.TRUE +}?.type ?: type data class FomodRecipeGenerationResult( val recipes: List, - val unsupportedMappings: List, + val plan: ModInstallPlan? = null, + val blockingIssues: List = emptyList(), ) +data class FomodFileDependency(val file: String, val state: FomodRequiredFileState) + +data class FomodPluginDependency(val plugin: String, val state: FomodRequiredFileState) + +data class FomodGameDependency(val version: String) + +enum class FomodRequiredFileState { ACTIVE, INACTIVE, MISSING } + +enum class FomodFactState { TRUE, FALSE, UNKNOWN } + object FomodInstallerDetector { fun moduleConfigFile(extractedRoot: File): File? = extractedRoot.childIgnoreCase("fomod")?.childIgnoreCase("ModuleConfig.xml")?.takeIf { it.isFile } @@ -155,6 +194,7 @@ object FomodParser { ?.childElements("pattern") ?.mapNotNull { parseConditionalPattern(it) } .orEmpty() + val moduleDependencies = parseDependencies(root.firstChildElement("moduleDependencies")) val steps = root.firstChildElement("installSteps") ?.childElements("installStep") ?.map { step -> @@ -176,13 +216,14 @@ object FomodParser { ) } .orEmpty() - unsupported += unsupportedFeatureWarnings(root, conditionalFileInstalls, steps) + unsupported += unsupportedFeatureWarnings(root, moduleDependencies, conditionalFileInstalls, steps) return FomodInstaller( moduleName = root.firstChildElement("moduleName")?.textContent?.trim().orEmpty(), requiredFiles = root.firstChildElement("requiredInstallFiles")?.fileMappings().orEmpty(), steps = steps, conditionalFileInstalls = conditionalFileInstalls, + moduleDependencies = moduleDependencies, unsupportedWarnings = unsupported, basePath = basePath, ) @@ -221,6 +262,9 @@ object FomodParser { private fun parseDependencies(dependencies: Element?): FomodDependencyExpression { if (dependencies == null) return FomodDependencyExpression() val flagDependencies = mutableListOf() + val fileDependencies = mutableListOf() + val pluginDependencies = mutableListOf() + val gameDependencies = mutableListOf() val childGroups = mutableListOf() var unsupportedCount = 0 @@ -229,7 +273,35 @@ object FomodParser { child.tagName.equals("flagDependency", ignoreCase = true) -> { val flag = child.attr("flag").trim() val value = child.attr("value").trim() - if (flag.isBlank()) unsupportedCount++ else flagDependencies += FomodFlagDependency(flag, value) + if (flag.isBlank()) { + unsupportedCount++ + } else { + flagDependencies += FomodFlagDependency(flag, value) + } + } + child.tagName.equals("fileDependency", ignoreCase = true) -> { + val file = child.attr("file").trim().replace('\\', '/') + if (file.isBlank()) { + unsupportedCount++ + } else { + fileDependencies += FomodFileDependency(file, requiredFileState(child.attr("state"))) + } + } + child.tagName.equals("pluginDependency", ignoreCase = true) -> { + val plugin = child.attr("plugin").ifBlank { child.attr("file") }.trim() + if (plugin.isBlank()) { + unsupportedCount++ + } else { + pluginDependencies += FomodPluginDependency(plugin, requiredFileState(child.attr("state"))) + } + } + child.tagName.equals("gameDependency", ignoreCase = true) -> { + val version = child.attr("version").trim() + if (version.isBlank()) { + unsupportedCount++ + } else { + gameDependencies += FomodGameDependency(version) + } } child.tagName.equals("dependencies", ignoreCase = true) -> childGroups += parseDependencies(child) child.tagName.endsWith("Dependency", ignoreCase = true) -> unsupportedCount++ @@ -239,6 +311,9 @@ object FomodParser { return FomodDependencyExpression( operator = dependencyOperator(dependencies.attr("operator")), flagDependencies = flagDependencies, + fileDependencies = fileDependencies, + pluginDependencies = pluginDependencies, + gameDependencies = gameDependencies, childGroups = childGroups, unsupportedDependencyCount = unsupportedCount, ) @@ -258,18 +333,24 @@ object FomodParser { ) } - private fun groupType(value: String): FomodGroupType = when (value.lowercase()) { + private fun groupType(value: String): FomodGroupType = when (value.lowercase(Locale.ROOT)) { "selectexactlyone" -> FomodGroupType.SELECT_EXACTLY_ONE "selectatmostone" -> FomodGroupType.SELECT_AT_MOST_ONE "selectatleastone" -> FomodGroupType.SELECT_AT_LEAST_ONE else -> FomodGroupType.SELECT_ANY } - private fun dependencyOperator(value: String): FomodDependencyOperator = when (value.lowercase()) { + private fun dependencyOperator(value: String): FomodDependencyOperator = when (value.lowercase(Locale.ROOT)) { "or" -> FomodDependencyOperator.OR else -> FomodDependencyOperator.AND } + private fun requiredFileState(value: String): FomodRequiredFileState = when (value.lowercase(Locale.ROOT)) { + "inactive" -> FomodRequiredFileState.INACTIVE + "missing" -> FomodRequiredFileState.MISSING + else -> FomodRequiredFileState.ACTIVE + } + private fun pluginType(typeDescriptor: Element?): FomodPluginType { val direct = typeDescriptor?.firstChildElement("type")?.attr("name") val default = typeDescriptor @@ -294,7 +375,7 @@ object FomodParser { .orEmpty() private fun pluginTypeFromName(typeName: String?): FomodPluginType = - when (typeName.orEmpty().lowercase()) { + when (typeName.orEmpty().lowercase(Locale.ROOT)) { "required" -> FomodPluginType.REQUIRED "recommended" -> FomodPluginType.RECOMMENDED "notusable" -> FomodPluginType.NOT_USABLE @@ -373,8 +454,31 @@ object FomodRecipeGenerator { targetRoot: String = ModTargetRoot.GAME_DIR.name, targetRelativePath: String = "Data", mode: String = ModPlacementMode.OVERWRITE_COPY.name, + extractedRoot: File? = null, + environment: FomodEnvironmentSnapshot = FomodEnvironmentSnapshot(), ): FomodRecipeGenerationResult { - val selectedPlugins = selectedPluginsForKeys(installer, selectedPluginKeys) + if (extractedRoot != null) { + val evaluation = FomodSelectionEvaluator.evaluate(installer, selectedPluginKeys, environment) + val plan = FomodPlanExpander.expand(installer, evaluation, extractedRoot, targetRoot, targetRelativePath, mode) + val recipes = plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { file -> + val destination = file.targetRelativePath.orEmpty() + ModPlacementRecipe( + installId = installId, + sourceSubpath = file.sourceRelativePath, + targetRoot = file.targetRoot ?: targetRoot, + targetRelativePath = destination.substringBeforeLast('/', missingDelimiterValue = ""), + targetFileName = destination.substringAfterLast('/'), + mode = mode, + includeSourceDirectory = false, + ) + } + return FomodRecipeGenerationResult( + recipes = recipes.distinctBy(::recipeIdentity), + plan = plan, + blockingIssues = plan.blockingIssues, + ) + } + val selectedPlugins = selectedPluginsForKeys(installer, selectedPluginKeys, environment) val selectedFiles = selectedFiles(installer, selectedPlugins) return generateFromFiles(installId, installer.basePath, selectedFiles, targetRoot, targetRelativePath, mode) @@ -383,6 +487,7 @@ object FomodRecipeGenerator { fun selectedPluginsForKeys( installer: FomodInstaller, selectedPluginKeys: Set, + environment: FomodEnvironmentSnapshot = FomodEnvironmentSnapshot(), ): List { val pluginEntries = installer.steps.flatMapIndexed { stepIndex, step -> step.groups.flatMapIndexed { groupIndex, group -> @@ -401,7 +506,7 @@ object FomodRecipeGenerator { .flatMap { (_, plugin) -> plugin.conditionFlags.entries } .associate { it.key to it.value } val requiredKeys = pluginEntries - .filter { (_, plugin) -> plugin.effectiveType(flags) == FomodPluginType.REQUIRED } + .filter { (_, plugin) -> plugin.effectiveType(flags, environment) == FomodPluginType.REQUIRED } .mapTo(mutableSetOf()) { it.first } val next = includedKeys + requiredKeys if (next == includedKeys) break @@ -413,7 +518,7 @@ object FomodRecipeGenerator { .flatMap { (_, plugin) -> plugin.conditionFlags.entries } .associate { it.key to it.value } return pluginEntries.mapNotNull { (key, plugin) -> - val effectiveType = plugin.effectiveType(finalFlags) + val effectiveType = plugin.effectiveType(finalFlags, environment) if (effectiveType == FomodPluginType.REQUIRED || (key in selectedPluginKeys && effectiveType != FomodPluginType.NOT_USABLE)) { plugin } else { @@ -445,7 +550,6 @@ object FomodRecipeGenerator { mode: String, ): FomodRecipeGenerationResult { val recipes = mutableListOf() - val unsupported = mutableListOf() selectedFiles .sortedWith(compareBy { it.priority }.thenBy { it.source }) .forEach { mapping -> @@ -462,28 +566,26 @@ object FomodRecipeGenerator { return@forEach } - val sourceName = mapping.source.substringAfterLast('/') val destinationName = destination.substringAfterLast('/') - if (!sourceName.equals(destinationName, ignoreCase = true)) { - unsupported += mapping - return@forEach - } recipes += ModPlacementRecipe( installId = installId, sourceSubpath = joinPath(basePath, mapping.source), targetRoot = targetRoot, targetRelativePath = joinPath(targetRelativePath, destination.substringBeforeLast('/', missingDelimiterValue = "")), + targetFileName = destinationName, mode = mode, includeSourceDirectory = false, ) } return FomodRecipeGenerationResult( - recipes = recipes.distinctBy { Triple(it.sourceSubpath, it.targetRoot, it.targetRelativePath) }, - unsupportedMappings = unsupported, + recipes = recipes.distinctBy(::recipeIdentity), ) } + private fun recipeIdentity(recipe: ModPlacementRecipe): List = + listOf(recipe.sourceSubpath, recipe.targetRoot, recipe.targetRelativePath, recipe.targetFileName) + private fun joinPath(left: String, right: String): String = listOf(left, right) .map { it.trim().trim('/', '\\') } @@ -493,6 +595,7 @@ object FomodRecipeGenerator { private fun unsupportedFeatureWarnings( root: Element, + moduleDependencies: FomodDependencyExpression, conditionalFileInstalls: List, steps: List, ): List = @@ -513,11 +616,8 @@ private fun unsupportedFeatureWarnings( if (unsupportedTypeRules > 0) { add("Some FOMOD option availability rules need manual review") } - if (root.getElementsByTagName("moduleDependencies").length > 0) { - add("FOMOD module dependency rules need manual placement") - } - if (root.getElementsByTagName("fileDependency").length > 0) { - add("Some FOMOD file dependency rules need manual placement") + if (moduleDependencies.unsupportedCount() > 0) { + add("Some FOMOD module requirements need manual review") } }.distinct() diff --git a/app/src/main/java/app/gamenative/mods/GenericOptionSetDetector.kt b/app/src/main/java/app/gamenative/mods/GenericOptionSetDetector.kt new file mode 100644 index 0000000000..3c3b907c54 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/GenericOptionSetDetector.kt @@ -0,0 +1,135 @@ +package app.gamenative.mods + +import java.util.Locale + +data class GenericOptionChoice( + val sourceDirectory: String, + val overlappingTargetCount: Int, +) + +data class GenericOptionGroup( + val stableId: String, + val choices: List, + val commonSourceDirectories: List, + val reason: String, +) + +object GenericOptionSetDetector { + private val installBoundaryPrefixes = buildSet { + add("data") + ModPlacementRulePacks.builtIns + .flatMap { it.directoryTargets.values } + .mapTo(this) { normalizeArchiveDisplayPath(it).lowercase(Locale.ROOT) } + } + + fun detect(index: ModArchiveIndex): List { + if (index.hasFomod) return emptyList() + val directories = index.nodes.filter { it.descendantFileCount > 0 } + val siblingsByParent = directories.groupBy { node -> node.normalizedKey.substringBeforeLast('/', "") } + val detected = siblingsByParent.values.flatMap { siblings -> detectSiblingGroups(index, siblings) } + .sortedWith(compareBy { it.choices.first().sourceDirectory.count { char -> char == '/' } }.thenBy { it.stableId }) + val accepted = mutableListOf() + detected.forEach { group -> + val nestedInsideAcceptedChoice = accepted.any { outer -> + outer.choices.any { choice -> + group.choices.all { nested -> + val path = normalizeArchiveDisplayPath(nested.sourceDirectory) + val root = normalizeArchiveDisplayPath(choice.sourceDirectory) + path.equals(root, ignoreCase = true) || path.startsWith("$root/", ignoreCase = true) + } + } + } + if (!nestedInsideAcceptedChoice) accepted += group + } + return accepted + } + + private fun detectSiblingGroups( + index: ModArchiveIndex, + siblings: List, + ): List { + if (siblings.size < 2) return emptyList() + val signatures = siblings.associateWith { node -> + index.filesUnder(node.displayPath) + .filter { it.role.participatesInAutomaticPlacement() } + .mapTo(mutableSetOf()) { file -> + file.normalizedKey.removePrefix("${node.normalizedKey}/") + } + } + val versionChoices = siblings.filter { + signatures.getValue(it).isNotEmpty() && it.displayPath.substringAfterLast('/').isVersionChoiceName() + } + .mapTo(mutableSetOf()) { it.normalizedKey } + val versionFamilyHasEvidence = versionChoices.size >= 3 || siblings.any { root -> + root.normalizedKey in versionChoices && siblings.any { other -> + other != root && other.normalizedKey in versionChoices && + signatures.getValue(root).intersect(signatures.getValue(other)).isNotEmpty() + } + } + val parentLooksLikeOptionContainer = siblings.first().normalizedKey + .substringBeforeLast('/', "") + .substringAfterLast('/') + .let { parent -> + listOf("option", "variant", "choose", "choice", "pick one").any(parent::contains) + } + val installBoundaries = siblings.associateWith { root -> + signatures.getValue(root).mapNotNullTo(mutableSetOf()) { relative -> + installBoundaryPrefixes.firstOrNull { boundary -> + relative == boundary || relative.startsWith("$boundary/") + } + } + } + val related = siblings.associateWith { root -> + siblings.filter { other -> + if (root == other) return@filter false + val versionAlternatives = versionFamilyHasEvidence && + root.normalizedKey in versionChoices && other.normalizedKey in versionChoices + val wrapperEvidence = root.optionStyleWrapper || + other.optionStyleWrapper || + parentLooksLikeOptionContainer || + installBoundaries.getValue(root).intersect(installBoundaries.getValue(other)).isNotEmpty() + if (versionAlternatives) return@filter true + if (!wrapperEvidence) return@filter false + val rootSignature = signatures.getValue(root) + val otherSignature = signatures.getValue(other) + val overlap = rootSignature.intersect(otherSignature).size + val smaller = minOf(rootSignature.size, otherSignature.size).coerceAtLeast(1) + overlap > 0 && overlap.toDouble() / smaller >= 0.6 + } + } + val visited = mutableSetOf() + val groups = mutableListOf>() + siblings.forEach { root -> + if (!visited.add(root.normalizedKey)) return@forEach + val component = mutableListOf(root) + val queue = ArrayDeque(related.getValue(root)) + while (queue.isNotEmpty()) { + val next = queue.removeFirst() + if (!visited.add(next.normalizedKey)) continue + component += next + queue.addAll(related.getValue(next)) + } + if (component.size > 1) groups += component + } + val optionRootKeys = groups.flatten().mapTo(mutableSetOf()) { it.normalizedKey } + val common = siblings.filter { it.normalizedKey !in optionRootKeys }.map { it.displayPath }.sorted() + return groups.map { choices -> + GenericOptionGroup( + stableId = choices.map { it.normalizedKey }.sorted().joinToString("|").hashCode().toUInt().toString(16), + choices = choices.sortedBy { it.normalizedKey }.map { root -> + val peers = choices.filter { it != root } + GenericOptionChoice( + sourceDirectory = root.displayPath, + overlappingTargetCount = + peers.maxOfOrNull { signatures.getValue(root).intersect(signatures.getValue(it)).size } ?: 0, + ) + }, + commonSourceDirectories = common, + reason = "Sibling folders contain competing files for the same normalized targets", + ) + }.sortedBy { it.choices.first().sourceDirectory.lowercase(Locale.ROOT) } + } + + private fun String.isVersionChoiceName(): Boolean = + matches(Regex("^v?\\d+(?:[._-]\\d+)+(?:[-_ ].*)?$", RegexOption.IGNORE_CASE)) +} diff --git a/app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt b/app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt index f92179943b..f5f057a283 100644 --- a/app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt +++ b/app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt @@ -371,6 +371,8 @@ object ModArchiveExtractor { return when { message.contains("encrypted", ignoreCase = true) -> UnsupportedModArchiveException("Encrypted $label archives are not supported") + message.contains("checksum", ignoreCase = true) -> + IOException("$label archive is damaged or incomplete (checksum validation failed). Retry the download.", error) message.contains("multi-volume", ignoreCase = true) || message.contains("multi volume", ignoreCase = true) || message.contains("volume", ignoreCase = true) -> diff --git a/app/src/main/java/app/gamenative/mods/ModArchiveIndex.kt b/app/src/main/java/app/gamenative/mods/ModArchiveIndex.kt new file mode 100644 index 0000000000..96da067cf4 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModArchiveIndex.kt @@ -0,0 +1,247 @@ +package app.gamenative.mods + +import java.util.Locale + +enum class ArchiveContentRole { + INSTALLABLE, + DOCUMENTATION, + METADATA, + INSTALLER_SUPPORT, + RISKY_ROOT, + INVALID, +} + +data class IndexedArchiveFile( + val displayPath: String, + val normalizedKey: String, + val sizeBytes: Long, + val role: ArchiveContentRole, +) + +data class ArchiveTreeNode( + val displayPath: String, + val normalizedKey: String, + val descendantFileCount: Int, + val descendantBytes: Long, + val semanticAnchors: Set, + val optionStyleWrapper: Boolean, +) + +data class ModArchiveIndex( + val files: List, + val nodes: List, + val caseCollisions: Map>, +) { + val hasFomod: Boolean + get() = files.any { it.normalizedKey.endsWith("fomod/moduleconfig.xml") } + + fun filesUnder(sourcePath: String): List { + val key = normalizedArchiveKey(sourcePath) ?: return emptyList() + if (key.isBlank()) return files + val prefix = "$key/" + val exactStart = lowerBound(key) + val prefixStart = lowerBound(prefix) + val result = mutableListOf() + var exactEnd = exactStart + while (exactEnd < files.size && files[exactEnd].normalizedKey == key) { + result += files[exactEnd] + exactEnd++ + } + var end = prefixStart + while (end < files.size && files[end].normalizedKey.startsWith(prefix)) { + result += files[end] + end++ + } + return result + } + + private fun lowerBound(key: String): Int { + var low = 0 + var high = files.size + while (low < high) { + val middle = (low + high) ushr 1 + if (files[middle].normalizedKey < key) low = middle + 1 else high = middle + } + return low + } + + fun isDirectory(sourcePath: String): Boolean { + val key = normalizedArchiveKey(sourcePath) ?: return false + return nodes.binarySearchBy(key) { it.normalizedKey } >= 0 + } + + companion object { + private val semanticAnchors = ModPlacementRulePacks.archiveSemanticAnchors + private val managerMetadataDirectories = setOf("bashtags", "omod conversion data") + private val documentationDirectories = setOf( + "doc", + "docs", + "documentation", + "help", + "manual", + "manuals", + "readmes", + "screenshot", + "screenshots", + ) + private val documentationNamePrefixes = setOf( + "readme", + "changelog", + "changes", + "license", + "copying", + "credits", + "authors", + "install instructions", + "installation instructions", + ) + private val documentationExtensions = setOf("htm", "html", "md", "pdf", "rtf", "txt") + private val documentationBoundaries = setOf(' ', '_', '-', '.') + + fun build(entries: List): ModArchiveIndex { + val indexedFiles = entries.asSequence() + .filterNot { it.directory } + .map { entry -> + val display = normalizeArchiveDisplayPath(entry.path) + val key = normalizedArchiveKey(entry.path) + IndexedArchiveFile( + displayPath = display.ifBlank { entry.path }, + normalizedKey = key.orEmpty(), + sizeBytes = entry.sizeBytes.coerceAtLeast(0L), + role = if (key == null) ArchiveContentRole.INVALID else classify(display), + ) + } + .sortedWith(compareBy { it.normalizedKey }.thenBy { it.displayPath }) + .toList() + val displayPaths = mutableMapOf() + val counts = mutableMapOf() + val bytes = mutableMapOf() + val anchors = mutableMapOf>() + entries.asSequence().filter { it.directory }.forEach { entry -> + val display = normalizeArchiveDisplayPath(entry.path) + val key = normalizedArchiveKey(display) + if (display.isNotBlank() && key != null) displayPaths.rememberDisplayPath(key, display) + } + indexedFiles.forEach { file -> + val displaySegments = file.displayPath.split('/') + val keySegments = file.normalizedKey.split('/') + val fileAnchors = keySegments.filterTo(mutableSetOf()) { it in semanticAnchors } + val keyBuilder = StringBuilder() + val displayBuilder = StringBuilder() + (0 until keySegments.lastIndex).forEach { index -> + if (index > 0) { + keyBuilder.append('/') + displayBuilder.append('/') + } + keyBuilder.append(keySegments[index]) + displayBuilder.append(displaySegments[index]) + val key = keyBuilder.toString() + val display = displayBuilder.toString() + displayPaths.rememberDisplayPath(key, display) + counts[key] = counts.getOrDefault(key, 0) + 1 + bytes[key] = bytes.getOrDefault(key, 0L) + file.sizeBytes + anchors.getOrPut(key, ::mutableSetOf).addAll(fileAnchors) + } + } + val nodes = displayPaths.entries + .sortedBy { it.key } + .map { (key, display) -> + ArchiveTreeNode( + displayPath = display, + normalizedKey = key, + descendantFileCount = counts.getOrDefault(key, 0), + descendantBytes = bytes.getOrDefault(key, 0L), + semanticAnchors = anchors[key].orEmpty(), + optionStyleWrapper = looksLikeOptionWrapper(display.substringAfterLast('/')), + ) + } + return ModArchiveIndex( + files = indexedFiles, + nodes = nodes, + caseCollisions = indexedFiles.filter { it.normalizedKey.isNotBlank() } + .groupBy { it.normalizedKey } + .filterValues { variants -> variants.map { it.displayPath }.distinct().size > 1 } + .mapValues { (_, variants) -> variants.map { it.displayPath }.distinct().sorted() }, + ) + } + + private fun classify(path: String): ArchiveContentRole { + val normalized = path.lowercase(Locale.ROOT) + val segments = normalized.split('/') + val name = normalized.substringAfterLast('/') + if (normalized.startsWith("__macosx/") || name in setOf(".ds_store", "thumbs.db", "desktop.ini")) { + return ArchiveContentRole.METADATA + } + if (segments.dropLast(1).any { it in managerMetadataDirectories }) { + return ArchiveContentRole.METADATA + } + if (normalized.contains("/fomod/") || normalized.startsWith("fomod/")) { + return ArchiveContentRole.INSTALLER_SUPPORT + } + val rootDocumentation = + segments.size == 1 && listOf(".htm", ".html", ".rtf").any(name::endsWith) + if ( + segments.dropLast(1).any(::isDocumentationDirectory) || + looksLikeDocumentationFileName(name) || + name.endsWith(".md") || + name.endsWith(".pdf") || + rootDocumentation + ) { + return ArchiveContentRole.DOCUMENTATION + } + if (!normalized.contains('/') && listOf(".dll", ".asi", ".exe", ".ini", ".bat", ".cmd", ".ps1", ".msi").any(name::endsWith)) { + return ArchiveContentRole.RISKY_ROOT + } + return ArchiveContentRole.INSTALLABLE + } + + private fun isDocumentationDirectory(segment: String): Boolean { + val name = segment.trim(' ', '_', '-', '.') + return name in documentationDirectories || + listOf("readme", "documentation", "manual", "screenshot").any { prefix -> hasPrefixAtBoundary(name, prefix) } + } + + private fun looksLikeDocumentationFileName(name: String): Boolean { + val extension = name.substringAfterLast('.', "") + return documentationNamePrefixes.any { prefix -> + hasPrefixAtBoundary(name, prefix) || + (name.startsWith(prefix) && extension in documentationExtensions) + } + } + + private fun hasPrefixAtBoundary(value: String, prefix: String): Boolean = + value.startsWith(prefix) && (value.length == prefix.length || value[prefix.length] in documentationBoundaries) + + private fun looksLikeOptionWrapper(name: String): Boolean { + val normalized = name.lowercase(Locale.ROOT) + return Regex("^\\d{1,2}[ _.-]").containsMatchIn(normalized) || + Regex("^v?\\d+(?:[._-]\\d+)+(?:[-_ ].*)?$").matches(normalized) || + listOf("optional", "option", "variant", "choose", "pick one").any(normalized::contains) + } + + private fun stableDisplayPath(left: String, right: String): String = + minOf(left, right, compareBy { it.lowercase(Locale.ROOT) }.thenBy { it }) + + private fun MutableMap.rememberDisplayPath(key: String, display: String) { + val existing = this[key] + when { + existing == null -> this[key] = display + existing != display -> this[key] = stableDisplayPath(existing, display) + } + } + } +} + +internal fun ArchiveContentRole.participatesInAutomaticPlacement(): Boolean = + this == ArchiveContentRole.INSTALLABLE || this == ArchiveContentRole.RISKY_ROOT + +internal fun normalizeArchiveDisplayPath(path: String): String = + path.trim().replace('\\', '/').split('/').filter { it.isNotBlank() && it != "." }.joinToString("/") + +internal fun normalizedArchiveKey(path: String): String? { + val normalized = path.trim().replace('\\', '/') + if (normalized.startsWith('/') || Regex("^[A-Za-z]:").containsMatchIn(normalized)) return null + val segments = normalized.split('/').filter(String::isNotBlank) + if (segments.any { it == "." || it == ".." }) return null + return segments.joinToString("/") { it.lowercase(Locale.ROOT) } +} diff --git a/app/src/main/java/app/gamenative/mods/ModConfigurationDraft.kt b/app/src/main/java/app/gamenative/mods/ModConfigurationDraft.kt new file mode 100644 index 0000000000..b166adb146 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModConfigurationDraft.kt @@ -0,0 +1,109 @@ +package app.gamenative.mods + +import app.gamenative.data.ModInstall +import app.gamenative.data.ModPlacementMode +import app.gamenative.data.ModPlacementRecipe +import app.gamenative.data.ModTargetRoot +import java.io.File +import java.io.FileOutputStream +import java.util.Locale +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class ModConfigurationDraft( + val version: Int = 1, + val installId: String, + val archiveIdentity: String, + val placementChoice: String, + val automaticOptions: Map = emptyMap(), + val riskyTargetsApproved: Boolean = false, + val fomodSelections: Map> = emptyMap(), + val recipes: List = emptyList(), + val updatedAt: Long = System.currentTimeMillis(), +) + +@Serializable +data class ModConfigurationRecipe( + val sourceSubpath: String = "", + val targetRoot: String = ModTargetRoot.GAME_DIR.name, + val targetRelativePath: String = "", + val targetFileName: String = "", + val mode: String = ModPlacementMode.SYMLINK.name, + val stripPrefixSegments: Int = 0, + val includeSourceDirectory: Boolean = false, + val enabled: Boolean = true, +) { + fun toRecipe(installId: String) = ModPlacementRecipe( + installId = installId, + sourceSubpath = sourceSubpath, + targetRoot = targetRoot, + targetRelativePath = targetRelativePath, + targetFileName = targetFileName, + mode = mode, + stripPrefixSegments = stripPrefixSegments, + includeSourceDirectory = includeSourceDirectory, + enabled = enabled, + ) + + companion object { + fun from(recipe: ModPlacementRecipe) = ModConfigurationRecipe( + sourceSubpath = recipe.sourceSubpath, + targetRoot = recipe.targetRoot, + targetRelativePath = recipe.targetRelativePath, + targetFileName = recipe.targetFileName, + mode = recipe.mode, + stripPrefixSegments = recipe.stripPrefixSegments, + includeSourceDirectory = recipe.includeSourceDirectory, + enabled = recipe.enabled, + ) + } +} + +object ModConfigurationDraftStore { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + fun archiveIdentity(install: ModInstall): String = install.archiveSha256.ifBlank { + listOf(install.fileName.lowercase(Locale.ROOT), install.sizeBytes.toString(), install.nexusFileId?.toString().orEmpty()) + .joinToString("|") + } + + fun read(root: File, install: ModInstall): ModConfigurationDraft? { + val file = file(root, install.installId) + if (!file.isFile) return null + val draft = runCatching { json.decodeFromString(file.readText()) }.getOrNull() + ?: return null + return draft.takeIf { + it.installId == install.installId && it.archiveIdentity == archiveIdentity(install) + } + } + + fun write(root: File, draft: ModConfigurationDraft): Boolean { + val target = file(root, draft.installId) + val temp = File(target.parentFile, "${target.name}.tmp") + return runCatching { + target.parentFile?.mkdirs() + FileOutputStream(temp).use { output -> + output.write(json.encodeToString(draft.copy(updatedAt = System.currentTimeMillis())).toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + replaceWithPreparedFile(temp, target) + }.fold( + onSuccess = { true }, + onFailure = { + temp.delete() + false + }, + ) + } + + fun delete(root: File, installId: String) { + file(root, installId).delete() + File(file(root, installId).parentFile, "${file(root, installId).name}.tmp").delete() + } + + private fun file(root: File, installId: String): File = + File(File(root, "configuration"), "${installId.replace(Regex("[^A-Za-z0-9._-]"), "_")}.json") +} diff --git a/app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt b/app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt index b8dfa280fe..c64fe0b7de 100644 --- a/app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt +++ b/app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt @@ -1,6 +1,7 @@ package app.gamenative.mods import app.gamenative.data.ModInstall +import app.gamenative.data.ModInstallStatus import app.gamenative.data.ModPlacementRecipe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -29,13 +30,36 @@ object ModConflictAnalyzer { prioritiesByInstallId: Map, gameRootDir: File?, winePrefix: String, + ownershipByInstallId: Map = emptyMap(), ): List = withContext(Dispatchers.IO) { val installById = installs.associateBy { it.installId } val plannedFiles = installs.flatMap { install -> val recipes = recipesByInstallId[install.installId].orEmpty() + val ownership = ownershipByInstallId[install.installId] + ?.takeIf { install.status == ModInstallStatus.APPLIED.name && it.state == ModOwnershipState.ACTIVE } + if (ownership != null) { + return@flatMap ownership.files + .filter { it.active } + .map { file -> + PlannedFile( + installId = install.installId, + source = File(install.extractedPath, file.sourceRelativePath), + target = File(file.targetPath), + ) + } + } runCatching { - ModMaterializer.plannedEntries(install, recipes, gameRootDir, winePrefix) - .flatMap { it.toPlannedFiles() } + val plan = ModMaterializer.materializationPlan( + install, + recipes, + gameRootDir, + winePrefix, + captureTargetHashes = false, + ) + check(plan.isComplete) { plan.errors.values.joinToString() } + plan.files.map { file -> + PlannedFile(file.installId, file.source, file.target) + } }.getOrElse { error -> Timber.w(error, "Skipping Nexus conflict analysis for install %s", install.installId) emptyList() @@ -43,9 +67,9 @@ object ModConflictAnalyzer { } plannedFiles - .groupBy { it.target.safeCanonicalPath() } + .groupBy { WindowsPathIdentity.absoluteKey(it.target) } .filterValues { it.map { file -> file.installId }.distinct().size > 1 } - .map { (targetPath, files) -> + .map { (_, files) -> val sorted = files.sortedWith( compareByDescending { prioritiesByInstallId[it.installId] ?: 0 } .thenByDescending { installById[it.installId]?.updatedAt ?: 0L } @@ -53,8 +77,8 @@ object ModConflictAnalyzer { ) val winner = sorted.first() ModFileConflictReport( - targetPath = targetPath, - targetRelativePath = relativeTargetPath(targetPath, gameRootDir, winePrefix), + targetPath = winner.target.absolutePath, + targetRelativePath = relativeTargetPath(winner.target.absolutePath, gameRootDir, winePrefix), winnerInstallId = winner.installId, participants = sorted.map { file -> val install = installById[file.installId] @@ -77,21 +101,6 @@ object ModConflictAnalyzer { val target: File, ) - private fun ModPlannedEntry.toPlannedFiles(): List { - if (source.isFile) { - return listOf(PlannedFile(installId, source, target)) - } - if (!source.isDirectory) return emptyList() - val sourceRoot = source.canonicalFile - return source.walkTopDown() - .filter { it.isFile } - .mapNotNull { file -> - val relative = file.canonicalFile.relativeToOrNull(sourceRoot)?.path ?: return@mapNotNull null - PlannedFile(installId, file, File(target, relative)) - } - .toList() - } - private fun relativeTargetPath(path: String, gameRootDir: File?, winePrefix: String): String { val target = File(path) val roots = ModTargetResolver.roots(gameRootDir, winePrefix) @@ -109,7 +118,4 @@ object ModConflictAnalyzer { val fileCanonical = canonicalFile fileCanonical == rootCanonical || fileCanonical.path.startsWith(rootCanonical.path + File.separator) }.getOrDefault(false) - - private fun File.safeCanonicalPath(): String = - runCatching { canonicalFile.absolutePath }.getOrDefault(absolutePath) } diff --git a/app/src/main/java/app/gamenative/mods/ModDeploymentCoordinator.kt b/app/src/main/java/app/gamenative/mods/ModDeploymentCoordinator.kt new file mode 100644 index 0000000000..c814a55c7b --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModDeploymentCoordinator.kt @@ -0,0 +1,36 @@ +package app.gamenative.mods + +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +object ModDeploymentCoordinator { + private data class Entry(val mutex: Mutex = Mutex(), var users: Int = 0) + private class HeldGameLocks(val keys: Set) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + } + + private val entries = mutableMapOf() + + suspend fun withGameLock(appId: String, block: suspend () -> T): T { + val key = appId.ifBlank { "unknown-game" } + val held = currentCoroutineContext()[HeldGameLocks] + if (key in held?.keys.orEmpty()) return block() + val entry = synchronized(entries) { + entries.getOrPut(key, ::Entry).also { it.users++ } + } + return try { + entry.mutex.withLock { + withContext(HeldGameLocks(held?.keys.orEmpty() + key)) { block() } + } + } finally { + synchronized(entries) { + entry.users-- + if (entry.users == 0) entries.remove(key, entry) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/mods/ModDeploymentJournal.kt b/app/src/main/java/app/gamenative/mods/ModDeploymentJournal.kt new file mode 100644 index 0000000000..8a0d485f1f --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModDeploymentJournal.kt @@ -0,0 +1,127 @@ +package app.gamenative.mods + +import java.io.File +import java.io.FileOutputStream +import java.util.UUID +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +enum class ModDeploymentCheckpoint { + PLANNED, + PREPARING, + APPLYING, + VERIFYING, + COMMITTED, + ROLLING_BACK, + ROLLED_BACK, + RECOVERY_REQUIRED, +} + +@Serializable +data class ModDeploymentJournal( + val version: Int = 1, + val operationId: String = UUID.randomUUID().toString(), + val installId: String, + val appId: String, + val planDigest: String, + val targetCount: Int, + val checkpoint: ModDeploymentCheckpoint = ModDeploymentCheckpoint.PLANNED, + val detail: String = "", + val startedAt: Long = System.currentTimeMillis(), + val updatedAt: Long = startedAt, +) + +object ModDeploymentJournalStore { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun begin(root: File, installId: String, appId: String, plan: ModMaterializationPlan): ModDeploymentJournal { + val journal = ModDeploymentJournal( + installId = installId, + appId = appId, + planDigest = plan.digest, + targetCount = plan.files.size, + ) + write(root, journal) + return journal + } + + fun checkpoint( + root: File, + journal: ModDeploymentJournal, + checkpoint: ModDeploymentCheckpoint, + detail: String = "", + ): ModDeploymentJournal = journal.copy( + checkpoint = checkpoint, + detail = detail.take(512), + updatedAt = System.currentTimeMillis(), + ).also { write(root, it) } + + fun read(root: File, installId: String): ModDeploymentJournal? = readFile(journalFile(root, installId)) + + fun readAll(root: File): List = + journalDir(root).listFiles().orEmpty().filter { it.isFile && it.extension == "json" }.mapNotNull(::readFile) + + fun delete(root: File, installId: String) { + val current = journalFile(root, installId) + current.delete() + File(current.parentFile, "${current.name}.tmp").delete() + } + + fun reconcile(root: File): List = readAll(root).map { journal -> + when (journal.checkpoint) { + ModDeploymentCheckpoint.PLANNED, + ModDeploymentCheckpoint.PREPARING, + -> checkpoint( + root, + journal, + ModDeploymentCheckpoint.ROLLED_BACK, + "Recovered before filesystem mutation", + ) + ModDeploymentCheckpoint.VERIFYING -> { + val ownership = ModOwnershipStore.read(root, journal.installId) + if ( + ownership?.planDigest == journal.planDigest && + ModDeploymentVerifier.verify(ownership).issues.isEmpty() + ) { + checkpoint(root, journal, ModDeploymentCheckpoint.COMMITTED, "Recovered verified deployment") + } else { + checkpoint(root, journal, ModDeploymentCheckpoint.RECOVERY_REQUIRED, "Deployment state needs review") + } + } + ModDeploymentCheckpoint.APPLYING, + ModDeploymentCheckpoint.ROLLING_BACK, + -> checkpoint( + root, + journal, + ModDeploymentCheckpoint.RECOVERY_REQUIRED, + "Filesystem mutation may have been interrupted", + ) + else -> journal + } + } + + private fun write(root: File, journal: ModDeploymentJournal) { + val current = journalFile(root, journal.installId) + val temp = File(current.parentFile, "${current.name}.tmp") + current.parentFile?.mkdirs() + FileOutputStream(temp).use { output -> + output.write(json.encodeToString(journal).toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + replaceWithPreparedFile(temp, current) + } + + private fun readFile(file: File): ModDeploymentJournal? = + if (!file.isFile) null else runCatching { json.decodeFromString(file.readText()) }.getOrNull() + + private fun journalDir(root: File): File = File(root, "journals") + + private fun journalFile(root: File, installId: String): File = + File(journalDir(root), "${installId.replace(Regex("[^A-Za-z0-9._-]"), "_")}.json") +} diff --git a/app/src/main/java/app/gamenative/mods/ModDeploymentVerifier.kt b/app/src/main/java/app/gamenative/mods/ModDeploymentVerifier.kt new file mode 100644 index 0000000000..9ea6c8bf16 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModDeploymentVerifier.kt @@ -0,0 +1,210 @@ +package app.gamenative.mods + +import java.io.File +import java.nio.file.Files +import java.util.Locale + +enum class ModVerificationIssueType { + MISSING, + MODIFIED, + WRONG_CASE, + AMBIGUOUS, + STALE, + OWNERSHIP, +} + +data class ModVerificationIssue( + val type: ModVerificationIssueType, + val targetPath: String, + val detail: String, + val installId: String = "", +) + +data class ModDeploymentVerification(val issues: List) { + val successful: Boolean get() = issues.isEmpty() +} + +enum class ModVerificationDepth { + FULL, + CHANGED_CONTENT, +} + +object ModDeploymentVerifier { + fun verify(plan: ModMaterializationPlan): ModDeploymentVerification { + val session = VerificationSession(ModVerificationDepth.FULL) + return ModDeploymentVerification( + plan.files.flatMap { file -> + session.verifyTarget( + target = file.target, + expectedHash = ModOwnershipStore.sha256(file.source), + expectedSize = file.source.length(), + expectedMtime = 0L, + stale = false, + installId = file.installId, + ) + }, + ) + } + + fun verify( + manifest: ModOwnershipManifest, + depth: ModVerificationDepth = ModVerificationDepth.FULL, + ): ModDeploymentVerification { + val session = VerificationSession(depth) + return ModDeploymentVerification( + manifest.files.flatMap { file -> + session.verifyTarget( + target = File(file.targetPath), + expectedHash = file.installedHash, + expectedSize = file.installedSize, + expectedMtime = file.installedMtime, + stale = !file.active, + installId = manifest.installId, + ) + }, + ) + } + + fun verify( + overlay: ModProfileOverlay, + depth: ModVerificationDepth = ModVerificationDepth.FULL, + ): ModDeploymentVerification { + val session = VerificationSession(depth) + return ModDeploymentVerification( + overlay.targets.values.flatMap { target -> + session.verifyTarget( + target = File(target.winner.file.targetPath), + expectedHash = target.winner.file.installedHash, + expectedSize = target.winner.file.installedSize, + expectedMtime = target.winner.file.installedMtime, + stale = false, + installId = target.winner.installId, + ) + if (target.hasCaseCollision) { + listOf( + ModVerificationIssue( + ModVerificationIssueType.AMBIGUOUS, + target.winner.file.targetPath, + "Enabled mods use case-variant target paths", + target.winner.installId, + ), + ) + } else { + emptyList() + } + }, + ) + } + + fun verifyPresence(manifest: ModOwnershipManifest): ModDeploymentVerification { + val session = VerificationSession(ModVerificationDepth.CHANGED_CONTENT) + return ModDeploymentVerification( + manifest.files + .asSequence() + .filter { it.active } + .flatMap { file -> session.verifyPresence(File(file.targetPath), manifest.installId).asSequence() } + .toList(), + ) + } + + fun verifyStale(manifest: ModOwnershipManifest): ModDeploymentVerification { + val session = VerificationSession(ModVerificationDepth.CHANGED_CONTENT) + return ModDeploymentVerification( + manifest.files + .asSequence() + .filter { !it.active && it.disposition == ModOwnedFileDisposition.STALE_PRESERVED } + .flatMap { file -> session.verifyStale(File(file.targetPath), manifest.installId).asSequence() } + .toList(), + ) + } + + private class VerificationSession(private val depth: ModVerificationDepth) { + private val childrenByParent = mutableMapOf>>() + + fun verifyTarget( + target: File, + expectedHash: String, + expectedSize: Long, + expectedMtime: Long, + stale: Boolean, + installId: String, + ): List { + val resolution = resolveTarget(target, installId) + resolution.issue?.let { return listOf(it) } + val actual = resolution.actual + if (actual == null) { + return if (stale) { + emptyList() + } else { + listOf(issue(ModVerificationIssueType.MISSING, target, "Required planned file is missing", installId)) + } + } + if (stale) { + return listOf(issue(ModVerificationIssueType.STALE, actual, "A preserved stale managed file is still present", installId)) + } + val issues = mutableListOf() + if (actual.name != target.name) { + issues += issue(ModVerificationIssueType.WRONG_CASE, actual, "Target exists with unexpected casing", installId) + } + if (!contentMatches(actual, expectedHash, expectedSize, expectedMtime)) { + issues += issue(ModVerificationIssueType.MODIFIED, actual, "Target content differs from the reviewed plan", installId) + } + return issues + } + + fun verifyPresence(target: File, installId: String): List { + val resolution = resolveTarget(target, installId) + resolution.issue?.let { return listOf(it) } + return if (resolution.actual == null) { + listOf(issue(ModVerificationIssueType.MISSING, target, "Required planned file is missing", installId)) + } else { + emptyList() + } + } + + fun verifyStale(target: File, installId: String): List { + val resolution = resolveTarget(target, installId) + resolution.issue?.let { return listOf(it) } + return resolution.actual?.let { + listOf(issue(ModVerificationIssueType.STALE, it, "A preserved stale managed file is still present", installId)) + }.orEmpty() + } + + private fun contentMatches(actual: File, expectedHash: String, expectedSize: Long, expectedMtime: Long): Boolean { + if (!actual.isFile || expectedHash.isBlank()) return false + if (depth == ModVerificationDepth.CHANGED_CONTENT) { + if (expectedSize >= 0L && actual.length() != expectedSize) return false + if (expectedMtime > 0L && actual.lastModified() == expectedMtime) return true + } + return ModOwnershipStore.sha256(actual) == expectedHash + } + + private fun resolveTarget(target: File, installId: String): TargetResolution { + val caseMatches = caseMatches(target) + if (caseMatches.size > 1) { + return TargetResolution( + issue = issue(ModVerificationIssueType.AMBIGUOUS, target, "Multiple case variants exist", installId), + ) + } + if (target.exists() || Files.isSymbolicLink(target.toPath())) return TargetResolution(target) + return TargetResolution(caseMatches.singleOrNull()) + } + + private fun caseMatches(target: File): List { + val parent = target.parentFile ?: return emptyList() + val children = childrenByParent.getOrPut(parent.absolutePath) { + parent.listFiles() + .orEmpty() + .groupBy { it.name.lowercase(Locale.ROOT) } + } + return children[target.name.lowercase(Locale.ROOT)].orEmpty() + } + } + + private data class TargetResolution( + val actual: File? = null, + val issue: ModVerificationIssue? = null, + ) + + private fun issue(type: ModVerificationIssueType, target: File, detail: String, installId: String) = + ModVerificationIssue(type, target.absolutePath, detail, installId) +} diff --git a/app/src/main/java/app/gamenative/mods/ModDiagnosticSanitizer.kt b/app/src/main/java/app/gamenative/mods/ModDiagnosticSanitizer.kt new file mode 100644 index 0000000000..c493d926dd --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModDiagnosticSanitizer.kt @@ -0,0 +1,25 @@ +package app.gamenative.mods + +object ModDiagnosticSanitizer { + private val urlUserInfo = Regex("(?i)(https?://)[^\\s/@]+@") + private val urlQuery = Regex("(?i)(https?://[^\\s?#]+)\\?[^\\s#]+") + private val signedQuery = Regex("(?i)([?&](?:token|key|auth|authorization|signature|sig|expires)\\s*=)[^&#\\s]+") + private val secretAssignment = Regex( + "(?i)\\b((?:api[-_ ]?key|token|authorization|auth|signature|secret)\\s*[:=]\\s*(?:bearer\\s+)?)[^&#\\s,;]+", + ) + private val windowsPath = Regex("(?i)(?:[A-Z]:[\\\\/])(?:[^\\s\\r\\n]+)") + private val androidPath = Regex("(? "${match.groupValues[1]}@" } + .replace(urlQuery) { match -> "${match.groupValues[1]}?" } + .replace(signedQuery) { match -> "${match.groupValues[1]}" } + .replace(secretAssignment) { match -> "${match.groupValues[1]}" } + .replace(windowsPath, "") + .replace(androidPath, "") + .replace('\n', ' ') + .replace('\r', ' ') + + fun relativePath(value: String): String = + text(value.substringBefore('?')).trim().replace('\\', '/').trimStart('/') +} diff --git a/app/src/main/java/app/gamenative/mods/ModInstallPlan.kt b/app/src/main/java/app/gamenative/mods/ModInstallPlan.kt new file mode 100644 index 0000000000..4e77501904 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModInstallPlan.kt @@ -0,0 +1,195 @@ +package app.gamenative.mods + +import app.gamenative.data.ModPlacementMode +import java.security.MessageDigest +import java.util.Locale + +enum class PlannedFileStatus { + PLACED, + INTENTIONALLY_IGNORED, + UNSUPPORTED, + MISSING, + CONFLICTED, +} + +enum class PlacementOrigin { + MANUAL_RECIPE, + GAME_RULE, + LEGACY_PRESET, + FOMOD_REQUIRED, + FOMOD_OPTION, + FOMOD_CONDITIONAL, +} + +enum class PlacementRisk { + SAFE, + REVIEW, + UNSAFE, +} + +data class PlannedModFile( + val sourceRelativePath: String, + val targetRoot: String? = null, + val targetRelativePath: String? = null, + val normalizedTargetKey: String? = null, + val status: PlannedFileStatus, + val origin: PlacementOrigin, + val priority: Int = 0, + val mode: String = ModPlacementMode.OVERWRITE_COPY.name, + val sizeBytes: Long = 0L, + val reason: String, + val evidence: List = emptyList(), + val risk: PlacementRisk = PlacementRisk.SAFE, + val riskApproved: Boolean = false, +) + +data class ModInstallPlan( + val files: List, + val warnings: List = emptyList(), + val blockingIssues: List = emptyList(), + val producerId: String = "unknown", + val producerVersion: Int = 1, +) { + val selectedCount: Int + get() = files.count { it.status != PlannedFileStatus.INTENTIONALLY_IGNORED } + + val placedCount: Int + get() = files.count { it.status == PlannedFileStatus.PLACED } + + val ignoredCount: Int + get() = files.count { it.status == PlannedFileStatus.INTENTIONALLY_IGNORED } + + val unresolvedCount: Int + get() = files.count { + it.status == PlannedFileStatus.UNSUPPORTED || + it.status == PlannedFileStatus.MISSING || + it.status == PlannedFileStatus.CONFLICTED + } + + val installableBytes: Long + get() = files.filter { it.status != PlannedFileStatus.INTENTIONALLY_IGNORED }.sumOf { it.sizeBytes } + + val placedBytes: Long + get() = files.filter { it.status == PlannedFileStatus.PLACED }.sumOf { it.sizeBytes } + + val coverage: Double + get() = if (installableBytes > 0L) { + placedBytes.toDouble() / installableBytes.toDouble() + } else if (selectedCount == 0) { + 1.0 + } else { + placedCount.toDouble() / selectedCount.toDouble() + } + + val isComplete: Boolean + get() = blockingIssues.isEmpty() && + unresolvedCount == 0 && + files.none { it.risk == PlacementRisk.UNSAFE && !it.riskApproved } + + val digest: String + get() { + val canonical = files + .sortedWith( + compareBy { it.normalizedTargetKey.orEmpty() } + .thenBy { it.sourceRelativePath.lowercase(Locale.ROOT) } + .thenByDescending { it.priority }, + ) + .joinToString("\n") { file -> + listOf( + file.sourceRelativePath, + file.targetRoot.orEmpty(), + file.targetRelativePath.orEmpty(), + file.normalizedTargetKey.orEmpty(), + file.status.name, + file.origin.name, + file.priority.toString(), + file.mode, + file.sizeBytes.toString(), + file.risk.name, + file.riskApproved.toString(), + ).joinToString("|") + } + return MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } + + fun sanitizedManifest(): String = buildString { + appendLine("plan-version: 1") + appendLine("producer: ${ModDiagnosticSanitizer.text(producerId)}@$producerVersion") + appendLine("digest: $digest") + appendLine("complete: $isComplete") + appendLine("placed: $placedCount/$selectedCount") + files.sortedBy { it.sourceRelativePath.lowercase(Locale.ROOT) }.forEach { file -> + append(file.status.name) + append(' ') + append(ModDiagnosticSanitizer.relativePath(file.sourceRelativePath)) + if (file.targetRoot != null && file.targetRelativePath != null) { + append(" -> ") + append(file.targetRoot) + append('/') + append( + if (file.targetRoot == "CUSTOM_ABSOLUTE") { + "" + } else { + ModDiagnosticSanitizer.relativePath(file.targetRelativePath) + }, + ) + } + append(" [") + append(ModDiagnosticSanitizer.text(file.reason)) + if (file.riskApproved) append("; high-risk target explicitly approved") + appendLine(']') + } + warnings.sorted().forEach { appendLine("warning: ${ModDiagnosticSanitizer.text(it)}") } + blockingIssues.sorted().forEach { appendLine("blocker: ${ModDiagnosticSanitizer.text(it)}") } + } + + fun withRiskApproval(approved: Boolean): ModInstallPlan = PlacementRiskPolicy.enforce(this).let { plan -> + plan.copy( + files = plan.files.map { file -> + if (file.risk == PlacementRisk.UNSAFE) file.copy(riskApproved = approved) else file + }, + blockingIssues = when { + approved -> plan.blockingIssues.filterNot { it == RISKY_ROOT_REVIEW_BLOCKER } + plan.files.none { it.risk == PlacementRisk.UNSAFE } -> plan.blockingIssues + RISKY_ROOT_REVIEW_BLOCKER in plan.blockingIssues -> plan.blockingIssues + else -> plan.blockingIssues + RISKY_ROOT_REVIEW_BLOCKER + }, + ) + } + + fun withRiskyRootApproval(approved: Boolean): ModInstallPlan = withRiskApproval(approved) + + companion object { + const val RISKY_ROOT_REVIEW_BLOCKER = "Risky game-root installer content requires review" + } +} + +data class PlacementPlanQuality( + val placedFiles: Int, + val placedBytes: Long, + val unresolvedFiles: Int, + val blockers: Int, + val highestRisk: PlacementRisk, +) + +object PlacementPlanRegressionPolicy { + fun quality(plan: ModInstallPlan): PlacementPlanQuality = PlacementPlanQuality( + placedFiles = plan.placedCount, + placedBytes = plan.placedBytes, + unresolvedFiles = plan.unresolvedCount, + blockers = plan.blockingIssues.size, + highestRisk = plan.files.maxOfOrNull { it.risk } ?: PlacementRisk.SAFE, + ) + + fun canReplace(baseline: ModInstallPlan, candidate: ModInstallPlan): Boolean { + val old = quality(baseline) + val new = quality(candidate) + return new.placedFiles >= old.placedFiles && + new.placedBytes >= old.placedBytes && + new.unresolvedFiles <= old.unresolvedFiles && + new.blockers <= old.blockers && + new.highestRisk <= old.highestRisk + } +} diff --git a/app/src/main/java/app/gamenative/mods/ModMaterializer.kt b/app/src/main/java/app/gamenative/mods/ModMaterializer.kt index cdff3548d4..aed6f87808 100644 --- a/app/src/main/java/app/gamenative/mods/ModMaterializer.kt +++ b/app/src/main/java/app/gamenative/mods/ModMaterializer.kt @@ -12,6 +12,7 @@ import java.io.FileInputStream import java.io.IOException import java.nio.file.Files import java.security.MessageDigest +import java.util.Locale data class ModPlacementConflict( val sourcePath: String, @@ -25,14 +26,57 @@ data class ModPlacementResult( val backedUp: Int, val errors: Map, val manifests: List, + val warnings: List = emptyList(), ) data class ModPlannedEntry( val installId: String, val source: File, val target: File, + val mode: ModPlacementMode = ModPlacementMode.SYMLINK, + val targetRoot: String = "", + val sourceRelativePath: String = "", + val targetRelativePath: String = "", + val normalizedTargetKey: String = WindowsPathIdentity.absoluteKey(target), + val targetExistedBefore: Boolean = target.exists() || Files.isSymbolicLink(target.toPath()), ) +data class ModPlannedFile( + val installId: String, + val source: File, + val target: File, + val mode: ModPlacementMode, + val targetRoot: String, + val sourceRelativePath: String, + val targetRelativePath: String, + val normalizedTargetKey: String = WindowsPathIdentity.absoluteKey(target), + val targetExistedBefore: Boolean = target.exists() || Files.isSymbolicLink(target.toPath()), + val targetHashBefore: String = "", +) + +data class ModMaterializationPlan( + val installId: String, + val operations: List, + val files: List, + val errors: Map = emptyMap(), + val reviewedPlan: ModInstallPlan = ModInstallPlan( + files = emptyList(), + blockingIssues = errors.values.toList(), + producerId = "legacy-runtime", + ), +) { + val isComplete: Boolean get() = errors.isEmpty() && reviewedPlan.isComplete + val digest: String + get() { + val canonical = reviewedPlan.digest + "\n" + files.joinToString("\n") { file -> + "${file.sourceRelativePath}|${file.targetRoot}|${file.targetRelativePath}|${file.normalizedTargetKey}|${file.mode}" + } + return MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } +} + object ModMaterializer { private const val COPY_SENTINEL = ".gamenative_mod_install" private const val APPLY_FREE_SPACE_RESERVE_BYTES = 512L * 1024L * 1024L @@ -42,10 +86,10 @@ object ModMaterializer { manifests: List, ): List { if (conflicts.isEmpty() || manifests.isEmpty()) return conflicts - val manifestsByTarget = manifests.groupBy { File(it.targetPath).absolutePath } + val manifestsByTarget = manifests.groupBy { WindowsPathIdentity.absoluteKey(File(it.targetPath)) } return conflicts.filter { conflict -> val target = File(conflict.targetPath) - val matchingManifests = manifestsByTarget[target.absolutePath].orEmpty() + val matchingManifests = manifestsByTarget[WindowsPathIdentity.absoluteKey(target)].orEmpty() matchingManifests.none { manifest -> targetMatchesApprovedState(target, manifest) } } } @@ -55,26 +99,32 @@ object ModMaterializer { recipes: List, gameRootDir: File?, winePrefix: String, + reviewedPlan: ModInstallPlan? = null, ): List = withContext(Dispatchers.IO) { + val plan = materializationPlan( + install, + recipes, + gameRootDir, + winePrefix, + captureTargetHashes = false, + reviewedPlan = reviewedPlan, + ) buildList { - recipes.filter { it.enabled }.forEach { recipe -> - val mode = runCatching { ModPlacementMode.valueOf(recipe.mode) }.getOrDefault(ModPlacementMode.SYMLINK) - plannedEntries(install, recipe, gameRootDir, winePrefix).forEach { entry -> - when (mode) { - ModPlacementMode.OVERWRITE_COPY -> addAll(overwriteConflicts(entry)) - else -> { - if (entry.target.exists() || Files.isSymbolicLink(entry.target.toPath())) { - val alreadyCorrectSymlink = Files.isSymbolicLink(entry.target.toPath()) && - resolveSymlinkTarget(entry.target)?.canonicalFile == entry.source.canonicalFile - if (!alreadyCorrectSymlink) { - add( - ModPlacementConflict( - sourcePath = entry.source.absolutePath, - targetPath = entry.target.absolutePath, - directory = entry.source.isDirectory, - ), - ) - } + plan.operations.forEach { entry -> + when (entry.mode) { + ModPlacementMode.OVERWRITE_COPY -> addAll(overwriteConflicts(entry)) + else -> { + if (entry.target.exists() || Files.isSymbolicLink(entry.target.toPath())) { + val alreadyCorrectSymlink = Files.isSymbolicLink(entry.target.toPath()) && + resolveSymlinkTarget(entry.target)?.canonicalFile == entry.source.canonicalFile + if (!alreadyCorrectSymlink) { + add( + ModPlacementConflict( + sourcePath = entry.source.absolutePath, + targetPath = entry.target.absolutePath, + directory = entry.source.isDirectory, + ), + ) } } } @@ -133,51 +183,55 @@ object ModMaterializer { winePrefix: String, backupRoot: File, allowOverwrite: Boolean, + ): ModPlacementResult { + val plan = withContext(Dispatchers.IO) { + materializationPlan(install, recipes, gameRootDir, winePrefix) + } + return apply(install, plan, backupRoot, allowOverwrite) + } + + suspend fun apply( + install: ModInstall, + plan: ModMaterializationPlan, + backupRoot: File, + allowOverwrite: Boolean, ): ModPlacementResult = withContext(Dispatchers.IO) { + if (!plan.isComplete) return@withContext blockedResult(plan) var created = 0 var skipped = 0 var backedUp = 0 - val errors = linkedMapOf() + val errors = linkedMapOf().apply { putAll(plan.errors) } val manifests = mutableListOf() val targetsWrittenThisApply = mutableSetOf() backupRoot.mkdirs() - recipes.filter { it.enabled }.forEach { recipe -> - val mode = runCatching { ModPlacementMode.valueOf(recipe.mode) }.getOrDefault(ModPlacementMode.SYMLINK) - val entries = runCatching { plannedEntries(install, recipe, gameRootDir, winePrefix) } - .getOrElse { e -> - errors[recipe.sourceSubpath.ifBlank { recipe.targetRelativePath.ifBlank { install.modName } }] = - e.message ?: e::class.simpleName.orEmpty() - emptyList() - } - entries.forEach { entry -> - try { - when (mode) { - ModPlacementMode.SYMLINK -> { - val result = ensureSymlink(entry.target, entry.source) - if (result) created++ else skipped++ - } - ModPlacementMode.COPY -> { - val result = copyWithoutOverwrite(entry.target, entry.source, install.installId) - if (result) created++ else skipped++ - } - ModPlacementMode.OVERWRITE_COPY -> { - val result = copyWithBackups( - install = install, - target = entry.target, - source = entry.source, - backupRoot = backupRoot, - allowOverwrite = allowOverwrite, - targetsWrittenThisApply = targetsWrittenThisApply, - ) - created += result.created - backedUp += result.backedUp - manifests += result.manifests - } + plan.operations.forEach { entry -> + try { + when (entry.mode) { + ModPlacementMode.SYMLINK -> { + val result = ensureSymlink(entry.target, entry.source) + if (result) created++ else skipped++ + } + ModPlacementMode.COPY -> { + val result = copyWithoutOverwrite(entry.target, entry.source, install.installId) + if (result) created++ else skipped++ + } + ModPlacementMode.OVERWRITE_COPY -> { + val result = copyWithBackups( + install = install, + target = entry.target, + source = entry.source, + backupRoot = backupRoot, + allowOverwrite = allowOverwrite, + targetsWrittenThisApply = targetsWrittenThisApply, + ) + created += result.created + backedUp += result.backedUp + manifests += result.manifests } - } catch (e: Exception) { - errors[entry.target.absolutePath] = "${e::class.simpleName}: ${e.message}" } + } catch (e: Exception) { + errors[entry.target.absolutePath] = "${e::class.simpleName}: ${e.message}" } } @@ -189,41 +243,43 @@ object ModMaterializer { recipes: List, gameRootDir: File?, winePrefix: String, + reviewedPlan: ModInstallPlan? = null, ): ModPlacementResult = withContext(Dispatchers.IO) { var created = 0 var skipped = 0 - val errors = linkedMapOf() + val plan = materializationPlan( + install, + recipes, + gameRootDir, + winePrefix, + captureTargetHashes = false, + reviewedPlan = reviewedPlan, + ) + if (!plan.isComplete) return@withContext blockedResult(plan) + val errors = linkedMapOf().apply { putAll(plan.errors) } - recipes.filter { it.enabled }.forEach { recipe -> - val mode = runCatching { ModPlacementMode.valueOf(recipe.mode) }.getOrDefault(ModPlacementMode.SYMLINK) - val entries = runCatching { plannedEntries(install, recipe, gameRootDir, winePrefix) } - .getOrElse { e -> - errors[recipe.sourceSubpath.ifBlank { recipe.targetRelativePath.ifBlank { install.modName } }] = - e.message ?: e::class.simpleName.orEmpty() - emptyList() - } - entries.forEach { entry -> - try { - when (mode) { - ModPlacementMode.SYMLINK -> { - if (entry.target.exists() || Files.isSymbolicLink(entry.target.toPath())) { - skipped++ - } else if (ensureSymlink(entry.target, entry.source)) { - created++ - } else { - skipped++ - } - } - ModPlacementMode.COPY, - ModPlacementMode.OVERWRITE_COPY -> { - val result = copyMissingFiles(entry.target, entry.source) - created += result.created - skipped += result.skipped + plan.operations.forEach { entry -> + try { + when (entry.mode) { + ModPlacementMode.SYMLINK -> { + if (entry.target.exists() || Files.isSymbolicLink(entry.target.toPath())) { + skipped++ + } else if (ensureSymlink(entry.target, entry.source)) { + created++ + } else { + skipped++ } } - } catch (e: Exception) { - errors[entry.target.absolutePath] = "${e::class.simpleName}: ${e.message}" + ModPlacementMode.COPY, + ModPlacementMode.OVERWRITE_COPY, + -> { + val result = copyMissingFiles(entry.target, entry.source) + created += result.created + skipped += result.skipped + } } + } catch (e: Exception) { + errors[entry.target.absolutePath] = "${e::class.simpleName}: ${e.message}" } } @@ -234,7 +290,7 @@ object ModMaterializer { val skipped = mutableListOf() val handledTargets = mutableSetOf() manifests.sortedByDescending { it.targetPath.length }.forEach { manifest -> - if (!handledTargets.add(manifest.targetPath)) return@forEach + if (!handledTargets.add(WindowsPathIdentity.absoluteKey(File(manifest.targetPath)))) return@forEach val target = File(manifest.targetPath) if (manifest.backupPath.isBlank()) return@forEach val backup = File(manifest.backupPath) @@ -262,55 +318,291 @@ object ModMaterializer { restoredOverwriteTargets: Set = emptySet(), ): List = withContext(Dispatchers.IO) { val skipped = mutableListOf() - recipes.filter { it.enabled }.forEach { recipe -> - val mode = runCatching { ModPlacementMode.valueOf(recipe.mode) }.getOrDefault(ModPlacementMode.SYMLINK) + val restoredOverwriteTargetKeys = restoredOverwriteTargets.mapTo(mutableSetOf()) { + WindowsPathIdentity.absoluteKey(File(it)) + } + val plan = materializationPlan(install, recipes, gameRootDir, winePrefix, captureTargetHashes = false) + plan.operations.forEach { entry -> runCatching { - plannedEntries(install, recipe, gameRootDir, winePrefix).forEach { entry -> - when (mode) { - ModPlacementMode.SYMLINK -> removeSymlink(entry.target, entry.source, skipped) - ModPlacementMode.COPY -> removeCopiedEntry( - target = entry.target, - source = entry.source, - installId = install.installId, - skipped = skipped, - allowOwnedDirectoryDelete = true, - reportChangedFiles = true, - ) - ModPlacementMode.OVERWRITE_COPY -> removeCopiedEntry( - target = entry.target, - source = entry.source, - installId = install.installId, - skipped = skipped, - allowOwnedDirectoryDelete = false, - reportChangedFiles = true, - ignoredChangedTargets = restoredOverwriteTargets, - removeLegacySentinel = true, - ) - } + when (entry.mode) { + ModPlacementMode.SYMLINK -> removeSymlink(entry.target, entry.source, skipped) + ModPlacementMode.COPY -> removeCopiedEntry( + target = entry.target, + source = entry.source, + installId = install.installId, + skipped = skipped, + allowOwnedDirectoryDelete = true, + reportChangedFiles = true, + ) + ModPlacementMode.OVERWRITE_COPY -> removeCopiedEntry( + target = entry.target, + source = entry.source, + installId = install.installId, + skipped = skipped, + allowOwnedDirectoryDelete = false, + reportChangedFiles = true, + ignoredChangedTargetKeys = restoredOverwriteTargetKeys, + removeLegacySentinel = true, + ) } - }.onFailure { skipped += "${recipe.targetRoot}:${recipe.targetRelativePath}" } + }.onFailure { skipped += "${entry.targetRoot}:${entry.targetRelativePath}" } } + skipped += plan.errors.keys skipped.distinct() } - fun plannedEntries( + /** Roll back only targets proven to have been absent before this exact plan. */ + suspend fun rollbackAppliedPlan( + plan: ModMaterializationPlan, + restoredOverwriteTargets: Set = emptySet(), + ): List = withContext(Dispatchers.IO) { + val skipped = mutableListOf() + val restoredKeys = restoredOverwriteTargets.mapTo(mutableSetOf()) { + WindowsPathIdentity.absoluteKey(File(it)) + } + + plan.operations + .filter { it.mode == ModPlacementMode.SYMLINK && !it.targetExistedBefore } + .sortedByDescending { it.target.absolutePath.length } + .forEach { operation -> + runCatching { removeSymlink(operation.target, operation.source, skipped) } + .onFailure { skipped += operation.target.absolutePath } + } + + plan.files.asReversed() + .filter { file -> + file.mode != ModPlacementMode.SYMLINK && + !file.targetExistedBefore && + file.normalizedTargetKey !in restoredKeys + } + .forEach { file -> + runCatching { + val target = file.target + if (!target.exists() && !Files.isSymbolicLink(target.toPath())) return@runCatching + val currentHash = if (target.isFile) sha256(target) else "" + val sourceHash = if (file.source.isFile) sha256(file.source) else "" + if (target.isFile && sourceHash.isNotBlank() && currentHash == sourceHash) { + target.delete() + target.parentFile?.let { deleteEmptyDirs(it, stopAt = target.parentFile?.parentFile) } + } else { + skipped += target.absolutePath + } + }.onFailure { skipped += file.target.absolutePath } + } + + plan.operations.asReversed() + .filter { operation -> + operation.mode == ModPlacementMode.COPY && + operation.source.isDirectory && + !operation.targetExistedBefore + } + .forEach { operation -> + runCatching { + val operationTargetPath = operation.target.absoluteFile.toPath().normalize() + val preservedTargetExists = plan.files.any { file -> + file.mode == ModPlacementMode.COPY && + file.target.absoluteFile.toPath().normalize().startsWith(operationTargetPath) && + (file.target.exists() || Files.isSymbolicLink(file.target.toPath())) + } + if (!preservedTargetExists) { + val sentinel = File(operation.target, COPY_SENTINEL) + if (sentinel.isFile && sentinel.readText() == plan.installId) { + check(sentinel.delete()) { "Could not remove rollback marker" } + } + deleteEmptyDirs(operation.target, stopAt = operation.target.parentFile) + } + }.onFailure { skipped += operation.target.absolutePath } + } + skipped.distinct() + } + + fun materializationPlan( install: ModInstall, recipes: List, gameRootDir: File?, winePrefix: String, - ): List = - recipes.filter { it.enabled }.flatMap { recipe -> - plannedEntries(install, recipe, gameRootDir, winePrefix) + captureTargetHashes: Boolean = true, + reviewedPlan: ModInstallPlan? = null, + ): ModMaterializationPlan { + if (reviewedPlan != null) { + return resolveReviewedPlan( + install, + PlacementRiskPolicy.enforce(reviewedPlan), + gameRootDir, + winePrefix, + captureTargetHashes, + ) } + val operations = mutableListOf() + val errors = linkedMapOf() + val targetSession = ModTargetResolver.session(gameRootDir, winePrefix) + recipes.filter { it.enabled }.forEach { recipe -> + runCatching { plannedEntries(install, recipe, targetSession) } + .onSuccess(operations::addAll) + .onFailure { error -> + val key = recipe.sourceSubpath.ifBlank { recipe.targetRelativePath.ifBlank { install.modName } } + errors[key] = error.message ?: error::class.simpleName.orEmpty() + } + } + val targetHashes = mutableMapOf() + val targetNamespaces = mutableMapOf() + val expandedFiles = buildList { + operations.forEach { entry -> + val targetNamespace = targetNamespaces.getOrPut(entry.normalizedTargetKey) { + WindowsTargetNamespace(entry.target) + } + runCatching { + expandPlannedFiles(entry, targetNamespace, targetHashes, captureTargetHashes) + } + .onSuccess(::addAll) + .onFailure { error -> + errors[entry.sourceRelativePath.ifBlank { entry.targetRelativePath }] = + error.message ?: error::class.simpleName.orEmpty() + } + } + } + val files = expandedFiles.groupBy { it.normalizedTargetKey }.flatMap { (targetKey, values) -> + val distinctSources = values.map { it.source.canonicalPath }.distinct() + when { + distinctSources.size <= 1 -> listOf(values.last()) + values.all { it.mode == ModPlacementMode.OVERWRITE_COPY } -> listOf(values.last()) + else -> { + errors[targetKey] = "Multiple selected files target the same Windows path: " + + values.joinToString { it.sourceRelativePath } + values + } + } + }.sortedWith(compareBy { it.normalizedTargetKey }.thenBy { it.sourceRelativePath.lowercase(Locale.ROOT) }) + if (files.isEmpty()) { + errors[install.modName] = "The saved placement does not contain any files to install" + } + val manualPlan = PlacementRiskPolicy.enforce(ModInstallPlan( + files = files.map { file -> + PlannedModFile( + sourceRelativePath = file.sourceRelativePath, + targetRoot = file.targetRoot, + targetRelativePath = file.targetRelativePath, + normalizedTargetKey = ModTargetResolver.normalizedTargetKey(file.targetRoot, file.targetRelativePath), + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.MANUAL_RECIPE, + mode = file.mode.name, + sizeBytes = file.source.length(), + reason = "Expanded from a saved placement recipe", + ) + }, + blockingIssues = errors.values.distinct(), + producerId = "manual-recipes", + producerVersion = 1, + )) + return ModMaterializationPlan(install.installId, operations, files, errors, manualPlan) + } - private fun plannedEntries( + private fun resolveReviewedPlan( install: ModInstall, - recipe: ModPlacementRecipe, + reviewedPlan: ModInstallPlan, gameRootDir: File?, winePrefix: String, + captureTargetHashes: Boolean, + ): ModMaterializationPlan { + val errors = linkedMapOf() + reviewedPlan.blockingIssues.forEachIndexed { index, issue -> errors["plan:$index"] = issue } + val extractedRoot = File(install.extractedPath).canonicalFile + val targetSession = ModTargetResolver.session(gameRootDir, winePrefix) + val targetHashes = mutableMapOf() + val operations = mutableListOf() + val files = mutableListOf() + + reviewedPlan.files.filter { it.status == PlannedFileStatus.PLACED } + .sortedWith(compareBy { it.normalizedTargetKey.orEmpty() }.thenBy { it.sourceRelativePath.lowercase(Locale.ROOT) }) + .forEach { planned -> + val source = resolveReviewedSource(extractedRoot, planned.sourceRelativePath) + val targetRoot = planned.targetRoot + val targetRelativePath = planned.targetRelativePath + when { + source == null || !source.isFile -> errors[planned.sourceRelativePath] = "Reviewed source file is missing or case-ambiguous" + targetRoot == null || targetRelativePath == null -> errors[planned.sourceRelativePath] = "Reviewed target is missing" + else -> { + val logicalKey = ModTargetResolver.normalizedTargetKey(targetRoot, targetRelativePath) + if (logicalKey == null || logicalKey != planned.normalizedTargetKey) { + errors[planned.sourceRelativePath] = "Reviewed target identity changed before apply" + return@forEach + } + val target = targetSession.resolve(targetRoot, targetRelativePath) + if (target == null) { + errors[planned.sourceRelativePath] = "Reviewed target is unavailable or case-ambiguous" + return@forEach + } + val mode = runCatching { ModPlacementMode.valueOf(planned.mode) } + .getOrDefault(ModPlacementMode.OVERWRITE_COPY) + val operation = ModPlannedEntry( + installId = install.installId, + source = source, + target = target, + mode = mode, + targetRoot = targetRoot, + sourceRelativePath = planned.sourceRelativePath, + targetRelativePath = targetRelativePath, + ) + operations += operation + files += expandPlannedFiles( + operation, + WindowsTargetNamespace(target.parentFile ?: target), + targetHashes, + captureTargetHashes, + ) + } + } + } + files.groupBy { it.normalizedTargetKey }.filterValues { it.size > 1 }.forEach { (key, contenders) -> + if (contenders.map { it.source.canonicalPath }.distinct().size > 1) { + errors[key] = "Reviewed plan contains multiple files for one Windows target" + } + } + if (files.size != reviewedPlan.placedCount) { + errors[install.modName] = "Resolved ${files.size} of ${reviewedPlan.placedCount} reviewed files" + } + return ModMaterializationPlan( + installId = install.installId, + operations = operations, + files = files.sortedWith(compareBy { it.normalizedTargetKey }.thenBy { it.sourceRelativePath.lowercase(Locale.ROOT) }), + errors = errors, + reviewedPlan = reviewedPlan, + ) + } + + private fun resolveReviewedSource(extractedRoot: File, sourceRelativePath: String): File? { + val segments = normalizedArchiveKey(sourceRelativePath)?.split('/').orEmpty() + val displaySegments = normalizeArchiveDisplayPath(sourceRelativePath).split('/').filter(String::isNotBlank) + if (segments.size != displaySegments.size) return null + var current = extractedRoot + displaySegments.forEach { segment -> + val matches = current.listFiles().orEmpty().filter { it.name.equals(segment, ignoreCase = true) } + if (matches.size != 1) return null + current = matches.single() + } + val source = runCatching { current.canonicalFile }.getOrNull() ?: return null + return source.takeIf { it.path.startsWith(extractedRoot.path + File.separator) } + } + + private fun blockedResult(plan: ModMaterializationPlan): ModPlacementResult { + val errors = linkedMapOf().apply { + putAll(plan.errors) + plan.reviewedPlan.blockingIssues.forEachIndexed { index, issue -> putIfAbsent("plan:$index", issue) } + plan.reviewedPlan.files + .filter { it.status != PlannedFileStatus.PLACED && it.status != PlannedFileStatus.INTENTIONALLY_IGNORED } + .forEach { file -> putIfAbsent(file.sourceRelativePath, file.reason) } + if (isEmpty()) put("plan", "The reviewed placement plan is incomplete") + } + return ModPlacementResult(0, 0, 0, errors, emptyList()) + } + + private fun plannedEntries( + install: ModInstall, + recipe: ModPlacementRecipe, + targetSession: ModTargetResolutionSession, ): List = sourceSubpathsForPlacement(recipe.sourceSubpath).flatMap { sourceSubpath -> - plannedEntriesForSource(install, recipe, sourceSubpath, gameRootDir, winePrefix) + plannedEntriesForSource(install, recipe, sourceSubpath, targetSession) } private fun sourceSubpathsForPlacement(sourceSubpath: String): List = @@ -320,8 +612,7 @@ object ModMaterializer { install: ModInstall, recipe: ModPlacementRecipe, sourceSubpath: String, - gameRootDir: File?, - winePrefix: String, + targetSession: ModTargetResolutionSession, ): List { val extractedRoot = File(install.extractedPath).canonicalFile val normalizedSource = ModPlacementSources.normalize(sourceSubpath) @@ -332,25 +623,125 @@ object ModMaterializer { if (!source.exists()) { throw IOException("Source path does not exist: $sourceSubpath") } - val targetDir = ModTargetResolver.resolve( - targetRoot = recipe.targetRoot, - targetRelativePath = recipe.targetRelativePath, - gameRootDir = gameRootDir, - winePrefix = winePrefix, - ) ?: throw IOException("Target root is unavailable: ${recipe.targetRoot}") + val targetDir = targetSession.resolve(recipe.targetRoot, recipe.targetRelativePath) + ?: throw IOException("Target root is unavailable: ${recipe.targetRoot}") + val mode = runCatching { ModPlacementMode.valueOf(recipe.mode) }.getOrDefault(ModPlacementMode.SYMLINK) val effectiveSource = stripPrefix(source, recipe.stripPrefixSegments) return when { - effectiveSource.isFile -> listOf(ModPlannedEntry(install.installId, effectiveSource, File(targetDir, effectiveSource.name))) + effectiveSource.isFile -> listOf( + plannedEntry( + install.installId, + effectiveSource, + targetDir, + recipe.targetFileName.ifBlank { effectiveSource.name }, + mode, + recipe.targetRoot, + extractedRoot, + recipe.targetRelativePath, + ), + ) recipe.includeSourceDirectory && effectiveSource != extractedRoot -> - listOf(ModPlannedEntry(install.installId, effectiveSource, File(targetDir, effectiveSource.name))) + listOf( + plannedEntry( + install.installId, + effectiveSource, + targetDir, + effectiveSource.name, + mode, + recipe.targetRoot, + extractedRoot, + recipe.targetRelativePath, + ), + ) else -> effectiveSource.listFiles() ?.filter { !it.name.startsWith(".") } - ?.map { ModPlannedEntry(install.installId, it, File(targetDir, it.name)) } + ?.map { + plannedEntry( + install.installId, + it, + targetDir, + it.name, + mode, + recipe.targetRoot, + extractedRoot, + recipe.targetRelativePath, + ) + } ?: emptyList() } } + private fun plannedEntry( + installId: String, + source: File, + targetRoot: File, + relative: String, + mode: ModPlacementMode, + targetRootName: String, + extractedRoot: File, + targetBaseRelative: String, + ): ModPlannedEntry { + val target = ModTargetResolver.resolveWithin(targetRoot, relative) + ?: throw IOException("Target path is invalid or case-ambiguous: $relative") + return ModPlannedEntry( + installId = installId, + source = source, + target = target, + mode = mode, + targetRoot = targetRootName, + sourceRelativePath = source.relativeTo(extractedRoot).path.replace(File.separatorChar, '/'), + targetRelativePath = listOf(targetBaseRelative, relative) + .filter(String::isNotBlank) + .joinToString("/") + .replace(File.separatorChar, '/'), + ) + } + + private fun expandPlannedFiles( + entry: ModPlannedEntry, + targetNamespace: WindowsTargetNamespace, + targetHashes: MutableMap, + captureTargetHashes: Boolean, + ): List { + val files = if (entry.source.isFile) sequenceOf(entry.source) else entry.source.walkTopDown().filter { it.isFile } + return files.map { sourceFile -> + val nested = if (entry.source.isFile) "" else sourceFile.relativeTo(entry.source).path + val targetFile = if (nested.isBlank()) { + entry.target + } else { + targetNamespace.resolve(nested).takeIf { it.isValid }?.file + ?: throw IOException("Target path escapes or is ambiguous in destination directory: $nested") + } + val targetKey = WindowsPathIdentity.absoluteKey(targetFile) + ModPlannedFile( + installId = entry.installId, + source = sourceFile, + target = targetFile, + mode = entry.mode, + targetRoot = entry.targetRoot, + sourceRelativePath = sourceFile.path.removePrefix(entry.source.path) + .trimStart(File.separatorChar) + .let { nestedSource -> + listOf(entry.sourceRelativePath, nestedSource) + .filter(String::isNotBlank) + .joinToString("/") + .replace(File.separatorChar, '/') + }, + targetRelativePath = listOf(entry.targetRelativePath, nested.replace(File.separatorChar, '/')) + .filter(String::isNotBlank) + .joinToString("/"), + targetHashBefore = if ( + captureTargetHashes && targetFile.isFile && !Files.isSymbolicLink(targetFile.toPath()) + ) { + targetHashes.getOrPut(targetKey) { sha256(targetFile) } + } else { + "" + }, + ) + }.toList() + } + private fun resolveSource(extractedRoot: File, normalizedSource: String): File { if (normalizedSource.isBlank()) return extractedRoot if (normalizedSource.split('/').any { it == ".." }) { @@ -445,7 +836,7 @@ object ModMaterializer { skipped: MutableList, allowOwnedDirectoryDelete: Boolean, reportChangedFiles: Boolean, - ignoredChangedTargets: Set = emptySet(), + ignoredChangedTargetKeys: Set = emptySet(), removeLegacySentinel: Boolean = false, ) { if (!target.exists() && !Files.isSymbolicLink(target.toPath())) return @@ -461,14 +852,14 @@ object ModMaterializer { source.walkTopDown() .filter { it.isFile } .forEach { sourceFile -> - val targetFile = File(target, sourceFile.relativeTo(source).path) - removeCopiedFileIfUnchanged(targetFile, sourceFile, skipped, reportChangedFiles, ignoredChangedTargets) + val targetFile = safeChildTarget(target, sourceFile.relativeTo(source).path) + removeCopiedFileIfUnchanged(targetFile, sourceFile, skipped, reportChangedFiles, ignoredChangedTargetKeys) } if (allowOwnedDirectoryDelete || removeLegacySentinel) { deleteEmptyDirs(target, stopAt = target.parentFile) } } else { - removeCopiedFileIfUnchanged(target, source, skipped, reportChangedFiles, ignoredChangedTargets) + removeCopiedFileIfUnchanged(target, source, skipped, reportChangedFiles, ignoredChangedTargetKeys) } } @@ -477,13 +868,14 @@ object ModMaterializer { source: File, skipped: MutableList, reportChangedFiles: Boolean, - ignoredChangedTargets: Set = emptySet(), + ignoredChangedTargetKeys: Set = emptySet(), ) { if (!target.exists() || !target.isFile || !source.isFile) return - if (target.absolutePath in ignoredChangedTargets) return + val targetKey = WindowsPathIdentity.absoluteKey(target) + if (targetKey in ignoredChangedTargetKeys) return if (sha256(target) == sha256(source)) { target.delete() - } else if (reportChangedFiles && target.absolutePath !in ignoredChangedTargets) { + } else if (reportChangedFiles) { skipped += target.absolutePath } } @@ -501,8 +893,19 @@ object ModMaterializer { private fun copyWithoutOverwrite(target: File, source: File, installId: String): Boolean { if (target.exists() || Files.isSymbolicLink(target.toPath())) return false target.parentFile?.mkdirs() - copyEntry(source, target, overwrite = false) - if (target.isDirectory) File(target, COPY_SENTINEL).writeText(installId) + if (source.isDirectory) { + target.mkdirs() + source.walkTopDown() + .filter { it.isFile } + .forEach { sourceFile -> + val targetFile = safeChildTarget(target, sourceFile.relativeTo(source).path) + ensureRealParentDirectories(targetFile, stopAt = target) + sourceFile.copyTo(targetFile, overwrite = false) + } + File(target, COPY_SENTINEL).writeText(installId) + } else { + source.copyTo(target, overwrite = false) + } return true } @@ -557,7 +960,8 @@ object ModMaterializer { allowOverwrite: Boolean, targetsWrittenThisApply: MutableSet, ): CopyBackupResult { - if (!allowOverwrite && source.isFile && targetNeedsOverwrite(target, source) && target.absolutePath !in targetsWrittenThisApply) { + val targetKey = WindowsPathIdentity.absoluteKey(target) + if (!allowOverwrite && source.isFile && targetNeedsOverwrite(target, source) && targetKey !in targetsWrittenThisApply) { throw IOException("Overwrite was not confirmed for ${target.absolutePath}") } if (!allowOverwrite && source.isDirectory && Files.isSymbolicLink(target.toPath())) { @@ -575,7 +979,8 @@ object ModMaterializer { .forEach { file -> val relative = file.relativeTo(source).path val targetFile = safeChildTarget(target, relative) - val sameApplyTarget = targetFile.absolutePath in targetsWrittenThisApply + val currentTargetKey = WindowsPathIdentity.absoluteKey(targetFile) + val sameApplyTarget = currentTargetKey in targetsWrittenThisApply if (!allowOverwrite && !sameApplyTarget && targetNeedsOverwrite(targetFile, file)) { throw IOException("Overwrite was not confirmed for ${targetFile.absolutePath}") } @@ -591,12 +996,12 @@ object ModMaterializer { deleteTargetSymlinkIfPresent(targetFile) ensureRealParentDirectories(targetFile, stopAt = target) file.copyTo(targetFile, overwrite = true) - targetsWrittenThisApply += targetFile.absolutePath + targetsWrittenThisApply += currentTargetKey created++ manifests.replaceLastForTarget(targetFile, install) } } else { - val sameApplyTarget = target.absolutePath in targetsWrittenThisApply + val sameApplyTarget = targetKey in targetsWrittenThisApply val backup = if (sameApplyTarget) BackupIfNeededResult() else backupIfNeeded(install, target, source, backupRoot) if (backup.manifest != null) { if (backup.backedUp) { @@ -611,7 +1016,7 @@ object ModMaterializer { deleteTargetSymlinkIfPresent(target) ensureRealParentDirectories(target, stopAt = target.parentFile) source.copyTo(target, overwrite = true) - targetsWrittenThisApply += target.absolutePath + targetsWrittenThisApply += targetKey created++ manifests.replaceLastForTarget(target, install) } @@ -619,7 +1024,8 @@ object ModMaterializer { } private fun MutableList.replaceLastForTarget(target: File, install: ModInstall) { - val index = indexOfLast { it.targetPath == target.absolutePath } + val targetKey = WindowsPathIdentity.absoluteKey(target) + val index = indexOfLast { WindowsPathIdentity.absoluteKey(File(it.targetPath)) == targetKey } if (index < 0 || !target.isFile) return val current = this[index] this[index] = current.copy( @@ -768,23 +1174,8 @@ object ModMaterializer { } private fun safeChildTarget(root: File, relative: String): File { - val rootCanonical = root.canonicalFile - val rootPath = rootCanonical.toPath().toAbsolutePath().normalize() - val targetPath = rootPath.resolve(relative).normalize() - if (targetPath != rootPath && !targetPath.startsWith(rootPath)) { - throw IOException("Target path escapes destination directory: $relative") - } - return targetPath.toFile() - } - - private fun copyEntry(source: File, target: File, overwrite: Boolean) { - if (source.isDirectory) { - if (!source.copyRecursively(target, overwrite = overwrite)) { - throw IOException("Failed to copy ${source.absolutePath}") - } - } else { - source.copyTo(target, overwrite = overwrite) - } + return ModTargetResolver.resolveWithin(root, relative) + ?: throw IOException("Target path escapes or is ambiguous in destination directory: $relative") } private fun sha256(file: File): String { diff --git a/app/src/main/java/app/gamenative/mods/ModOwnershipManifest.kt b/app/src/main/java/app/gamenative/mods/ModOwnershipManifest.kt new file mode 100644 index 0000000000..08c7261048 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModOwnershipManifest.kt @@ -0,0 +1,572 @@ +package app.gamenative.mods + +import app.gamenative.data.ModOverwriteManifest +import com.github.luben.zstd.Zstd +import com.github.luben.zstd.ZstdInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.file.Files +import java.security.MessageDigest +import java.util.Locale +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +enum class ModOwnershipState { + ACTIVE, + DISABLED, + RECOVERY_REQUIRED, +} + +@Serializable +enum class ModOwnedFileDisposition { + CREATED, + MERGED, + OVERWROTE, + BACKED_UP, + SHARED, + STALE_PRESERVED, +} + +@Serializable +data class ModOwnedFile( + val sourceRelativePath: String, + val targetRoot: String, + val targetRelativePath: String, + val targetPath: String, + val normalizedTargetKey: String, + val mode: String, + val installedHash: String, + val installedSize: Long, + val installedMtime: Long, + val disposition: ModOwnedFileDisposition, + val priority: Int = 0, + val active: Boolean = true, +) + +@Serializable +data class ModOwnedOperation( + val sourcePath: String, + val targetPath: String, + val normalizedTargetKey: String, + val mode: String, +) + +@Serializable +data class ModInstallDecision( + val sourceRelativePath: String, + val targetRoot: String = "", + val targetRelativePath: String = "", + val normalizedTargetKey: String = "", + val status: String, + val origin: String, + val mode: String, + val priority: Int, + val reason: String, + val outcome: String, + val risk: String = PlacementRisk.SAFE.name, + val riskApproved: Boolean = false, + val sizeBytes: Long = 0L, + val evidence: List = emptyList(), +) + +@Serializable +data class ModOwnershipManifest( + val version: Int = 3, + val installId: String, + val appId: String, + val profileId: String = "", + val planDigest: String, + val state: ModOwnershipState = ModOwnershipState.ACTIVE, + val files: List, + val operations: List = emptyList(), + val decisions: List = emptyList(), + val planProducerId: String = "legacy", + val planProducerVersion: Int = 1, + val reviewedPlanDigest: String = "", + val planWarnings: List = emptyList(), + val createdAt: Long = System.currentTimeMillis(), +) + +fun ModOwnershipManifest.reviewedPlanOrNull(): ModInstallPlan? { + val ownedBySourceAndTarget = files.associateBy { + Triple(it.sourceRelativePath, it.targetRoot, it.targetRelativePath) + } + val planned = if (decisions.isNotEmpty()) { + decisions.mapNotNull { decision -> + val status = runCatching { PlannedFileStatus.valueOf(decision.status) }.getOrNull() ?: return@mapNotNull null + val origin = runCatching { PlacementOrigin.valueOf(decision.origin) }.getOrDefault(PlacementOrigin.MANUAL_RECIPE) + val owned = ownedBySourceAndTarget[ + Triple(decision.sourceRelativePath, decision.targetRoot, decision.targetRelativePath), + ] + ?: files.firstOrNull { it.sourceRelativePath == decision.sourceRelativePath } + PlannedModFile( + sourceRelativePath = decision.sourceRelativePath, + targetRoot = decision.targetRoot.takeIf(String::isNotBlank) ?: owned?.targetRoot, + targetRelativePath = decision.targetRelativePath.takeIf(String::isNotBlank) ?: owned?.targetRelativePath, + normalizedTargetKey = decision.normalizedTargetKey.takeIf(String::isNotBlank) ?: owned?.normalizedTargetKey, + status = status, + origin = origin, + mode = decision.mode, + priority = decision.priority, + sizeBytes = decision.sizeBytes.takeIf { it > 0L } ?: owned?.installedSize ?: 0L, + reason = decision.reason.ifBlank { "Restored from the applied ownership manifest" }, + evidence = decision.evidence, + risk = runCatching { PlacementRisk.valueOf(decision.risk) }.getOrDefault(PlacementRisk.SAFE), + riskApproved = decision.riskApproved, + ) + } + } else { + files.filter { it.active }.map { owned -> + PlannedModFile( + sourceRelativePath = owned.sourceRelativePath, + targetRoot = owned.targetRoot, + targetRelativePath = owned.targetRelativePath, + normalizedTargetKey = owned.normalizedTargetKey, + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.MANUAL_RECIPE, + mode = owned.mode, + sizeBytes = owned.installedSize, + reason = "Conservatively adopted from a historical ownership manifest", + ) + } + } + if (planned.none { it.status == PlannedFileStatus.PLACED }) return null + return PlacementRiskPolicy.enforce( + ModInstallPlan( + files = planned, + warnings = planWarnings, + producerId = planProducerId, + producerVersion = planProducerVersion, + ), + ) +} + +data class ModOverlayContribution( + val installId: String, + val priority: Int, + val file: ModOwnedFile, +) + +data class ModOverlayTarget( + val normalizedTargetKey: String, + val contributors: List, + val winner: ModOverlayContribution, + val identicalContents: Boolean, + val hasCaseCollision: Boolean, +) + +data class ModProfileOverlay( + val targets: Map, + val conflicts: List, +) + +data class ModProfileOverlayTransition( + val current: ModProfileOverlay, + val desired: ModProfileOverlay, + val changedWinnerKeys: List, + val currentVerification: ModDeploymentVerification, +) { + val requiresRebuild: Boolean get() = changedWinnerKeys.isNotEmpty() + val safeToRebuild: Boolean get() = currentVerification.successful +} + +enum class ModPlanChangeType { + ADDED, + CHANGED, + MOVED, + STALE, + UNCHANGED, +} + +data class ModPlanChange( + val type: ModPlanChangeType, + val sourceRelativePath: String, + val previousTarget: String = "", + val newTarget: String = "", +) + +data class ModReconfigurationDiff(val changes: List) { + val added: Int get() = changes.count { it.type == ModPlanChangeType.ADDED } + val changed: Int get() = changes.count { it.type == ModPlanChangeType.CHANGED } + val moved: Int get() = changes.count { it.type == ModPlanChangeType.MOVED } + val stale: Int get() = changes.count { it.type == ModPlanChangeType.STALE } + val hasChanges: Boolean get() = changes.any { it.type != ModPlanChangeType.UNCHANGED } +} + +object ModOwnershipPlanDiffer { + fun compare(previous: ModOwnershipManifest?, next: ModInstallPlan): ModReconfigurationDiff { + if (previous == null) { + return ModReconfigurationDiff( + next.files.filter { it.status == PlannedFileStatus.PLACED }.map { file -> + ModPlanChange(ModPlanChangeType.ADDED, file.sourceRelativePath, newTarget = file.normalizedTargetKey.orEmpty()) + }, + ) + } + val oldFiles = previous.files.filter { it.active } + val oldByTarget = oldFiles.associateBy { it.normalizedTargetKey } + val oldBySource = oldFiles.groupBy { it.sourceRelativePath } + val newFiles = next.files.filter { it.status == PlannedFileStatus.PLACED } + val newAbsoluteKeys = newFiles.mapNotNull { planned -> + oldFiles.firstOrNull { + it.targetRoot == planned.targetRoot && it.targetRelativePath.equals(planned.targetRelativePath, ignoreCase = true) + }?.normalizedTargetKey + }.toSet() + val changes = newFiles.map { planned -> + val logicalTarget = planned.normalizedTargetKey.orEmpty() + val sameSource = oldBySource[planned.sourceRelativePath].orEmpty().singleOrNull() + val sameTarget = oldByTarget.values.firstOrNull { + it.targetRoot == planned.targetRoot && it.targetRelativePath.equals(planned.targetRelativePath, ignoreCase = true) + } + when { + sameSource != null && sameTarget == null -> ModPlanChange( + ModPlanChangeType.MOVED, + planned.sourceRelativePath, + sameSource.normalizedTargetKey, + logicalTarget, + ) + sameTarget != null && sameTarget.sourceRelativePath != planned.sourceRelativePath -> ModPlanChange( + ModPlanChangeType.CHANGED, + planned.sourceRelativePath, + sameTarget.normalizedTargetKey, + logicalTarget, + ) + sameTarget != null -> ModPlanChange( + ModPlanChangeType.UNCHANGED, + planned.sourceRelativePath, + sameTarget.normalizedTargetKey, + logicalTarget, + ) + else -> ModPlanChange(ModPlanChangeType.ADDED, planned.sourceRelativePath, newTarget = logicalTarget) + } + }.toMutableList() + oldFiles.filter { old -> + old.normalizedTargetKey !in newAbsoluteKeys && newFiles.none { + it.targetRoot == old.targetRoot && it.targetRelativePath.equals(old.targetRelativePath, ignoreCase = true) + } && newFiles.none { it.sourceRelativePath == old.sourceRelativePath } + }.forEach { old -> + changes += ModPlanChange(ModPlanChangeType.STALE, old.sourceRelativePath, old.normalizedTargetKey) + } + return ModReconfigurationDiff(changes) + } +} + +object ModProfileOverlayPlanner { + fun build( + manifests: List, + enabledPriorities: Map, + ): ModProfileOverlay { + val targets = manifests.asSequence() + .filter { it.state == ModOwnershipState.ACTIVE && it.installId in enabledPriorities } + .flatMap { manifest -> + manifest.files.asSequence() + .filter { it.active } + .map { file -> ModOverlayContribution(manifest.installId, enabledPriorities.getValue(manifest.installId), file) } + } + .groupBy { it.file.normalizedTargetKey } + .mapValues { (key, contributions) -> + val ordered = contributions.sortedWith(compareBy { it.priority }.thenBy { it.installId }) + val paths = ordered.map { it.file.targetPath.replace('\\', '/') }.distinct() + ModOverlayTarget( + normalizedTargetKey = key, + contributors = ordered, + winner = ordered.last(), + identicalContents = ordered.map { it.file.installedHash }.filter(String::isNotBlank).distinct().size <= 1, + hasCaseCollision = paths.map { it.lowercase(Locale.ROOT) }.distinct().size < paths.size, + ) + } + .toSortedMap() + return ModProfileOverlay(targets, targets.values.filter { it.contributors.size > 1 && !it.identicalContents }) + } + + fun transition( + manifests: List, + desiredPriorities: Map, + ): ModProfileOverlayTransition { + val currentPriorities = manifests + .filter { it.state == ModOwnershipState.ACTIVE } + .associate { manifest -> + manifest.installId to (manifest.files.filter { it.active }.maxOfOrNull { it.priority } ?: 0) + } + val current = build(manifests, currentPriorities) + val desired = build(manifests, desiredPriorities) + val changedWinnerKeys = (current.targets.keys + desired.targets.keys) + .filter { key -> + val before = current.targets[key]?.winner + val after = desired.targets[key]?.winner + before?.installId != after?.installId || + before?.file?.installedHash != after?.file?.installedHash || + before?.file?.targetPath != after?.file?.targetPath + } + .sorted() + return ModProfileOverlayTransition( + current = current, + desired = desired, + changedWinnerKeys = changedWinnerKeys, + currentVerification = if (changedWinnerKeys.isEmpty()) { + ModDeploymentVerification(emptyList()) + } else { + ModDeploymentVerifier.verify( + // Reordering rebuilds the entire managed overlay, not only the + // targets whose winner changes. Verify every current winner so + // unrelated user edits cannot be removed during that rebuild. + current, + ModVerificationDepth.CHANGED_CONTENT, + ) + }, + ) + } +} + +object ModOwnershipStore { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun read(root: File, installId: String): ModOwnershipManifest? = + readFile(currentFile(root, installId)) ?: readFile(previousFile(root, installId)) + + fun readPrevious(root: File, installId: String): ModOwnershipManifest? = readFile(previousFile(root, installId)) + + fun reviewedPlan(root: File, installId: String): ModInstallPlan? = read(root, installId)?.reviewedPlanOrNull() + + fun readAll(root: File): List = + ownershipDir(root).listFiles() + .orEmpty() + .filter { it.isFile && it.name.endsWith(".json.zst") && !it.name.endsWith(".previous.json.zst") } + .mapNotNull(::readFile) + + fun write(root: File, manifest: ModOwnershipManifest) { + val current = currentFile(root, manifest.installId) + val previous = previousFile(root, manifest.installId) + val temp = File(current.parentFile, "${current.name}.tmp") + current.parentFile?.mkdirs() + val payload = Zstd.compress(json.encodeToString(manifest).toByteArray(Charsets.UTF_8), 3) + FileOutputStream(temp).use { output -> + output.write(payload) + output.fd.sync() + } + if (current.isFile) { + replaceWithPreparedFile(current, previous) + } + replaceWithPreparedFile(temp, current) + } + + fun delete(root: File, installId: String) { + currentFile(root, installId).delete() + previousFile(root, installId).delete() + File(currentFile(root, installId).parentFile, "${currentFile(root, installId).name}.tmp").delete() + } + + fun create( + appId: String, + plan: ModMaterializationPlan, + overwriteManifests: List, + profileId: String = "", + priority: Int = 0, + preservedStale: List = emptyList(), + ): ModOwnershipManifest { + val overwriteByTarget = overwriteManifests.associateBy { WindowsPathIdentity.absoluteKey(File(it.targetPath)) } + val files = plan.files.map { planned -> + val target = planned.target + val overwrite = overwriteByTarget[planned.normalizedTargetKey] + val installedHash = when { + target.isFile -> sha256(target) + planned.source.isFile -> sha256(planned.source) + else -> "" + } + val disposition = when { + overwrite?.backupPath?.isNotBlank() == true -> ModOwnedFileDisposition.BACKED_UP + planned.targetExistedBefore && planned.targetHashBefore == installedHash -> ModOwnedFileDisposition.SHARED + planned.mode.name == "OVERWRITE_COPY" && planned.targetExistedBefore -> ModOwnedFileDisposition.OVERWROTE + planned.targetExistedBefore -> ModOwnedFileDisposition.MERGED + else -> ModOwnedFileDisposition.CREATED + } + ModOwnedFile( + sourceRelativePath = planned.sourceRelativePath, + targetRoot = planned.targetRoot, + targetRelativePath = planned.targetRelativePath, + targetPath = target.absolutePath, + normalizedTargetKey = planned.normalizedTargetKey, + mode = planned.mode.name, + installedHash = installedHash, + installedSize = if (target.isFile) target.length() else planned.source.length(), + installedMtime = if (target.exists()) target.lastModified() else planned.source.lastModified(), + disposition = disposition, + priority = priority, + ) + } + return ModOwnershipManifest( + installId = plan.installId, + appId = appId, + profileId = profileId, + planDigest = plan.digest, + files = (files + preservedStale).distinctBy { it.normalizedTargetKey to it.active }, + operations = plan.operations.map { operation -> + ModOwnedOperation( + sourcePath = operation.source.absolutePath, + targetPath = operation.target.absolutePath, + normalizedTargetKey = operation.normalizedTargetKey, + mode = operation.mode.name, + ) + }, + decisions = plan.reviewedPlan.files.map { decision -> + val owned = files.firstOrNull { file -> + file.sourceRelativePath == decision.sourceRelativePath && + file.targetRoot == decision.targetRoot && + file.targetRelativePath == decision.targetRelativePath + } + ModInstallDecision( + sourceRelativePath = decision.sourceRelativePath, + targetRoot = decision.targetRoot.orEmpty(), + targetRelativePath = decision.targetRelativePath.orEmpty(), + normalizedTargetKey = decision.normalizedTargetKey.orEmpty(), + status = decision.status.name, + origin = decision.origin.name, + mode = decision.mode, + priority = decision.priority, + reason = decision.reason, + outcome = owned?.disposition?.name ?: when (decision.status) { + PlannedFileStatus.INTENTIONALLY_IGNORED -> "INTENTIONALLY_SKIPPED" + PlannedFileStatus.UNSUPPORTED -> "BLOCKED_UNSUPPORTED" + PlannedFileStatus.MISSING -> "BLOCKED_MISSING" + PlannedFileStatus.CONFLICTED -> "BLOCKED_CONFLICT" + PlannedFileStatus.PLACED -> "PLANNED" + }, + risk = decision.risk.name, + riskApproved = decision.riskApproved, + sizeBytes = decision.sizeBytes, + evidence = decision.evidence, + ) + }, + planProducerId = plan.reviewedPlan.producerId, + planProducerVersion = plan.reviewedPlan.producerVersion, + reviewedPlanDigest = plan.reviewedPlan.digest, + planWarnings = plan.reviewedPlan.warnings, + ) + } + + private fun ownershipDir(root: File): File = File(root, "ownership") + + private fun currentFile(root: File, installId: String): File = + File(ownershipDir(root), "${safeName(installId)}.json.zst") + + private fun previousFile(root: File, installId: String): File = + File(ownershipDir(root), "${safeName(installId)}.previous.json.zst") + + private fun safeName(value: String): String = value.replace(Regex("[^A-Za-z0-9._-]"), "_") + + private fun readFile(file: File): ModOwnershipManifest? { + if (!file.isFile) return null + return runCatching { + ZstdInputStream(FileInputStream(file)).bufferedReader().use { reader -> + json.decodeFromString(reader.readText()) + } + }.getOrNull() + } + + internal fun sha256(file: File): String { + if (!file.isFile) return "" + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} + +data class ModOwnershipCleanupResult( + val removed: Int, + val restored: Int, + val preserved: List, +) { + val skippedPaths: List get() = preserved.map { it.targetPath }.distinct() +} + +object ModOwnershipReconciler { + suspend fun removeOwnedFiles( + manifest: ModOwnershipManifest, + overwriteManifests: List, + targetKeys: Set = manifest.files.filter { it.active }.mapTo(mutableSetOf()) { it.normalizedTargetKey }, + restoreBackups: Boolean = true, + ): ModOwnershipCleanupResult { + val selected = manifest.files.filter { it.active && it.normalizedTargetKey in targetKeys } + val overwriteByKey = overwriteManifests.associateBy { WindowsPathIdentity.absoluteKey(File(it.targetPath)) } + val restoreCandidates = if (restoreBackups) selected.mapNotNull { overwriteByKey[it.normalizedTargetKey] } else emptyList() + val restoreSkipped = ModMaterializer.restoreBackups(restoreCandidates) + .mapTo(mutableSetOf()) { WindowsPathIdentity.absoluteKey(File(it)) } + val restoredKeys = restoreCandidates.asSequence() + .filter { it.backupPath.isNotBlank() && it.normalizedKey() !in restoreSkipped } + .mapTo(mutableSetOf()) { it.normalizedKey() } + var removed = 0 + val preserved = mutableListOf() + + val symlinkOperations = manifest.operations.filter { operation -> + val operationPath = operation.targetPath + File.separator + val ownedUnderOperation = manifest.files.filter { owned -> + owned.mode == "SYMLINK" && + (owned.normalizedTargetKey == operation.normalizedTargetKey || owned.targetPath.startsWith(operationPath)) + } + operation.mode == "SYMLINK" && + ownedUnderOperation.isNotEmpty() && + ownedUnderOperation.all { it.normalizedTargetKey in targetKeys } + } + symlinkOperations.forEach { operation -> + val target = File(operation.targetPath) + val source = File(operation.sourcePath) + val link = target.toPath() + val pointsToSource = runCatching { + val raw = Files.readSymbolicLink(link) + val resolved = if (raw.isAbsolute) raw else link.parent.resolve(raw) + resolved.normalize().toFile().canonicalFile == source.canonicalFile + }.getOrDefault(false) + if (Files.isSymbolicLink(link) && pointsToSource) { + Files.deleteIfExists(link) + removed++ + } else { + preserved += selected.filter { owned -> + owned.mode == "SYMLINK" && + ( + owned.normalizedTargetKey == operation.normalizedTargetKey || + owned.targetPath.startsWith(operation.targetPath + File.separator) + ) + } + } + } + + selected.filter { it.mode != "SYMLINK" }.forEach { owned -> + val overwrite = overwriteByKey[owned.normalizedTargetKey] + when { + owned.normalizedTargetKey in restoredKeys -> Unit + overwrite != null && !restoreBackups -> preserved += owned.preserved() + overwrite?.backupPath?.isBlank() == true -> Unit + owned.disposition == ModOwnedFileDisposition.SHARED -> Unit + owned.normalizedTargetKey in restoreSkipped -> preserved += owned.preserved() + else -> { + val target = File(owned.targetPath) + val currentHash = ModOwnershipStore.sha256(target) + if (target.isFile && currentHash.isNotBlank() && currentHash == owned.installedHash) { + if (target.delete()) removed++ else preserved += owned.preserved() + } else if (target.exists() || Files.isSymbolicLink(target.toPath())) { + preserved += owned.preserved() + } + } + } + } + return ModOwnershipCleanupResult(removed, restoredKeys.size, preserved.distinctBy { it.normalizedTargetKey }) + } + + private fun ModOwnedFile.preserved(): ModOwnedFile = + copy(active = false, disposition = ModOwnedFileDisposition.STALE_PRESERVED) + + private fun ModOverwriteManifest.normalizedKey(): String = WindowsPathIdentity.absoluteKey(File(targetPath)) +} diff --git a/app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt b/app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt index 13f7553853..7c6e6e1b54 100644 --- a/app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt +++ b/app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt @@ -1,6 +1,7 @@ package app.gamenative.mods import app.gamenative.data.ModPlacementMode +import app.gamenative.data.ModTargetRoot import java.util.Locale data class ModPlacementPreset( @@ -13,28 +14,13 @@ data class ModPlacementPreset( data class ModPlacementPresetDraft( val sourceSubpath: String, val targetRelativePath: String, + val targetRoot: String = ModTargetRoot.GAME_DIR.name, val mode: String = ModPlacementMode.OVERWRITE_COPY.name, val includeSourceDirectory: Boolean = false, ) object ModPlacementPresetDetector { - private val bethesdaContentDirs = setOf( - "meshes", - "textures", - "scripts", - "interface", - "sound", - "seq", - "skse", - "f4se", - "sfse", - "strings", - "video", - "music", - "lodsettings", - "calientetools", - "nemesis_engine", - ) + private val bethesdaRule = ModPlacementRulePacks.bethesda fun detect(gameName: String, entries: List): List { if (entries.isEmpty()) return emptyList() @@ -69,7 +55,7 @@ object ModPlacementPresetDetector { else -> "" } return ModPlacementPreset( - id = "bethesda-data", + id = bethesdaRule.stableId, label = "Bethesda Data folder", description = "For Skyrim, Fallout, Oblivion, Morrowind, and Starfield mods.", drafts = listOf( @@ -83,15 +69,17 @@ object ModPlacementPresetDetector { } private fun detectBepInEx(entries: List): ModPlacementPreset? { - val source = entries.findDirectoryPath("BepInEx") ?: return null + val rule = ModPlacementRulePacks.bepInEx + val sourceName = rule.directoryTargets.keys.single() + val source = entries.findDirectoryPath(sourceName) ?: return null return ModPlacementPreset( - id = "bepinex", + id = rule.stableId, label = "BepInEx mod", description = "Places BepInEx plugins, patchers, config, and related folders into BepInEx.", drafts = listOf( ModPlacementPresetDraft( sourceSubpath = source, - targetRelativePath = "BepInEx", + targetRelativePath = rule.directoryTargets.getValue(sourceName), includeSourceDirectory = false, ), ), @@ -99,19 +87,19 @@ object ModPlacementPresetDetector { } private fun detectMelonLoader(entries: List): ModPlacementPreset? { - val drafts = listOf("Mods", "UserData", "Plugins") - .mapNotNull { name -> - entries.findDirectoryPath(name)?.let { source -> - ModPlacementPresetDraft( - sourceSubpath = source, - targetRelativePath = name, - includeSourceDirectory = false, - ) - } + val rule = ModPlacementRulePacks.melonLoader + val drafts = rule.directoryTargets.mapNotNull { (sourceName, target) -> + entries.findDirectoryPath(sourceName)?.let { source -> + ModPlacementPresetDraft( + sourceSubpath = source, + targetRelativePath = target, + includeSourceDirectory = false, + ) } + } if (drafts.isEmpty()) return null return ModPlacementPreset( - id = "melonloader", + id = rule.stableId, label = "MelonLoader mod", description = "Places MelonLoader Mods, Plugins, or UserData folders into the game folder.", drafts = drafts, @@ -119,6 +107,7 @@ object ModPlacementPresetDetector { } private fun detectUnreal(entries: List): ModPlacementPreset? { + val rule = ModPlacementRulePacks.unreal val contentPaks = entries.findDirectoryPath("Content/Paks") val paks = entries.findDirectoryPath("Paks") val loosePakFiles = entries @@ -126,7 +115,7 @@ object ModPlacementPresetDetector { .map { it.path } .filter { path -> path.count { it == '/' } == 0 && - listOf(".pak", ".ucas", ".utoc").any { path.endsWith(it, ignoreCase = true) } + path.substringAfterLast('.', "").lowercase(Locale.ROOT) in rule.looseExtensions } val source = when { @@ -136,7 +125,7 @@ object ModPlacementPresetDetector { else -> return null } return ModPlacementPreset( - id = "unreal-paks", + id = rule.stableId, label = "Unreal Engine Paks", description = "Places Pak, UCAS, and UTOC files into Content/Paks.", drafts = listOf( @@ -153,19 +142,11 @@ object ModPlacementPresetDetector { gameName: String, entries: List, ): ModPlacementPreset? { - val looksLikeCyberpunk = gameName.lowercase(Locale.US).let { name -> - name.contains("cyberpunk") || name.contains("redmod") - } + val rule = ModPlacementRulePacks.redmod + val looksLikeCyberpunk = gameName.lowercase(Locale.US).let { name -> rule.gameNameTokens.any(name::contains) } if (!looksLikeCyberpunk) return null - val targets = listOf( - "archive/pc/mod", - "r6", - "red4ext", - "bin/x64/plugins", - "mods", - ) - val drafts = targets.mapNotNull { target -> - entries.findDirectoryPath(target)?.let { source -> + val drafts = rule.directoryTargets.mapNotNull { (sourcePath, target) -> + entries.findDirectoryPath(sourcePath)?.let { source -> ModPlacementPresetDraft( sourceSubpath = source, targetRelativePath = target, @@ -175,7 +156,7 @@ object ModPlacementPresetDetector { } if (drafts.isEmpty()) return null return ModPlacementPreset( - id = "redmod", + id = rule.stableId, label = "Cyberpunk / REDmod", description = "Places archive, r6, red4ext, bin plugin, or REDmod folders into the game.", drafts = drafts, @@ -209,7 +190,7 @@ object ModPlacementPresetDetector { val roots = mapNotNull { entry -> val path = normalizePath(entry.path) val segments = path.split('/').filter { it.isNotBlank() } - val contentIndex = segments.indexOfFirst { it.lowercase(Locale.US) in bethesdaContentDirs } + val contentIndex = segments.indexOfFirst { it.lowercase(Locale.US) in bethesdaRule.directoryTargets } when { contentIndex == 0 -> segments.first() contentIndex > 0 -> segments.take(contentIndex).joinToString("/") @@ -229,11 +210,11 @@ object ModPlacementPresetDetector { val normalized = normalizePath(source) return normalized.isNotBlank() && !normalized.contains("/") && - normalized.lowercase(Locale.US) in bethesdaContentDirs + normalized.lowercase(Locale.US) in bethesdaRule.directoryTargets } private fun String.endsWithBethesdaDataFile(): Boolean = - listOf(".esp", ".esm", ".esl", ".bsa", ".ba2").any { endsWith(it, ignoreCase = true) } + substringAfterLast('.', "").lowercase(Locale.US) in bethesdaRule.looseExtensions private fun normalizePath(path: String): String = path.replace('\\', '/') diff --git a/app/src/main/java/app/gamenative/mods/ModPlacementRulePacks.kt b/app/src/main/java/app/gamenative/mods/ModPlacementRulePacks.kt new file mode 100644 index 0000000000..55929dab63 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/ModPlacementRulePacks.kt @@ -0,0 +1,47 @@ +package app.gamenative.mods + +import java.util.Locale + +data class ModPlacementRulePack( + val stableId: String, + val version: Int, + val directoryTargets: Map = emptyMap(), + val looseExtensions: Set = emptySet(), + val gameNameTokens: Set = emptySet(), +) + +object ModPlacementRulePacks { + val bethesda = ModPlacementRulePack( + stableId = "bethesda-data", + version = 1, + directoryTargets = setOf( + "meshes", "textures", "scripts", "interface", "sound", "sounds", "strings", "skse", "f4se", + "sfse", "seq", "video", "music", "lodsettings", "calientetools", "nemesis_engine", + ).associateWith { "Data" }, + looseExtensions = setOf("esp", "esm", "esl", "bsa", "ba2"), + ) + val bepInEx = ModPlacementRulePack("bepinex", 1, mapOf("BepInEx" to "BepInEx")) + val melonLoader = ModPlacementRulePack( + "melonloader", + 1, + setOf("Mods", "UserData", "Plugins").associateWith { it }, + ) + val unreal = ModPlacementRulePack( + "unreal-paks", + 1, + mapOf("Content/Paks" to "Content/Paks", "Paks" to "Content/Paks"), + setOf("pak", "ucas", "utoc"), + ) + val redmod = ModPlacementRulePack( + "redmod", + 1, + setOf("archive/pc/mod", "r6", "red4ext", "bin/x64/plugins", "mods").associateWith { it }, + gameNameTokens = setOf("cyberpunk", "redmod"), + ) + + val builtIns: List = listOf(bethesda, bepInEx, melonLoader, unreal, redmod) + + val archiveSemanticAnchors: Set = builtIns.flatMapTo(mutableSetOf()) { rule -> + rule.directoryTargets.keys.flatMap { it.lowercase(Locale.ROOT).split('/') } + } +} diff --git a/app/src/main/java/app/gamenative/mods/ModTargetResolver.kt b/app/src/main/java/app/gamenative/mods/ModTargetResolver.kt index f9cf58b5c5..4877bf4028 100644 --- a/app/src/main/java/app/gamenative/mods/ModTargetResolver.kt +++ b/app/src/main/java/app/gamenative/mods/ModTargetResolver.kt @@ -9,10 +9,22 @@ data class ResolvedModTargetRoot( val dir: File, ) +data class ModTargetPlanInspection( + val caseMerges: List, + val ambiguousPaths: List, +) + object ModTargetResolver { fun normalizeRelativePath(path: String): String = path.trim().replace('\\', '/').trim('/') + fun normalizedTargetKey(targetRoot: String, targetRelativePath: String): String? = + if (targetRoot == ModTargetRoot.CUSTOM_ABSOLUTE.name) { + WindowsPathIdentity.absoluteKey(File(targetRelativePath.trim().replace('\\', '/'))) + } else { + WindowsPathIdentity.targetKey(targetRoot, targetRelativePath) + } + fun roots(gameRootDir: File?, winePrefix: String): List { val result = mutableListOf() if (gameRootDir?.isDirectory == true) { @@ -38,28 +50,35 @@ object ModTargetResolver { targetRelativePath: String, gameRootDir: File?, winePrefix: String, - ): File? { - val rootType = runCatching { ModTargetRoot.valueOf(targetRoot) }.getOrNull() ?: return null - if (rootType == ModTargetRoot.CUSTOM_ABSOLUTE) { - val rawTarget = File(targetRelativePath.trim().replace('\\', '/')) - if (!rawTarget.isAbsolute) return null - val target = rawTarget.safeCanonicalFile() ?: return null - val allowedRoots = roots(gameRootDir, winePrefix).mapNotNull { it.dir.safeCanonicalFile() } - return target.takeIf { candidate -> - allowedRoots.any { root -> candidate.isInsideOrEqual(root) } - } - } - val root = roots(gameRootDir, winePrefix).firstOrNull { it.type == rootType }?.dir ?: return null - val cleanRelative = normalizeRelativePath(targetRelativePath) + ): File? = session(gameRootDir, winePrefix).resolve(targetRoot, targetRelativePath) + + fun session(gameRootDir: File?, winePrefix: String): ModTargetResolutionSession = + ModTargetResolutionSession(roots(gameRootDir, winePrefix)) + + fun resolveWithin(root: File, relativePath: String): File? { val rootCanonical = root.safeCanonicalFile() ?: return null - val target = if (cleanRelative.isBlank()) { - rootCanonical - } else { - File(rootCanonical, cleanRelative).safeCanonicalFile() ?: return null - } + val resolution = WindowsTargetNamespace(rootCanonical).resolve(relativePath) + val target = resolution.takeIf { it.isValid }?.file ?: return null return target.takeIf { it.isInsideOrEqual(rootCanonical) } } + fun inspectPlan( + plan: ModInstallPlan, + resolvedRoots: List, + ): ModTargetPlanInspection { + val caseMerges = mutableSetOf() + val ambiguities = mutableSetOf() + val namespaces = resolvedRoots.associate { it.type.name to WindowsTargetNamespace(it.dir) } + plan.files.filter { it.status == PlannedFileStatus.PLACED }.forEach { file -> + val relative = file.targetRelativePath ?: return@forEach + val namespace = namespaces[file.targetRoot] ?: return@forEach + val resolution = namespace.resolve(relative) + caseMerges += resolution.caseMerges + if (resolution.ambiguousSegments.isNotEmpty()) ambiguities += relative + } + return ModTargetPlanInspection(caseMerges.sorted(), ambiguities.sorted()) + } + private fun File.safeCanonicalFile(): File? = runCatching { canonicalFile }.getOrNull() @@ -70,3 +89,45 @@ object ModTargetResolver { return path.startsWith(rootPath.trimEnd(File.separatorChar) + File.separator) } } + +/** Reuses one Windows-style namespace for every target in a reviewed plan. */ +class ModTargetResolutionSession internal constructor( + resolvedRoots: List, +) { + private val rootsByType = resolvedRoots.mapNotNull { root -> + root.dir.safeCanonicalFile()?.let { canonical -> root.type to canonical } + }.toMap() + private val namespaces = rootsByType.mapValues { (_, root) -> WindowsTargetNamespace(root) } + + fun resolve(targetRoot: String, targetRelativePath: String): File? { + val rootType = runCatching { ModTargetRoot.valueOf(targetRoot) }.getOrNull() ?: return null + if (rootType == ModTargetRoot.CUSTOM_ABSOLUTE) return resolveCustom(targetRelativePath) + val root = rootsByType[rootType] ?: return null + val relative = ModTargetResolver.normalizeRelativePath(targetRelativePath) + if (WindowsPathIdentity.relativeSegments(relative) == null) return null + return namespaces.getValue(rootType).resolve(relative).takeIf { it.isValid }?.file + ?.takeIf { it.isInsideOrEqual(root) } + } + + private fun resolveCustom(path: String): File? { + val raw = File(path.trim().replace('\\', '/')) + if (!raw.isAbsolute) return null + val candidate = raw.safeCanonicalFile() ?: return null + val matchingRoot = rootsByType.entries + .filter { (_, root) -> candidate.isInsideOrEqual(root) } + .maxByOrNull { (_, root) -> root.path.length } + ?: return null + val relative = candidate.relativeToOrNull(matchingRoot.value)?.path.orEmpty() + return namespaces.getValue(matchingRoot.key).resolve(relative).takeIf { it.isValid }?.file + ?.takeIf { it.isInsideOrEqual(matchingRoot.value) } + } + + private fun File.safeCanonicalFile(): File? = runCatching { canonicalFile }.getOrNull() + + private fun File.isInsideOrEqual(root: File): Boolean { + if (this == root) return true + val rootPath = root.path + if (rootPath == File.separator) return path.startsWith(rootPath) + return path.startsWith(rootPath.trimEnd(File.separatorChar) + File.separator) + } +} diff --git a/app/src/main/java/app/gamenative/mods/NexusModManager.kt b/app/src/main/java/app/gamenative/mods/NexusModManager.kt index 02a5bbd119..0b16fa5451 100644 --- a/app/src/main/java/app/gamenative/mods/NexusModManager.kt +++ b/app/src/main/java/app/gamenative/mods/NexusModManager.kt @@ -66,18 +66,48 @@ enum class ModHealthSeverity { WARNING, } +enum class ModHealthAction { + REAPPLY_MISSING, + RECONFIGURE, + REVIEW_PLACEMENT, + REBUILD_PROFILE, + ADOPT_OWNERSHIP, + RESTORE_PREVIOUS, +} + data class ModHealthIssue( val severity: ModHealthSeverity, val title: String, val detail: String, val installName: String = "", + val installId: String = "", + val recommendedAction: ModHealthAction = ModHealthAction.REBUILD_PROFILE, ) data class ModHealthReport( val issues: List, + val facts: List = emptyList(), ) { val errorCount: Int get() = issues.count { it.severity == ModHealthSeverity.ERROR } val warningCount: Int get() = issues.count { it.severity == ModHealthSeverity.WARNING } + + fun sanitizedManifest(): String = buildString { + appendLine("health-version: 1") + appendLine("errors: $errorCount") + appendLine("warnings: $warningCount") + facts.forEach { fact -> appendLine("fact: ${ModDiagnosticSanitizer.text(fact)}") } + issues.forEach { issue -> + append(issue.severity.name) + append(' ') + if (issue.installName.isNotBlank()) append("${ModDiagnosticSanitizer.text(issue.installName)}: ") + append(ModDiagnosticSanitizer.text(issue.title)) + append(" action=") + append(issue.recommendedAction.name) + append(" [") + append(ModDiagnosticSanitizer.text(issue.detail)) + appendLine(']') + } + } } object NexusModManager { @@ -513,42 +543,136 @@ object NexusModManager { allowOverwrite: Boolean, saveLastPlacement: Boolean = true, preserveStatusOnError: Boolean = false, + profileId: String = "", + priority: Int = 0, + reviewedPlan: ModInstallPlan? = null, + checkpointHook: (ModDeploymentCheckpoint) -> Unit = {}, + ): ModPlacementResult = ModDeploymentCoordinator.withGameLock(install.appId) { + applyInstallLocked( + context = context, + install = install, + recipes = recipes, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + allowOverwrite = allowOverwrite, + saveLastPlacement = saveLastPlacement, + preserveStatusOnError = preserveStatusOnError, + profileId = profileId, + priority = priority, + reviewedPlan = reviewedPlan, + checkpointHook = checkpointHook, + ) + } + + private suspend fun applyInstallLocked( + context: Context, + install: ModInstall, + recipes: List, + gameRootDir: File?, + winePrefix: String, + allowOverwrite: Boolean, + saveLastPlacement: Boolean, + preserveStatusOnError: Boolean, + profileId: String, + priority: Int, + reviewedPlan: ModInstallPlan?, + checkpointHook: (ModDeploymentCheckpoint) -> Unit, ): ModPlacementResult = withContext(Dispatchers.IO) { val dao = dao(context) - val existingBackupPaths = dao.getOverwriteManifests(install.installId) + val existingManifests = dao.getOverwriteManifests(install.installId) + val existingBackupPaths = existingManifests .mapNotNull { it.backupPath.takeIf(String::isNotBlank) } .toSet() - val result = ModMaterializer.apply( + val ownershipRoot = cacheRoot(context, install.appId) + val previousOwnership = ModOwnershipStore.read(ownershipRoot, install.installId) + val authoritativePlan = reviewedPlan ?: previousOwnership?.reviewedPlanOrNull() + val plan = ModMaterializer.materializationPlan( install = install, recipes = recipes, gameRootDir = gameRootDir, winePrefix = winePrefix, + reviewedPlan = authoritativePlan, + ) + var journal = ModDeploymentJournalStore.begin(ownershipRoot, install.installId, install.appId, plan) + checkpointHook(ModDeploymentCheckpoint.PLANNED) + fun advance(checkpoint: ModDeploymentCheckpoint, detail: String = "") { + journal = ModDeploymentJournalStore.checkpoint(ownershipRoot, journal, checkpoint, detail) + checkpointHook(checkpoint) + } + advance(ModDeploymentCheckpoint.PREPARING) + advance(ModDeploymentCheckpoint.APPLYING) + val applied = ModMaterializer.apply( + install = install, + plan = plan, backupRoot = backupRoot(context, install.appId), allowOverwrite = allowOverwrite, ) + if (applied.errors.isEmpty()) advance(ModDeploymentCheckpoint.VERIFYING) + val verification = if (applied.errors.isEmpty()) ModDeploymentVerifier.verify(plan) else ModDeploymentVerification(emptyList()) + val result = if (verification.successful) { + applied + } else { + applied.copy( + errors = applied.errors + verification.issues.associate { issue -> + issue.targetPath to "${issue.type}: ${issue.detail}" + }, + ) + } if (result.errors.isEmpty()) { if (result.manifests.isNotEmpty()) { dao.replaceOverwriteManifestsForTargets(install.installId, result.manifests) } + val newTargetKeys = plan.files.mapTo(mutableSetOf()) { it.normalizedTargetKey } + val staleKeys = previousOwnership?.files + .orEmpty() + .filter { it.active && it.normalizedTargetKey !in newTargetKeys } + .mapTo(mutableSetOf()) { it.normalizedTargetKey } + val staleCleanup = if (previousOwnership != null && staleKeys.isNotEmpty()) { + ModOwnershipReconciler.removeOwnedFiles( + manifest = previousOwnership, + overwriteManifests = existingManifests, + targetKeys = staleKeys, + ) + } else { + ModOwnershipCleanupResult(0, 0, emptyList()) + } + val safelyReconciledTargets = staleKeys - staleCleanup.preserved.mapTo(mutableSetOf()) { it.normalizedTargetKey } + if (safelyReconciledTargets.isNotEmpty()) { + val staleManifestTargets = existingManifests + .filter { WindowsPathIdentity.absoluteKey(File(it.targetPath)) in safelyReconciledTargets } + .map { it.targetPath } + if (staleManifestTargets.isNotEmpty()) { + dao.deleteOverwriteManifestsForTargets(install.installId, staleManifestTargets) + } + } + val ownership = ModOwnershipStore.create( + appId = install.appId, + plan = plan, + overwriteManifests = existingManifests + result.manifests, + profileId = profileId, + priority = priority, + preservedStale = staleCleanup.preserved, + ) + ModOwnershipStore.write(ownershipRoot, ownership) if (install.status != ModInstallStatus.APPLIED.name) { dao.updateInstallStatus(install.installId, ModInstallStatus.APPLIED.name) } if (saveLastPlacement) saveLastPlacementForApp(install.appId, recipes) + advance(ModDeploymentCheckpoint.COMMITTED) + return@withContext result.copy(warnings = staleCleanup.skippedPaths) } else if (!preserveStatusOnError && install.status != ModInstallStatus.ERROR.name) { dao.updateInstallStatus(install.installId, ModInstallStatus.ERROR.name) } - if (result.errors.isEmpty()) return@withContext result + advance(ModDeploymentCheckpoint.ROLLING_BACK, "Apply or verification failed") val restoreSkipped = ModMaterializer.restoreBackups(result.manifests) val restoredTargets = result.manifests + .filter { it.backupPath.isNotBlank() } .map { it.targetPath } .filterNot { it in restoreSkipped } .toSet() - val removeSkipped = ModMaterializer.removeAppliedFiles( - install = install, - recipes = recipes, - gameRootDir = gameRootDir, - winePrefix = winePrefix, + val removeSkipped = ModMaterializer.rollbackAppliedPlan( + plan = plan, restoredOverwriteTargets = restoredTargets, ) result.manifests @@ -559,8 +683,10 @@ object NexusModManager { .forEach { deleteFileBytes(File(it)) } val rollbackSkipped = (restoreSkipped + removeSkipped).distinct() if (rollbackSkipped.isEmpty()) { + advance(ModDeploymentCheckpoint.ROLLED_BACK) result } else { + advance(ModDeploymentCheckpoint.RECOVERY_REQUIRED, "${rollbackSkipped.size} changed file(s) were preserved") result.copy( errors = result.errors + ("Rollback" to "${rollbackSkipped.size} changed file(s) left in place after failed apply"), ) @@ -572,41 +698,105 @@ object NexusModManager { recipes: List, gameRootDir: File?, winePrefix: String, + reviewedPlan: ModInstallPlan? = null, ): ModPlacementResult = - ModMaterializer.repairMissingTargets( + ModDeploymentCoordinator.withGameLock(install.appId) { + ModMaterializer.repairMissingTargets( + install = install, + recipes = recipes, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + reviewedPlan = reviewedPlan, + ) + } + + suspend fun restorePreviousDeployment( + context: Context, + install: ModInstall, + recipes: List, + gameRootDir: File?, + winePrefix: String, + profileId: String = "", + priority: Int = 0, + ): ModPlacementResult { + val previous = withContext(Dispatchers.IO) { + ModOwnershipStore.readPrevious(cacheRoot(context, install.appId), install.installId) + } + val plan = previous?.reviewedPlanOrNull() + ?: return ModPlacementResult( + created = 0, + skipped = 0, + backedUp = 0, + errors = mapOf(install.modName to "No previous reviewed deployment is available"), + manifests = emptyList(), + ) + return applyInstall( + context = context, install = install, recipes = recipes, gameRootDir = gameRootDir, winePrefix = winePrefix, + allowOverwrite = true, + saveLastPlacement = false, + preserveStatusOnError = true, + profileId = profileId, + priority = priority, + reviewedPlan = plan, ) + } - suspend fun cleanupBeforeRecipeReplacement( + suspend fun adoptHistoricalDeployment( context: Context, install: ModInstall, - oldRecipes: List, - newRecipes: List, + recipes: List, gameRootDir: File?, winePrefix: String, - ): List = withContext(Dispatchers.IO) { - if (oldRecipes.isEmpty() || samePlacementRecipes(oldRecipes, newRecipes)) return@withContext emptyList() - val dao = dao(context) - val manifests = dao.getOverwriteManifests(install.installId) - val restoreSkipped = ModMaterializer.restoreBackups(manifests) - val restoredTargets = manifests - .map { it.targetPath } - .filterNot { it in restoreSkipped } - .toSet() - val removeSkipped = ModMaterializer.removeAppliedFiles( - install = install, - recipes = oldRecipes, - gameRootDir = gameRootDir, - winePrefix = winePrefix, - restoredOverwriteTargets = restoredTargets, - ) - if (restoredTargets.isNotEmpty()) { - dao.deleteOverwriteManifestsForTargets(install.installId, restoredTargets.toList()) + profileId: String = "", + priority: Int = 0, + ): ModPlacementResult = ModDeploymentCoordinator.withGameLock(install.appId) { + withContext(Dispatchers.IO) { + val root = cacheRoot(context, install.appId) + if (install.status != ModInstallStatus.APPLIED.name || ModOwnershipStore.read(root, install.installId) != null) { + return@withContext ModPlacementResult( + created = 0, + skipped = 0, + backedUp = 0, + errors = mapOf( + install.modName to + "File tracking setup is only available for an older applied mod that is not tracked yet", + ), + manifests = emptyList(), + ) + } + val plan = ModMaterializer.materializationPlan( + install = install, + recipes = recipes, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + ) + val verification = ModDeploymentVerifier.verify(plan) + if (!plan.isComplete || !verification.successful) { + return@withContext ModPlacementResult( + created = 0, + skipped = 0, + backedUp = 0, + errors = plan.errors + verification.issues.associate { issue -> + issue.targetPath to "${issue.type}: ${issue.detail}" + }, + manifests = emptyList(), + ) + } + val manifests = dao(context).getOverwriteManifests(install.installId) + val ownership = ModOwnershipStore.create( + appId = install.appId, + plan = plan, + overwriteManifests = manifests, + profileId = profileId, + priority = priority, + ) + ModOwnershipStore.write(root, ownership) + ModPlacementResult(0, plan.files.size, 0, emptyMap(), emptyList()) } - restoreSkipped + removeSkipped } fun lastPlacementRecipesForApp(appId: String, installId: String): List { @@ -621,6 +811,7 @@ object NexusModManager { sourceSubpath = recipe.optString("sourceSubpath"), targetRoot = recipe.optString("targetRoot", ModTargetRoot.GAME_DIR.name), targetRelativePath = recipe.optString("targetRelativePath"), + targetFileName = recipe.optString("targetFileName"), mode = recipe.optString("mode", ModPlacementMode.SYMLINK.name), stripPrefixSegments = recipe.optInt("stripPrefixSegments", 0), includeSourceDirectory = recipe.optBoolean("includeSourceDirectory", false), @@ -646,6 +837,7 @@ object NexusModManager { .put("sourceSubpath", recipe.sourceSubpath) .put("targetRoot", recipe.targetRoot) .put("targetRelativePath", recipe.targetRelativePath) + .put("targetFileName", recipe.targetFileName) .put("mode", recipe.mode) .put("stripPrefixSegments", recipe.stripPrefixSegments) .put("includeSourceDirectory", recipe.includeSourceDirectory) @@ -656,58 +848,48 @@ object NexusModManager { PrefManager.nexusLastPlacementJson = root.toString() } - private fun samePlacementRecipes( - first: List, - second: List, - ): Boolean = - first.map(::recipeKey).sorted() == second.map(::recipeKey).sorted() - - private fun recipeKey(recipe: ModPlacementRecipe): String = - listOf( - recipe.sourceSubpath, - recipe.targetRoot, - normalizedRecipeTarget(recipe).lowercase(), - recipe.mode, - recipe.stripPrefixSegments.toString(), - recipe.includeSourceDirectory.toString(), - recipe.enabled.toString(), - ).joinToString("|") - - private fun normalizedRecipeTarget(recipe: ModPlacementRecipe): String = - if (recipe.targetRoot == ModTargetRoot.CUSTOM_ABSOLUTE.name) { - recipe.targetRelativePath.trim().replace('\\', '/') - } else { - ModTargetResolver.normalizeRelativePath(recipe.targetRelativePath) - } - suspend fun disableInstall( context: Context, install: ModInstall, restoreBackups: Boolean, gameRootDir: File? = null, winePrefix: String = ModContainerResolver.getWinePrefix(context, install.appId), + ): List = ModDeploymentCoordinator.withGameLock(install.appId) { + disableInstallLocked(context, install, restoreBackups, gameRootDir, winePrefix) + } + + private suspend fun disableInstallLocked( + context: Context, + install: ModInstall, + restoreBackups: Boolean, + gameRootDir: File?, + winePrefix: String, ): List = withContext(Dispatchers.IO) { val dao = dao(context) if (install.status != ModInstallStatus.APPLIED.name) { dao.updateInstallEnabled(install.installId, false, ModInstallStatus.DISABLED.name) return@withContext emptyList() } - val recipes = dao.getRecipesForInstall(install.installId) val manifests = dao.getOverwriteManifests(install.installId) - val skipped = if (restoreBackups) { - ModMaterializer.restoreBackups(manifests) - } else { - emptyList() + val ownershipRoot = cacheRoot(context, install.appId) + val ownership = ModOwnershipStore.read(ownershipRoot, install.installId) + if (ownership == null) { + dao.updateInstallEnabled(install.installId, false, ModInstallStatus.DISABLED.name) + return@withContext listOf("Verify and track this mod's installed files before disabling or removing it safely") } - val removalSkipped = ModMaterializer.removeAppliedFiles( - install = install, - recipes = recipes, - gameRootDir = gameRootDir, - winePrefix = winePrefix, - restoredOverwriteTargets = manifests.map { it.targetPath }.toSet(), + val cleanup = ModOwnershipReconciler.removeOwnedFiles(ownership, manifests, restoreBackups = restoreBackups) + val preservedByTarget = cleanup.preserved.associateBy { it.normalizedTargetKey } + ModOwnershipStore.write( + ownershipRoot, + ownership.copy( + state = ModOwnershipState.DISABLED, + files = ownership.files.map { file -> + preservedByTarget[file.normalizedTargetKey] ?: file.copy(active = false) + }, + ), ) dao.updateInstallEnabled(install.installId, false, ModInstallStatus.DISABLED.name) - skipped + removalSkipped + cleanup.skippedPaths } suspend fun deleteInstall( @@ -716,21 +898,26 @@ object NexusModManager { restoreBackups: Boolean, gameRootDir: File? = null, winePrefix: String = ModContainerResolver.getWinePrefix(context, install.appId), - ): List = withContext(Dispatchers.IO) { - val skipped = disableInstall(context, install, restoreBackups, gameRootDir, winePrefix) - val dao = dao(context) - dao.deleteOverwriteManifests(install.installId) - dao.deleteInstall(install.installId) - if (install.archivePath.isNotBlank()) { - val archiveFile = File(install.archivePath) - archiveFile.delete() - archiveFile.parentFile?.let { File(it, "${archiveFile.name}.part").delete() } + ): List = ModDeploymentCoordinator.withGameLock(install.appId) { + val skipped = disableInstallLocked(context, install, restoreBackups, gameRootDir, winePrefix) + withContext(Dispatchers.IO) { + val dao = dao(context) + dao.deleteOverwriteManifests(install.installId) + dao.deleteInstall(install.installId) + val installCacheRoot = cacheRoot(context, install.appId) + ModOwnershipStore.delete(installCacheRoot, install.installId) + ModDeploymentJournalStore.delete(installCacheRoot, install.installId) + if (install.archivePath.isNotBlank()) { + val archiveFile = File(install.archivePath) + archiveFile.delete() + archiveFile.parentFile?.let { File(it, "${archiveFile.name}.part").delete() } + } + File(install.extractedPath).deleteRecursively() + File("${install.extractedPath}.tmp").deleteRecursively() + File("${install.extractedPath}.previous").deleteRecursively() + File(backupRoot(context, install.appId), install.installId).deleteRecursively() + skipped } - File(install.extractedPath).deleteRecursively() - File("${install.extractedPath}.tmp").deleteRecursively() - File("${install.extractedPath}.previous").deleteRecursively() - File(backupRoot(context, install.appId), install.installId).deleteRecursively() - skipped } suspend fun deleteInstallsForApp( @@ -959,15 +1146,58 @@ object NexusModManager { title: String, detail: String, install: ModInstall? = null, + action: ModHealthAction = ModHealthAction.REBUILD_PROFILE, ) { - issues += ModHealthIssue(severity, title, detail, install?.modName.orEmpty()) + issues += ModHealthIssue( + severity, + title, + detail, + install?.modName.orEmpty(), + install?.installId.orEmpty(), + action, + ) } + val ownershipRoot = cacheRoot(context, appId) + val journals = ModDeploymentCoordinator.withGameLock(appId) { + ModDeploymentJournalStore.reconcile(ownershipRoot) + }.associateBy { it.installId } + val ownershipByInstallId = installs.mapNotNull { install -> + ModOwnershipStore.read(ownershipRoot, install.installId)?.let { install.installId to it } + }.toMap() + val activeProfile = dao.getActiveProfileForApp(appId) + val enabledPriorities = activeProfile?.let { profile -> + dao.getProfileInstallStates(appId, profile.profileId) + .filter { it.enabled } + .associate { it.installId to it.priority } + }.orEmpty() + val overlay = ModProfileOverlayPlanner.build(ownershipByInstallId.values.toList(), enabledPriorities) + val overlayFindings = ModDeploymentVerifier.verify( + overlay, + ModVerificationDepth.CHANGED_CONTENT, + ).issues + val overlayInstallIds = overlay.targets.values.asSequence() + .flatMap { it.contributors.asSequence() } + .mapTo(mutableSetOf()) { it.installId } + installs.forEach { install -> val status = runCatching { ModInstallStatus.valueOf(install.status) }.getOrNull() val extracted = File(install.extractedPath) val recipes = dao.getRecipesForInstall(install.installId) val manifests = dao.getOverwriteManifests(install.installId) + val ownership = ownershipByInstallId[install.installId] + val journal = journals[install.installId] + + if (journal?.checkpoint == ModDeploymentCheckpoint.RECOVERY_REQUIRED) { + val canRestore = ModOwnershipStore.readPrevious(ownershipRoot, install.installId)?.reviewedPlanOrNull() != null + add( + ModHealthSeverity.ERROR, + "Deployment recovery is required", + journal.detail, + install, + if (canRestore) ModHealthAction.RESTORE_PREVIOUS else ModHealthAction.RECONFIGURE, + ) + } if (status == null) { add(ModHealthSeverity.ERROR, "Unknown install status", install.status, install) @@ -983,7 +1213,42 @@ object NexusModManager { if (install.status == ModInstallStatus.APPLIED.name) { if (recipes.none { it.enabled }) { add(ModHealthSeverity.ERROR, "Applied mod has no placement recipe", "GameNative cannot verify or safely remove deployed files.", install) - } else if (extracted.isDirectory) { + } else if (ownership == null) { + add( + ModHealthSeverity.WARNING, + "Finish tracking installed files", + "This mod was installed before file tracking was added. Verify the files already in place so GameNative can disable or remove the mod safely. No files will be moved or replaced.", + install, + ModHealthAction.ADOPT_OWNERSHIP, + ) + } else if (ownership.state != ModOwnershipState.ACTIVE) { + add(ModHealthSeverity.ERROR, "Ownership state does not match the applied mod", ownership.state.name, install) + } else { + val findingsForInstall = overlayFindings.filter { it.installId == install.installId } + + ModDeploymentVerifier.verifyStale(ownership).issues + findingsForInstall.groupBy { it.type }.forEach { (type, findings) -> + val severity = if (type == ModVerificationIssueType.STALE) ModHealthSeverity.WARNING else ModHealthSeverity.ERROR + add( + severity, + when (type) { + ModVerificationIssueType.MISSING -> "Managed files are missing" + ModVerificationIssueType.MODIFIED -> "Managed files were modified" + ModVerificationIssueType.WRONG_CASE -> "Managed files have unexpected casing" + ModVerificationIssueType.AMBIGUOUS -> "Case-ambiguous target files exist" + ModVerificationIssueType.STALE -> "Stale managed files were preserved" + ModVerificationIssueType.OWNERSHIP -> "Ownership records need review" + }, + findings.take(3).joinToString("\n") { it.targetPath }, + install, + if (type == ModVerificationIssueType.MISSING) { + ModHealthAction.REAPPLY_MISSING + } else { + ModHealthAction.RECONFIGURE + }, + ) + } + } + if ((ownership == null || install.installId !in overlayInstallIds) && extracted.isDirectory) { val missing = missingAppliedTargets(install, recipes, gameRootDir, winePrefix).take(3) if (missing.isNotEmpty()) { add( @@ -991,9 +1256,31 @@ object NexusModManager { "Some mod files are missing from the game folder", "Apply order can restore them if the mod cache is still available.\n${missing.joinToString("\n")}", install, + ModHealthAction.REAPPLY_MISSING, ) } } + } else if (ownership != null) { + val preserved = ownership.copy( + files = ownership.files.filter { file -> + file.normalizedTargetKey !in overlay.targets + }, + ) + val findings = ModDeploymentVerifier.verifyStale(preserved).issues + if (findings.isNotEmpty()) { + add( + ModHealthSeverity.WARNING, + "Disabled mod still has changed files in the game folder", + buildString { + append("${findings.size} changed file(s) were kept to avoid deleting user changes. ") + append("They can still affect the game while this mod is disabled. ") + append("Review the placement to decide what to keep or remove; Apply order will not remove them.") + findings.take(3).forEach { finding -> append("\n${finding.targetPath}") } + }, + install, + ModHealthAction.REVIEW_PLACEMENT, + ) + } } if (install.status == ModInstallStatus.READY.name && manifests.isNotEmpty()) { add(ModHealthSeverity.WARNING, "Ready mod has overwrite records", "This mod is not applied but still has ${manifests.size} overwrite record(s).", install) @@ -1045,18 +1332,50 @@ object NexusModManager { .take(3) .forEach { add(ModHealthSeverity.WARNING, "Orphaned extracted cache", it.name) } - ModHealthReport(issues) + val ownershipProducers = ownershipByInstallId.values + .groupingBy { "${it.planProducerId}@${it.planProducerVersion}" } + .eachCount() + .entries + .sortedBy { it.key } + .joinToString(",") { "${it.key}:${it.value}" } + .ifBlank { "none" } + val journalCheckpoints = journals.values + .groupingBy { it.checkpoint.name } + .eachCount() + .entries + .sortedBy { it.key } + .joinToString(",") { "${it.key}:${it.value}" } + .ifBlank { "none" } + + ModHealthReport( + issues = issues, + facts = listOf( + "installs=${installs.size}", + "ownership-manifests=${ownershipByInstallId.size}", + "ownership-producers=$ownershipProducers", + "deployment-journals=${journals.size}", + "journal-checkpoints=$journalCheckpoints", + "profile-enabled=${enabledPriorities.size}", + "overlay-targets=${overlay.targets.size}", + ), + ) } + suspend fun reconcilePendingDeploymentsForApp(context: Context, appId: String): List = + ModDeploymentCoordinator.withGameLock(appId) { + withContext(Dispatchers.IO) { ModDeploymentJournalStore.reconcile(cacheRoot(context, appId)) } + } + fun hasMissingAppliedTargets( install: ModInstall, recipes: List, gameRootDir: File?, winePrefix: String, + reviewedPlan: ModInstallPlan? = null, ): Boolean = install.status == ModInstallStatus.APPLIED.name && recipes.any { it.enabled } && - missingAppliedTargets(install, recipes, gameRootDir, winePrefix).isNotEmpty() + missingAppliedTargets(install, recipes, gameRootDir, winePrefix, reviewedPlan).isNotEmpty() suspend fun archiveEntries(install: ModInstall): List = withContext(Dispatchers.IO) { ModArchiveExtractor.listExtractedEntries(File(install.extractedPath)) } @@ -1252,41 +1571,29 @@ object NexusModManager { recipes: List, gameRootDir: File?, winePrefix: String, + reviewedPlan: ModInstallPlan? = null, ): List { val missing = mutableListOf() - recipes.filter { it.enabled }.forEach { recipe -> - val mode = runCatching { ModPlacementMode.valueOf(recipe.mode) }.getOrDefault(ModPlacementMode.SYMLINK) - val entries = runCatching { - ModMaterializer.plannedEntries(install, listOf(recipe), gameRootDir, winePrefix) - }.getOrElse { e -> - missing += e.message ?: "${recipe.targetRoot}:${recipe.targetRelativePath}" - return@forEach - } - entries.forEach { entry -> - missing += missingTargetsForEntry(entry, mode) - if (missing.size >= 3) return missing + val plan = ModMaterializer.materializationPlan( + install, + recipes, + gameRootDir, + winePrefix, + captureTargetHashes = false, + reviewedPlan = reviewedPlan, + ) + missing += plan.errors.values + plan.operations.filter { it.mode == ModPlacementMode.SYMLINK }.forEach { entry -> + if (!Files.isSymbolicLink(entry.target.toPath())) { + missing += entry.target.absolutePath } + if (missing.size >= 3) return missing.take(3) } - return missing - } - - private fun missingTargetsForEntry(entry: ModPlannedEntry, mode: ModPlacementMode): List { - if (mode == ModPlacementMode.SYMLINK) { - return if (Files.isSymbolicLink(entry.target.toPath()) || entry.target.exists()) emptyList() else listOf(entry.target.absolutePath) + plan.files.filter { it.mode != ModPlacementMode.SYMLINK }.forEach { file -> + if (!file.target.isFile) missing += file.target.absolutePath + if (missing.size >= 3) return missing.take(3) } - if (entry.source.isFile) { - return if (entry.target.isFile) emptyList() else listOf(entry.target.absolutePath) - } - if (!entry.source.isDirectory) return emptyList() - return entry.source.walkTopDown() - .filter { it.isFile } - .take(50) - .mapNotNull { sourceFile -> - val relative = sourceFile.relativeTo(entry.source).path - File(entry.target, relative).takeUnless { it.isFile }?.absolutePath - } - .take(3) - .toList() + return missing } private fun partialFileFor(archiveFile: File): File? = diff --git a/app/src/main/java/app/gamenative/mods/PlacementRiskPolicy.kt b/app/src/main/java/app/gamenative/mods/PlacementRiskPolicy.kt new file mode 100644 index 0000000000..58932d1f42 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/PlacementRiskPolicy.kt @@ -0,0 +1,69 @@ +package app.gamenative.mods + +import app.gamenative.data.ModTargetRoot +import java.util.Locale + +data class PlacementRiskAssessment( + val risk: PlacementRisk, + val evidence: String = "", +) + +/** + * One origin-independent policy for destinations that deserve extra review. + * Individual mods and archive names must never participate in this decision. + */ +object PlacementRiskPolicy { + private val executableExtensions = setOf( + "asi", "bat", "cmd", "com", "dll", "exe", "jar", "js", "msi", "ps1", "scr", "vbs", + ) + private val rootConfigurationExtensions = setOf("cfg", "conf", "ini", "json", "toml", "xml", "yaml", "yml") + + fun assess(targetRoot: String?, targetRelativePath: String?): PlacementRiskAssessment { + val root = runCatching { ModTargetRoot.valueOf(targetRoot.orEmpty()) }.getOrNull() + val path = normalizeArchiveDisplayPath(targetRelativePath.orEmpty()) + val extension = path.substringAfterLast('.', "").lowercase(Locale.ROOT) + val atRoot = '/' !in path.trim('/') + + return when { + root == ModTargetRoot.CUSTOM_ABSOLUTE -> PlacementRiskAssessment( + PlacementRisk.UNSAFE, + "Custom absolute destinations require explicit confirmation", + ) + root == ModTargetRoot.WINE_C -> PlacementRiskAssessment( + PlacementRisk.UNSAFE, + "Direct writes to the Wine C: drive require explicit confirmation", + ) + root == ModTargetRoot.GAME_DIR && atRoot && extension in executableExtensions -> PlacementRiskAssessment( + PlacementRisk.UNSAFE, + "Executable or loader content in the game directory requires explicit confirmation", + ) + root == ModTargetRoot.GAME_DIR && atRoot && extension in rootConfigurationExtensions -> PlacementRiskAssessment( + PlacementRisk.REVIEW, + "Game-directory configuration file should be reviewed", + ) + else -> PlacementRiskAssessment(PlacementRisk.SAFE) + } + } + + fun enforce(plan: ModInstallPlan): ModInstallPlan { + val files = plan.files.map { file -> + if (file.status != PlannedFileStatus.PLACED) return@map file + val assessment = assess(file.targetRoot, file.targetRelativePath) + if (assessment.risk <= file.risk) return@map file + file.copy( + risk = assessment.risk, + evidence = (file.evidence + assessment.evidence).filter(String::isNotBlank).distinct(), + ) + } + val needsApproval = files.any { it.risk == PlacementRisk.UNSAFE && !it.riskApproved } + return plan.copy( + files = files, + blockingIssues = when { + needsApproval && ModInstallPlan.RISKY_ROOT_REVIEW_BLOCKER !in plan.blockingIssues -> + plan.blockingIssues + ModInstallPlan.RISKY_ROOT_REVIEW_BLOCKER + !needsApproval -> plan.blockingIssues.filterNot { it == ModInstallPlan.RISKY_ROOT_REVIEW_BLOCKER } + else -> plan.blockingIssues + }, + ) + } +} diff --git a/app/src/main/java/app/gamenative/mods/WindowsTargetNamespace.kt b/app/src/main/java/app/gamenative/mods/WindowsTargetNamespace.kt new file mode 100644 index 0000000000..e033f765d2 --- /dev/null +++ b/app/src/main/java/app/gamenative/mods/WindowsTargetNamespace.kt @@ -0,0 +1,131 @@ +package app.gamenative.mods + +import java.io.File +import java.util.Locale + +data class WindowsTargetResolution( + val file: File?, + val normalizedRelativeKey: String?, + val caseMerges: List = emptyList(), + val ambiguousSegments: List = emptyList(), +) { + val isValid: Boolean + get() = file != null && normalizedRelativeKey != null && ambiguousSegments.isEmpty() +} + +class WindowsTargetNamespace( + root: File, +) { + private val root = root.canonicalFile + private val listingCache = mutableMapOf>>() + private val plannedChildren = mutableMapOf>() + + fun resolve(relativePath: String): WindowsTargetResolution { + val segments = WindowsPathIdentity.relativeSegments(relativePath) + ?: return WindowsTargetResolution(null, null) + var current = root + val caseMerges = mutableListOf() + val ambiguities = mutableListOf() + + for ((index, requested) in segments.withIndex()) { + val matches = childrenByWindowsName(current)[WindowsPathIdentity.segmentKey(requested)].orEmpty() + when { + matches.size > 1 -> { + ambiguities += segments.take(index + 1).joinToString("/") + break + } + matches.size == 1 -> { + val existing = matches.single() + if (existing.name != requested) { + caseMerges += "${segments.take(index).joinToString("/")}/$requested -> ${existing.name}" + .trimStart('/') + } + current = existing + } + else -> { + val parentKey = directoryKey(current) + val requestedKey = WindowsPathIdentity.segmentKey(requested) + val planned = plannedChildren[parentKey]?.get(requestedKey) + if (planned != null) { + if (planned.name != requested) { + caseMerges += "${segments.take(index).joinToString("/")}/$requested -> ${planned.name}" + .trimStart('/') + } + current = planned + } else { + current = File(current, requested) + plannedChildren.getOrPut(parentKey, ::mutableMapOf)[requestedKey] = current + } + } + } + } + + return WindowsTargetResolution( + file = current.takeIf { ambiguities.isEmpty() }, + normalizedRelativeKey = segments.joinToString("/") { WindowsPathIdentity.segmentKey(it) }, + caseMerges = caseMerges, + ambiguousSegments = ambiguities, + ) + } + + fun invalidate() { + listingCache.clear() + plannedChildren.clear() + } + + private fun childrenByWindowsName(dir: File): Map> { + val key = directoryKey(dir) + return listingCache.getOrPut(key) { + if (!dir.isDirectory) { + emptyMap() + } else { + dir.listFiles().orEmpty().groupBy { WindowsPathIdentity.segmentKey(it.name) } + } + } + } + + private fun directoryKey(dir: File): String = + WindowsPathIdentity.absoluteKey(runCatching { dir.canonicalFile }.getOrDefault(dir.absoluteFile)) +} + +object WindowsPathIdentity { + private val invalidSegmentChars = setOf('<', '>', ':', '"', '|', '?', '*') + private val reservedNames = buildSet { + addAll(listOf("con", "prn", "aux", "nul")) + (1..9).forEach { index -> + add("com$index") + add("lpt$index") + } + } + + fun normalizedRelativeKey(path: String): String? = + relativeSegments(path)?.joinToString("/", transform = ::segmentKey) + + fun targetKey(targetRoot: String, relativePath: String): String? = + normalizedRelativeKey(relativePath)?.let { "$targetRoot:$it" } + + fun absoluteKey(file: File): String = + file.absoluteFile.normalize().path + .replace(File.separatorChar, '/') + .split('/') + .joinToString("/", transform = ::segmentKey) + + internal fun segmentKey(segment: String): String = + segment.trimEnd(' ', '.').lowercase(Locale.ROOT) + + internal fun relativeSegments(path: String): List? { + val normalized = path.trim().replace('\\', '/') + if (normalized.startsWith('/') || normalized.startsWith("//")) return null + if (Regex("^[A-Za-z]:").containsMatchIn(normalized)) return null + val segments = normalized.split('/').filter(String::isNotBlank) + if (segments.any { it == "." || it == ".." }) return null + if (segments.any(::isUnsafeWindowsSegment)) return null + return segments + } + + private fun isUnsafeWindowsSegment(segment: String): Boolean { + val key = segmentKey(segment) + if (key.isBlank() || key.substringBefore('.') in reservedNames) return true + return segment.any { it.code < 32 || it in invalidSegmentChars } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt index 028be7691c..de092c2946 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -44,6 +45,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -65,6 +67,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri +import androidx.core.content.FileProvider import app.gamenative.R import app.gamenative.data.LibraryItem import app.gamenative.data.ModInstall @@ -82,8 +85,14 @@ import app.gamenative.mods.BethesdaPluginAssetIssue import app.gamenative.mods.BethesdaPluginDependencyIssue import app.gamenative.mods.BethesdaPluginManager import app.gamenative.mods.AuthorizedNexusWebsiteDownload +import app.gamenative.mods.AutomaticPlacementPlanner +import app.gamenative.mods.AutomaticPlacementCandidate +import app.gamenative.mods.AutomaticPlacementContext +import app.gamenative.mods.AutomaticPlacementResult import app.gamenative.mods.BrowserFirstNexusWebsiteDownload import app.gamenative.mods.FomodInstaller +import app.gamenative.mods.FomodEnvironmentSnapshot +import app.gamenative.mods.FomodEnvironmentSnapshotBuilder import app.gamenative.mods.FomodAutoSelector import app.gamenative.mods.FomodInstallerDetector import app.gamenative.mods.FomodParser @@ -95,13 +104,23 @@ import app.gamenative.mods.truncateAtCodePointBoundary import app.gamenative.mods.ModArchiveEntry import app.gamenative.mods.ModArchiveInstallAssessor import app.gamenative.mods.ModConflictAnalyzer +import app.gamenative.mods.ModConfigurationDraft +import app.gamenative.mods.ModConfigurationDraftStore +import app.gamenative.mods.ModConfigurationRecipe import app.gamenative.mods.ModDownloadInfo import app.gamenative.mods.ModDownloadRegistry +import app.gamenative.mods.ModDeploymentCoordinator import app.gamenative.mods.ModFileConflictReport +import app.gamenative.mods.ModHealthAction import app.gamenative.mods.ModHealthReport import app.gamenative.mods.ModHealthSeverity import app.gamenative.mods.ModImportProgress +import app.gamenative.mods.ModInstallPlan import app.gamenative.mods.ModMaterializer +import app.gamenative.mods.ModOwnershipManifest +import app.gamenative.mods.ModOwnershipStore +import app.gamenative.mods.PlannedFileStatus +import app.gamenative.mods.PlacementRiskPolicy import app.gamenative.mods.ModPathDetector import app.gamenative.mods.ModPlacementConflict import app.gamenative.mods.ModPlacementPreset @@ -133,18 +152,24 @@ import app.gamenative.mods.NexusUserInfo import app.gamenative.mods.PendingNexusWebsiteDownload import app.gamenative.mods.NexusUrlParser import app.gamenative.mods.isPastPendingTtl +import app.gamenative.mods.reviewedPlanOrNull import app.gamenative.service.NexusModImportService import app.gamenative.ui.screen.auth.NexusOAuthBrowserLauncher import app.gamenative.ui.util.LocalSnackbarHostController import app.gamenative.ui.util.SnackbarManager import app.gamenative.utils.StorageUtils -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File @@ -154,6 +179,7 @@ internal data class RecipeDraft( val sourceSubpath: String = "", val targetRoot: String = ModTargetRoot.GAME_DIR.name, val targetRelativePath: String = "", + val targetFileName: String = "", val mode: String = ModPlacementMode.SYMLINK.name, val stripPrefixSegments: Int = 0, val includeSourceDirectory: Boolean = false, @@ -166,6 +192,12 @@ internal enum class PlacementChoice { CUSTOM, } +private data class PlacementPlanPreview( + val installId: String, + val drafts: List, + val plan: ModInstallPlan, +) + private enum class ManageModsTab { IMPORT, MODS, @@ -295,15 +327,25 @@ internal data class CollectionQueueItem( internal data class PendingFomodResult( val drafts: List, + val plan: ModInstallPlan?, val unsupportedCount: Int, + val unresolvedDetails: List, + val blockingIssues: List, val selectedOptions: List, val conditionalRuleCount: Int, ) +internal data class PlacementApplyFailure( + val installId: String, + val installName: String, + val errors: Map, +) + internal data class PendingApply( val install: ModInstall, val recipes: List, val conflicts: List, + val reviewedPlan: ModInstallPlan? = null, ) internal data class PendingProfileApply( @@ -337,12 +379,17 @@ private data class ModDiagnosticsSnapshot( ) private data class ProfileOrderPlan( + val profileId: String, val stateByInstallId: Map, val disabledInstalls: List, val configuredInstalls: List, val installsToApply: List, + val rebuildManagedOverlay: Boolean, + val overlayTransitionBlockerCount: Int, val missingTargetRepairInstallIds: Set, val recipesByInstallId: Map>, + val ownershipByInstallId: Map, + val reviewedPlansByInstallId: Map, val recipesToPersistByInstallId: Map>, val unconfiguredCount: Int, val unconfiguredNames: List, @@ -364,6 +411,7 @@ private data class ProfileOrderApplyResult( val plugins: List, val pluginIssues: List, val pluginAssetIssues: List, + val disabledSkipped: Int = 0, ) @Composable @@ -646,6 +694,11 @@ private fun InstallHealthSection( report: ModHealthReport?, loading: Boolean, onCheck: () -> Unit, + onRebuild: () -> Unit, + onReconfigure: (String) -> Unit, + onAdoptOwnership: (String) -> Unit, + onRestorePrevious: (String) -> Unit, + onExport: (ModHealthReport) -> Unit, ) { NexusSectionCard { NexusSectionHeader(stringResource(R.string.nexus_install_health_title), loading, stringResource(R.string.nexus_check), onCheck) @@ -660,11 +713,54 @@ private fun InstallHealthSection( } else { val summaryColor = if (current.errorCount > 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant Text(stringResource(R.string.nexus_install_health_summary, current.errorCount, current.warningCount), style = MaterialTheme.typography.bodySmall, color = summaryColor) + if ( + current.issues.any { + it.recommendedAction == ModHealthAction.REAPPLY_MISSING || + it.recommendedAction == ModHealthAction.REBUILD_PROFILE + } + ) { + OutlinedButton(onClick = onRebuild, enabled = !loading, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.nexus_apply_order)) + } + } + OutlinedButton(onClick = { onExport(current) }, enabled = !loading, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.nexus_plan_export)) + } current.issues.take(8).forEach { issue -> Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { val titleColor = if (issue.severity == ModHealthSeverity.ERROR) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface Text(listOf(issue.installName, issue.title).filter(String::isNotBlank).joinToString(": "), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = titleColor) Text(issue.detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (issue.installId.isNotBlank()) { + TextButton( + enabled = !loading, + onClick = { + when (issue.recommendedAction) { + ModHealthAction.REAPPLY_MISSING, + ModHealthAction.REBUILD_PROFILE, + -> onRebuild() + ModHealthAction.ADOPT_OWNERSHIP -> onAdoptOwnership(issue.installId) + ModHealthAction.RESTORE_PREVIOUS -> onRestorePrevious(issue.installId) + ModHealthAction.RECONFIGURE, + ModHealthAction.REVIEW_PLACEMENT, + -> onReconfigure(issue.installId) + } + }, + ) { + Text( + stringResource( + when (issue.recommendedAction) { + ModHealthAction.REAPPLY_MISSING -> R.string.nexus_reapply_missing_files + ModHealthAction.REBUILD_PROFILE -> R.string.nexus_apply_order + ModHealthAction.ADOPT_OWNERSHIP -> R.string.nexus_adopt_ownership + ModHealthAction.RESTORE_PREVIOUS -> R.string.nexus_restore_previous_deployment + ModHealthAction.RECONFIGURE -> R.string.nexus_configure + ModHealthAction.REVIEW_PLACEMENT -> R.string.nexus_review_placement + }, + ), + ) + } + } } } if (current.issues.size > 8) { @@ -675,6 +771,31 @@ private fun InstallHealthSection( } } +@Composable +private fun PlacementApplyFailureSection( + failure: PlacementApplyFailure, + onReconfigure: () -> Unit, +) { + NexusSectionCard { + Text( + stringResource(R.string.nexus_apply_failure_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.error, + ) + Text(failure.installName, style = MaterialTheme.typography.labelLarge) + Text( + stringResource(R.string.nexus_apply_failure_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PlacementApplyFailureDetails(failure.errors) + OutlinedButton(onClick = onReconfigure, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.nexus_review_placement)) + } + } +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun NexusModsDialog( @@ -729,6 +850,7 @@ fun NexusModsDialog( var selectedInstall by remember { mutableStateOf(null) } var archiveEntries by remember { mutableStateOf>(emptyList()) } var selectedFomodInstaller by remember { mutableStateOf(null) } + var fomodEnvironment by remember { mutableStateOf(FomodEnvironmentSnapshot()) } var conflictReports by remember { mutableStateOf>(emptyList()) } var placementNeededInstallIds by remember { mutableStateOf>(emptySet()) } var bethesdaGame by remember(libraryItem.name) { mutableStateOf(BethesdaPluginManager.detectGame(libraryItem.name)) } @@ -741,7 +863,7 @@ fun NexusModsDialog( var localImportRetryInstallId by rememberSaveable(libraryItem.appId) { mutableStateOf(null) } - var localInspectionGeneration by remember { mutableStateOf(0L) } + var localInspectionGeneration by remember { mutableLongStateOf(0L) } var pendingCollectionSelection by remember { mutableStateOf(null) } var selectedCollectionKeys by remember { mutableStateOf>(emptySet()) } val collectionQueue = remember { mutableStateMapOf() } @@ -774,11 +896,24 @@ fun NexusModsDialog( var modApplyInProgress by remember { mutableStateOf(false) } var profileApplyInProgress by remember { mutableStateOf(false) } var placementApplyStatusMessage by remember { mutableStateOf(null) } + var placementApplyFailure by remember { mutableStateOf(null) } var pendingProfileNameEdit by remember { mutableStateOf(null) } var pendingProfileDelete by remember { mutableStateOf(null) } var placementChoice by remember { mutableStateOf(PlacementChoice.AUTOMATIC) } + var reviewedPlacementPlan by remember { mutableStateOf(null) } + var placementPlanPreview by remember { mutableStateOf(null) } + var automaticOptionSelections by remember { mutableStateOf>(emptyMap()) } + var riskyAutomaticPlanApproved by remember { mutableStateOf(false) } + var fomodSelectionDraft by remember { mutableStateOf>>(emptyMap()) } + var configurationDraftLoaded by remember { mutableStateOf(false) } + var automaticPlacementResult by remember { mutableStateOf(null) } + var automaticPlacementLoading by remember { mutableStateOf(false) } + var selectedOwnership by remember { mutableStateOf(null) } + var selectedPreviousOwnership by remember { mutableStateOf(null) } + var placementOwnershipManifests by remember(libraryItem.appId) { mutableStateOf>(emptyList()) } var lastPlacementDrafts by remember(libraryItem.appId) { mutableStateOf>(emptyList()) } var detectedDefaultDraft by remember(libraryItem.appId) { mutableStateOf(null) } + var automaticPlacementContext by remember(libraryItem.appId) { mutableStateOf(AutomaticPlacementContext()) } val defaultDraft = detectedDefaultDraft ?: fallbackDefaultDraft var selectedTab by remember(libraryItem.appId) { mutableStateOf(ManageModsTab.MODS) } val recipeDrafts = remember { mutableStateListOf() } @@ -788,6 +923,46 @@ fun NexusModsDialog( var healthLoading by remember(libraryItem.appId) { mutableStateOf(false) } var diagnosticsPaused by remember { mutableStateOf(false) } var nexusAuthActionInProgress by remember { mutableStateOf(false) } + + LaunchedEffect( + selectedInstall?.installId, + libraryItem.name, + archiveEntries, + automaticOptionSelections, + riskyAutomaticPlanApproved, + automaticPlacementContext, + ) { + val install = selectedInstall + if (install == null || archiveEntries.isEmpty()) { + automaticPlacementResult = null + automaticPlacementLoading = false + return@LaunchedEffect + } + automaticPlacementLoading = true + automaticPlacementResult = null + val placement = withContext(Dispatchers.Default) { + val planned = AutomaticPlacementPlanner.plan( + libraryItem.name, + archiveEntries, + automaticOptionSelections, + automaticPlacementContext, + ) + planned.copy( + candidates = planned.candidates.map { candidate -> + candidate.copy(plan = candidate.plan.withRiskyRootApproval(riskyAutomaticPlanApproved)) + }, + recommended = planned.recommended?.let { candidate -> + candidate.copy(plan = candidate.plan.withRiskyRootApproval(riskyAutomaticPlanApproved)) + }, + ) + } + automaticPlacementResult = placement + automaticPlacementLoading = false + if (placementChoice == PlacementChoice.AUTOMATIC && install.canPlaceFiles()) { + recipeDrafts.clear() + recipeDrafts += automaticDraftsFor(placement, libraryItem.name, archiveEntries, defaultDraft) + } + } val nexusAuthenticationUnavailableMessage = context.getString( when { @@ -1104,6 +1279,7 @@ fun NexusModsDialog( } launch { delay(750) + NexusModManager.reconcilePendingDeploymentsForApp(context, libraryItem.appId) NexusModImportService.resumeInterruptedImports(context) NexusModManager.cleanupOrphanedFilesForApp(context, libraryItem.appId) storageBreakdown = NexusModManager.scanStorageForApp(context, libraryItem.appId) @@ -1112,15 +1288,27 @@ fun NexusModsDialog( LaunchedEffect(roots, gameRootDir, winePrefix, libraryItem.name) { detectedDefaultDraft = null - detectedDefaultDraft = withContext(Dispatchers.IO) { + automaticPlacementContext = AutomaticPlacementContext() + val detected = withContext(Dispatchers.IO) { + val existingGameDirectories = gameRootDir?.listFiles().orEmpty() + .filter { it.isDirectory } + .mapTo(linkedSetOf()) { it.name } BethesdaPluginManager.detectGame(libraryItem.name)?.let { game -> - return@withContext RecipeDraft( + val draft = RecipeDraft( targetRoot = ModTargetRoot.GAME_DIR.name, targetRelativePath = game.dataDirName, mode = ModPlacementMode.OVERWRITE_COPY.name, ) + return@withContext draft to AutomaticPlacementContext( + defaultTargetRoot = draft.targetRoot, + defaultTargetRelativePath = draft.targetRelativePath, + defaultTargetIsProven = true, + existingGameDirectories = existingGameDirectories, + ) } - val detectedDir = ModPathDetector.detect(gameRootDir, winePrefix, libraryItem.name) + val pathDetection = ModPathDetector.detect(gameRootDir, winePrefix, libraryItem.name) + val detectedDir = pathDetection + ?.takeIf { it.confidence == "HIGH" } ?.targetDirs ?.firstOrNull() ?.canonicalFile @@ -1135,11 +1323,19 @@ fun NexusModsDialog( } else { "" } - RecipeDraft( + val draft = RecipeDraft( targetRoot = root?.type?.name ?: ModTargetRoot.GAME_DIR.name, targetRelativePath = relative, ) + draft to AutomaticPlacementContext( + defaultTargetRoot = draft.targetRoot, + defaultTargetRelativePath = draft.targetRelativePath, + defaultTargetIsProven = detectedDir != null && relative.isNotBlank(), + existingGameDirectories = existingGameDirectories, + ) } + detectedDefaultDraft = detected.first + automaticPlacementContext = detected.second } LaunchedEffect(pendingCollectionSelection) { @@ -1177,14 +1373,23 @@ fun NexusModsDialog( val recipesByInstallId = usableInstalls.associate { install -> install.installId to dao.getRecipesForInstall(install.installId) } + val ownershipRoot = NexusModManager.cacheRoot(context, libraryItem.appId) + val ownershipByInstallId = usableInstalls.mapNotNull { install -> + ModOwnershipStore.read(ownershipRoot, install.installId) + ?.let { install.installId to it } + }.toMap() val conflicts = ModConflictAnalyzer.analyze( installs = usableInstalls, recipesByInstallId = recipesByInstallId, prioritiesByInstallId = priorities, gameRootDir = gameRootDir, winePrefix = winePrefix, + ownershipByInstallId = ownershipByInstallId, ) val game = BethesdaPluginManager.detectGame(libraryItem.name) + val pluginFiles = game?.let { + BethesdaPluginManager.pluginFiles(winePrefix, it, libraryItem.gameSource) + } val detectedPlugins = game?.let { BethesdaPluginManager.detectPlugins( installs = usableInstalls, @@ -1192,7 +1397,8 @@ fun NexusModsDialog( prioritiesByInstallId = priorities, gameRootDir = gameRootDir, winePrefix = winePrefix, - pluginsFile = BethesdaPluginManager.pluginsFile(winePrefix, it), + pluginsFile = pluginFiles?.stateFile, + ownershipByInstallId = ownershipByInstallId, ) }.orEmpty() ModDiagnosticsSnapshot( @@ -1207,7 +1413,7 @@ fun NexusModsDialog( managedPlugins = detectedPlugins, game = it, gameRootDir = gameRootDir, - pluginsFile = BethesdaPluginManager.pluginsFile(winePrefix, it), + pluginsFile = pluginFiles?.stateFile, ) }.orEmpty(), pluginAssetIssues = if (game != null) BethesdaPluginManager.diagnosePluginAssets(detectedPlugins) else emptyList(), @@ -1381,20 +1587,21 @@ fun NexusModsDialog( fun writePluginState(updated: List) { val game = bethesdaGame ?: return - val pluginsFile = BethesdaPluginManager.pluginsFile(winePrefix, game) ?: return + val pluginFiles = BethesdaPluginManager.pluginFiles(winePrefix, game, libraryItem.gameSource) ?: return scope.launch { val issues = withContext(Dispatchers.IO) { BethesdaPluginManager.updateManagedPluginsTxt( - file = pluginsFile, + file = pluginFiles.targetFile, managedPlugins = updated, game = game, gameRootDir = gameRootDir, + migrationSourceFile = pluginFiles.stateFile, ) BethesdaPluginManager.diagnosePluginMasters( managedPlugins = updated, game = game, gameRootDir = gameRootDir, - pluginsFile = pluginsFile, + pluginsFile = pluginFiles.targetFile, ) } bethesdaPlugins = updated @@ -1429,7 +1636,7 @@ fun NexusModsDialog( } fun applyProfileOrder(allowOverwrite: Boolean): kotlinx.coroutines.Job? { - if (profileApplyInProgress) { + if (profileApplyInProgress || modApplyInProgress) { SnackbarManager.show(context.getString(R.string.nexus_mod_order_already_applying)) return null } @@ -1468,9 +1675,40 @@ fun NexusModsDialog( } install.installId to effectiveRecipes } - val configuredInstalls = orderedInstalls.filter { recipesByInstallId[it.installId].orEmpty().isNotEmpty() } + val allOwnership = currentInstalls.mapNotNull { install -> + app.gamenative.mods.ModOwnershipStore.read( + NexusModManager.cacheRoot(context, libraryItem.appId), + install.installId, + ) + } + val ownershipByInstallId = allOwnership.associateBy { it.installId } + val reviewedPlansByInstallId = allOwnership.mapNotNull { ownership -> + ownership.reviewedPlanOrNull()?.let { ownership.installId to it } + }.toMap() + val configuredInstalls = orderedInstalls.filter { install -> + recipesByInstallId[install.installId].orEmpty().isNotEmpty() || + install.installId in reviewedPlansByInstallId + } val unconfiguredInstalls = orderedInstalls - configuredInstalls.toSet() + val configuredOwnershipIds = allOwnership + .filter { it.state == app.gamenative.mods.ModOwnershipState.ACTIVE } + .mapTo(mutableSetOf()) { it.installId } + val desiredPriorities = stateByInstallId.values + .filter { it.enabled } + .associate { it.installId to it.priority } + val overlayTransition = app.gamenative.mods.ModProfileOverlayPlanner.transition( + allOwnership, + desiredPriorities, + ) + val rebuildManagedOverlay = requiresManagedOverlayRebuild( + configuredInstallIds = configuredInstalls.mapTo(mutableSetOf()) { it.installId }, + activeOwnershipInstallIds = configuredOwnershipIds, + transition = overlayTransition, + ) val game = BethesdaPluginManager.detectGame(libraryItem.name) + val pluginFiles = game?.let { + BethesdaPluginManager.pluginFiles(winePrefix, it, libraryItem.gameSource) + } val plugins = game?.let { BethesdaPluginManager.detectPlugins( installs = configuredInstalls, @@ -1478,7 +1716,8 @@ fun NexusModsDialog( prioritiesByInstallId = stateByInstallId.mapValues { state -> state.value.priority }, gameRootDir = gameRootDir, winePrefix = winePrefix, - pluginsFile = BethesdaPluginManager.pluginsFile(winePrefix, it), + pluginsFile = pluginFiles?.stateFile, + ownershipByInstallId = ownershipByInstallId, defaultEnabled = true, ) }.orEmpty() @@ -1488,34 +1727,58 @@ fun NexusModsDialog( managedPlugins = plugins, game = it, gameRootDir = gameRootDir, - pluginsFile = BethesdaPluginManager.pluginsFile(winePrefix, it), + pluginsFile = pluginFiles?.stateFile, ) }.orEmpty() val pluginAssetIssues = if (game != null) BethesdaPluginManager.diagnosePluginAssets(plugins) else emptyList() val assetRepairInstallIds = pluginAssetIssues.mapNotNull { it.plugin.installId }.toSet() val missingTargetRepairInstallIds = configuredInstalls .filter { install -> - NexusModManager.hasMissingAppliedTargets( + val ownership = ownershipByInstallId[install.installId] + if (ownership?.state == app.gamenative.mods.ModOwnershipState.ACTIVE) { + app.gamenative.mods.ModDeploymentVerifier.verifyPresence(ownership).issues.any { + it.type == app.gamenative.mods.ModVerificationIssueType.MISSING + } + } else { + NexusModManager.hasMissingAppliedTargets( + install = install, + recipes = recipesByInstallId[install.installId].orEmpty(), + gameRootDir = gameRootDir, + winePrefix = winePrefix, + reviewedPlan = reviewedPlansByInstallId[install.installId], + ) + } + } + .mapTo(mutableSetOf()) { it.installId } + val installsToApply = if (rebuildManagedOverlay) { + configuredInstalls + } else { + configuredInstalls.filter { install -> + shouldApplyProfileInstall( install = install, - recipes = recipesByInstallId[install.installId].orEmpty(), - gameRootDir = gameRootDir, - winePrefix = winePrefix, + hasActiveOwnership = install.installId in configuredOwnershipIds, + hasConflict = install.installId in conflictInstallIds, + needsAssetRepair = install.installId in assetRepairInstallIds, + hasMissingTarget = install.installId in missingTargetRepairInstallIds, ) } - .mapTo(mutableSetOf()) { it.installId } - val installsToApply = configuredInstalls.filter { install -> - install.status != ModInstallStatus.APPLIED.name || - install.installId in conflictInstallIds || - install.installId in assetRepairInstallIds || - install.installId in missingTargetRepairInstallIds } ProfileOrderPlan( + profileId = profile.profileId, stateByInstallId = stateByInstallId, disabledInstalls = disabledInstalls, configuredInstalls = configuredInstalls, installsToApply = installsToApply, + rebuildManagedOverlay = rebuildManagedOverlay, + overlayTransitionBlockerCount = if (overlayTransition.requiresRebuild && !overlayTransition.safeToRebuild) { + overlayTransition.currentVerification.issues.size + } else { + 0 + }, missingTargetRepairInstallIds = missingTargetRepairInstallIds, recipesByInstallId = recipesByInstallId, + ownershipByInstallId = ownershipByInstallId, + reviewedPlansByInstallId = reviewedPlansByInstallId, recipesToPersistByInstallId = recipesToPersistByInstallId, unconfiguredCount = unconfiguredInstalls.size, unconfiguredNames = unconfiguredInstalls.map { it.modName }, @@ -1529,6 +1792,15 @@ fun NexusModsDialog( bethesdaPlugins = plan.plugins bethesdaPluginIssues = plan.pluginIssues bethesdaPluginAssetIssues = plan.pluginAssetIssues + if (plan.overlayTransitionBlockerCount > 0) { + SnackbarManager.show( + context.getString( + R.string.nexus_mod_order_blocked_managed_files, + plan.overlayTransitionBlockerCount, + ), + ) + return@launch + } if (plan.pluginIssues.hasBlockingPluginIssues()) { SnackbarManager.show(context.getString(R.string.nexus_fix_plugin_warnings_before_apply)) return@launch @@ -1548,12 +1820,16 @@ fun NexusModsDialog( recipes = plan.recipesByInstallId[install.installId].orEmpty(), gameRootDir = gameRootDir, winePrefix = winePrefix, + reviewedPlan = plan.reviewedPlansByInstallId[install.installId], ) } ProfileOrderConflictCheck( rawConflicts = rawConflicts, conflicts = ModMaterializer.filterUnapprovedConflicts(rawConflicts, overwriteManifests), - hasOverwriteRecipe = plan.recipesByInstallId.values.flatten().any { it.mode == ModPlacementMode.OVERWRITE_COPY.name }, + hasOverwriteRecipe = plan.recipesByInstallId.values.flatten().any { it.mode == ModPlacementMode.OVERWRITE_COPY.name } || + plan.reviewedPlansByInstallId.values.any { reviewed -> + reviewed.files.any { it.status == PlannedFileStatus.PLACED && it.mode == ModPlacementMode.OVERWRITE_COPY.name } + }, ) } if (check.conflicts.isNotEmpty() && check.hasOverwriteRecipe) { @@ -1583,10 +1859,10 @@ fun NexusModsDialog( } } - if (plan.disabledInstalls.isNotEmpty()) { - loadingMessage = context.getString(R.string.nexus_applying_mod_order) - disabledSkipped = withContext(Dispatchers.IO) { - plan.disabledInstalls.sumOf { install -> + loadingMessage = context.getString(R.string.nexus_applying_mod_order) + val result = withContext(Dispatchers.IO) { + ModDeploymentCoordinator.withGameLock(libraryItem.appId) { + var transactionDisabledSkipped = plan.disabledInstalls.sumOf { install -> NexusModManager.disableInstall( context = context, install = install, @@ -1595,82 +1871,118 @@ fun NexusModsDialog( winePrefix = winePrefix, ).size } - } - } - - loadingMessage = context.getString(R.string.nexus_applying_mod_order) - val result = withContext(Dispatchers.IO) { - var errors = 0 - plan.installsToApply.forEach { install -> - val recipes = plan.recipesByInstallId[install.installId].orEmpty() - val result = if ( - !effectiveAllowOverwrite && - install.status == ModInstallStatus.APPLIED.name && - install.installId in plan.missingTargetRepairInstallIds - ) { - NexusModManager.repairMissingAppliedTargets( - install = install, - recipes = recipes, - gameRootDir = gameRootDir, - winePrefix = winePrefix, - ) - } else { - NexusModManager.applyInstall( - context = context, - install = install, - recipes = recipes, - gameRootDir = gameRootDir, - winePrefix = winePrefix, - allowOverwrite = effectiveAllowOverwrite, - saveLastPlacement = false, - preserveStatusOnError = true, - ) + if (plan.rebuildManagedOverlay) { + transactionDisabledSkipped += plan.configuredInstalls.asReversed().sumOf { install -> + NexusModManager.disableInstall( + context = context, + install = install, + restoreBackups = true, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + ).size + } + effectiveAllowOverwrite = true } - errors += result.errors.size - } - val game = BethesdaPluginManager.detectGame(libraryItem.name) - if (errors == 0 && game != null) { - val pluginsFile = BethesdaPluginManager.pluginsFile(winePrefix, game) - if (pluginsFile != null) { - val appliedInstalls = plan.configuredInstalls.map { it.copy(status = ModInstallStatus.APPLIED.name) } - val detectedPlugins = applyCollectionPluginOrder( - BethesdaPluginManager.detectPlugins( - installs = appliedInstalls, - recipesByInstallId = plan.recipesByInstallId, - prioritiesByInstallId = plan.stateByInstallId.mapValues { it.value.priority }, + var errors = 0 + for (install in plan.installsToApply) { + val recipes = plan.recipesByInstallId[install.installId].orEmpty() + val applyResult = if ( + !effectiveAllowOverwrite && + install.status == ModInstallStatus.APPLIED.name && + install.installId in plan.missingTargetRepairInstallIds + ) { + NexusModManager.repairMissingAppliedTargets( + install = install, + recipes = recipes, gameRootDir = gameRootDir, winePrefix = winePrefix, - pluginsFile = pluginsFile, - defaultEnabled = true, - ), - collectionPluginOrder, - ) - BethesdaPluginManager.updateManagedPluginsTxt( - file = pluginsFile, - managedPlugins = detectedPlugins, - game = game, - gameRootDir = gameRootDir, - ) - val issues = BethesdaPluginManager.diagnosePluginMasters( - managedPlugins = detectedPlugins, - game = game, - gameRootDir = gameRootDir, - pluginsFile = pluginsFile, - ) - ProfileOrderApplyResult( - errors = errors, - bethesdaGame = game, - plugins = detectedPlugins, - pluginIssues = issues, - pluginAssetIssues = BethesdaPluginManager.diagnosePluginAssets(detectedPlugins), - ) + reviewedPlan = plan.reviewedPlansByInstallId[install.installId], + ) + } else { + NexusModManager.applyInstall( + context = context, + install = if (plan.rebuildManagedOverlay) { + install.copy(status = ModInstallStatus.DISABLED.name) + } else { + install + }, + recipes = recipes, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + allowOverwrite = effectiveAllowOverwrite, + saveLastPlacement = false, + preserveStatusOnError = true, + profileId = plan.profileId, + priority = plan.stateByInstallId[install.installId]?.priority ?: 0, + reviewedPlan = plan.reviewedPlansByInstallId[install.installId], + ) + } + errors += applyResult.errors.size + // A rebuild disables every configured install first. Keep + // restoring later installs even if one plan fails so a + // single bad mod cannot leave the rest disabled. + if (applyResult.errors.isNotEmpty() && !plan.rebuildManagedOverlay) break + } + val game = BethesdaPluginManager.detectGame(libraryItem.name) + if (errors == 0 && game != null) { + val pluginFiles = BethesdaPluginManager.pluginFiles(winePrefix, game, libraryItem.gameSource) + if (pluginFiles != null) { + val appliedInstalls = plan.configuredInstalls.map { it.copy(status = ModInstallStatus.APPLIED.name) } + val appliedOwnership = plan.ownershipByInstallId.toMutableMap().apply { + plan.installsToApply.forEach { install -> + ModOwnershipStore.read( + NexusModManager.cacheRoot(context, libraryItem.appId), + install.installId, + )?.let { put(install.installId, it) } + } + } + val detectedPlugins = applyCollectionPluginOrder( + BethesdaPluginManager.detectPlugins( + installs = appliedInstalls, + recipesByInstallId = plan.recipesByInstallId, + prioritiesByInstallId = plan.stateByInstallId.mapValues { it.value.priority }, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + pluginsFile = pluginFiles.stateFile, + ownershipByInstallId = appliedOwnership, + defaultEnabled = true, + ), + collectionPluginOrder, + ) + BethesdaPluginManager.updateManagedPluginsTxt( + file = pluginFiles.targetFile, + managedPlugins = detectedPlugins, + game = game, + gameRootDir = gameRootDir, + migrationSourceFile = pluginFiles.stateFile, + ) + val issues = BethesdaPluginManager.diagnosePluginMasters( + managedPlugins = detectedPlugins, + game = game, + gameRootDir = gameRootDir, + pluginsFile = pluginFiles.targetFile, + ) + ProfileOrderApplyResult( + errors = errors, + bethesdaGame = game, + plugins = detectedPlugins, + pluginIssues = issues, + pluginAssetIssues = BethesdaPluginManager.diagnosePluginAssets(detectedPlugins), + disabledSkipped = transactionDisabledSkipped, + ) + } else { + ProfileOrderApplyResult(errors, null, emptyList(), emptyList(), emptyList(), transactionDisabledSkipped) + } } else { - ProfileOrderApplyResult(errors, null, emptyList(), emptyList(), emptyList()) + ProfileOrderApplyResult(errors, null, emptyList(), emptyList(), emptyList(), transactionDisabledSkipped) } - } else { - ProfileOrderApplyResult(errors, null, emptyList(), emptyList(), emptyList()) } } + disabledSkipped = result.disabledSkipped + if (plan.rebuildManagedOverlay && disabledSkipped > 0) { + SnackbarManager.show(context.getString(R.string.nexus_changed_disabled_files_left_in_place, disabledSkipped)) + return@launch + } result.bethesdaGame?.let { bethesdaGame = it bethesdaPlugins = result.plugins @@ -1721,49 +2033,154 @@ fun NexusModsDialog( if (install == null || !install.canPlaceFiles()) { archiveEntries = emptyList() selectedFomodInstaller = null + fomodEnvironment = FomodEnvironmentSnapshot() return } + archiveEntries = emptyList() + selectedFomodInstaller = null + fomodEnvironment = FomodEnvironmentSnapshot() scope.launch { - val (entries, fomodInstaller) = withContext(Dispatchers.IO) { + val (entries, fomodInstaller, environment) = withContext(Dispatchers.IO) { val extractedRoot = File(install.extractedPath) val parsedFomod = FomodInstallerDetector.moduleConfigFile(extractedRoot) ?.let { runCatching { FomodParser.parse(it, extractedRoot) }.getOrNull() } - NexusModManager.archiveEntries(install) to parsedFomod + val game = BethesdaPluginManager.detectGame(libraryItem.name) + Triple( + NexusModManager.archiveEntries(install), + parsedFomod, + parsedFomod?.let { installer -> + FomodEnvironmentSnapshotBuilder.build( + installer = installer, + gameName = libraryItem.name, + gameRootDir = gameRootDir, + pluginsFile = game?.let { + BethesdaPluginManager.pluginFiles(winePrefix, it, libraryItem.gameSource)?.stateFile + }, + ) + } ?: FomodEnvironmentSnapshot(), + ) } archiveEntries = entries selectedFomodInstaller = fomodInstaller - if (placementChoice == PlacementChoice.AUTOMATIC && install.canPlaceFiles()) { - recipeDrafts.clear() - recipeDrafts += automaticDraftsFor(libraryItem.name, entries, defaultDraft) + fomodEnvironment = environment + } + } + + fun adoptInstallOwnership(installId: String) { + val install = installs.firstOrNull { it.installId == installId } ?: return + scope.launch { + healthLoading = true + try { + val profile = activeProfile ?: ModProfileManager.ensureActiveProfile(dao, libraryItem.appId) + val state = ModProfileManager.ensureStateForInstall(dao, profile, install.installId) + val result = NexusModManager.adoptHistoricalDeployment( + context = context, + install = install, + recipes = dao.getRecipesForInstall(install.installId), + gameRootDir = gameRootDir, + winePrefix = winePrefix, + profileId = profile.profileId, + priority = state.priority, + ) + SnackbarManager.show( + if (result.errors.isEmpty()) { + context.getString(R.string.nexus_ownership_adopted) + } else { + context.getString(R.string.nexus_file_tracking_setup_failed, result.errors.size) + }, + ) + healthReport = NexusModManager.checkInstallHealthForApp(context, libraryItem.appId, gameRootDir, winePrefix) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + SnackbarManager.show(context.getString(R.string.nexus_file_tracking_setup_failed, 1)) + } finally { + healthLoading = false + } + } + } + + fun restorePreviousDeployment(installId: String) { + val install = installs.firstOrNull { it.installId == installId } ?: return + if (modApplyInProgress || profileApplyInProgress) return + scope.launch { + modApplyInProgress = true + diagnosticsPaused = true + try { + loadingMessage = context.getString(R.string.nexus_restoring_previous_deployment) + val profile = activeProfile ?: ModProfileManager.ensureActiveProfile(dao, libraryItem.appId) + val state = ModProfileManager.ensureStateForInstall(dao, profile, install.installId) + val result = NexusModManager.restorePreviousDeployment( + context = context, + install = install, + recipes = dao.getRecipesForInstall(install.installId), + gameRootDir = gameRootDir, + winePrefix = winePrefix, + profileId = profile.profileId, + priority = state.priority, + ) + val root = NexusModManager.cacheRoot(context, install.appId) + if (result.errors.isEmpty() && selectedInstall?.installId == install.installId) { + selectedOwnership = app.gamenative.mods.ModOwnershipStore.read(root, install.installId) + selectedPreviousOwnership = app.gamenative.mods.ModOwnershipStore.readPrevious(root, install.installId) + reviewedPlacementPlan = selectedOwnership?.reviewedPlanOrNull() + } + SnackbarManager.show( + if (result.errors.isEmpty()) { + context.getString(R.string.nexus_previous_deployment_restored) + } else { + context.getString(R.string.nexus_previous_deployment_restore_failed, result.errors.size) + }, + ) + if (healthReport != null) { + healthReport = NexusModManager.checkInstallHealthForApp(context, libraryItem.appId, gameRootDir, winePrefix) + } + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + SnackbarManager.show(context.getString(R.string.nexus_previous_deployment_restore_failed, 1)) + } finally { + modApplyInProgress = false + loadingMessage = null + diagnosticsPaused = false } } } fun loadRecipes(install: ModInstall?) { + configurationDraftLoaded = false + reviewedPlacementPlan = null + automaticOptionSelections = emptyMap() + riskyAutomaticPlanApproved = false + fomodSelectionDraft = emptyMap() + automaticPlacementResult = null recipeDrafts.clear() if (install == null || !install.canPlaceFiles()) { recipeDrafts += defaultDraft return } scope.launch { - val recipes = withContext(Dispatchers.IO) { dao.getRecipesForInstall(install.installId) } + val (recipes, savedDraft) = withContext(Dispatchers.IO) { + dao.getRecipesForInstall(install.installId) to + ModConfigurationDraftStore.read(NexusModManager.cacheRoot(context, install.appId), install) + } + val restoredRecipes = savedDraft?.recipes.orEmpty().map { it.toRecipe(install.installId) } + automaticOptionSelections = savedDraft?.automaticOptions.orEmpty() + riskyAutomaticPlanApproved = savedDraft?.riskyTargetsApproved == true + fomodSelectionDraft = savedDraft?.fomodSelections.orEmpty().mapValues { it.value.toSet() } recipeDrafts.clear() - if (recipes.isEmpty()) { - placementChoice = PlacementChoice.AUTOMATIC - recipeDrafts += automaticDraftsFor(libraryItem.name, archiveEntries, defaultDraft) + val selectedRecipes = restoredRecipes.ifEmpty { recipes } + val restoredChoice = savedDraft?.placementChoice + ?.let { runCatching { PlacementChoice.valueOf(it) }.getOrNull() } + if (selectedRecipes.isEmpty()) { + placementChoice = restoredChoice ?: PlacementChoice.AUTOMATIC + recipeDrafts += automaticPlacementResult + ?.let { automaticDraftsFor(it, libraryItem.name, archiveEntries, defaultDraft) } + .orEmpty() + .ifEmpty { listOf(defaultDraft) } } else { - placementChoice = PlacementChoice.CUSTOM - recipeDrafts += recipes.map { - RecipeDraft( - sourceSubpath = it.sourceSubpath, - targetRoot = it.targetRoot, - targetRelativePath = it.targetRelativePath, - mode = it.mode, - stripPrefixSegments = it.stripPrefixSegments, - includeSourceDirectory = it.includeSourceDirectory, - ) - } + placementChoice = restoredChoice ?: PlacementChoice.CUSTOM + recipeDrafts += selectedRecipes.map { it.toDraft() } } + configurationDraftLoaded = true } } @@ -2229,14 +2646,19 @@ fun NexusModsDialog( error = context.getString(R.string.nexus_collection_manual_external_entry), ) } + val embeddedFile = collectionFile.toFallbackNexusFile() + val embeddedModInfo = collectionFile.toEmbeddedNexusModInfo() return try { - val modInfo = apiClient.getModInfo(collectionFile.gameDomain, collectionFile.modId) + val modInfo = embeddedModInfo + ?: apiClient.getModInfo(collectionFile.gameDomain, collectionFile.modId) val files = apiClient.getModFiles(collectionFile.gameDomain, collectionFile.modId) val file = files.firstOrNull { it.fileId == collectionFile.fileId } - ?: collectionFile.toFallbackNexusFile() + ?: embeddedFile PendingCollectionMod(collectionFile, modInfo, file) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - val fallbackFile = collectionFile.toFallbackNexusFile() + val fallbackFile = embeddedFile if (fallbackFile != null) { PendingCollectionMod( collectionFile = collectionFile, @@ -2272,10 +2694,21 @@ fun NexusModsDialog( SnackbarManager.show(context.getString(R.string.nexus_collection_no_downloadable_mods)) return@launch } - val resolvedMods = mutableListOf() - collection.files.forEachIndexed { index, file -> - loadingMessage = context.getString(R.string.nexus_resolving_collection_item, index + 1, collection.files.size) - resolvedMods += resolveCollectionMod(file) + var resolvedCount = 0 + val resolutionGate = Semaphore(4) + val resolvedMods = coroutineScope { + collection.files.map { file -> + async { + val resolved = resolutionGate.withPermit { resolveCollectionMod(file) } + resolvedCount++ + loadingMessage = context.getString( + R.string.nexus_resolving_collection_item, + resolvedCount, + collection.files.size, + ) + resolved + } + }.awaitAll() } pendingFileSelection = null pendingCollectionSelection = PendingCollectionSelection(collection, resolvedMods) @@ -2375,11 +2808,24 @@ fun NexusModsDialog( val bethesdaGameForMod = BethesdaPluginManager.detectGame(libraryItem.name) val fomodAutoSelection = fomodInstaller?.let { installer -> bethesdaGameForMod?.let { game -> - FomodAutoSelector.selectDeterministic( - installId = install.installId, - installer = installer, - targetRelativePath = game.dataDirName, - ) + val environment = withContext(Dispatchers.IO) { + FomodEnvironmentSnapshotBuilder.build( + installer = installer, + gameName = libraryItem.name, + gameRootDir = gameRootDir, + pluginsFile = BethesdaPluginManager + .pluginFiles(winePrefix, game, libraryItem.gameSource) + ?.stateFile, + ) + } + withContext(Dispatchers.Default) { + FomodAutoSelector.selectDeterministic( + installId = install.installId, + installer = installer, + targetRelativePath = game.dataDirName, + environment = environment, + ) + } } } val assessment = ModArchiveInstallAssessor.assess( @@ -2395,7 +2841,9 @@ fun NexusModsDialog( val drafts = if (hasFomodInstaller || !assessment.allowsAutomaticPlacement) { emptyList() } else { - automaticDraftsFor(libraryItem.name, entries, defaultDraft) + withContext(Dispatchers.Default) { + automaticDraftsFor(libraryItem.name, entries, defaultDraft) + } } val existingRecipes = withContext(Dispatchers.IO) { dao.getRecipesForInstall(install.installId) } if (existingRecipes.isEmpty()) { @@ -2784,30 +3232,92 @@ fun NexusModsDialog( } } - fun buildRecipes(install: ModInstall): List = + fun buildRecipes(install: ModInstall, drafts: List): List = BethesdaPlacementRecipeExpander.expand( gameName = libraryItem.name, install = install, - recipes = recipeDrafts.map { draft -> draft.toRecipe(install.installId) }, + recipes = drafts.map { draft -> draft.toRecipe(install.installId) }, ) + LaunchedEffect( + selectedInstall?.installId, + placementChoice, + recipeDrafts.toList(), + ) { + val install = selectedInstall + val snapshot = recipeDrafts.toList() + if ( + install == null || + !install.canPlaceFiles() || + placementChoice == PlacementChoice.AUTOMATIC || + snapshot.isEmpty() || + reviewedPlacementPlan != null + ) { + placementPlanPreview = null + return@LaunchedEffect + } + delay(200) + val recipes = BethesdaPlacementRecipeExpander.expand( + gameName = libraryItem.name, + install = install, + recipes = snapshot.map { it.toRecipe(install.installId) }, + ) + val preview = withContext(Dispatchers.IO) { + runCatching { + ModMaterializer.materializationPlan( + install = install, + recipes = recipes, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + captureTargetHashes = false, + ).reviewedPlan + }.getOrNull() + } + placementPlanPreview = preview?.let { PlacementPlanPreview(install.installId, snapshot, it) } + } + + LaunchedEffect( + selectedInstall?.installId, + configurationDraftLoaded, + placementChoice, + automaticOptionSelections, + riskyAutomaticPlanApproved, + fomodSelectionDraft, + recipeDrafts.toList(), + ) { + val install = selectedInstall ?: return@LaunchedEffect + if (!configurationDraftLoaded || !install.canPlaceFiles()) return@LaunchedEffect + delay(200) + val draftSnapshot = recipeDrafts.toList() + val placementChoiceSnapshot = placementChoice.name + val automaticOptionsSnapshot = automaticOptionSelections + val riskyTargetsApprovedSnapshot = riskyAutomaticPlanApproved + val fomodSelectionsSnapshot = fomodSelectionDraft.mapValues { (_, values) -> values.sorted() } + withContext(Dispatchers.IO) { + val recipes = draftSnapshot.map { it.toRecipe(install.installId) } + ModConfigurationDraftStore.write( + NexusModManager.cacheRoot(context, install.appId), + ModConfigurationDraft( + installId = install.installId, + archiveIdentity = ModConfigurationDraftStore.archiveIdentity(install), + placementChoice = placementChoiceSnapshot, + automaticOptions = automaticOptionsSnapshot, + riskyTargetsApproved = riskyTargetsApprovedSnapshot, + fomodSelections = fomodSelectionsSnapshot, + recipes = recipes.map(ModConfigurationRecipe::from), + ), + ) + } + } + suspend fun applyRecipesInternal( install: ModInstall, recipes: List, allowOverwrite: Boolean, + reviewedPlan: ModInstallPlan? = null, ) { loadingMessage = context.getString(R.string.nexus_applying_mod_files) - val (cleanupSkipped, result) = withContext(Dispatchers.IO) { - val oldRecipes = dao.getRecipesForInstall(install.installId) - val skipped = NexusModManager.cleanupBeforeRecipeReplacement( - context = context, - install = install, - oldRecipes = oldRecipes, - newRecipes = recipes, - gameRootDir = gameRootDir, - winePrefix = winePrefix, - ) - dao.replaceRecipes(install.installId, recipes) + val result = withContext(Dispatchers.IO) { val applied = NexusModManager.applyInstall( context = context, install = install, @@ -2815,58 +3325,88 @@ fun NexusModsDialog( gameRootDir = gameRootDir, winePrefix = winePrefix, allowOverwrite = allowOverwrite, + preserveStatusOnError = true, + reviewedPlan = reviewedPlan, ) if (applied.errors.isEmpty()) { + dao.replaceRecipes(install.installId, recipes) val profile = activeProfile ?: ModProfileManager.ensureActiveProfile(dao, libraryItem.appId) val state = ModProfileManager.ensureStateForInstall(dao, profile, install.installId) dao.upsertProfileInstallState(state.copy(enabled = true, updatedAt = System.currentTimeMillis())) } - skipped to applied + applied + } + if (selectedInstall?.installId == install.installId) { + val ownership = withContext(Dispatchers.IO) { + val root = NexusModManager.cacheRoot(context, install.appId) + ModOwnershipStore.read(root, install.installId) to ModOwnershipStore.readPrevious(root, install.installId) + } + selectedOwnership = ownership.first + selectedPreviousOwnership = ownership.second } val message = if (result.errors.isEmpty()) { + placementApplyFailure = null lastPlacementDrafts = recipes.map { it.toDraft() } - val cleanupSuffix = if (cleanupSkipped.isNotEmpty()) { - context.getString(R.string.nexus_old_files_left_in_place_suffix, cleanupSkipped.size) + placementNeededInstallIds = placementNeededInstallIds - install.installId + val cleanupSuffix = if (result.warnings.isNotEmpty()) { + context.getString(R.string.nexus_old_files_left_in_place_suffix, result.warnings.size) } else { "" } - context.getString(R.string.nexus_applied_items_backups, result.created, result.backedUp, cleanupSuffix) + context.getString( + R.string.nexus_placement_complete_summary, + result.created + result.skipped, + result.created, + result.skipped, + result.backedUp, + cleanupSuffix, + ) } else { - context.getString(R.string.nexus_applied_with_errors, result.errors.size) + placementApplyFailure = PlacementApplyFailure(install.installId, install.modName, result.errors) + context.getString(R.string.nexus_apply_failed_rolled_back, result.errors.size) } placementApplyStatusMessage = message SnackbarManager.show(message) - selectedInstall = install.copy(status = if (result.errors.isEmpty()) ModInstallStatus.APPLIED.name else ModInstallStatus.ERROR.name) + if (selectedInstall?.installId == install.installId) { + selectedInstall = install.copy( + status = if (result.errors.isEmpty()) ModInstallStatus.APPLIED.name else install.status, + ) + } } fun applyRecipes( install: ModInstall, recipes: List, allowOverwrite: Boolean, + reviewedPlan: ModInstallPlan? = null, ) { - if (modApplyInProgress) { + if (modApplyInProgress || profileApplyInProgress) { SnackbarManager.show(context.getString(R.string.nexus_mod_apply_already_running)) return } scope.launch { modApplyInProgress = true + diagnosticsPaused = true try { placementApplyStatusMessage = null - applyRecipesInternal(install, recipes, allowOverwrite) + applyRecipesInternal(install, recipes, allowOverwrite, reviewedPlan) } catch (e: Exception) { val message = e.message ?: context.getString(R.string.nexus_failed_to_apply_mod) + placementApplyFailure = PlacementApplyFailure( + install.installId, + install.modName, + mapOf(install.modName to message), + ) placementApplyStatusMessage = message SnackbarManager.show(message) } finally { modApplyInProgress = false loadingMessage = null + diagnosticsPaused = false } } } - fun applyRecipes(install: ModInstall, allowOverwrite: Boolean) = - applyRecipes(install, buildRecipes(install), allowOverwrite) - fun saveAndApply() { val install = selectedInstall ?: return if (!install.canPlaceFiles()) { @@ -2877,30 +3417,73 @@ fun NexusModsDialog( SnackbarManager.show(context.getString(R.string.nexus_choose_destination_inside)) return } + val automaticPlan = if (placementChoice == PlacementChoice.AUTOMATIC) { + automaticPlacementResult?.recommended?.plan + } else { + null + } + if (placementChoice == PlacementChoice.AUTOMATIC) { + if (automaticPlan?.isComplete != true) { + SnackbarManager.show(context.getString(R.string.nexus_plan_blocked)) + return + } + } + val draftSnapshot = recipeDrafts.toList() + val currentPreview = placementPlanPreview?.takeIf { + it.installId == install.installId && it.drafts == draftSnapshot + }?.plan + val initialReviewedPlan = automaticPlan ?: reviewedPlacementPlan ?: currentPreview if ( selectedFomodInstaller != null && placementChoice != PlacementChoice.CUSTOM && - recipeDrafts.any { draft -> ModPlacementSources.decode(draft.sourceSubpath).isEmpty() } + draftSnapshot.any { draft -> ModPlacementSources.decode(draft.sourceSubpath).isEmpty() } ) { SnackbarManager.show(context.getString(R.string.nexus_fomod_or_custom_required)) return } - val recipes = buildRecipes(install) if (modApplyInProgress) { SnackbarManager.show(context.getString(R.string.nexus_mod_apply_already_running)) return } scope.launch { modApplyInProgress = true + diagnosticsPaused = true try { placementApplyStatusMessage = null loadingMessage = context.getString(R.string.nexus_checking_target_files) + val hasAmbiguousTargets = automaticPlan?.let { plan -> + withContext(Dispatchers.IO) { ModTargetResolver.inspectPlan(plan, roots) } + .ambiguousPaths.isNotEmpty() + } == true + if (hasAmbiguousTargets) { + SnackbarManager.show(context.getString(R.string.nexus_plan_blocked)) + return@launch + } + val recipes = withContext(Dispatchers.Default) { buildRecipes(install, draftSnapshot) } + val reviewedPlan = withContext(Dispatchers.IO) { + val base = initialReviewedPlan ?: ModMaterializer.materializationPlan( + install = install, + recipes = recipes, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + captureTargetHashes = false, + ).reviewedPlan + PlacementRiskPolicy.enforce(base).withRiskApproval(riskyAutomaticPlanApproved) + } + if (!reviewedPlan.isComplete) { + reviewedPlacementPlan = reviewedPlan + val message = context.getString(R.string.nexus_plan_blocked) + placementApplyStatusMessage = message + SnackbarManager.show(message) + return@launch + } val (rawConflicts, conflicts) = withContext(Dispatchers.IO) { val raw = ModMaterializer.scanConflicts( install = install, recipes = recipes, gameRootDir = gameRootDir, winePrefix = winePrefix, + reviewedPlan = reviewedPlan, ) raw to ModMaterializer.filterUnapprovedConflicts( conflicts = raw, @@ -2909,26 +3492,85 @@ fun NexusModsDialog( } val hasOverwriteRecipe = recipes.any { it.mode == ModPlacementMode.OVERWRITE_COPY.name } if (conflicts.isNotEmpty() && hasOverwriteRecipe) { - pendingApply = PendingApply(install, recipes, conflicts) + pendingApply = PendingApply(install, recipes, conflicts, reviewedPlan) } else if (conflicts.isNotEmpty()) { val message = context.getString(R.string.nexus_target_files_exist_overwrite) placementApplyStatusMessage = message SnackbarManager.show(message) } else { - applyRecipesInternal(install, recipes, allowOverwrite = rawConflicts.isNotEmpty()) + applyRecipesInternal( + install, + recipes, + allowOverwrite = rawConflicts.isNotEmpty(), + reviewedPlan = reviewedPlan, + ) } } catch (e: Exception) { val message = e.message ?: context.getString(R.string.nexus_scan_placement_conflicts_failed) + placementApplyFailure = PlacementApplyFailure( + install.installId, + install.modName, + mapOf(install.modName to message), + ) placementApplyStatusMessage = message SnackbarManager.show(message) } finally { modApplyInProgress = false loadingMessage = null + diagnosticsPaused = false + } + } + } + + LaunchedEffect(selectedInstall?.installId) { + val ownership = selectedInstall?.let { install -> + withContext(Dispatchers.IO) { + val root = NexusModManager.cacheRoot(context, install.appId) + app.gamenative.mods.ModOwnershipStore.read(root, install.installId) to + app.gamenative.mods.ModOwnershipStore.readPrevious(root, install.installId) + } + } + selectedOwnership = ownership?.first + selectedPreviousOwnership = ownership?.second + } + + LaunchedEffect( + libraryItem.appId, + installs.map { it.installId to it.status }, + selectedOwnership?.planDigest, + ) { + placementOwnershipManifests = withContext(Dispatchers.IO) { + val root = NexusModManager.cacheRoot(context, libraryItem.appId) + installs.mapNotNull { install -> ModOwnershipStore.read(root, install.installId) } + } + } + + fun shareDiagnostic(fileName: String, content: String) { + scope.launch { + val file = withContext(Dispatchers.IO) { + val outputDir = File(context.cacheDir, "mod-diagnostics").apply { mkdirs() } + File(outputDir, fileName.replace(Regex("[^A-Za-z0-9._-]"), "_")).apply { + writeText(content) + } + } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + context.startActivity(Intent.createChooser(intent, context.getString(R.string.nexus_plan_export))) } } - val issueCount = conflictReports.size + bethesdaPluginIssues.size + bethesdaPluginAssetIssues.size + (healthReport?.issues?.size ?: 0) + fun exportPlacementPlan(install: ModInstall, plan: ModInstallPlan) = + shareDiagnostic("placement-${install.installId}.txt", plan.sanitizedManifest()) + + fun exportHealthReport(report: ModHealthReport) = + shareDiagnostic("mod-health-${libraryItem.appId}.txt", report.sanitizedManifest()) + + val issueCount = conflictReports.size + bethesdaPluginIssues.size + bethesdaPluginAssetIssues.size + + (healthReport?.issues?.size ?: 0) + if (placementApplyFailure == null) 0 else 1 fun selectInstallForPlacement(install: ModInstall) { selectedInstall = install @@ -3167,22 +3809,55 @@ fun NexusModsDialog( ManageModsTab.PLACEMENT -> { selectedInstall?.let { install -> val presetOptions = placementPresetOptions(libraryItem.name, archiveEntries, defaultDraft) + val automaticPlacement = automaticPlacementResult + ?: AutomaticPlacementResult(emptyList(), null, emptyList()) PlacementSection( install = install, entries = archiveEntries, fomodInstaller = selectedFomodInstaller, + fomodEnvironment = fomodEnvironment, + fomodBaseDraft = defaultDraft, roots = roots, drafts = recipeDrafts, presetOptions = presetOptions, + automaticPlacement = automaticPlacement, + automaticPlanLoading = automaticPlacementLoading, + selectedAutomaticOptions = automaticOptionSelections, + onAutomaticOptionSelected = { groupId, sourceDirectory -> + placementApplyStatusMessage = null + reviewedPlacementPlan = null + placementChoice = PlacementChoice.AUTOMATIC + val updatedSelections = automaticOptionSelections + (groupId to sourceDirectory) + automaticOptionSelections = updatedSelections + }, + riskyAutomaticPlanApproved = riskyAutomaticPlanApproved, + onRiskyAutomaticPlanApprovalChange = { approved -> + placementApplyStatusMessage = null + riskyAutomaticPlanApproved = approved + }, + reviewedPlan = reviewedPlacementPlan ?: placementPlanPreview?.takeIf { + it.installId == install.installId && it.drafts == recipeDrafts.toList() + }?.plan, + initialFomodSelections = fomodSelectionDraft, + onFomodSelectionsChanged = { fomodSelectionDraft = it }, + previousOwnership = selectedOwnership, + ownershipManifests = placementOwnershipManifests, + installNamesById = installs.associate { it.installId to it.modName }, + canRestorePrevious = selectedPreviousOwnership?.reviewedPlanOrNull() != null, + onRestorePrevious = { restorePreviousDeployment(install.installId) }, placementChoice = placementChoice, canUseLastPlacement = lastPlacementDrafts.isNotEmpty(), onPlacementChoiceChange = { choice -> placementApplyStatusMessage = null + reviewedPlacementPlan = null val currentDrafts = recipeDrafts.toList() placementChoice = choice recipeDrafts.clear() recipeDrafts += when (choice) { - PlacementChoice.AUTOMATIC -> automaticDraftsFor(libraryItem.name, archiveEntries, defaultDraft) + PlacementChoice.AUTOMATIC -> automaticPlacementResult + ?.let { automaticDraftsFor(it, libraryItem.name, archiveEntries, defaultDraft) } + .orEmpty() + .ifEmpty { listOf(defaultDraft) } PlacementChoice.PRESET -> presetOptions.firstOrNull()?.drafts ?: automaticDraftsFor(libraryItem.name, archiveEntries, defaultDraft) PlacementChoice.LAST_USED -> compatibleLastPlacementDrafts(lastPlacementDrafts, archiveEntries, defaultDraft) @@ -3191,33 +3866,39 @@ fun NexusModsDialog( }, onUseLastPlacement = { placementApplyStatusMessage = null + reviewedPlacementPlan = null placementChoice = PlacementChoice.LAST_USED recipeDrafts.clear() recipeDrafts += compatibleLastPlacementDrafts(lastPlacementDrafts, archiveEntries, defaultDraft) }, onPresetSelected = { drafts -> placementApplyStatusMessage = null + reviewedPlacementPlan = null placementChoice = PlacementChoice.PRESET recipeDrafts.clear() recipeDrafts += drafts }, onUpdateDraft = { index, draft -> placementApplyStatusMessage = null + reviewedPlacementPlan = null recipeDrafts[index] = draft }, onAddDraft = { placementApplyStatusMessage = null + reviewedPlacementPlan = null recipeDrafts += defaultDraft }, onRemoveDraft = { index -> if (recipeDrafts.size > 1) { placementApplyStatusMessage = null + reviewedPlacementPlan = null recipeDrafts.removeAt(index) } }, - onFomodRecipes = { drafts, unsupportedCount -> + onFomodRecipes = { drafts, plan, unsupportedCount -> placementApplyStatusMessage = null placementChoice = PlacementChoice.CUSTOM + reviewedPlacementPlan = plan recipeDrafts.clear() recipeDrafts += drafts if (unsupportedCount > 0) { @@ -3226,17 +3907,59 @@ fun NexusModsDialog( SnackbarManager.show(context.getString(R.string.nexus_fomod_choices_added)) } }, + onUseAutomaticCandidate = { candidate: AutomaticPlacementCandidate -> + placementApplyStatusMessage = null + placementChoice = PlacementChoice.CUSTOM + reviewedPlacementPlan = candidate.plan + recipeDrafts.clear() + recipeDrafts += automaticDraftsFor( + AutomaticPlacementResult(listOf(candidate), candidate), + libraryItem.name, + archiveEntries, + defaultDraft, + ) + }, + onResolveAutomaticPlan = { unresolvedSources -> + placementApplyStatusMessage = null + reviewedPlacementPlan = null + placementChoice = PlacementChoice.CUSTOM + val resolvedDrafts = draftsWithUnresolvedSources(recipeDrafts.toList(), unresolvedSources, defaultDraft) + recipeDrafts.clear() + recipeDrafts += resolvedDrafts + }, applyStatusMessage = placementApplyStatusMessage, + applyErrors = placementApplyFailure + ?.takeIf { it.installId == install.installId } + ?.errors + .orEmpty(), + onExportPlan = { plan -> exportPlacementPlan(install, plan) }, onSaveAndApply = ::saveAndApply, ) } ?: EmptyWorkflowSection(stringResource(R.string.nexus_no_mod_selected), stringResource(R.string.nexus_select_mod_from_mods_tab)) } ManageModsTab.ISSUES -> { + placementApplyFailure?.let { failure -> + PlacementApplyFailureSection( + failure = failure, + onReconfigure = { + installs.firstOrNull { it.installId == failure.installId } + ?.let(::selectInstallForPlacement) + }, + ) + } InstallHealthSection( report = healthReport, loading = healthLoading, onCheck = ::runInstallHealthCheck, + onRebuild = { applyProfileOrder(allowOverwrite = false) }, + onReconfigure = { installId -> + installs.firstOrNull { it.installId == installId }?.let(::selectInstallForPlacement) + selectedTab = ManageModsTab.PLACEMENT + }, + onAdoptOwnership = ::adoptInstallOwnership, + onRestorePrevious = ::restorePreviousDeployment, + onExport = ::exportHealthReport, ) StorageCleanupSection( breakdown = storageBreakdown, @@ -3388,7 +4111,12 @@ fun NexusModsDialog( confirmLabel = stringResource(R.string.nexus_backup_overwrite), onConfirm = { pendingApply = null - applyRecipes(pending.install, pending.recipes, allowOverwrite = true) + applyRecipes( + pending.install, + pending.recipes, + allowOverwrite = true, + reviewedPlan = pending.reviewedPlan, + ) }, onDismiss = { pendingApply = null }, ) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt index a785ba4b4e..4056ad16a0 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt @@ -19,15 +19,19 @@ import app.gamenative.data.ModProfile import app.gamenative.data.ModTargetRoot import app.gamenative.mods.BethesdaPluginManager import app.gamenative.mods.BethesdaPluginDependencyIssue +import app.gamenative.mods.AutomaticPlacementPlanner +import app.gamenative.mods.AutomaticPlacementResult import app.gamenative.mods.ModArchiveEntry import app.gamenative.mods.ModDownloadInfo import app.gamenative.mods.ModImportProgress import app.gamenative.mods.ModPlacementPreset import app.gamenative.mods.ModPlacementPresetDetector import app.gamenative.mods.ModPlacementSources +import app.gamenative.mods.ModProfileOverlayTransition import app.gamenative.mods.ModTargetResolver import app.gamenative.mods.NexusCollectionFile import app.gamenative.mods.NexusModFile +import app.gamenative.mods.NexusModInfo import app.gamenative.mods.ResolvedModTargetRoot import java.io.File import kotlinx.coroutines.CoroutineStart @@ -99,6 +103,28 @@ internal fun ModInstall.profileStatus(enabledInProfile: Boolean): String = else -> status } +internal fun requiresManagedOverlayRebuild( + configuredInstallIds: Set, + activeOwnershipInstallIds: Set, + transition: ModProfileOverlayTransition, +): Boolean = + configuredInstallIds.isNotEmpty() && + configuredInstallIds.all(activeOwnershipInstallIds::contains) && + transition.requiresRebuild && + transition.safeToRebuild + +internal fun shouldApplyProfileInstall( + install: ModInstall, + hasActiveOwnership: Boolean, + hasConflict: Boolean, + needsAssetRepair: Boolean, + hasMissingTarget: Boolean, +): Boolean = + install.status != ModInstallStatus.APPLIED.name || + (hasConflict && !hasActiveOwnership) || + needsAssetRepair || + hasMissingTarget + internal fun ModDownloadInfo.toImportProgress(): ModImportProgress = ModImportProgress( status = status, @@ -183,6 +209,7 @@ internal fun ModPlacementRecipe.toDraft(): RecipeDraft = sourceSubpath = sourceSubpath, targetRoot = targetRoot, targetRelativePath = targetRelativePath, + targetFileName = targetFileName, mode = mode, stripPrefixSegments = stripPrefixSegments, includeSourceDirectory = includeSourceDirectory, @@ -194,6 +221,7 @@ internal fun RecipeDraft.toRecipe(installId: String): ModPlacementRecipe = sourceSubpath = ModPlacementSources.encode(ModPlacementSources.decode(sourceSubpath)), targetRoot = targetRoot, targetRelativePath = normalizedTargetPath(), + targetFileName = targetFileName, mode = mode, stripPrefixSegments = stripPrefixSegments, includeSourceDirectory = includeSourceDirectory, @@ -217,9 +245,40 @@ internal fun automaticDraftsFor( gameName: String, entries: List, fallback: RecipeDraft, + selectedOptions: Map = emptyMap(), +): List { + val result = AutomaticPlacementPlanner.plan(gameName, entries, selectedOptions) + return automaticDraftsFor(result, gameName, entries, fallback) +} + +internal fun NexusCollectionFile.toEmbeddedNexusModInfo(): NexusModInfo? { + val resolvedModName = modName.ifBlank { return null } + return NexusModInfo( + modId = modId, + name = resolvedModName, + summary = "", + version = version, + ) +} + +internal fun automaticDraftsFor( + result: AutomaticPlacementResult, + gameName: String, + entries: List, + fallback: RecipeDraft, ): List { - val preset = placementPresetOptions(gameName, entries, fallback).firstOrNull() - if (preset != null) return preset.drafts + val recommendation = result.recommended + if (recommendation != null) { + return recommendation.drafts.map { draft -> + fallback.copy( + sourceSubpath = draft.sourceSubpath, + targetRoot = draft.targetRoot, + targetRelativePath = draft.targetRelativePath, + mode = draft.mode, + includeSourceDirectory = draft.includeSourceDirectory, + ) + } + } val bethesdaGame = BethesdaPluginManager.detectGame(gameName) if (bethesdaGame != null) { @@ -322,6 +381,23 @@ internal fun compatibleLastPlacementDrafts( } } +internal fun draftsWithUnresolvedSources( + current: List, + unresolvedSources: List, + fallback: RecipeDraft, +): List { + val existingSources = current.flatMap { ModPlacementSources.decode(it.sourceSubpath) }.toSet() + val unresolvedDrafts = unresolvedSources.distinct().filterNot { it in existingSources }.map { source -> + fallback.copy( + sourceSubpath = ModPlacementSources.encode(listOf(source)), + targetRoot = "", + targetRelativePath = "", + includeSourceDirectory = false, + ) + } + return current + unresolvedDrafts +} + internal fun archiveContainsSource(entries: List, source: String): Boolean { val normalizedSource = normalizeArchivePath(source) if (normalizedSource.isBlank()) return true diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt index 3c23e6ba3c..5e1aaeaf86 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,10 +31,13 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,13 +52,21 @@ import androidx.compose.ui.window.DialogProperties import app.gamenative.R import app.gamenative.data.ModPlacementMode import app.gamenative.mods.FomodGroupType +import app.gamenative.mods.FomodEnvironmentSnapshot import app.gamenative.mods.FomodInstaller +import app.gamenative.mods.ModInstallPlan +import app.gamenative.mods.PlannedFileStatus import app.gamenative.mods.FomodPluginType import app.gamenative.mods.FomodRecipeGenerator import app.gamenative.mods.effectiveType import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable internal fun FomodSummarySection( installer: FomodInstaller, @@ -66,7 +78,8 @@ internal fun FomodSummarySection( Text(stringResource(R.string.nexus_fomod_installer), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f)) OutlinedButton( onClick = onConfigure, - enabled = installer.steps.isNotEmpty() && installer.unsupportedWarnings.none { it.contains("C# FOMOD", ignoreCase = true) }, + enabled = (installer.steps.isNotEmpty() || installer.requiredFiles.isNotEmpty()) && + installer.unsupportedWarnings.isEmpty(), ) { Text(stringResource(R.string.nexus_configure)) } @@ -97,13 +110,19 @@ internal fun FomodSummarySection( internal fun FomodWizardDialog( installId: String, installer: FomodInstaller, + environment: FomodEnvironmentSnapshot, extractedRoot: File, baseDraft: RecipeDraft, - onApply: (List, Int) -> Unit, + initialSelections: Map> = emptyMap(), + onSelectionsChanged: (Map>) -> Unit = {}, + onApply: (List, ModInstallPlan?, Int) -> Unit, onDismiss: () -> Unit, ) { var previewImage by remember { mutableStateOf(null) } var pendingResult by remember { mutableStateOf(null) } + var generatingPlan by remember(installId, installer) { mutableStateOf(false) } + var generationError by remember(installId, installer) { mutableStateOf(null) } + val scope = rememberCoroutineScope() val selectedByGroup = remember(installer) { mutableStateMapOf>().apply { installer.steps.forEachIndexed { stepIndex, step -> @@ -148,14 +167,33 @@ internal fun FomodWizardDialog( put("$stepIndex:$groupIndex", defaults) } } + initialSelections.forEach { (groupKey, selected) -> + val parts = groupKey.split(':').mapNotNull(String::toIntOrNull) + val group = parts.takeIf { it.size == 2 } + ?.let { installer.steps.getOrNull(it[0])?.groups?.getOrNull(it[1]) } + ?: return@forEach + val allowed = group.plugins.indices.mapTo(mutableSetOf()) { pluginIndex -> + FomodRecipeGenerator.pluginKey(parts[0], parts[1], pluginIndex) + } + val restored = selected.intersect(allowed) + if (selected.isEmpty() || restored.isNotEmpty()) { + put(groupKey, restored) + } + } } } + LaunchedEffect(selectedByGroup) { + snapshotFlow { selectedByGroup.toMap() } + .distinctUntilChanged() + .collect(onSelectionsChanged) + } + val selectedFlags = fomodSelectedFlags(installer, selectedByGroup) val fallbackStepNames = installer.steps.indices.map { index -> stringResource(R.string.nexus_fomod_step, index + 1) } - val invalidGroups = fomodInvalidGroups(installer, selectedByGroup, selectedFlags, fallbackStepNames) + val invalidGroups = fomodInvalidGroups(installer, selectedByGroup, selectedFlags, fallbackStepNames, environment) Dialog( onDismissRequest = onDismiss, @@ -179,6 +217,14 @@ internal fun FomodWizardDialog( overflow = TextOverflow.Ellipsis, ) } + generationError?.let { message -> + Text( + text = message, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } HorizontalDivider() Column( modifier = Modifier @@ -219,7 +265,7 @@ internal fun FomodWizardDialog( group.plugins.forEachIndexed { pluginIndex, plugin -> val pluginKey = FomodRecipeGenerator.pluginKey(stepIndex, groupIndex, pluginIndex) val selected = selectedByGroup[groupKey].orEmpty() - val effectiveType = plugin.effectiveType(selectedFlags) + val effectiveType = plugin.effectiveType(selectedFlags, environment) val checked = effectiveType == FomodPluginType.REQUIRED || (effectiveType != FomodPluginType.NOT_USABLE && pluginKey in selected) val enabled = effectiveType != FomodPluginType.REQUIRED && effectiveType != FomodPluginType.NOT_USABLE Row( @@ -281,25 +327,64 @@ internal fun FomodWizardDialog( Button( onClick = { val selectedKeys = selectedByGroup.values.flatten().toSet() - val result = FomodRecipeGenerator.generateForPluginKeys( - installId = installId, - installer = installer, - selectedPluginKeys = selectedKeys, - targetRoot = baseDraft.targetRoot, - targetRelativePath = baseDraft.targetRelativePath.ifBlank { "Data" }, - mode = ModPlacementMode.OVERWRITE_COPY.name, - ) - pendingResult = PendingFomodResult( - drafts = result.recipes.map { it.toDraft() }, - unsupportedCount = result.unsupportedMappings.size, - selectedOptions = fomodSelectedOptionLabels(installer, selectedKeys, fallbackStepNames), - conditionalRuleCount = installer.conditionalFileInstalls.size, + val selectedOptions = fomodSelectedOptionLabels( + installer, + selectedKeys, + fallbackStepNames, + environment, ) + generationError = null + generatingPlan = true + scope.launch { + try { + val result = withContext(Dispatchers.IO) { + FomodRecipeGenerator.generateForPluginKeys( + installId = installId, + installer = installer, + selectedPluginKeys = selectedKeys, + targetRoot = baseDraft.targetRoot, + targetRelativePath = baseDraft.targetRelativePath.ifBlank { "Data" }, + mode = ModPlacementMode.OVERWRITE_COPY.name, + extractedRoot = extractedRoot, + environment = environment, + ) + } + if (selectedByGroup.values.flatten().toSet() == selectedKeys) { + pendingResult = PendingFomodResult( + drafts = result.recipes.map { it.toDraft() }, + plan = result.plan, + unsupportedCount = result.plan?.unresolvedCount ?: 0, + unresolvedDetails = result.plan?.files.orEmpty() + .filter { file -> + file.status == PlannedFileStatus.UNSUPPORTED || + file.status == PlannedFileStatus.MISSING || + file.status == PlannedFileStatus.CONFLICTED + } + .map { file -> "${file.sourceRelativePath}: ${file.reason}" }, + blockingIssues = result.plan?.blockingIssues + ?: result.blockingIssues, + selectedOptions = selectedOptions, + conditionalRuleCount = installer.conditionalFileInstalls.size, + ) + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + generationError = error.message ?: error.javaClass.simpleName + } finally { + generatingPlan = false + } + } }, - enabled = invalidGroups.isEmpty(), + enabled = invalidGroups.isEmpty() && !generatingPlan, modifier = Modifier.padding(start = 8.dp), ) { - Text(stringResource(R.string.nexus_fomod_use_choices)) + if (generatingPlan) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.nexus_building_install_plan)) + } else { + Text(stringResource(R.string.nexus_fomod_use_choices)) + } } } } @@ -350,6 +435,22 @@ internal fun FomodWizardDialog( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) + result.unresolvedDetails.take(4).forEach { detail -> + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + if (result.blockingIssues.isNotEmpty()) { + result.blockingIssues.take(4).forEach { issue -> + Text( + issue, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } result.selectedOptions.take(12).forEach { option -> Text(option, style = MaterialTheme.typography.bodySmall) @@ -367,10 +468,19 @@ internal fun FomodWizardDialog( TextButton( onClick = { pendingResult = null - onApply(result.drafts, result.unsupportedCount) + onApply(result.drafts, result.plan, result.unsupportedCount) }, + enabled = result.blockingIssues.isEmpty(), ) { - Text(stringResource(R.string.nexus_fomod_apply_choices)) + Text( + stringResource( + if (result.unsupportedCount > 0) { + R.string.nexus_review_placement + } else { + R.string.nexus_fomod_apply_choices + }, + ), + ) } }, dismissButton = { @@ -428,20 +538,26 @@ private fun fomodInvalidGroups( selectedByGroup: Map>, flags: Map, fallbackStepNames: List, + environment: FomodEnvironmentSnapshot, ): List = buildList { installer.steps.forEachIndexed { stepIndex, step -> step.groups.forEachIndexed { groupIndex, group -> val selected = selectedByGroup["$stepIndex:$groupIndex"].orEmpty() val selectable = group.plugins.mapIndexedNotNull { pluginIndex, plugin -> - if (plugin.effectiveType(flags) == FomodPluginType.NOT_USABLE) null else FomodRecipeGenerator.pluginKey(stepIndex, groupIndex, pluginIndex) + if (plugin.effectiveType(flags, environment) == FomodPluginType.NOT_USABLE) { + null + } else { + FomodRecipeGenerator.pluginKey(stepIndex, groupIndex, pluginIndex) + } }.toSet() val selectedUsable = selected.intersect(selectable) val invalid = when (group.type) { FomodGroupType.SELECT_EXACTLY_ONE -> selectedUsable.size != 1 FomodGroupType.SELECT_AT_LEAST_ONE -> selectedUsable.isEmpty() FomodGroupType.SELECT_AT_MOST_ONE, - FomodGroupType.SELECT_ANY -> false + FomodGroupType.SELECT_ANY, + -> false } if (invalid) { add("${step.name.ifBlank { fallbackStepNames.getOrElse(stepIndex) { "" } }} / ${group.name}") @@ -472,9 +588,10 @@ private fun fomodSelectedOptionLabels( installer: FomodInstaller, selectedKeys: Set, fallbackStepNames: List, + environment: FomodEnvironmentSnapshot, ): List = buildList { - val selectedPlugins = FomodRecipeGenerator.selectedPluginsForKeys(installer, selectedKeys).toSet() + val selectedPlugins = FomodRecipeGenerator.selectedPluginsForKeys(installer, selectedKeys, environment).toSet() installer.steps.forEachIndexed { stepIndex, step -> step.groups.forEachIndexed { groupIndex, group -> group.plugins.forEachIndexed { pluginIndex, plugin -> diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsPlacementSections.kt b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsPlacementSections.kt index d25f7bba27..dba3a2f3ee 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsPlacementSections.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/NexusModsPlacementSections.kt @@ -2,8 +2,11 @@ package app.gamenative.ui.component.dialog import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -11,6 +14,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -29,10 +33,14 @@ import androidx.compose.material.icons.filled.FolderOff import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Gamepad import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SnippetFolder import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -50,6 +58,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -59,6 +68,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -68,17 +78,32 @@ import app.gamenative.data.ModInstall import app.gamenative.data.ModInstallStatus import app.gamenative.data.ModPlacementMode import app.gamenative.data.ModTargetRoot +import app.gamenative.mods.AutomaticPlacementPlanner +import app.gamenative.mods.AutomaticPlacementCandidate +import app.gamenative.mods.AutomaticPlacementResult import app.gamenative.mods.FomodInstaller +import app.gamenative.mods.FomodEnvironmentSnapshot import app.gamenative.mods.ModArchiveEntry +import app.gamenative.mods.ModInstallPlan +import app.gamenative.mods.ModOwnershipManifest +import app.gamenative.mods.ModOwnershipPlanDiffer +import app.gamenative.mods.ModReconfigurationDiff import app.gamenative.mods.ModPlacementPreset import app.gamenative.mods.ModPlacementSources import app.gamenative.mods.ModTargetResolver +import app.gamenative.mods.ModTargetPlanInspection +import app.gamenative.mods.PlannedFileStatus +import app.gamenative.mods.PlacementRisk +import app.gamenative.mods.PlacementRiskPolicy import app.gamenative.mods.ResolvedModTargetRoot import app.gamenative.ui.component.NoExtractOutlinedTextField import app.gamenative.utils.StorageUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import java.io.File +import java.text.DateFormat +import java.util.Date @Composable internal fun StatusChip(status: String) { val (label, color, contentColor) = when (status) { @@ -109,24 +134,88 @@ internal fun PlacementSection( install: ModInstall, entries: List, fomodInstaller: FomodInstaller?, + fomodEnvironment: FomodEnvironmentSnapshot, + fomodBaseDraft: RecipeDraft, roots: List, drafts: List, presetOptions: List, + automaticPlacement: AutomaticPlacementResult, + automaticPlanLoading: Boolean, + selectedAutomaticOptions: Map, + onAutomaticOptionSelected: (String, String) -> Unit, + riskyAutomaticPlanApproved: Boolean, + onRiskyAutomaticPlanApprovalChange: (Boolean) -> Unit, + reviewedPlan: ModInstallPlan?, + initialFomodSelections: Map>, + onFomodSelectionsChanged: (Map>) -> Unit, + previousOwnership: ModOwnershipManifest?, + ownershipManifests: List = emptyList(), + installNamesById: Map = emptyMap(), + canRestorePrevious: Boolean, + onRestorePrevious: () -> Unit, placementChoice: PlacementChoice, canUseLastPlacement: Boolean, onPlacementChoiceChange: (PlacementChoice) -> Unit, + onUseAutomaticCandidate: (AutomaticPlacementCandidate) -> Unit = {}, + onResolveAutomaticPlan: (List) -> Unit = {}, onUseLastPlacement: () -> Unit, onPresetSelected: (List) -> Unit, onUpdateDraft: (Int, RecipeDraft) -> Unit, onAddDraft: () -> Unit, onRemoveDraft: (Int) -> Unit, - onFomodRecipes: (List, Int) -> Unit, + onFomodRecipes: (List, ModInstallPlan?, Int) -> Unit, applyStatusMessage: String?, + applyErrors: Map = emptyMap(), + onExportPlan: (ModInstallPlan) -> Unit, onSaveAndApply: () -> Unit, ) { var showArchiveBrowser by remember(install.installId, entries) { mutableStateOf(false) } var showFomodWizard by remember(install.installId, fomodInstaller) { mutableStateOf(false) } + var showAdvancedPlacement by remember(install.installId) { mutableStateOf(placementChoice != PlacementChoice.AUTOMATIC) } + var customDraftPage by remember(install.installId) { mutableIntStateOf(0) } + val visibleDraftPage = remember(drafts.size, customDraftPage) { + placementDraftPage(drafts.size, customDraftPage) + } + LaunchedEffect(placementChoice) { + if (placementChoice != PlacementChoice.AUTOMATIC) showAdvancedPlacement = true + } + LaunchedEffect(visibleDraftPage.pageIndex) { + if (customDraftPage != visibleDraftPage.pageIndex) customDraftPage = visibleDraftPage.pageIndex + } val destinationsValid = drafts.all { draft -> roots.any { it.type.name == draft.targetRoot } } + val automaticPlan = remember(automaticPlacement.recommended?.plan, riskyAutomaticPlanApproved) { + automaticPlacement.recommended?.plan + ?.let(PlacementRiskPolicy::enforce) + ?.withRiskApproval(riskyAutomaticPlanApproved) + } + val configuredPlan = remember(reviewedPlan, riskyAutomaticPlanApproved) { + reviewedPlan + ?.let(PlacementRiskPolicy::enforce) + ?.withRiskApproval(riskyAutomaticPlanApproved) + } + val visiblePlan = if (placementChoice == PlacementChoice.AUTOMATIC) automaticPlan else configuredPlan + val reconfigurationDiff = remember(previousOwnership, visiblePlan) { + visiblePlan?.takeIf { previousOwnership != null }?.let { ModOwnershipPlanDiffer.compare(previousOwnership, it) } + } + var targetInspection by remember(automaticPlan, roots) { mutableStateOf(null) } + var targetInspectionLoading by remember(automaticPlan, roots) { mutableStateOf(automaticPlan != null) } + LaunchedEffect(automaticPlan, roots) { + targetInspectionLoading = automaticPlan != null + targetInspection = automaticPlan?.let { plan -> + withContext(Dispatchers.IO) { ModTargetResolver.inspectPlan(plan, roots) } + } + targetInspectionLoading = false + } + val applyBlocked = when { + placementChoice == PlacementChoice.AUTOMATIC -> { + automaticPlanLoading || + targetInspectionLoading || + automaticPlan?.isComplete != true || + targetInspection?.ambiguousPaths?.isNotEmpty() == true + } + visiblePlan != null -> !visiblePlan.isComplete + else -> false + } Surface( modifier = Modifier.fillMaxWidth(), @@ -179,10 +268,146 @@ internal fun PlacementSection( selected = placementChoice, hasPresets = presetOptions.isNotEmpty(), canUseLastPlacement = canUseLastPlacement, + showAdvanced = showAdvancedPlacement, onSelect = { if (it == PlacementChoice.LAST_USED) onUseLastPlacement() else onPlacementChoiceChange(it) }, ) + TextButton(onClick = { showAdvancedPlacement = !showAdvancedPlacement }) { + Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + if (showAdvancedPlacement) { + stringResource(R.string.nexus_hide_advanced_placement) + } else { + stringResource(R.string.nexus_advanced_placement) + }, + ) + } + if (!showAdvancedPlacement) { + Text( + stringResource(R.string.nexus_advanced_placement_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (placementChoice == PlacementChoice.AUTOMATIC && automaticPlanLoading) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text(stringResource(R.string.nexus_building_install_plan), style = MaterialTheme.typography.bodySmall) + } + } else if (placementChoice == PlacementChoice.AUTOMATIC && automaticPlan != null) { + PlacementPlanReview( + automaticPlacement = automaticPlacement, + plan = automaticPlan, + roots = roots, + diff = reconfigurationDiff, + caseMerges = targetInspection?.caseMerges.orEmpty(), + ambiguousPaths = targetInspection?.ambiguousPaths.orEmpty(), + ownershipManifests = ownershipManifests, + selectedInstallId = install.installId, + installNamesById = installNamesById, + onResolve = { + val unresolvedSources = automaticPlan.files.filter { + it.status == PlannedFileStatus.UNSUPPORTED || + it.status == PlannedFileStatus.MISSING || + it.status == PlannedFileStatus.CONFLICTED || + it.targetRelativePath in targetInspection?.ambiguousPaths.orEmpty() + }.map { it.sourceRelativePath }.distinct() + onResolveAutomaticPlan(unresolvedSources) + }, + onUseCandidate = onUseAutomaticCandidate, + onExport = { onExportPlan(automaticPlan) }, + ) + } else if (placementChoice == PlacementChoice.AUTOMATIC) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(R.string.nexus_plan_blocked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + OutlinedButton(onClick = { onResolveAutomaticPlan(emptyList()) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.nexus_placement_custom)) + } + } + } else if (configuredPlan != null) { + val unresolvedSources = configuredPlan.files.filter { + it.status == PlannedFileStatus.UNSUPPORTED || + it.status == PlannedFileStatus.MISSING || + it.status == PlannedFileStatus.CONFLICTED + }.map { it.sourceRelativePath }.distinct() + PlacementPlanReview( + automaticPlacement = null, + plan = configuredPlan, + roots = roots, + diff = reconfigurationDiff, + caseMerges = emptyList(), + ambiguousPaths = emptyList(), + ownershipManifests = ownershipManifests, + selectedInstallId = install.installId, + installNamesById = installNamesById, + onResolve = unresolvedSources.takeIf { it.isNotEmpty() }?.let { sources -> + { onResolveAutomaticPlan(sources) } + }, + onUseCandidate = onUseAutomaticCandidate, + onExport = { onExportPlan(configuredPlan) }, + ) + } + + if (placementChoice == PlacementChoice.AUTOMATIC && automaticPlacement.optionGroups.isNotEmpty()) { + automaticPlacement.optionGroups.forEach { group -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.nexus_choose_package_variant), style = MaterialTheme.typography.labelLarge) + Text(group.reason, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + group.choices.forEach { choice -> + val selected = selectedAutomaticOptions[group.stableId] == choice.sourceDirectory + if (selected) { + Button( + onClick = { onAutomaticOptionSelected(group.stableId, choice.sourceDirectory) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(choice.sourceDirectory) } + } else { + OutlinedButton( + onClick = { onAutomaticOptionSelected(group.stableId, choice.sourceDirectory) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(choice.sourceDirectory) } + } + } + } + } + } + + if ( + visiblePlan?.files.orEmpty().any { it.risk == PlacementRisk.UNSAFE } + ) { + Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.errorContainer) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onRiskyAutomaticPlanApprovalChange(!riskyAutomaticPlanApproved) } + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = riskyAutomaticPlanApproved, + onCheckedChange = onRiskyAutomaticPlanApprovalChange, + ) + Column { + Text( + stringResource(R.string.nexus_confirm_risky_game_root_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + stringResource(R.string.nexus_confirm_risky_game_root_files_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + } if (placementChoice == PlacementChoice.PRESET && presetOptions.isNotEmpty()) { PresetSelectionSection( @@ -201,12 +426,26 @@ internal fun PlacementSection( ) } } else { - drafts.forEachIndexed { index, draft -> + if (visibleDraftPage.pageCount > 1) { + PlacementDraftPager( + page = visibleDraftPage, + totalRules = drafts.size, + onPrevious = { customDraftPage-- }, + onNext = { customDraftPage++ }, + ) + } + (visibleDraftPage.startIndex until visibleDraftPage.endIndexExclusive).forEach { index -> + val draft = drafts[index] PlacementDraftEditor( index = index, draft = draft, entries = entries, roots = roots, + plan = visiblePlan, + ownershipManifests = ownershipManifests, + selectedInstallId = install.installId, + installNamesById = installNamesById, + showAdvanced = showAdvancedPlacement, canRemove = drafts.size > 1, onUpdate = { onUpdateDraft(index, it) }, onRemove = { onRemoveDraft(index) }, @@ -226,7 +465,25 @@ internal fun PlacementSection( Text( text = message, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + color = if (applyErrors.isEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + } + if (applyErrors.isNotEmpty()) { + PlacementApplyFailureDetails(applyErrors) + } + + if (canRestorePrevious) { + OutlinedButton( + onClick = onRestorePrevious, + enabled = applyStatusMessage == null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.nexus_restore_previous_deployment)) + } + Text( + stringResource(R.string.nexus_restore_previous_deployment_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -243,7 +500,7 @@ internal fun PlacementSection( } Button( onClick = onSaveAndApply, - enabled = roots.isNotEmpty() && destinationsValid, + enabled = roots.isNotEmpty() && destinationsValid && !applyBlocked, modifier = Modifier.fillMaxWidth(), ) { Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp)) @@ -260,7 +517,10 @@ internal fun PlacementSection( Text(stringResource(R.string.nexus_add_location)) } } - Button(onClick = onSaveAndApply, enabled = roots.isNotEmpty() && destinationsValid) { + Button( + onClick = onSaveAndApply, + enabled = roots.isNotEmpty() && destinationsValid && !applyBlocked, + ) { Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.size(8.dp)) Text(stringResource(R.string.nexus_apply_mod)) @@ -285,17 +545,417 @@ internal fun PlacementSection( FomodWizardDialog( installId = install.installId, installer = fomodInstaller, + environment = fomodEnvironment, extractedRoot = File(install.extractedPath), - baseDraft = drafts.firstOrNull() ?: RecipeDraft(), - onApply = { generatedDrafts, unsupportedCount -> + // FOMOD destinations are relative to the game's content root. Reusing the + // first generated mapping here recursively prefixes that mapping whenever + // an installer is reconfigured. + baseDraft = fomodBaseDraft, + initialSelections = initialFomodSelections, + onSelectionsChanged = onFomodSelectionsChanged, + onApply = { generatedDrafts, plan, unsupportedCount -> showFomodWizard = false - onFomodRecipes(generatedDrafts, unsupportedCount) + onFomodRecipes(generatedDrafts, plan, unsupportedCount) }, onDismiss = { showFomodWizard = false }, ) } } +@Composable +internal fun PlacementApplyFailureDetails(errors: Map) { + Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.errorContainer) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(R.string.nexus_apply_failure_details, errors.size), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + errors.entries.take(8).forEach { (path, reason) -> + Text( + compactPlacementErrorPath(path), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onErrorContainer, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + if (errors.size > 8) { + Text( + stringResource(R.string.nexus_more_prefixed, errors.size - 8), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} + +internal fun compactPlacementErrorPath(path: String): String { + val normalized = path.replace('\\', '/').trimEnd('/') + return normalized.split('/').filter(String::isNotBlank).takeLast(4).joinToString("/").ifBlank { path } +} + +@Composable +private fun PlacementDraftPager( + page: PlacementDraftPage, + totalRules: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevious, enabled = page.pageIndex > 0) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.nexus_previous_rule_page)) + } + Text( + stringResource( + R.string.nexus_placement_rule_range, + page.startIndex + 1, + page.endIndexExclusive, + totalRules, + ), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge, + ) + IconButton(onClick = onNext, enabled = page.pageIndex < page.pageCount - 1) { + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = stringResource(R.string.nexus_next_rule_page)) + } + } + } +} + +@Composable +private fun PlacementPlanReview( + automaticPlacement: AutomaticPlacementResult?, + plan: ModInstallPlan, + roots: List, + diff: ModReconfigurationDiff?, + caseMerges: List, + ambiguousPaths: List, + ownershipManifests: List, + selectedInstallId: String, + installNamesById: Map, + onResolve: (() -> Unit)?, + onUseCandidate: (AutomaticPlacementCandidate) -> Unit, + onExport: () -> Unit, +) { + val stalePlacementReason = stringResource(R.string.nexus_stale_placement_reason) + var showWhy by remember(plan.digest) { mutableStateOf(false) } + var showAllFiles by remember(plan.digest) { mutableStateOf(false) } + var browseTarget by remember(plan.digest) { mutableStateOf(null) } + var rows by remember(plan.digest, diff, roots, stalePlacementReason) { + mutableStateOf>(emptyList()) + } + var rowsLoading by remember(plan.digest, diff, roots, stalePlacementReason) { mutableStateOf(true) } + LaunchedEffect(plan, diff, roots, stalePlacementReason) { + rowsLoading = true + rows = withContext(Dispatchers.IO) { + placementReviewRows(plan, diff, roots, stalePlacementReason) + } + rowsLoading = false + } + Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.nexus_plan_review_title), style = MaterialTheme.typography.labelLarge) + Text( + stringResource( + R.string.nexus_plan_coverage, + plan.placedCount, + plan.selectedCount, + plan.ignoredCount, + plan.unresolvedCount, + ), + style = MaterialTheme.typography.bodySmall, + ) + val replacedDefaults = plan.files.filter { file -> + file.status == PlannedFileStatus.INTENTIONALLY_IGNORED && + file.reason.startsWith("Replaced by selected FOMOD file") + } + if (replacedDefaults.isNotEmpty()) { + Text( + stringResource(R.string.nexus_fomod_defaults_replaced, replacedDefaults.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + replacedDefaults.take(4).forEach { replaced -> + Text( + stringResource( + R.string.nexus_fomod_selected_winner, + replaced.targetRelativePath.orEmpty(), + replaced.reason.removePrefix("Replaced by selected FOMOD file "), + ), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + TextButton(onClick = { showWhy = !showWhy }) { + Text(if (showWhy) stringResource(R.string.nexus_hide_placement_reason) else stringResource(R.string.nexus_why_this_placement)) + } + if (showWhy) { + automaticPlacement?.recommended?.evidence.orEmpty().forEach { evidence -> + Text("\u2022 $evidence", style = MaterialTheme.typography.bodySmall) + } + if (automaticPlacement?.candidates.orEmpty().size > 1) { + Text(stringResource(R.string.nexus_plan_ranked), style = MaterialTheme.typography.labelMedium) + automaticPlacement?.candidates.orEmpty().take(3).forEachIndexed { index, candidate -> + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource( + R.string.nexus_plan_score, + index + 1, + candidate.label, + (candidate.plan.coverage * 100).toInt(), + ), + style = MaterialTheme.typography.bodySmall, + ) + if (candidate.plan.digest != plan.digest) { + TextButton(onClick = { onUseCandidate(candidate) }) { + Text(stringResource(R.string.nexus_use_this_placement)) + } + } + } + } + } + } + caseMerges.take(5).forEach { merge -> + Text(stringResource(R.string.nexus_plan_case_merge, merge), style = MaterialTheme.typography.bodySmall) + } + plan.files.filter { + it.status == PlannedFileStatus.UNSUPPORTED || + it.status == PlannedFileStatus.MISSING || + it.status == PlannedFileStatus.CONFLICTED + }.take(8).forEach { file -> + Text( + "${file.sourceRelativePath}: ${file.reason}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + ambiguousPaths.take(5).forEach { path -> + Text( + "$path: ${stringResource(R.string.nexus_plan_ambiguous_case)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + if (!plan.isComplete || ambiguousPaths.isNotEmpty()) { + Text( + stringResource(R.string.nexus_plan_blocked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + if (onResolve != null) { + val resolveCount = plan.unresolvedCount + ambiguousPaths.size + Button(onClick = onResolve, modifier = Modifier.fillMaxWidth()) { + Text( + if (resolveCount > 0) { + stringResource(R.string.nexus_resolve_files, resolveCount) + } else { + stringResource(R.string.nexus_custom_placement) + }, + ) + } + if (resolveCount > 0) { + Text( + stringResource(R.string.nexus_resolve_files_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (diff?.hasChanges == true) { + Text( + stringResource(R.string.nexus_reconfiguration_summary, diff.added, diff.changed, diff.moved, diff.stale), + style = MaterialTheme.typography.bodySmall, + ) + } + plan.warnings.take(5).forEach { warning -> + Text("\u2022 $warning", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = { showAllFiles = true }, enabled = !rowsLoading) { + if (rowsLoading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(6.dp)) + } + Text(stringResource(R.string.nexus_review_all_files, rows.size.takeUnless { rowsLoading } ?: plan.files.size)) + } + TextButton(onClick = onExport) { + Text(stringResource(R.string.nexus_plan_export)) + } + } + } + } + + if (showAllFiles) { + PlacementPlanFilesDialog( + rows = rows, + onBrowseDestination = { row -> + browseTarget = RecipeDraft(targetRoot = row.targetRoot, targetRelativePath = row.targetRelativePath) + }, + onDismiss = { showAllFiles = false }, + ) + } + + browseTarget?.let { target -> + ContainerDestinationPickerDialog( + roots = roots, + currentDraft = target, + plan = plan, + ownershipManifests = ownershipManifests, + selectedInstallId = selectedInstallId, + installNamesById = installNamesById, + readOnly = true, + onSelect = {}, + onDismiss = { browseTarget = null }, + ) + } +} + +@Composable +private fun PlacementPlanFilesDialog( + rows: List, + onBrowseDestination: (PlacementReviewRow) -> Unit, + onDismiss: () -> Unit, +) { + var query by remember(rows) { mutableStateOf("") } + var category by remember(rows) { mutableStateOf(null) } + val categories = remember(rows) { rows.map { it.category }.distinct() } + val filtered = remember(rows, query, category) { + rows.filter { (category == null || it.category == category) && it.matches(query) } + } + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + modifier = Modifier.fillMaxWidth(0.96f).height(640.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column(Modifier.fillMaxSize()) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.nexus_all_planned_files), style = MaterialTheme.typography.headlineSmall) + NoExtractOutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.nexus_search_planned_files)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + ) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PlacementChoiceButton( + text = stringResource(R.string.nexus_all_files_count, rows.size), + selected = category == null, + onClick = { category = null }, + ) + categories.forEach { option -> + PlacementChoiceButton( + text = "${placementReviewCategoryLabel(option)} (${rows.count { it.category == option }})", + selected = category == option, + onClick = { category = option }, + ) + } + } + } + HorizontalDivider() + if (filtered.isEmpty()) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.nexus_no_matching_planned_files), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyColumn(Modifier.weight(1f).fillMaxWidth()) { + filtered.groupBy { it.category }.forEach { (group, groupRows) -> + item(key = "header:${group.name}") { + Surface(Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant) { + Text( + "${placementReviewCategoryLabel(group)} (${groupRows.size})", + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + itemsIndexed( + groupRows, + key = { index, row -> "${group.name}:${row.source}:${row.target}:${row.previousTarget}:$index" }, + ) { _, row -> + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(row.source, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace) + if (row.previousTarget.isNotBlank()) { + Text( + stringResource(R.string.nexus_previous_destination_value, row.previousTarget), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (row.target.isNotBlank()) { + Text( + stringResource(R.string.nexus_destination_value, row.target), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (row.reason.isNotBlank()) { + Text(row.reason, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (row.sizeBytes > 0L) { + Text(StorageUtils.formatBinarySize(row.sizeBytes), style = MaterialTheme.typography.labelSmall) + } + if (row.targetRoot.isNotBlank()) { + TextButton(onClick = { onBrowseDestination(row) }) { + Text(stringResource(R.string.nexus_browse_destination)) + } + } + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp), thickness = 0.5.dp) + } + } + } + } + HorizontalDivider() + Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.End) { + Button(onClick = onDismiss) { Text(stringResource(R.string.close)) } + } + } + } + } +} + +@Composable +private fun placementReviewCategoryLabel(category: PlacementReviewCategory): String = when (category) { + PlacementReviewCategory.ADDED -> stringResource(R.string.nexus_plan_group_added) + PlacementReviewCategory.REPLACED -> stringResource(R.string.nexus_plan_group_replaced) + PlacementReviewCategory.MOVED -> stringResource(R.string.nexus_plan_group_moved) + PlacementReviewCategory.REMOVED -> stringResource(R.string.nexus_plan_group_removed) + PlacementReviewCategory.IGNORED -> stringResource(R.string.nexus_plan_group_ignored) + PlacementReviewCategory.BLOCKED -> stringResource(R.string.nexus_plan_group_blocked) + PlacementReviewCategory.UNCHANGED -> stringResource(R.string.nexus_plan_group_unchanged) +} + @Composable private fun PresetSelectionSection( presets: List, @@ -331,18 +991,20 @@ private fun PlacementChoiceSelector( selected: PlacementChoice, hasPresets: Boolean, canUseLastPlacement: Boolean, + showAdvanced: Boolean, onSelect: (PlacementChoice) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(stringResource(R.string.nexus_placement_label), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) BoxWithConstraints(Modifier.fillMaxWidth()) { val compact = maxWidth < 420.dp - val choices = listOf( + val allChoices = listOf( Triple(PlacementChoice.AUTOMATIC, stringResource(R.string.nexus_placement_automatic), true), Triple(PlacementChoice.PRESET, stringResource(R.string.nexus_placement_preset), hasPresets), Triple(PlacementChoice.LAST_USED, stringResource(R.string.nexus_placement_last_used), canUseLastPlacement), Triple(PlacementChoice.CUSTOM, stringResource(R.string.nexus_placement_custom), true), ) + val choices = if (showAdvanced || selected != PlacementChoice.AUTOMATIC) allChoices else allChoices.take(1) val rows = if (compact) choices.chunked(2) else listOf(choices) Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { rows.forEach { rowChoices -> @@ -370,9 +1032,9 @@ private fun PlacementChoiceSelector( private fun PlacementChoiceButton( text: String, selected: Boolean, - enabled: Boolean = true, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { val content: @Composable () -> Unit = { Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -489,6 +1151,11 @@ private fun PlacementDraftEditor( draft: RecipeDraft, entries: List, roots: List, + plan: ModInstallPlan?, + ownershipManifests: List, + selectedInstallId: String, + installNamesById: Map, + showAdvanced: Boolean, canRemove: Boolean, onUpdate: (RecipeDraft) -> Unit, onRemove: () -> Unit, @@ -496,6 +1163,17 @@ private fun PlacementDraftEditor( var showSourcePicker by remember(index) { mutableStateOf(false) } var showDestinationPicker by remember(index) { mutableStateOf(false) } var showManualPaths by remember(index) { mutableStateOf(false) } + val layout = remember(draft.sourceSubpath, draft.targetRelativePath, draft.includeSourceDirectory, entries) { + placementLayoutModel(draft, entries) + } + val recommendedKeepFolder = remember(draft.sourceSubpath, draft.targetRelativePath, entries, layout.visible) { + layout.visible && + AutomaticPlacementPlanner.inferIncludeSourceDirectory( + selectedPaths = ModPlacementSources.decode(draft.sourceSubpath), + entries = entries, + targetRelativePath = draft.targetRelativePath, + ) + } Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -523,37 +1201,83 @@ private fun PlacementDraftEditor( ) DestinationScopeText(draft, roots) - DropdownField( - label = stringResource(R.string.nexus_install_method), - value = placementModeLabelText(draft.mode), - options = ModPlacementMode.entries.map { placementModeLabelText(it.name) to it.name }, - onSelect = { onUpdate(draft.copy(mode = it)) }, - modifier = Modifier.fillMaxWidth(), - ) - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Checkbox( - checked = draft.includeSourceDirectory, - onCheckedChange = { onUpdate(draft.copy(includeSourceDirectory = it)) }, - ) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(stringResource(R.string.nexus_create_folder_for_selection)) + if (layout.visible) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( - text = stringResource(R.string.nexus_create_folder_for_selection_description), + stringResource(if (layout.editable) R.string.nexus_folder_layout else R.string.nexus_everything_folder_layout), + style = MaterialTheme.typography.labelLarge, + ) + if (layout.editable) { + val names = layout.selectedNames.take(2).joinToString(", ") + if (layout.selectedNames.size > 2) ", ..." else "" + val contentsLabel = if (layout.multipleFolders) { + stringResource(R.string.nexus_merge_selected_folder_contents) + } else { + stringResource(R.string.nexus_install_contents_of, names) + } + val folderLabel = if (layout.multipleFolders) { + stringResource(R.string.nexus_keep_selected_folder_names) + } else { + stringResource(R.string.nexus_install_folder_and_contents, names) + } + PlacementChoiceButton( + text = contentsLabel + if (!recommendedKeepFolder) stringResource(R.string.nexus_recommended_suffix) else "", + selected = !draft.includeSourceDirectory, + onClick = { onUpdate(draft.copy(includeSourceDirectory = false)) }, + modifier = Modifier.fillMaxWidth(), + ) + PlacementChoiceButton( + text = folderLabel + if (recommendedKeepFolder) stringResource(R.string.nexus_recommended_suffix) else "", + selected = draft.includeSourceDirectory, + onClick = { onUpdate(draft.copy(includeSourceDirectory = true)) }, + modifier = Modifier.fillMaxWidth(), + ) + } + Text( + stringResource(R.string.nexus_folder_layout_result, layout.resultExample), style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (layout.duplicateFolderWarning) { + Text( + stringResource(R.string.nexus_duplicate_folder_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } - TextButton(onClick = { showManualPaths = !showManualPaths }) { - Text(if (showManualPaths) stringResource(R.string.nexus_hide_manual_paths) else stringResource(R.string.nexus_manual_paths)) + if (showAdvanced) { + DropdownField( + label = stringResource(R.string.nexus_install_method), + value = placementModeLabelText(draft.mode), + options = ModPlacementMode.entries.map { placementModeLabelText(it.name) to it.name }, + onSelect = { onUpdate(draft.copy(mode = it)) }, + modifier = Modifier.fillMaxWidth(), + ) + + TextButton(onClick = { showManualPaths = !showManualPaths }) { + Text(if (showManualPaths) stringResource(R.string.nexus_hide_manual_paths) else stringResource(R.string.nexus_manual_paths)) + } } - if (showManualPaths) { + if (showAdvanced && showManualPaths) { NoExtractOutlinedTextField( value = sourceManualText(draft.sourceSubpath), - onValueChange = { onUpdate(draft.copy(sourceSubpath = ModPlacementSources.encode(it.lines()))) }, + onValueChange = { value -> + val sources = value.lines().filter(String::isNotBlank) + onUpdate( + draft.copy( + sourceSubpath = ModPlacementSources.encode(sources), + includeSourceDirectory = AutomaticPlacementPlanner.inferIncludeSourceDirectory( + sources, + entries, + draft.targetRelativePath, + ), + ), + ) + }, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.nexus_source_folders_files_label)) }, placeholder = { Text(stringResource(R.string.nexus_source_paths_placeholder)) }, @@ -589,7 +1313,16 @@ private fun PlacementDraftEditor( .filter { it.isNotBlank() } .toSet(), onSelectMultiple = { paths -> - onUpdate(draft.copy(sourceSubpath = ModPlacementSources.encode(paths))) + onUpdate( + draft.copy( + sourceSubpath = ModPlacementSources.encode(paths), + includeSourceDirectory = AutomaticPlacementPlanner.inferIncludeSourceDirectory( + paths, + entries, + draft.targetRelativePath, + ), + ), + ) showSourcePicker = false }, onDismiss = { showSourcePicker = false }, @@ -600,6 +1333,10 @@ private fun PlacementDraftEditor( ContainerDestinationPickerDialog( roots = roots, currentDraft = draft, + plan = plan, + ownershipManifests = ownershipManifests, + selectedInstallId = selectedInstallId, + installNamesById = installNamesById, onSelect = { onUpdate(it) showDestinationPicker = false @@ -856,15 +1593,20 @@ private fun ArchiveBrowserDialog( private fun ContainerDestinationPickerDialog( roots: List, currentDraft: RecipeDraft, + plan: ModInstallPlan?, + ownershipManifests: List, + selectedInstallId: String, + installNamesById: Map, + readOnly: Boolean = false, onSelect: (RecipeDraft) -> Unit, onDismiss: () -> Unit, ) { var currentRootName by remember(currentDraft.targetRoot, roots) { mutableStateOf(roots.firstOrNull { it.type.name == currentDraft.targetRoot }?.type?.name.orEmpty()) } - var currentDir by remember(currentDraft.targetRoot, currentDraft.targetRelativePath, roots) { + var selectedDestination by remember(currentDraft.targetRoot, currentDraft.targetRelativePath, roots) { val root = roots.firstOrNull { it.type.name == currentDraft.targetRoot } - val current = root?.let { targetRoot -> + mutableStateOf(root?.let { targetRoot -> val candidate = if (currentDraft.targetRelativePath.isBlank()) { targetRoot.dir } else { @@ -872,209 +1614,420 @@ private fun ContainerDestinationPickerDialog( } runCatching { candidate.canonicalFile } .getOrNull() - ?.takeIf { it.isDirectory && it.isInsideOrEqual(targetRoot.dir) } + ?.takeIf { it.isInsideOrEqual(targetRoot.dir) } + }) + } + var currentDir by remember(currentDraft.targetRoot, currentDraft.targetRelativePath, roots) { + val root = roots.firstOrNull { it.type.name == currentDraft.targetRoot } + val desired = root?.let { targetRoot -> + if (currentDraft.targetRelativePath.isBlank()) targetRoot.dir else File(targetRoot.dir, currentDraft.targetRelativePath) } - mutableStateOf(current) + var existing = desired + while (existing != null && !existing.isDirectory) existing = existing.parentFile + mutableStateOf(existing?.takeIf { root != null && it.isInsideOrEqual(root.dir) }) } - var subDirs by remember { mutableStateOf>(emptyList()) } + var browserEntries by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + var showHidden by remember { mutableStateOf(false) } + var showNewFolderDialog by remember { mutableStateOf(false) } + var newFolderName by remember { mutableStateOf("") } val currentRoot = roots.firstOrNull { it.type.name == currentRootName } - LaunchedEffect(currentDir, currentRoot) { + LaunchedEffect(currentDir, currentRoot, plan, ownershipManifests, query, showHidden, selectedDestination) { val dir = currentDir if (dir != null && dir.isDirectory) { loading = true try { + delay(150) val root = currentRoot - subDirs = withContext(Dispatchers.IO) { - dir.listFiles() - ?.filter { - it.isDirectory && - !it.name.startsWith(".") && - (root == null || it.isInsideOrEqual(root.dir)) - } - ?.sortedBy { it.name.lowercase() } - ?: emptyList() + browserEntries = withContext(Dispatchers.IO) { + if (root == null) { + emptyList() + } else { + val virtualName = selectedDestination + ?.takeIf { !it.exists() && it.parentFile?.canonicalFile == dir.canonicalFile } + ?.name + destinationBrowserEntries( + directory = dir, + root = root, + plan = plan, + ownership = ownershipManifests, + selectedInstallId = selectedInstallId, + showHidden = showHidden, + query = query, + virtualFolderName = virtualName, + ) + } } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e - subDirs = emptyList() + browserEntries = emptyList() } finally { loading = false } } else { - subDirs = emptyList() + browserEntries = emptyList() } } - val breadcrumb = remember(currentDir, currentRoot) { - val dir = currentDir ?: return@remember "" - val root = currentRoot ?: return@remember dir.name - val relative = runCatching { - dir.canonicalFile.relativeToOrNull(root.dir.canonicalFile)?.path.orEmpty() - }.getOrDefault("") - if (relative.isBlank()) root.label else "${root.label} / ${relative.replace(File.separatorChar, '/')}" - } - Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Surface( - modifier = Modifier - .fillMaxWidth(0.94f) - .height(540.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column(modifier = Modifier.fillMaxSize()) { - Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(stringResource(R.string.nexus_destination_folder), style = MaterialTheme.typography.headlineSmall) - Text( - text = if (currentDir == null) stringResource(R.string.nexus_choose_game_container_location) else breadcrumb, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - if (currentDir != null) { - Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant) { - Row( - modifier = Modifier - .clickable { - val root = currentRoot - val parent = currentDir?.parentFile - currentDir = if (root != null && parent != null && parent.isInsideOrEqual(root.dir)) { - parent - } else { - null + BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val compactHeight = maxHeight < 600.dp + Surface( + modifier = Modifier + .fillMaxWidth(0.94f) + .fillMaxHeight(if (compactHeight) 0.98f else 0.94f), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + Modifier.padding(if (compactHeight) 12.dp else 20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + stringResource(if (readOnly) R.string.nexus_destination_contents else R.string.nexus_destination_folder), + style = if (compactHeight) MaterialTheme.typography.titleLarge else MaterialTheme.typography.headlineSmall, + ) + if (currentDir == null || currentRoot == null) { + Text( + text = stringResource(R.string.nexus_choose_game_container_location), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + DestinationBreadcrumb(currentRoot, currentDir!!) { destination -> + currentDir = destination + selectedDestination = destination + query = "" + } + } + if (roots.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + roots.forEach { root -> + OutlinedButton( + onClick = { + currentRootName = root.type.name + currentDir = root.dir + selectedDestination = root.dir + query = "" + }, + ) { + Text(root.label, maxLines = 1) } } - .padding(horizontal = 20.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), modifier = Modifier.size(18.dp)) - Text(currentDir?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) + } } } - HorizontalDivider() - } - if (currentDir == null) { - LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { - items(roots, key = { it.type.name }) { root -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - currentRootName = root.type.name - currentDir = root.dir - } - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( + if (currentDir != null) { + if (!compactHeight) { + Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant) { + Row( modifier = Modifier - .size(36.dp) - .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), - shape = RoundedCornerShape(8.dp), - ), - contentAlignment = Alignment.Center, + .clickable { + val root = currentRoot + val parent = currentDir?.parentFile + currentDir = if (root != null && parent != null && parent.isInsideOrEqual(root.dir)) { + parent + } else { + null + } + selectedDestination = currentDir + query = "" + } + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + modifier = Modifier.size(18.dp), + ) + Text(currentDir?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + HorizontalDivider() + DestinationBrowserTools( + compact = compactHeight, + query = query, + onQueryChange = { query = it }, + showHidden = showHidden, + onShowHiddenChange = { showHidden = it }, + readOnly = readOnly, + onNewFolder = { + newFolderName = "" + showNewFolderDialog = true + }, + ) + HorizontalDivider() + } + + if (currentDir == null) { + LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { + items(roots, key = { it.type.name }) { root -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + currentRootName = root.type.name + currentDir = root.dir + selectedDestination = root.dir + query = "" + } + .padding(horizontal = 20.dp, vertical = if (compactHeight) 8.dp else 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + targetRootIcon(root.type), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text(root.label, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) Icon( - targetRootIcon(root.type), + Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text(root.label, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } else if (loading) { + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + } + } else if (browserEntries.isEmpty()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { Icon( - Icons.AutoMirrored.Filled.ArrowForward, + Icons.Default.FolderOff, contentDescription = null, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text( + stringResource(R.string.nexus_destination_folder_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - } - } else if (loading) { - Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp) - } - } else if (subDirs.isEmpty()) { - Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.Default.FolderOff, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(stringResource(R.string.nexus_no_subdirectories), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { + items(browserEntries, key = { "${it.file.absolutePath}:${it.virtual}" }) { entry -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = entry.directory && !entry.virtual) { + currentDir = entry.file + selectedDestination = entry.file + query = "" + } + .padding(horizontal = 20.dp, vertical = if (compactHeight) 8.dp else 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + if (entry.directory) Icons.Default.Folder else Icons.Default.Description, + contentDescription = null, + tint = if (entry.directory) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(entry.file.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + DestinationEntryMetadata(entry, installNamesById, selectedInstallId) + } + if (entry.directory && !entry.virtual) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), + ) + } } } - } else { - LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { - items(subDirs, key = { it.absolutePath }) { dir -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { currentDir = dir } - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = if (compactHeight) 6.dp else 12.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!readOnly) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + val selectedRoot = currentRoot + val selectedDir = selectedDestination ?: currentDir + if (readOnly) { + Button(onClick = onDismiss, modifier = Modifier.padding(start = 8.dp)) { + Text(stringResource(R.string.close)) + } + } else if (selectedRoot != null && selectedDir != null) { + Button( + onClick = { + val relative = runCatching { + selectedDir.canonicalFile.relativeToOrNull(selectedRoot.dir.canonicalFile) + ?.path + ?.replace(File.separatorChar, '/') + .orEmpty() + }.getOrDefault("") + onSelect( + currentDraft.copy( + targetRoot = selectedRoot.type.name, + targetRelativePath = relative, + ), + ) + }, + modifier = Modifier.padding(start = 8.dp), ) { - Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Text(dir.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.nexus_select)) } - HorizontalDivider( - modifier = Modifier.padding(horizontal = 20.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), - ) } } } + } + } + } - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - val selectedRoot = currentRoot - val selectedDir = currentDir - if (selectedRoot != null && selectedDir != null) { - Button( - onClick = { - val relative = runCatching { - selectedDir.canonicalFile.relativeToOrNull(selectedRoot.dir.canonicalFile) - ?.path - ?.replace(File.separatorChar, '/') - .orEmpty() - }.getOrDefault("") - onSelect( - currentDraft.copy( - targetRoot = selectedRoot.type.name, - targetRelativePath = relative, - ), - ) - }, - modifier = Modifier.padding(start = 8.dp), - ) { - Text(stringResource(R.string.nexus_select)) + if (showNewFolderDialog && !readOnly) { + val validDestination = validVirtualDestinationFolder(currentDir, newFolderName) + AlertDialog( + onDismissRequest = { showNewFolderDialog = false }, + title = { Text(stringResource(R.string.nexus_new_destination_folder)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.nexus_new_destination_folder_description)) + NoExtractOutlinedTextField( + value = newFolderName, + onValueChange = { newFolderName = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.nexus_folder_name)) }, + isError = newFolderName.isNotBlank() && !validDestination, + singleLine = true, + ) + Text( + stringResource(R.string.nexus_folder_created_when_applied), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button( + onClick = { + currentDir?.let { parent -> + File(parent, newFolderName.trim()).takeIf { !it.exists() || it.isDirectory }?.let { + selectedDestination = it + showNewFolderDialog = false + } } + }, + enabled = validDestination, + ) { Text(stringResource(R.string.nexus_use_folder)) } + }, + dismissButton = { + TextButton(onClick = { showNewFolderDialog = false }) { Text(stringResource(R.string.cancel)) } + }, + ) + } +} + +@Composable +private fun DestinationBrowserTools( + compact: Boolean, + query: String, + onQueryChange: (String) -> Unit, + showHidden: Boolean, + onShowHiddenChange: (Boolean) -> Unit, + readOnly: Boolean, + onNewFolder: () -> Unit, +) { + if (compact) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NoExtractOutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.nexus_search_destination)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + ) + IconButton(onClick = { onShowHiddenChange(!showHidden) }) { + Icon( + if (showHidden) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = stringResource(R.string.nexus_show_hidden_files), + tint = if (showHidden) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!readOnly) { + IconButton(onClick = onNewFolder) { + Icon( + Icons.Default.CreateNewFolder, + contentDescription = stringResource(R.string.nexus_new_destination_folder), + ) + } + } + } + } else { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + NoExtractOutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.nexus_search_destination)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = showHidden, onCheckedChange = onShowHiddenChange) + Text(stringResource(R.string.nexus_show_hidden_files), modifier = Modifier.weight(1f)) + if (!readOnly) { + TextButton(onClick = onNewFolder) { + Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.nexus_new_destination_folder)) } } } @@ -1082,6 +2035,85 @@ private fun ContainerDestinationPickerDialog( } } +@Composable +private fun DestinationBreadcrumb( + root: ResolvedModTargetRoot, + directory: File, + onNavigate: (File) -> Unit, +) { + val relativeSegments = remember(root, directory) { + runCatching { + directory.canonicalFile.relativeTo(root.dir.canonicalFile).path + .replace(File.separatorChar, '/') + .split('/') + .filter(String::isNotBlank) + }.getOrDefault(emptyList()) + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { onNavigate(root.dir) }) { Text(root.label, maxLines = 1) } + var destination = root.dir + relativeSegments.forEach { segment -> + destination = File(destination, segment) + val segmentDestination = destination + Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = { onNavigate(segmentDestination) }) { Text(segment, maxLines = 1) } + } + } +} + +@Composable +private fun DestinationEntryMetadata( + entry: DestinationBrowserEntry, + installNamesById: Map, + selectedInstallId: String, +) { + val details = listOfNotNull( + StorageUtils.formatBinarySize(entry.sizeBytes).takeIf { !entry.directory && entry.sizeBytes > 0L }, + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + .format(Date(entry.modifiedAt)).takeIf { entry.modifiedAt > 0L }, + stringResource(R.string.nexus_will_be_created).takeIf { entry.virtual }, + ) + if (details.isNotEmpty()) { + Text( + details.joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + entry.impacts.forEach { impact -> + val label = when (impact) { + DestinationEntryImpact.WILL_ADD -> stringResource(R.string.nexus_impact_will_add) + DestinationEntryImpact.WILL_REPLACE -> stringResource(R.string.nexus_impact_will_replace) + DestinationEntryImpact.WILL_BACK_UP -> stringResource(R.string.nexus_impact_will_backup) + DestinationEntryImpact.PLAN_CONFLICT -> stringResource(R.string.nexus_impact_plan_conflict) + DestinationEntryImpact.MANAGED_BY_THIS_MOD -> stringResource(R.string.nexus_impact_managed_by_this_mod) + DestinationEntryImpact.MANAGED_BY_ANOTHER_MOD -> { + val names = entry.ownerInstallIds.filterNot { it == selectedInstallId } + .map { installNamesById[it] ?: it } + .take(2) + .joinToString(", ") + stringResource(R.string.nexus_impact_managed_by_mod, names) + } + DestinationEntryImpact.MODIFIED -> stringResource(R.string.nexus_impact_modified) + DestinationEntryImpact.GAME_OR_UNMANAGED -> stringResource(R.string.nexus_impact_game_or_unmanaged) + DestinationEntryImpact.WILL_RECEIVE_FILES -> stringResource(R.string.nexus_impact_will_receive_files) + } + Surface(shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.secondaryContainer) { + Text( + label, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + @Composable private fun DropdownField( label: String, diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/PlacementWorkspaceModels.kt b/app/src/main/java/app/gamenative/ui/component/dialog/PlacementWorkspaceModels.kt new file mode 100644 index 0000000000..d53b528ecd --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/PlacementWorkspaceModels.kt @@ -0,0 +1,300 @@ +package app.gamenative.ui.component.dialog + +import app.gamenative.data.ModPlacementMode +import app.gamenative.mods.ModInstallPlan +import app.gamenative.mods.ModOwnershipManifest +import app.gamenative.mods.ModOwnershipState +import app.gamenative.mods.ModPlacementSources +import app.gamenative.mods.ModPlanChangeType +import app.gamenative.mods.ModReconfigurationDiff +import app.gamenative.mods.ModTargetResolver +import app.gamenative.mods.PlannedFileStatus +import app.gamenative.mods.PlannedModFile +import app.gamenative.mods.ResolvedModTargetRoot +import app.gamenative.mods.WindowsPathIdentity +import java.io.File +import java.util.Locale + +internal data class PlacementLayoutModel( + val visible: Boolean, + val editable: Boolean, + val multipleFolders: Boolean, + val selectedNames: List, + val resultExample: String, + val duplicateFolderWarning: Boolean, +) + +internal data class PlacementDraftPage( + val pageIndex: Int, + val pageCount: Int, + val startIndex: Int, + val endIndexExclusive: Int, +) + +internal fun placementDraftPage( + totalRules: Int, + requestedPage: Int, + pageSize: Int = 20, +): PlacementDraftPage { + require(pageSize > 0) + val safeTotal = totalRules.coerceAtLeast(0) + val pageCount = maxOf(1, (safeTotal + pageSize - 1) / pageSize) + val pageIndex = requestedPage.coerceIn(0, pageCount - 1) + val startIndex = (pageIndex * pageSize).coerceAtMost(safeTotal) + return PlacementDraftPage( + pageIndex = pageIndex, + pageCount = pageCount, + startIndex = startIndex, + endIndexExclusive = (startIndex + pageSize).coerceAtMost(safeTotal), + ) +} + +internal fun placementLayoutModel( + draft: RecipeDraft, + entries: List, +): PlacementLayoutModel { + if (draft.targetFileName.isNotBlank()) { + return PlacementLayoutModel(false, false, false, emptyList(), "", false) + } + val sources = ModPlacementSources.decode(draft.sourceSubpath).filter(String::isNotBlank) + val selectingEverything = sources.isEmpty() + val folders = sources.filter { source -> + entries.any { entry -> + val path = normalizeArchivePath(entry.path) + path.equals(source, ignoreCase = true) && entry.directory || + path.startsWith("${normalizeArchivePath(source)}/", ignoreCase = true) + } + } + val selectedNames = if (selectingEverything) { + entries.mapNotNull { entry -> + normalizeArchivePath(entry.path).substringBefore('/', "").takeIf(String::isNotBlank) + }.distinctBy { it.lowercase(Locale.ROOT) } + } else { + folders.map { it.substringAfterLast('/') }.distinct() + } + val destination = draft.targetRelativePath.trim('/').ifBlank { "" } + val result = when { + selectingEverything && selectedNames.size == 1 -> "$destination/${selectedNames.single()}/" + selectingEverything -> + "$destination/{${selectedNames.take(3).joinToString(", ")}" + + "${if (selectedNames.size > 3) ", ..." else ""}}/" + selectedNames.isEmpty() || !draft.includeSourceDirectory -> "$destination/" + selectedNames.size == 1 -> "$destination/${selectedNames.single()}/" + else -> + "$destination/{${selectedNames.take(3).joinToString(", ")}" + + "${if (selectedNames.size > 3) ", ..." else ""}}/" + } + return PlacementLayoutModel( + visible = selectedNames.isNotEmpty(), + editable = !selectingEverything, + multipleFolders = selectedNames.size > 1, + selectedNames = selectedNames, + resultExample = result, + duplicateFolderWarning = draft.includeSourceDirectory && selectedNames.any { selected -> + destination.substringAfterLast('/').equals(selected, ignoreCase = true) + }, + ) +} + +internal enum class DestinationEntryImpact { + WILL_ADD, + WILL_REPLACE, + WILL_BACK_UP, + PLAN_CONFLICT, + MANAGED_BY_THIS_MOD, + MANAGED_BY_ANOTHER_MOD, + MODIFIED, + GAME_OR_UNMANAGED, + WILL_RECEIVE_FILES, +} + +internal data class DestinationBrowserEntry( + val file: File, + val directory: Boolean, + val sizeBytes: Long, + val modifiedAt: Long, + val impacts: Set, + val ownerInstallIds: Set, + val virtual: Boolean = false, +) + +internal fun destinationBrowserEntries( + directory: File, + root: ResolvedModTargetRoot, + plan: ModInstallPlan?, + ownership: List, + selectedInstallId: String, + showHidden: Boolean, + query: String, + virtualFolderName: String? = null, +): List { + val planFiles = plan?.files.orEmpty() + val activeOwnership = ownership.filter { it.state == ModOwnershipState.ACTIVE } + val normalizedQuery = query.trim().lowercase(Locale.ROOT) + val children = directory.listFiles().orEmpty() + .asSequence() + .filter { it.isInsideOrEqual(root.dir) } + .filter { showHidden || !it.name.startsWith('.') } + .filter { normalizedQuery.isBlank() || normalizedQuery in it.name.lowercase(Locale.ROOT) } + .map { child -> + destinationBrowserEntry(child, root, planFiles, activeOwnership, selectedInstallId) + } + .toMutableList() + virtualFolderName?.takeIf { name -> + name.isNotBlank() && (normalizedQuery.isBlank() || normalizedQuery in name.lowercase(Locale.ROOT)) + }?.let { name -> + children += DestinationBrowserEntry( + file = File(directory, name), + directory = true, + sizeBytes = 0L, + modifiedAt = 0L, + impacts = setOf(DestinationEntryImpact.WILL_RECEIVE_FILES), + ownerInstallIds = emptySet(), + virtual = true, + ) + } + return children.sortedWith(compareByDescending { it.directory }.thenBy { it.file.name.lowercase(Locale.ROOT) }) +} + +private fun destinationBrowserEntry( + file: File, + root: ResolvedModTargetRoot, + planFiles: List, + ownership: List, + selectedInstallId: String, +): DestinationBrowserEntry { + val relative = runCatching { + file.canonicalFile.relativeTo(root.dir.canonicalFile).path.replace(File.separatorChar, '/') + }.getOrDefault("") + val logicalKey = ModTargetResolver.normalizedTargetKey(root.type.name, relative) + val absoluteKey = WindowsPathIdentity.absoluteKey(file) + val exactPlan = logicalKey?.let { key -> + planFiles.filter { it.normalizedTargetKey == key } + }.orEmpty() + val directoryPrefix = logicalKey?.let { "$it/" } + val receivesFiles = file.isDirectory && directoryPrefix != null && planFiles.any { + it.status == PlannedFileStatus.PLACED && it.normalizedTargetKey?.startsWith(directoryPrefix) == true + } + val ownedFiles = ownership.flatMap { manifest -> + manifest.files.filter { it.active && it.normalizedTargetKey == absoluteKey }.map { manifest.installId to it } + } + val impacts = linkedSetOf() + if (receivesFiles) impacts += DestinationEntryImpact.WILL_RECEIVE_FILES + if (exactPlan.any { it.status == PlannedFileStatus.CONFLICTED }) impacts += DestinationEntryImpact.PLAN_CONFLICT + exactPlan.filter { it.status == PlannedFileStatus.PLACED }.forEach { planned -> + if (!file.exists()) { + impacts += DestinationEntryImpact.WILL_ADD + } else { + impacts += DestinationEntryImpact.WILL_REPLACE + if (planned.mode == ModPlacementMode.OVERWRITE_COPY.name) impacts += DestinationEntryImpact.WILL_BACK_UP + } + } + if (ownedFiles.any { it.first == selectedInstallId }) impacts += DestinationEntryImpact.MANAGED_BY_THIS_MOD + if (ownedFiles.any { it.first != selectedInstallId }) impacts += DestinationEntryImpact.MANAGED_BY_ANOTHER_MOD + if (ownedFiles.any { (_, owned) -> file.isFile && (file.length() != owned.installedSize || file.lastModified() != owned.installedMtime) }) { + impacts += DestinationEntryImpact.MODIFIED + } + if (file.isFile && ownedFiles.isEmpty() && exactPlan.isEmpty()) impacts += DestinationEntryImpact.GAME_OR_UNMANAGED + return DestinationBrowserEntry( + file = file, + directory = file.isDirectory, + sizeBytes = if (file.isFile) file.length() else 0L, + modifiedAt = file.lastModified(), + impacts = impacts, + ownerInstallIds = ownedFiles.mapTo(linkedSetOf()) { it.first }, + ) +} + +internal fun validVirtualDestinationFolderName(value: String): Boolean { + val name = value.trim() + return name.isNotEmpty() && + '/' !in name && '\\' !in name && + WindowsPathIdentity.normalizedRelativeKey(name) != null +} + +internal fun validVirtualDestinationFolder(parent: File?, value: String): Boolean { + if (parent == null || !validVirtualDestinationFolderName(value)) return false + val candidate = File(parent, value.trim()) + return !candidate.exists() || candidate.isDirectory +} + +internal enum class PlacementReviewCategory { + ADDED, + REPLACED, + MOVED, + REMOVED, + IGNORED, + BLOCKED, + UNCHANGED, +} + +internal data class PlacementReviewRow( + val category: PlacementReviewCategory, + val source: String, + val previousTarget: String = "", + val target: String = "", + val targetRoot: String = "", + val targetRelativePath: String = "", + val reason: String = "", + val sizeBytes: Long = 0L, +) { + fun matches(query: String): Boolean { + val needle = query.trim().lowercase(Locale.ROOT) + return needle.isBlank() || listOf(source, previousTarget, target, reason).any { needle in it.lowercase(Locale.ROOT) } + } +} + +internal fun placementReviewRows( + plan: ModInstallPlan, + diff: ModReconfigurationDiff?, + roots: List, + staleReason: String, +): List { + val changesBySource = diff?.changes.orEmpty().groupBy { it.sourceRelativePath } + val rows = plan.files.map { file -> + val change = changesBySource[file.sourceRelativePath].orEmpty().firstOrNull { it.type != ModPlanChangeType.UNCHANGED } + val category = when (file.status) { + PlannedFileStatus.INTENTIONALLY_IGNORED -> PlacementReviewCategory.IGNORED + PlannedFileStatus.UNSUPPORTED, PlannedFileStatus.MISSING, PlannedFileStatus.CONFLICTED -> PlacementReviewCategory.BLOCKED + PlannedFileStatus.PLACED -> when (change?.type) { + ModPlanChangeType.MOVED -> PlacementReviewCategory.MOVED + ModPlanChangeType.CHANGED -> PlacementReviewCategory.REPLACED + ModPlanChangeType.STALE -> PlacementReviewCategory.REMOVED + ModPlanChangeType.ADDED -> if (plannedTargetExists(file, roots)) PlacementReviewCategory.REPLACED else PlacementReviewCategory.ADDED + ModPlanChangeType.UNCHANGED, null -> if (plannedTargetExists(file, roots) && diff == null) { + PlacementReviewCategory.REPLACED + } else { + PlacementReviewCategory.UNCHANGED + } + } + } + PlacementReviewRow( + category = category, + source = file.sourceRelativePath, + previousTarget = change?.previousTarget.orEmpty(), + target = file.targetDisplay(), + targetRoot = file.targetRoot.orEmpty(), + targetRelativePath = file.targetRelativePath.orEmpty(), + reason = file.reason, + sizeBytes = file.sizeBytes, + ) + }.toMutableList() + diff?.changes.orEmpty().filter { it.type == ModPlanChangeType.STALE }.forEach { change -> + rows += PlacementReviewRow( + category = PlacementReviewCategory.REMOVED, + source = change.sourceRelativePath, + previousTarget = change.previousTarget, + reason = staleReason, + ) + } + return rows.sortedWith(compareBy { it.category.ordinal }.thenBy { it.source.lowercase(Locale.ROOT) }) +} + +private fun plannedTargetExists(file: PlannedModFile, roots: List): Boolean { + val root = roots.firstOrNull { it.type.name == file.targetRoot } ?: return false + val relative = file.targetRelativePath ?: return false + return app.gamenative.mods.ModTargetResolver.resolveWithin(root.dir, relative)?.exists() == true +} + +private fun PlannedModFile.targetDisplay(): String = + listOfNotNull(targetRoot, targetRelativePath).filter(String::isNotBlank).joinToString("/") diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index d70c9e8784..28421e3a98 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1862,8 +1862,6 @@ Mappe/fil fra mod Destinationsmappe Installationsmetode - Opret en mappe til valget - Til: Den valgte mappe er installeret på destinationen. Fra: Kun dens indhold placeres der. Skjul manuelle stier Manuelle stier Mapper/filer fra mod @@ -1999,7 +1997,6 @@ Anvendelse af mod-filer Anvendte elementer: %1$d, sikkerhedskopier: %2$d%3$s ; gamle filer efterladt på plads: %1$d - Anvendt med fejl: %1$d Mod-filer anvendes allerede. Kunne ikke anvende mod Konfigurer FOMOD-installationsprogrammet, eller vælg Brugerdefineret placering, før du ansøger. @@ -2321,6 +2318,83 @@ Føjet til favoritter Føjede %1$s til favoritter VR-opdateringsfrekvens + Gennemse placering + %1$d af %2$d filer planlagt • %3$d ignoreret • %4$d kræver gennemgang + Rangerede forslag + %1$d. %2$s — %3$d%% dækning + Sammenfletning af store/små bogstaver: %1$s + tvetydige varianter med store/små bogstaver findes allerede + Automatisk anvendelse er blokeret, indtil alle installerbare filer har en sikker destination. + Del diagnosticering + Forhåndsvisning af omkonfiguration + %1$d tilføjet • %2$d ændret • %3$d flyttet • %4$d forældet + Vælg en pakkevariant + Jeg har gennemgået disse filer i spilmappen + DLL-, EXE- og indlæserfiler kan ændre, hvordan spillet starter. Bekræft kun, når arkivet er betroet, og forhåndsvisningen matcher installationsvejledningen. + Opretter filplaceringsplan… + Genanvend manglende filer + Kontrollér og registrer filer + Filerne blev kontrolleret og registreret uden at ændre dem. + Kunne ikke kontrollere og registrere installerede filer (%1$d problem(er)) + Produceres ikke længere af denne placering + Gendan forrige udrulning + Erstatter den aktuelle filplaceringsplan med den senest fungerende plan for dette mod. + Gendanner forrige udrulning… + Den forrige udrulning blev gendannet. + Den forrige udrulning kunne ikke gendannes (%1$d problem(er)) + Søg i denne mappe + Vis skjulte filer og mapper + Ny mappe + Vælg et mappenavn i den aktuelle destination. + Mappenavn + Intet ændres endnu. Mappen oprettes først, når den gennemgåede plan anvendes. + Brug mappe + Oprettes + Ingen matchende filer eller mapper + Tilføjes + Erstattes + Sikkerhedskopieres + Plankonflikt + Administreret af dette mod + Administreret af %1$s + Ændret siden installation + Spilfil eller ikke-administreret fil + Modtager filer + Avanceret placering + Skjul avanceret placering + Automatisk placering er et bedste skøn. Kontrollér, at de planlagte filer og destinationer følger moddens installationsvejledning. Hvis ikke, skal du åbne Avanceret placering og vælge Brugerdefineret for selv at placere dem. + Installer på destinationen + Indholdet af %1$s + Mappen %1$s og dens indhold + Flet indholdet af valgte mapper + Behold navnene på hver valgt mappe + \u0020(Anbefalet) + Resultat: %1$s + Dette opretter et gentaget mappenavn som Data/Data. Vælg indhold, medmindre moddet kræver den ekstra mappe. + Hvorfor denne placering? + Skjul placeringsdetaljer + Brug denne placering + Løs %1$d filer + Skifter til brugerdefineret placering og bevarer de anbefalede tilknytninger, så uløste filer kan tildeles sikkert. + Gennemgå alle filer (%1$d) + Alle planlagte filer + Søg i kilde, destination eller årsag + Alle (%1$d) + Ingen planlagte filer matcher filteret + Tidligere: %1$s + Destination: %1$s + Gennemse destination + Destinationens indhold + Tilføjet + Erstattet + Flyttet + Fjernet + Ignoreret + Blokeret + Uændret + Forrige side med placeringsregler + Næste side med placeringsregler + Placeringsregler %1$d–%2$d af %3$d Hurtigere indlæsning fra eksternt lager Kan gøre spilstart fra eksternt lager markant hurtigere, men kan give problemer i nogle spil Deaktiver libredirect @@ -2390,4 +2464,13 @@ %1$.1f°/s %1$d ms Nulstil gyroindstillinger + Gennemgå placering + Mapper på øverste niveau beholder deres navne; filer i roden placeres direkte i destinationen + %1$d standardfil(er) erstattet af dine FOMOD-valg + Modrækkefølgen blev ikke ændret. Manglende eller ændrede administrerede filer: %1$d. Kør kontrol af installationen, og løs problemerne først. + Placering fuldført. I alt: %1$d; opdateret: %2$d; allerede ajour: %3$d; sikkerhedskopieret: %4$d%5$s + Installationen mislykkedes; sikker tilbagerulning blev forsøgt. Fejl: %1$d + Sidste modinstallation mislykkedes + Moddet blev ikke markeret som installeret. Ændringer blev rullet tilbage, hvor det kunne bekræftes sikkert. Gennemgå filfejlene nedenfor, og gå tilbage til Placering for at rette eller prøve installationen igen. + Filfejl: %1$d diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 183e2c5cc5..7bbb3f0888 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1932,8 +1932,6 @@ Ordner/Datei aus dem Mod Zielordner Methode installieren - Erstellen Sie einen Ordner für die Auswahl - Ein: Der ausgewählte Ordner wird im Ziel installiert. Aus: Nur der Inhalt wird dort platziert. Manuelle Pfade ausblenden Manuelle Pfade Ordner/Dateien aus dem Mod @@ -2069,7 +2067,6 @@ Anwenden von Mod-Dateien Angewandte %1$d Artikel, gesichert %2$d%3$s ; alte Dateien an Ort und Stelle belassen: %1$d - Angewendet mit Fehlern: %1$d Mod-Dateien werden bereits angewendet. Mod konnte nicht angewendet werden Konfigurieren Sie vor der Anwendung das FOMOD-Installationsprogramm oder wählen Sie „Benutzerdefinierte Platzierung“. @@ -2391,6 +2388,83 @@ Zu Favoriten hinzugefügt %1$s zu Favoriten hinzugefügt VR-Bildwiederholrate + Platzierung prüfen + %1$d von %2$d Dateien geplant • %3$d ignoriert • %4$d zu prüfen + Sortierte Vorschläge + %1$d. %2$s — %3$d%% Abdeckung + Groß-/Kleinschreibung zusammengeführt: %1$s + mehrdeutige Varianten der Groß-/Kleinschreibung sind bereits vorhanden + Die automatische Anwendung ist blockiert, bis jede installierbare Datei ein eindeutiges Ziel hat. + Diagnose teilen + Vorschau der Neukonfiguration + %1$d hinzugefügt • %2$d geändert • %3$d verschoben • %4$d veraltet + Paketvariante auswählen + Ich habe diese Dateien im Spielordner geprüft + DLL-, EXE- und Loader-Dateien können den Spielstart verändern. Nur bestätigen, wenn das Archiv vertrauenswürdig ist und die Vorschau der Installationsanleitung entspricht. + Dateiplatzierungsplan wird erstellt… + Fehlende Dateien erneut anwenden + Dateien prüfen und erfassen + Die Dateien wurden geprüft und erfasst, ohne sie zu ändern. + Installierte Dateien konnten nicht überprüft und erfasst werden (%1$d Problem(e)) + Wird von dieser Platzierung nicht mehr erzeugt + Vorherige Bereitstellung wiederherstellen + Ersetzt den aktuellen Dateiplatzierungsplan durch den letzten funktionierenden Plan dieses Mods. + Vorherige Bereitstellung wird wiederhergestellt… + Die vorherige Bereitstellung wurde wiederhergestellt. + Die vorherige Bereitstellung konnte nicht wiederhergestellt werden (%1$d Problem(e)) + Diesen Ordner durchsuchen + Versteckte Dateien und Ordner anzeigen + Neuer Ordner + Wähle einen Ordnernamen im aktuellen Ziel. + Ordnername + Noch wird nichts geändert. Der Ordner wird erst beim Anwenden des geprüften Plans erstellt. + Ordner verwenden + Wird erstellt + Keine passenden Dateien oder Ordner + Wird hinzugefügt + Wird ersetzt + Wird gesichert + Plankonflikt + Von diesem Mod verwaltet + Verwaltet von %1$s + Seit der Bereitstellung geändert + Spiel- oder nicht verwaltete Datei + Erhält Dateien + Erweiterte Platzierung + Erweiterte Platzierung ausblenden + Die automatische Platzierung ist eine Schätzung. Prüfe, ob die geplanten Dateien und Zielordner der Installationsanleitung des Mods entsprechen. Falls nicht, öffne Erweiterte Platzierung und wähle Benutzerdefiniert, um sie selbst zuzuordnen. + Am Ziel installieren + Inhalt von %1$s + Ordner %1$s und sein Inhalt + Inhalte ausgewählter Ordner zusammenführen + Namen aller ausgewählten Ordner beibehalten + \u0020(Empfohlen) + Ergebnis: %1$s + Dadurch entsteht ein doppelter Ordnername wie Data/Data. Wähle Inhalte, sofern der Mod den zusätzlichen Ordner nicht ausdrücklich benötigt. + Warum diese Platzierung? + Platzierungsdetails ausblenden + Diese Platzierung verwenden + %1$d Dateien lösen + Wechselt zur benutzerdefinierten Platzierung und behält empfohlene Zuordnungen bei, damit ungelöste Dateien sicher zugewiesen werden können. + Alle Dateien prüfen (%1$d) + Alle geplanten Dateien + Quelle, Ziel oder Grund durchsuchen + Alle (%1$d) + Keine geplanten Dateien entsprechen dem Filter + Vorher: %1$s + Ziel: %1$s + Ziel durchsuchen + Zielinhalt + Hinzugefügt + Ersetzt + Verschoben + Entfernt + Ignoriert + Blockiert + Unverändert + Vorherige Seite mit Platzierungsregeln + Nächste Seite mit Platzierungsregeln + Platzierungsregeln %1$d–%2$d von %3$d Schnelleres Laden von externem Speicher Kann den Spielstart von externem Speicher deutlich beschleunigen, kann aber bei manchen Spielen Probleme verursachen libredirect deaktivieren @@ -2460,4 +2534,13 @@ %1$.1f°/s %1$d ms Gyro-Einstellungen zurücksetzen + Platzierung prüfen + Ordner der obersten Ebene behalten ihre Namen; Dateien im Stammverzeichnis werden direkt am Ziel abgelegt + %1$d Standarddatei(en) durch deine FOMOD-Auswahl ersetzt + Die Mod-Reihenfolge wurde nicht geändert. Fehlende oder veränderte verwaltete Dateien: %1$d. Prüfe zuerst den Installationszustand und behebe die Probleme. + Platzierung abgeschlossen. Insgesamt: %1$d; aktualisiert: %2$d; bereits aktuell: %3$d; gesichert: %4$d%5$s + Installation fehlgeschlagen; eine sichere Rücksetzung wurde versucht. Fehler: %1$d + Letzte Mod-Installation fehlgeschlagen + Der Mod wurde nicht als installiert markiert. Änderungen wurden zurückgesetzt, soweit dies sicher überprüft werden konnte. Prüfe die Dateifehler unten und kehre zu Platzierung zurück, um den Mod zu korrigieren oder erneut zu versuchen. + Dateifehler: %1$d diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5c1ed0d65b..1c33e36d27 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1990,8 +1990,6 @@ Carpeta/archivo del mod Carpeta de destino Método de instalación - Crear una carpeta para la selección - Activado: La carpeta seleccionada se instala en el destino. Apagado: Sólo se coloca allí su contenido. Ocultar rutas manuales Rutas manuales Carpetas/archivos del mod @@ -2127,7 +2125,6 @@ Aplicar archivos mod Elementos aplicados: %1$d, copias de seguridad: %2$d%3$s ; archivos antiguos dejados en su lugar: %1$d - Aplicado con errores: %1$d Los archivos Mod ya se están aplicando. No se pudo aplicar el mod Configure el instalador FOMOD o elija Ubicación personalizada antes de realizar la solicitud. @@ -2449,6 +2446,83 @@ Añadido a favoritos %1$s añadido a favoritos Frecuencia de actualización de RV + Revisión de ubicación + %1$d de %2$d archivos planificados • %3$d ignorados • %4$d por revisar + Sugerencias clasificadas + %1$d. %2$s — %3$d%% de cobertura + Unificación de mayúsculas: %1$s + ya existen variantes ambiguas de mayúsculas y minúsculas + La aplicación automática está bloqueada hasta que cada archivo instalable tenga un destino comprobado. + Compartir diagnóstico + Vista previa de la reconfiguración + %1$d añadidos • %2$d cambiados • %3$d movidos • %4$d obsoletos + Elige una variante del paquete + He revisado estos archivos de la carpeta del juego + Los archivos DLL, EXE y de carga pueden cambiar el inicio del juego. Confirma solo si confías en el archivo y la vista previa coincide con sus instrucciones de instalación. + Creando el plan de ubicación de archivos… + Reaplicar archivos que faltan + Verificar y registrar archivos + Los archivos se verificaron y registraron sin modificarlos. + No se pudieron verificar y registrar los archivos instalados (%1$d problema(s)) + Esta colocación ya no genera este archivo + Restaurar despliegue anterior + Sustituye el plan de ubicación actual por el último plan funcional de este mod. + Restaurando el despliegue anterior… + Se restauró el despliegue anterior. + No se pudo restaurar el despliegue anterior (%1$d problema(s)) + Buscar en esta carpeta + Mostrar archivos y carpetas ocultos + Nueva carpeta + Elige un nombre de carpeta dentro del destino actual. + Nombre de carpeta + Aún no se cambia nada. La carpeta se creará al aplicar el plan revisado. + Usar carpeta + Se creará + No hay archivos ni carpetas coincidentes + Se añadirá + Se reemplazará + Se respaldará + Conflicto del plan + Gestionado por este mod + Gestionado por %1$s + Cambiado desde el despliegue + Archivo del juego o no gestionado + Recibirá archivos + Colocación avanzada + Ocultar colocación avanzada + La colocación automática es una estimación. Comprueba que los archivos y destinos previstos coincidan con las instrucciones del mod. Si no es así, abre Colocación avanzada y elige Personalizada para asignarlos tú mismo. + En el destino, instalar + Contenido de %1$s + Carpeta %1$s y su contenido + Combinar el contenido de las carpetas seleccionadas + Conservar el nombre de cada carpeta seleccionada + \u0020(Recomendado) + Resultado: %1$s + Esto crea un nombre de carpeta repetido, como Data/Data. Elige el contenido salvo que el mod requiera la carpeta adicional. + ¿Por qué esta colocación? + Ocultar detalles de colocación + Usar esta colocación + Resolver %1$d archivos + Cambia a colocación personalizada y conserva las asignaciones recomendadas para asignar con seguridad los archivos sin resolver. + Revisar todos los archivos (%1$d) + Todos los archivos planificados + Buscar origen, destino o motivo + Todos (%1$d) + Ningún archivo planificado coincide con el filtro + Anterior: %1$s + Destino: %1$s + Explorar destino + Contenido del destino + Añadidos + Reemplazados + Movidos + Eliminados + Ignorados + Bloqueados + Sin cambios + Página anterior de reglas de ubicación + Página siguiente de reglas de ubicación + Reglas de ubicación %1$d–%2$d de %3$d Carga más rápida desde almacenamiento externo Puede acelerar considerablemente el inicio de juegos desde almacenamiento externo, pero puede causar problemas en algunos juegos Desactivar libredirect @@ -2518,4 +2592,13 @@ %1$.1f°/s %1$d ms Restablecer ajustes del giroscopio + Revisar colocación + Las carpetas de nivel superior conservan sus nombres; los archivos raíz se colocan directamente en el destino + %1$d archivo(s) predeterminado(s) reemplazado(s) por tus opciones de FOMOD + No se cambió el orden de los mods. Archivos gestionados ausentes o modificados: %1$d. Comprueba primero el estado de la instalación y resuelve esos problemas. + Colocación completada. Total: %1$d; actualizados: %2$d; ya al día: %3$d; con copia de seguridad: %4$d%5$s + La instalación falló; se intentó una reversión segura. Errores: %1$d + Falló la última instalación del mod + El mod no se marcó como instalado. Los cambios se revirtieron cuando pudieron verificarse de forma segura. Revisa los errores de archivo de abajo y vuelve a Colocación para corregir o reintentar este mod. + Errores de archivo: %1$d diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1f18045f18..cd969bad78 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2010,8 +2010,6 @@ Dossier/fichier du mod Dossier de destination Méthode d\'installation - Créer un dossier pour la sélection - Activé : Le dossier sélectionné est installé dans la destination. Désactivé : seul son contenu y est placé. Masquer les chemins manuels Chemins manuels Dossiers/fichiers du mod @@ -2147,7 +2145,6 @@ Application des fichiers mod Éléments appliqués : %1$d, sauvegardes : %2$d%3$s ; anciens fichiers laissés en place : %1$d - Appliqué avec les erreurs %1$d Les fichiers Mod sont déjà appliqués. Échec de l\'application du mod Configurez le programme d\'installation FOMOD ou choisissez Placement personnalisé avant de postuler. @@ -2451,6 +2448,83 @@ Ajouté aux favoris %1$s ajouté aux favoris Taux de rafraîchissement VR + Vérification du placement + %1$d fichiers planifiés sur %2$d • %3$d ignorés • %4$d à vérifier + Suggestions classées + %1$d. %2$s — couverture de %3$d%% + Fusion de casse : %1$s + des variantes de casse ambiguës existent déjà + L’application automatique est bloquée tant que chaque fichier installable n’a pas de destination vérifiée. + Partager le diagnostic + Aperçu de la reconfiguration + %1$d ajoutés • %2$d modifiés • %3$d déplacés • %4$d obsolètes + Choisir une variante du paquet + J’ai vérifié ces fichiers du dossier du jeu + Les fichiers DLL, EXE et de chargement peuvent modifier le démarrage du jeu. Confirmez uniquement si l’archive est fiable et si l’aperçu correspond à ses instructions d’installation. + Création du plan de placement des fichiers… + Réappliquer les fichiers manquants + Vérifier et suivre les fichiers + Les fichiers ont été vérifiés et sont désormais suivis sans avoir été modifiés. + Impossible de vérifier et de suivre les fichiers installés (%1$d problème(s)) + Ce placement ne produit plus ce fichier + Restaurer le déploiement précédent + Remplace le plan de placement actuel par le dernier plan fonctionnel de ce mod. + Restauration du déploiement précédent… + Le déploiement précédent a été restauré. + Impossible de restaurer le déploiement précédent (%1$d problème(s)) + Rechercher dans ce dossier + Afficher les fichiers et dossiers cachés + Nouveau dossier + Choisissez un nom de dossier dans la destination actuelle. + Nom du dossier + Rien ne change encore. Le dossier sera créé uniquement lors de l’application du plan vérifié. + Utiliser le dossier + Sera créé + Aucun fichier ou dossier correspondant + Sera ajouté + Sera remplacé + Sera sauvegardé + Conflit du plan + Géré par ce mod + Géré par %1$s + Modifié depuis le déploiement + Fichier du jeu ou non géré + Recevra des fichiers + Placement avancé + Masquer le placement avancé + Le placement automatique est une estimation. Vérifiez que les fichiers et leurs destinations correspondent aux instructions du mod. Sinon, ouvrez Placement avancé et choisissez Personnalisé pour les associer vous-même. + À la destination, installer + Contenu de %1$s + Dossier %1$s et son contenu + Fusionner le contenu des dossiers sélectionnés + Conserver le nom de chaque dossier sélectionné + \u0020(Recommandé) + Résultat : %1$s + Cela crée un nom de dossier répété, comme Data/Data. Choisissez le contenu sauf si le mod exige le dossier supplémentaire. + Pourquoi ce placement ? + Masquer les détails du placement + Utiliser ce placement + Résoudre %1$d fichiers + Passe au placement personnalisé tout en conservant les associations recommandées afin d’affecter les fichiers non résolus en toute sécurité. + Vérifier tous les fichiers (%1$d) + Tous les fichiers planifiés + Rechercher la source, la destination ou la raison + Tous (%1$d) + Aucun fichier planifié ne correspond au filtre + Précédent : %1$s + Destination : %1$s + Parcourir la destination + Contenu de la destination + Ajoutés + Remplacés + Déplacés + Supprimés + Ignorés + Bloqués + Inchangés + Page précédente des règles de placement + Page suivante des règles de placement + Règles de placement %1$d–%2$d sur %3$d Chargement plus rapide depuis le stockage externe Peut accélérer considérablement le démarrage des jeux depuis le stockage externe, mais peut causer des problèmes dans certains jeux Désactiver libredirect @@ -2520,4 +2594,13 @@ %1$.1f°/s %1$d ms Réinitialiser les réglages du gyroscope + Vérifier le placement + Les dossiers de premier niveau conservent leur nom ; les fichiers racine sont placés directement dans la destination + %1$d fichier(s) par défaut remplacé(s) par vos choix FOMOD + L’ordre des mods n’a pas été modifié. Fichiers gérés manquants ou modifiés : %1$d. Vérifiez d’abord l’état de l’installation et résolvez ces problèmes. + Placement terminé. Total : %1$d ; mis à jour : %2$d ; déjà à jour : %3$d ; sauvegardés : %4$d%5$s + Échec de l’installation ; un retour arrière sûr a été tenté. Erreurs : %1$d + Échec de la dernière installation de mod + Le mod n’a pas été marqué comme installé. Les modifications ont été annulées lorsque cela pouvait être vérifié sans risque. Consultez les erreurs de fichiers ci-dessous, puis revenez à Placement pour corriger ou réessayer ce mod. + Erreurs de fichiers : %1$d diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 13d47caf00..1a80870103 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1983,8 +1983,6 @@ Cartella/file dal mod Cartella di destinazione Metodo di installazione - Creare una cartella per la selezione - On: La cartella selezionata viene installata nella destinazione. Off: solo i suoi contenuti vengono posizionati lì. Nascondi percorsi manuali Percorsi manuali Cartelle/file dal mod @@ -2120,7 +2118,6 @@ Applicazione dei file mod Elemento/i %1$d applicato/i, sottoposto a backup %2$d%3$s ; %1$d vecchi file lasciati al loro posto - Applicato con errore/i %1$d I file mod sono già applicati. Impossibile applicare il mod Configura il programma di installazione FOMOD o scegli Posizionamento personalizzato prima dell\'applicazione. @@ -2442,6 +2439,83 @@ Aggiunto ai preferiti %1$s aggiunto ai preferiti Frequenza di aggiornamento VR + Revisione posizionamento + %1$d file pianificati su %2$d • %3$d ignorati • %4$d da verificare + Suggerimenti classificati + %1$d. %2$s — copertura %3$d%% + Unione maiuscole/minuscole: %1$s + esistono già varianti ambigue di maiuscole/minuscole + L’applicazione automatica è bloccata finché ogni file installabile non ha una destinazione verificata. + Condividi diagnostica + Anteprima riconfigurazione + %1$d aggiunti • %2$d modificati • %3$d spostati • %4$d obsoleti + Scegli una variante del pacchetto + Ho controllato questi file nella cartella del gioco + I file DLL, EXE e loader possono cambiare il modo in cui si avvia il gioco. Conferma solo se l’archivio è attendibile e l’anteprima corrisponde alle istruzioni di installazione. + Creazione del piano di posizionamento dei file… + Riapplica i file mancanti + Verifica e registra i file + I file sono stati verificati e registrati senza modificarli. + Impossibile verificare e registrare i file installati (%1$d problema/i) + Non viene più prodotto da questo posizionamento + Ripristina distribuzione precedente + Sostituisce il piano di posizionamento corrente con l’ultimo piano funzionante di questa mod. + Ripristino della distribuzione precedente… + La distribuzione precedente è stata ripristinata. + Impossibile ripristinare la distribuzione precedente (%1$d problema/i) + Cerca in questa cartella + Mostra file e cartelle nascosti + Nuova cartella + Scegli un nome di cartella nella destinazione attuale. + Nome cartella + Non viene ancora modificato nulla. La cartella verrà creata solo applicando il piano verificato. + Usa cartella + Verrà creata + Nessun file o cartella corrispondente + Verrà aggiunto + Verrà sostituito + Verrà salvato + Conflitto del piano + Gestito da questa mod + Gestito da %1$s + Modificato dopo il deployment + File di gioco o non gestito + Riceverà file + Posizionamento avanzato + Nascondi posizionamento avanzato + Il posizionamento automatico è una stima. Verifica che i file e le destinazioni previsti corrispondano alle istruzioni del mod. In caso contrario, apri Posizionamento avanzato e scegli Personalizzato per assegnarli manualmente. + Nella destinazione, installa + Contenuto di %1$s + Cartella %1$s e relativo contenuto + Unisci il contenuto delle cartelle selezionate + Mantieni il nome di ogni cartella selezionata + \u0020(Consigliato) + Risultato: %1$s + Verrà ripetuto un nome di cartella, ad esempio Data/Data. Scegli il contenuto, a meno che la mod non richieda la cartella aggiuntiva. + Perché questo posizionamento? + Nascondi dettagli del posizionamento + Usa questo posizionamento + Risolvi %1$d file + Passa al posizionamento personalizzato conservando le mappature consigliate, così i file irrisolti possono essere assegnati in sicurezza. + Verifica tutti i file (%1$d) + Tutti i file pianificati + Cerca origine, destinazione o motivo + Tutti (%1$d) + Nessun file pianificato corrisponde al filtro + Precedente: %1$s + Destinazione: %1$s + Sfoglia destinazione + Contenuto destinazione + Aggiunti + Sostituiti + Spostati + Rimossi + Ignorati + Bloccati + Invariati + Pagina precedente delle regole di posizionamento + Pagina successiva delle regole di posizionamento + Regole di posizionamento %1$d–%2$d di %3$d Caricamento più veloce dalla memoria esterna Può velocizzare notevolmente l\'avvio dei giochi dalla memoria esterna, ma può causare problemi in alcuni giochi Disattiva libredirect @@ -2511,4 +2585,13 @@ %1$.1f°/s %1$d ms Ripristina impostazioni giroscopio + Controlla posizionamento + Le cartelle di primo livello mantengono i loro nomi; i file nella radice vengono inseriti direttamente nella destinazione + %1$d file predefinito/i sostituito/i dalle tue scelte FOMOD + L’ordine dei mod non è stato modificato. File gestiti mancanti o modificati: %1$d. Controlla prima lo stato di installazione e risolvi i problemi. + Posizionamento completato. Totale: %1$d; aggiornati: %2$d; già aggiornati: %3$d; salvati in backup: %4$d%5$s + Installazione non riuscita; è stato tentato un ripristino sicuro. Errori: %1$d + Ultima installazione del mod non riuscita + Il mod non è stato contrassegnato come installato. Le modifiche sono state annullate dove era possibile verificarle in sicurezza. Controlla gli errori dei file qui sotto, poi torna a Posizionamento per correggere o riprovare questo mod. + Errori dei file: %1$d diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d32c84019a..b7ef8a4147 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1948,8 +1948,6 @@ MOD からのフォルダー/ファイル 保存先フォルダー インストール方法 - 選択範囲のフォルダーを作成する - オン: 選択したフォルダーがインストール先にインストールされます。 Off: コンテンツのみがそこに配置されます。 手動パスを非表示にする 手動パス MOD のフォルダー/ファイル @@ -2085,7 +2083,6 @@ MOD ファイルの適用 適用された %1$d 項目、バックアップされた %2$d%3$s ; %1$d 古いファイルがそのまま残されている - %1$d エラーが適用されました Mod ファイルはすでに適用されています。 MODの適用に失敗しました 適用する前に、FOMOD インストーラーを設定するか、カスタム配置を選択してください。 @@ -2404,6 +2401,83 @@ お気に入りに追加しました %1$s をお気に入りに追加しました VRリフレッシュレート + 配置の確認 + %2$d 個中 %1$d 個のファイルを計画 • %3$d 個を除外 • %4$d 個を要確認 + 候補ランキング + %1$d. %2$s — カバー率 %3$d%% + 大文字小文字を統合: %1$s + 大文字小文字だけが異なる曖昧な項目がすでに存在します + インストール可能なすべてのファイルの配置先が確認されるまで、自動適用はブロックされます。 + 診断情報を共有 + 再設定のプレビュー + 追加 %1$d • 変更 %2$d • 移動 %3$d • 古い出力 %4$d + パッケージのバリアントを選択 + ゲームフォルダのこれらのファイルを確認しました + DLL、EXE、ローダーファイルはゲームの起動方法を変更することがあります。信頼できるアーカイブで、プレビューがインストール手順と一致する場合のみ確認してください。 + ファイル配置プランを作成中… + 不足しているファイルを再適用 + ファイルを確認して追跡 + ファイルを変更せずに確認し、追跡対象にしました。 + インストール済みファイルを確認して追跡できませんでした(問題 %1$d 件) + この配置では生成されなくなりました + 以前の配置を復元 + 現在のファイル配置プランを、この Mod で最後に動作したプランに置き換えます。 + 以前の配置を復元中… + 以前の配置を復元しました。 + 以前の配置を復元できませんでした(問題 %1$d 件) + このフォルダーを検索 + 隠しファイルとフォルダーを表示 + 新しいフォルダー + 現在のインストール先に作成するフォルダー名を選択します。 + フォルダー名 + まだ何も変更されません。フォルダーは確認済みプランの適用時にのみ作成されます。 + このフォルダーを使用 + 作成予定 + 一致するファイルまたはフォルダーはありません + 追加予定 + 置換予定 + バックアップ予定 + プランの競合 + この Mod が管理 + %1$s が管理 + 配置後に変更済み + ゲームまたは未管理のファイル + ファイルの配置先 + 詳細な配置 + 詳細な配置を隠す + 自動配置は推定結果です。予定されたファイルと配置先が MOD の導入手順に合っているか確認してください。合わない場合は詳細な配置を開き、カスタムを選んで手動で指定してください。 + インストール先での配置 + %1$s の内容 + %1$s フォルダーとその内容 + 選択したフォルダーの内容を統合 + 選択した各フォルダー名を維持 + (推奨) + 結果: %1$s + Data/Data のようにフォルダー名が重複します。Mod が追加フォルダーを必要とする場合を除き、内容を選択してください。 + この配置を選んだ理由 + 配置の詳細を隠す + この配置を使用 + %1$d 個のファイルを解決 + 推奨マッピングを維持してカスタム配置に切り替え、未解決ファイルを安全に割り当てます。 + すべてのファイルを確認(%1$d) + プラン内の全ファイル + 元、配置先、理由を検索 + すべて(%1$d) + フィルターに一致するファイルはありません + 以前: %1$s + 配置先: %1$s + 配置先を参照 + 配置先の内容 + 追加 + 置換 + 移動 + 削除 + 無視 + ブロック + 変更なし + 前の配置ルールページ + 次の配置ルールページ + 配置ルール %1$d~%2$d / %3$d 外部ストレージからの高速読み込み 外部ストレージからのゲーム起動を大幅に高速化できますが、一部のゲームで問題が発生する可能性があります libredirect を無効にする @@ -2473,4 +2547,13 @@ %1$.1f°/秒 %1$d ミリ秒 ジャイロ設定をリセット + 配置を確認 + 最上位フォルダーは名前を保持し、ルートファイルは移動先に直接配置されます + FOMOD の選択により %1$d 個の既定ファイルを置き換えました + MOD の順序は変更されませんでした。不足または変更された管理対象ファイル: %1$d。先にインストール状態を確認し、問題を解決してください。 + 配置完了。合計: %1$d、更新: %2$d、変更不要: %3$d、バックアップ: %4$d%5$s + インストールに失敗しました。安全なロールバックを試行しました。エラー: %1$d + 直前の MOD のインストールに失敗しました + MOD は適用済みとして記録されませんでした。安全に確認できた変更は元に戻しました。以下のファイルエラーを確認し、配置画面に戻って修正または再試行してください。 + ファイルエラー: %1$d diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 799cea6fbc..17e93d0d9c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1989,8 +1989,6 @@ 모드의 폴더/파일 대상 폴더 설치 방법 - 선택 항목에 대한 폴더 생성 - On: 선택한 폴더가 대상에 설치됩니다. 끄기: 해당 내용만 여기에 배치됩니다. 수동 경로 숨기기 수동 경로 모드의 폴더/파일 @@ -2126,7 +2124,6 @@ 모드 파일 적용 적용됨 %1$d 항목, 백업됨 %2$d%3$s ; %1$d 이전 파일이 그대로 남아 있습니다. - %1$d 오류와 함께 적용됨 Mod 파일이 이미 적용되고 있습니다. 모드 적용 실패 적용하기 전에 FOMOD 설치 프로그램을 구성하거나 사용자 정의 배치를 선택하십시오. @@ -2445,6 +2442,83 @@ 즐겨찾기에 추가됨 %1$s을(를) 즐겨찾기에 추가함 VR 재생 빈도 + 배치 검토 + 파일 %2$d개 중 %1$d개 계획됨 • %3$d개 제외됨 • %4$d개 검토 필요 + 순위별 제안 + %1$d. %2$s — 포함률 %3$d%% + 대소문자 병합: %1$s + 대소문자만 다른 모호한 항목이 이미 있습니다 + 설치 가능한 모든 파일의 대상 위치가 확인될 때까지 자동 적용이 차단됩니다. + 진단 공유 + 재설정 미리보기 + 추가 %1$d • 변경 %2$d • 이동 %3$d • 이전 출력 %4$d + 패키지 변형 선택 + 게임 폴더의 이 파일을 검토했습니다 + DLL, EXE 및 로더 파일은 게임 시작 방식을 바꿀 수 있습니다. 아카이브를 신뢰할 수 있고 미리보기가 설치 안내와 일치할 때만 확인하세요. + 파일 배치 계획 생성 중… + 누락된 파일 다시 적용 + 파일 확인 및 추적 + 파일을 변경하지 않고 확인하여 추적하도록 등록했습니다. + 설치된 파일을 확인하고 추적하지 못했습니다(문제 %1$d개) + 이 배치에서 더 이상 생성되지 않음 + 이전 배포 복원 + 현재 파일 배치 계획을 이 모드에서 마지막으로 작동한 계획으로 바꿉니다. + 이전 배포 복원 중… + 이전 배포를 복원했습니다. + 이전 배포를 복원하지 못했습니다(문제 %1$d개) + 이 폴더 검색 + 숨김 파일 및 폴더 표시 + 새 폴더 + 현재 대상 안에 사용할 폴더 이름을 선택하세요. + 폴더 이름 + 아직 변경되지 않습니다. 검토한 계획을 적용할 때만 폴더가 생성됩니다. + 폴더 사용 + 생성 예정 + 일치하는 파일 또는 폴더 없음 + 추가 예정 + 교체 예정 + 백업 예정 + 계획 충돌 + 이 모드가 관리함 + %1$s에서 관리함 + 배포 후 변경됨 + 게임 또는 관리되지 않는 파일 + 파일을 받을 폴더 + 고급 배치 + 고급 배치 숨기기 + 자동 배치는 추정 결과입니다. 배치할 파일과 대상 폴더가 모드 설치 안내와 일치하는지 확인하세요. 일치하지 않으면 고급 배치에서 사용자 지정을 선택해 직접 지정하세요. + 대상에 설치할 항목 + %1$s의 내용 + %1$s 폴더와 그 내용 + 선택한 폴더 내용 병합 + 선택한 각 폴더 이름 유지 + \u0020(권장) + 결과: %1$s + Data/Data처럼 폴더 이름이 반복됩니다. 모드에 추가 폴더가 필요한 경우가 아니면 내용을 선택하세요. + 이 배치를 선택한 이유 + 배치 세부 정보 숨기기 + 이 배치 사용 + 파일 %1$d개 해결 + 권장 매핑을 유지한 채 사용자 지정 배치로 전환하여 미해결 파일을 안전하게 할당합니다. + 모든 파일 검토 (%1$d) + 계획된 모든 파일 + 원본, 대상 또는 이유 검색 + 모두 (%1$d) + 필터와 일치하는 계획 파일 없음 + 이전: %1$s + 대상: %1$s + 대상 찾아보기 + 대상 내용 + 추가됨 + 교체됨 + 이동됨 + 제거됨 + 무시됨 + 차단됨 + 변경 없음 + 이전 배치 규칙 페이지 + 다음 배치 규칙 페이지 + 배치 규칙 %1$d~%2$d / %3$d 외부 저장소에서 더 빠른 로딩 외부 저장소에서의 게임 부팅 속도를 크게 높일 수 있지만 일부 게임에서 문제가 발생할 수 있습니다 libredirect 비활성화 @@ -2514,4 +2588,13 @@ %1$.1f°/초 %1$d ms 자이로 설정 초기화 + 배치 검토 + 최상위 폴더는 이름을 유지하고 루트 파일은 대상에 직접 배치됩니다 + FOMOD 선택으로 기본 파일 %1$d개 교체됨 + 모드 순서를 변경하지 않았습니다. 누락되었거나 수정된 관리 파일: %1$d. 먼저 설치 상태를 확인하고 문제를 해결하세요. + 배치 완료. 전체: %1$d; 업데이트: %2$d; 이미 최신 상태: %3$d; 백업: %4$d%5$s + 설치에 실패했습니다. 안전한 롤백을 시도했습니다. 오류: %1$d + 마지막 모드 설치 실패 + 모드가 설치 완료로 표시되지 않았습니다. 안전하게 확인할 수 있는 변경 사항은 되돌렸습니다. 아래 파일 오류를 확인한 뒤 배치 화면으로 돌아가 수정하거나 다시 시도하세요. + 파일 오류: %1$d diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 36c072bdfb..aec000c8c3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1992,8 +1992,6 @@ Folder/plik z mod Folder docelowy Metoda instalacji - Utwórz folder dla zaznaczenia - Wł.: Wybrany folder zostanie zainstalowany w miejscu docelowym. Wyłączone: umieszczana jest tam tylko jego zawartość. Ukryj ścieżki ręczne Ścieżki ręczne Foldery/pliki z mod @@ -2129,7 +2127,6 @@ Stosowanie plików modów Zastosowane elementy: %1$d, kopie zapasowe: %2$d%3$s ; %1$d stare pliki pozostawione na miejscu - Zastosowano z błędami %1$d Pliki modów są już stosowane. Nie udało się zastosować mod Skonfiguruj instalator FOMOD lub przed zastosowaniem wybierz opcję Umieszczenie niestandardowe. @@ -2457,6 +2454,83 @@ Dodano do ulubionych Dodano %1$s do ulubionych Częstotliwość odświeżania VR + Przegląd rozmieszczenia + Zaplanowano %1$d z %2$d plików • pominięto %3$d • %4$d do sprawdzenia + Uszeregowane sugestie + %1$d. %2$s — pokrycie %3$d%% + Scalenie wielkości liter: %1$s + istnieją już niejednoznaczne warianty wielkości liter + Automatyczne zastosowanie jest zablokowane, dopóki każdy plik instalacyjny nie ma potwierdzonego miejsca docelowego. + Udostępnij diagnostykę + Podgląd ponownej konfiguracji + Dodano: %1$d • zmieniono: %2$d • przeniesiono: %3$d • nieaktualne: %4$d + Wybierz wariant pakietu + Sprawdzono te pliki w folderze gry + Pliki DLL, EXE i moduły ładujące mogą zmienić sposób uruchamiania gry. Potwierdź tylko wtedy, gdy archiwum jest zaufane, a podgląd odpowiada instrukcji instalacji. + Tworzenie planu rozmieszczenia plików… + Zastosuj ponownie brakujące pliki + Zweryfikuj i śledź pliki + Pliki zweryfikowano i objęto śledzeniem bez ich zmieniania. + Nie udało się zweryfikować i śledzić zainstalowanych plików (%1$d problem(y)) + Nie jest już tworzony przez to rozmieszczenie + Przywróć poprzednie wdrożenie + Zastępuje bieżący plan rozmieszczenia ostatnim działającym planem tego moda. + Przywracanie poprzedniego wdrożenia… + Poprzednie wdrożenie zostało przywrócone. + Nie udało się przywrócić poprzedniego wdrożenia (%1$d problem(y)) + Przeszukaj ten folder + Pokaż ukryte pliki i foldery + Nowy folder + Wybierz nazwę folderu w bieżącym miejscu docelowym. + Nazwa folderu + Nic nie zostanie jeszcze zmienione. Folder powstanie dopiero po zastosowaniu sprawdzonego planu. + Użyj folderu + Zostanie utworzony + Brak pasujących plików lub folderów + Zostanie dodany + Zostanie zastąpiony + Zostanie zapisany + Konflikt planu + Zarządzany przez ten mod + Zarządzany przez %1$s + Zmieniony od wdrożenia + Plik gry lub niezarządzany + Otrzyma pliki + Zaawansowane rozmieszczenie + Ukryj zaawansowane rozmieszczenie + Automatyczne rozmieszczenie jest jedynie oszacowaniem. Sprawdź, czy planowane pliki i miejsca docelowe są zgodne z instrukcją instalacji moda. Jeśli nie, otwórz Zaawansowane rozmieszczenie i wybierz Własne, aby przypisać je ręcznie. + W miejscu docelowym zainstaluj + Zawartość %1$s + Folder %1$s i jego zawartość + Scal zawartość wybranych folderów + Zachowaj nazwę każdego wybranego folderu + \u0020(Zalecane) + Wynik: %1$s + Powstanie powtórzona nazwa folderu, np. Data/Data. Wybierz zawartość, chyba że mod wymaga dodatkowego folderu. + Dlaczego to rozmieszczenie? + Ukryj szczegóły rozmieszczenia + Użyj tego rozmieszczenia + Rozwiąż %1$d plików + Przełącza na rozmieszczenie niestandardowe z zachowaniem zalecanych mapowań, aby bezpiecznie przypisać nierozwiązane pliki. + Sprawdź wszystkie pliki (%1$d) + Wszystkie zaplanowane pliki + Szukaj źródła, celu lub powodu + Wszystkie (%1$d) + Żaden zaplanowany plik nie pasuje do filtra + Poprzednio: %1$s + Cel: %1$s + Przeglądaj cel + Zawartość celu + Dodane + Zastąpione + Przeniesione + Usunięte + Pominięte + Zablokowane + Bez zmian + Poprzednia strona reguł umieszczania + Następna strona reguł umieszczania + Reguły umieszczania %1$d–%2$d z %3$d Szybsze wczytywanie z pamięci zewnętrznej Może znacznie przyspieszyć uruchamianie gier z pamięci zewnętrznej, ale może powodować problemy w niektórych grach Wyłącz libredirect @@ -2526,4 +2600,13 @@ %1$.1f°/s %1$d ms Resetuj ustawienia żyroskopu + Sprawdź rozmieszczenie + Foldery najwyższego poziomu zachowują nazwy; pliki główne trafiają bezpośrednio do miejsca docelowego + Liczba plików domyślnych zastąpionych przez wybory FOMOD: %1$d + Kolejność modów nie została zmieniona. Brakujące lub zmodyfikowane pliki zarządzane: %1$d. Najpierw sprawdź stan instalacji i rozwiąż problemy. + Rozmieszczenie zakończone. Łącznie: %1$d; zaktualizowano: %2$d; już aktualne: %3$d; utworzono kopie zapasowe: %4$d%5$s + Instalacja nie powiodła się; podjęto próbę bezpiecznego wycofania zmian. Błędy: %1$d + Ostatnia instalacja moda nie powiodła się + Mod nie został oznaczony jako zainstalowany. Zmiany cofnięto tam, gdzie można było to bezpiecznie zweryfikować. Sprawdź poniższe błędy plików, a następnie wróć do Rozmieszczenia, aby poprawić lub ponowić instalację moda. + Błędy plików: %1$d diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e286507780..55ca2f622f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1862,8 +1862,6 @@ Pasta/arquivo do mod Pasta de destino Método de instalação - Crie uma pasta para a seleção - Ligado: A pasta selecionada é instalada no destino. Desligado: Somente seu conteúdo é colocado lá. Ocultar caminhos manuais Caminhos manuais Pastas/arquivos do mod @@ -1999,7 +1997,6 @@ Aplicando arquivos mod Itens aplicados: %1$d, backups: %2$d%3$s ; arquivos antigos deixados no lugar: %1$d - Aplicado com erros: %1$d Os arquivos Mod já estão sendo aplicados. Falha ao aplicar o mod Configure o instalador FOMOD ou escolha Posicionamento personalizado antes de aplicar. @@ -2321,6 +2318,83 @@ Adicionado aos favoritos %1$s adicionado aos favoritos Taxa de atualização de VR + Revisão do posicionamento + %1$d de %2$d arquivos planejados • %3$d ignorados • %4$d para revisar + Sugestões classificadas + %1$d. %2$s — %3$d%% de cobertura + Mesclagem de maiúsculas/minúsculas: %1$s + já existem variantes ambíguas de maiúsculas e minúsculas + A aplicação automática está bloqueada até que cada arquivo instalável tenha um destino comprovado. + Compartilhar diagnóstico + Prévia da reconfiguração + %1$d adicionados • %2$d alterados • %3$d movidos • %4$d obsoletos + Escolha uma variante do pacote + Revisei estes arquivos da pasta do jogo + Arquivos DLL, EXE e de carregamento podem mudar como o jogo inicia. Confirme somente se o arquivo for confiável e a prévia corresponder às instruções de instalação. + Criando o plano de posicionamento de arquivos… + Reaplicar arquivos ausentes + Verificar e rastrear arquivos + Os arquivos foram verificados e rastreados sem alterações. + Não foi possível verificar e rastrear os arquivos instalados (%1$d problema(s)) + Não é mais produzido por este posicionamento + Restaurar implantação anterior + Substitui o plano de posicionamento atual pelo último plano funcional deste mod. + Restaurando a implantação anterior… + A implantação anterior foi restaurada. + Não foi possível restaurar a implantação anterior (%1$d problema(s)) + Pesquisar nesta pasta + Mostrar arquivos e pastas ocultos + Nova pasta + Escolha um nome de pasta dentro do destino atual. + Nome da pasta + Nada foi alterado ainda. A pasta só será criada ao aplicar o plano revisado. + Usar pasta + Será criada + Nenhum arquivo ou pasta correspondente + Será adicionado + Será substituído + Terá backup + Conflito no plano + Gerenciado por este mod + Gerenciado por %1$s + Alterado desde a implantação + Arquivo do jogo ou não gerenciado + Receberá arquivos + Posicionamento avançado + Ocultar posicionamento avançado + O posicionamento automático é uma estimativa. Confira se os arquivos e destinos planejados seguem as instruções de instalação do mod. Caso contrário, abra Posicionamento avançado e escolha Personalizado para defini-los manualmente. + No destino, instalar + Conteúdo de %1$s + Pasta %1$s e seu conteúdo + Mesclar o conteúdo das pastas selecionadas + Manter o nome de cada pasta selecionada + \u0020(Recomendado) + Resultado: %1$s + Isso cria um nome de pasta repetido, como Data/Data. Escolha o conteúdo, a menos que o mod exija a pasta adicional. + Por que este posicionamento? + Ocultar detalhes do posicionamento + Usar este posicionamento + Resolver %1$d arquivos + Muda para o posicionamento personalizado preservando os mapeamentos recomendados para atribuir com segurança os arquivos não resolvidos. + Revisar todos os arquivos (%1$d) + Todos os arquivos planejados + Pesquisar origem, destino ou motivo + Todos (%1$d) + Nenhum arquivo planejado corresponde ao filtro + Anterior: %1$s + Destino: %1$s + Explorar destino + Conteúdo do destino + Adicionados + Substituídos + Movidos + Removidos + Ignorados + Bloqueados + Sem alterações + Página anterior de regras de posicionamento + Próxima página de regras de posicionamento + Regras de posicionamento %1$d–%2$d de %3$d Carregamento mais rápido do armazenamento externo Pode acelerar bastante a inicialização de jogos no armazenamento externo, mas pode causar problemas em alguns jogos Desativar libredirect @@ -2390,4 +2464,13 @@ %1$.1f°/s %1$d ms Redefinir configurações do giroscópio + Revisar posicionamento + As pastas de nível superior mantêm seus nomes; os arquivos da raiz vão diretamente para o destino + %1$d arquivo(s) padrão substituído(s) pelas suas escolhas no FOMOD + A ordem dos mods não foi alterada. Arquivos gerenciados ausentes ou modificados: %1$d. Verifique a integridade da instalação e resolva esses problemas primeiro. + Posicionamento concluído. Total: %1$d; atualizados: %2$d; já atualizados: %3$d; com backup: %4$d%5$s + A instalação falhou; foi tentada uma reversão segura. Erros: %1$d + A última instalação do mod falhou + O mod não foi marcado como instalado. As alterações foram revertidas onde isso pôde ser verificado com segurança. Confira os erros de arquivo abaixo e volte para Posicionamento para corrigir ou tentar instalar este mod novamente. + Erros de arquivo: %1$d diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index f9fa813086..796d130470 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1983,8 +1983,6 @@ Dosar/fișier din mod Folder de destinație Metoda de instalare - Creați un folder pentru selecție - Activat: folderul selectat este instalat în destinație. Off: Doar conținutul său este plasat acolo. Ascunde căile manuale Căi manuale Foldere/fișiere din mod @@ -2120,7 +2118,6 @@ Aplicarea fișierelor mod Elemente aplicate: %1$d, copii de rezervă: %2$d%3$s ; fișiere vechi lăsate pe loc: %1$d - Aplicat cu erori: %1$d Fișierele mod sunt deja aplicate. Nu s-a aplicat mod Configurați programul de instalare FOMOD sau alegeți Plasare personalizată înainte de a aplica. @@ -2445,6 +2442,83 @@ Adăugat la favorite %1$s adăugat la favorite Rată de reîmprospătare VR + Verificarea amplasării + %1$d din %2$d fișiere planificate • %3$d ignorate • %4$d de verificat + Sugestii clasificate + %1$d. %2$s — acoperire %3$d%% + Îmbinare majuscule/minuscule: %1$s + există deja variante ambigue de majuscule/minuscule + Aplicarea automată este blocată până când fiecare fișier instalabil are o destinație verificată. + Partajează diagnosticul + Previzualizarea reconfigurării + %1$d adăugate • %2$d modificate • %3$d mutate • %4$d învechite + Alege o variantă a pachetului + Am verificat aceste fișiere din dosarul jocului + Fișierele DLL, EXE și de încărcare pot schimba modul în care pornește jocul. Confirmă numai dacă arhiva este de încredere, iar previzualizarea corespunde instrucțiunilor de instalare. + Se creează planul de amplasare a fișierelor… + Reaplică fișierele lipsă + Verifică și urmărește fișierele + Fișierele au fost verificate și urmărite fără a fi modificate. + Fișierele instalate nu au putut fi verificate și urmărite (%1$d problemă(e)) + Nu mai este produs de această plasare + Restaurează implementarea anterioară + Înlocuiește planul curent de amplasare cu ultimul plan funcțional al acestui mod. + Se restaurează implementarea anterioară… + Implementarea anterioară a fost restaurată. + Implementarea anterioară nu a putut fi restaurată (%1$d problemă(e)) + Caută în acest folder + Afișează fișierele și folderele ascunse + Folder nou + Alege un nume de folder în destinația curentă. + Numele folderului + Încă nu se modifică nimic. Folderul va fi creat doar când se aplică planul verificat. + Folosește folderul + Va fi creat + Niciun fișier sau folder corespunzător + Va fi adăugat + Va fi înlocuit + Va avea copie de rezervă + Conflict în plan + Gestionat de acest mod + Gestionat de %1$s + Modificat după implementare + Fișier de joc sau negestionat + Va primi fișiere + Plasare avansată + Ascunde plasarea avansată + Plasarea automată este doar o estimare. Verifică dacă fișierele și destinațiile planificate respectă instrucțiunile de instalare ale modului. Dacă nu, deschide Plasare avansată și alege Personalizat pentru a le atribui manual. + La destinație, instalează + Conținutul din %1$s + Folderul %1$s și conținutul său + Îmbină conținutul folderelor selectate + Păstrează numele fiecărui folder selectat + \u0020(Recomandat) + Rezultat: %1$s + Aceasta creează un nume de folder repetat, precum Data/Data. Alege conținutul dacă modul nu necesită folderul suplimentar. + De ce această plasare? + Ascunde detaliile plasării + Folosește această plasare + Rezolvă %1$d fișiere + Trece la plasarea personalizată și păstrează mapările recomandate pentru atribuirea sigură a fișierelor nerezolvate. + Verifică toate fișierele (%1$d) + Toate fișierele planificate + Caută sursa, destinația sau motivul + Toate (%1$d) + Niciun fișier planificat nu corespunde filtrului + Anterior: %1$s + Destinație: %1$s + Răsfoiește destinația + Conținutul destinației + Adăugate + Înlocuite + Mutate + Eliminate + Ignorate + Blocate + Neschimbate + Pagina anterioară cu reguli de plasare + Pagina următoare cu reguli de plasare + Reguli de plasare %1$d–%2$d din %3$d Încărcare mai rapidă de pe stocarea externă Poate accelera semnificativ pornirea jocurilor de pe stocarea externă, dar poate cauza probleme în unele jocuri Dezactivează libredirect @@ -2514,6 +2588,9 @@ %1$.1f°/s %1$d ms Resetează setările giroscopului + Verifică plasarea + Folderele de nivel superior își păstrează numele; fișierele rădăcină ajung direct la destinație + %1$d fișier(e) implicit(e) înlocuit(e) de opțiunile FOMOD Actualizare necesară Acest joc are o actualizare. Clientul Steam nu îl poate porni până când actualizarea nu este instalată. Actualizează acum, apoi pornește din nou. Autentificare Rockstar necesară @@ -2525,4 +2602,10 @@ Se pregătește suportul pentru launcherul Rockstar Se instalează runtime-ul Social Club Programul de instalare Social Club lipsește din fișierele jocului. Verifică fișierele jocului în Steam și încearcă din nou. + Ordinea modurilor nu a fost schimbată. Fișiere gestionate lipsă sau modificate: %1$d. Verifică mai întâi starea instalării și rezolvă problemele. + Plasare finalizată. Total: %1$d; actualizate: %2$d; deja la zi: %3$d; cu copii de rezervă: %4$d%5$s + Instalarea a eșuat; s-a încercat revenirea în siguranță. Erori: %1$d + Ultima instalare a modului a eșuat + Modul nu a fost marcat ca instalat. Modificările au fost anulate acolo unde acest lucru a putut fi verificat în siguranță. Verifică erorile de fișier de mai jos, apoi revino la Plasare pentru a corecta sau reîncerca instalarea. + Erori de fișier: %1$d diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5156f3a03a..49779d33f6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1918,8 +1918,6 @@ https://gamenative.app Папка/файл из мода Папка назначения Способ установки - Создать папку для выбора - Вкл.: выбранная папка устанавливается в место назначения. Выкл.: туда помещается только его содержимое. Скрыть пути, заданные вручную Ручные пути Папки/файлы из мода @@ -2055,7 +2053,6 @@ https://gamenative.app Применение файлов модов Применены элементы %1$d, резервная копия %2$d%3$s ; %1$d старые файлы остались на месте - Применяется при ошибках %1$d. Файлы модов уже применяются. Не удалось применить мод. Перед подачей заявки настройте установщик FOMOD или выберите «Пользовательское размещение». @@ -2383,6 +2380,83 @@ https://gamenative.app Добавлено в избранное %1$s добавлено в избранное Частота обновления VR + Проверка размещения + Запланировано файлов: %1$d из %2$d • пропущено: %3$d • требуют проверки: %4$d + Ранжированные предложения + %1$d. %2$s — покрытие %3$d%% + Объединение регистра: %1$s + уже существуют неоднозначные варианты регистра + Автоматическое применение заблокировано, пока для каждого устанавливаемого файла не подтверждено место назначения. + Поделиться диагностикой + Предпросмотр перенастройки + Добавлено: %1$d • изменено: %2$d • перемещено: %3$d • устарело: %4$d + Выберите вариант пакета + Эти файлы в папке игры проверены + Файлы DLL, EXE и загрузчики могут изменить запуск игры. Подтверждайте, только если архиву можно доверять, а предпросмотр совпадает с инструкцией по установке. + Создание плана размещения файлов… + Повторно применить отсутствующие файлы + Проверить и отслеживать файлы + Файлы проверены и добавлены в отслеживание без изменений. + Не удалось проверить и отслеживать установленные файлы (проблем: %1$d) + Больше не создаётся этим вариантом размещения + Восстановить предыдущее развёртывание + Заменяет текущий план размещения последним рабочим планом этого мода. + Восстановление предыдущего развёртывания… + Предыдущее развёртывание восстановлено. + Не удалось восстановить предыдущее развёртывание (проблем: %1$d) + Поиск в этой папке + Показывать скрытые файлы и папки + Новая папка + Выберите имя папки в текущем месте назначения. + Имя папки + Пока ничего не изменяется. Папка будет создана только при применении проверенного плана. + Использовать папку + Будет создана + Нет подходящих файлов или папок + Будет добавлен + Будет заменён + Будет сохранён + Конфликт плана + Управляется этим модом + Управляется %1$s + Изменён после развёртывания + Файл игры или неуправляемый файл + Получит файлы + Расширенное размещение + Скрыть расширенное размещение + Автоматическое размещение — лишь предварительная оценка. Проверьте, соответствуют ли файлы и папки назначения инструкции по установке мода. Если нет, откройте расширенное размещение и выберите ручную настройку. + В месте назначения установить + Содержимое %1$s + Папку %1$s и её содержимое + Объединить содержимое выбранных папок + Сохранить имя каждой выбранной папки + \u0020(Рекомендуется) + Результат: %1$s + Это создаст повторяющееся имя папки, например Data/Data. Выберите содержимое, если моду не нужна дополнительная папка. + Почему выбрано это размещение? + Скрыть сведения о размещении + Использовать это размещение + Разрешить файлы: %1$d + Переходит к пользовательскому размещению, сохраняя рекомендуемые сопоставления для безопасного назначения нерешённых файлов. + Проверить все файлы (%1$d) + Все запланированные файлы + Поиск источника, назначения или причины + Все (%1$d) + Нет запланированных файлов, соответствующих фильтру + Ранее: %1$s + Назначение: %1$s + Просмотреть назначение + Содержимое назначения + Добавлено + Заменено + Перемещено + Удалено + Игнорируется + Заблокировано + Без изменений + Предыдущая страница правил размещения + Следующая страница правил размещения + Правила размещения %1$d–%2$d из %3$d Быстрая загрузка с внешнего накопителя Может значительно ускорить запуск игр с внешнего накопителя, но может вызывать проблемы в некоторых играх Отключить libredirect @@ -2452,4 +2526,13 @@ https://gamenative.app %1$.1f°/с %1$d мс Сбросить настройки гироскопа + Проверить размещение + Папки верхнего уровня сохраняют свои имена; файлы из корня помещаются непосредственно в папку назначения + Файлов по умолчанию заменено выбранными параметрами FOMOD: %1$d + Порядок модов не изменён. Отсутствующие или изменённые управляемые файлы: %1$d. Сначала проверьте состояние установки и устраните проблемы. + Размещение завершено. Всего: %1$d; обновлено: %2$d; уже актуальны: %3$d; созданы резервные копии: %4$d%5$s + Установка не удалась; предпринята попытка безопасного отката. Ошибки: %1$d + Последняя установка мода не удалась + Мод не отмечен как установленный. Изменения отменены там, где это удалось безопасно проверить. Просмотрите ошибки файлов ниже, затем вернитесь к размещению, чтобы исправить или повторить установку. + Ошибки файлов: %1$d diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 28be753640..6b4fe48862 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1988,8 +1988,6 @@ Папка/файл із мод Папка призначення Спосіб встановлення - Створіть папку для вибору - Увімк.: вибрану папку встановлено в цільову папку. Вимкнено: там розміщується лише його вміст. Приховати ручні шляхи Ручні шляхи Папки/файли з мод @@ -2125,7 +2123,6 @@ Застосування файлів мод Застосовані елементи: %1$d, резервні копії: %2$d%3$s ; %1$d старі файли, залишені на місці - Застосовано з помилками %1$d Модифікаційні файли вже застосовуються. Не вдалося застосувати мод Налаштуйте інсталятор FOMOD або виберіть Нестандартне розміщення перед застосуванням. @@ -2453,6 +2450,83 @@ Додано до вибраного %1$s додано до вибраного Частота оновлення VR + Перевірка розміщення + Заплановано файлів: %1$d із %2$d • пропущено: %3$d • потребують перевірки: %4$d + Ранжовані пропозиції + %1$d. %2$s — покриття %3$d%% + Об’єднання регістру: %1$s + неоднозначні варіанти регістру вже існують + Автоматичне застосування заблоковано, доки для кожного встановлюваного файлу не підтверджено місце призначення. + Поділитися діагностикою + Попередній перегляд переналаштування + Додано: %1$d • змінено: %2$d • переміщено: %3$d • застаріло: %4$d + Виберіть варіант пакета + Ці файли в папці гри перевірено + Файли DLL, EXE та завантажувачі можуть змінити запуск гри. Підтверджуйте, лише якщо архіву можна довіряти, а попередній перегляд відповідає інструкції зі встановлення. + Створення плану розміщення файлів… + Повторно застосувати відсутні файли + Перевірити й відстежувати файли + Файли перевірено й додано до відстеження без змін. + Не вдалося перевірити й відстежувати встановлені файли (проблем: %1$d) + Більше не створюється цим варіантом розміщення + Відновити попереднє розгортання + Замінює поточний план розміщення останнім робочим планом цього мода. + Відновлення попереднього розгортання… + Попереднє розгортання відновлено. + Не вдалося відновити попереднє розгортання (проблем: %1$d) + Пошук у цій папці + Показувати приховані файли та папки + Нова папка + Виберіть назву папки в поточному місці призначення. + Назва папки + Поки нічого не змінюється. Папку буде створено лише під час застосування перевіреного плану. + Використати папку + Буде створено + Немає відповідних файлів або папок + Буде додано + Буде замінено + Буде створено резервну копію + Конфлікт плану + Керується цим модом + Керується %1$s + Змінено після розгортання + Файл гри або некерований файл + Отримає файли + Розширене розміщення + Сховати розширене розміщення + Автоматичне розміщення — лише попередня оцінка. Перевірте, чи відповідають заплановані файли та папки призначення інструкції зі встановлення мода. Якщо ні, відкрийте розширене розміщення та виберіть ручне налаштування. + У місці призначення встановити + Вміст %1$s + Папку %1$s та її вміст + Об’єднати вміст вибраних папок + Зберегти назву кожної вибраної папки + \u0020(Рекомендовано) + Результат: %1$s + Це створить повторювану назву папки, наприклад Data/Data. Виберіть вміст, якщо мод не вимагає додаткової папки. + Чому це розміщення? + Сховати подробиці розміщення + Використати це розміщення + Вирішити файли: %1$d + Переходить до власного розміщення, зберігаючи рекомендовані зіставлення для безпечного призначення невирішених файлів. + Перевірити всі файли (%1$d) + Усі заплановані файли + Пошук джерела, призначення або причини + Усі (%1$d) + Немає запланованих файлів, що відповідають фільтру + Раніше: %1$s + Призначення: %1$s + Переглянути призначення + Вміст призначення + Додано + Замінено + Переміщено + Видалено + Ігнорується + Заблоковано + Без змін + Попередня сторінка правил розміщення + Наступна сторінка правил розміщення + Правила розміщення %1$d–%2$d із %3$d Швидше завантаження із зовнішнього накопичувача Може значно пришвидшити запуск ігор із зовнішнього накопичувача, але може спричиняти проблеми в деяких іграх Вимкнути libredirect @@ -2522,4 +2596,13 @@ %1$.1f°/с %1$d мс Скинути налаштування гіроскопа + Перевірити розміщення + Папки верхнього рівня зберігають свої назви; файли з кореня розміщуються безпосередньо в папці призначення + Файлів за замовчуванням замінено вибраними параметрами FOMOD: %1$d + Порядок модів не змінено. Відсутні або змінені керовані файли: %1$d. Спочатку перевірте стан встановлення та усуньте проблеми. + Розміщення завершено. Усього: %1$d; оновлено: %2$d; уже актуальні: %3$d; створено резервні копії: %4$d%5$s + Не вдалося встановити мод; виконано спробу безпечного відкату. Помилки: %1$d + Останнє встановлення мода не вдалося + Мод не позначено як встановлений. Зміни скасовано там, де це вдалося безпечно перевірити. Перегляньте помилки файлів нижче, а потім поверніться до розміщення, щоб виправити або повторити встановлення. + Помилки файлів: %1$d diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d97037e5e0..87bb5c2211 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2009,8 +2009,6 @@ 模组中的文件夹/文件 目标文件夹 安装方法 - 为选择创建一个文件夹 - 开:所选文件夹安装到目标位置。关:仅放置其内容。 隐藏手动路径 手动路径 模组中的文件夹/文件 @@ -2146,7 +2144,6 @@ 应用 mod 文件 已应用%1$d项目,备份%2$d%3$s ; %1$d 旧文件保留在原处 - 应用时出现 %1$d 错误 Mod 文件已被应用。 应用模组失败 配置FOMOD 安装程序或在应用之前选择自定义放置。 @@ -2465,6 +2462,83 @@ 已添加到收藏 已将 %1$s 添加到收藏 VR 刷新率 + 放置审核 + 已规划 %1$d/%2$d 个文件 • 已忽略 %3$d 个 • %4$d 个需要审核 + 建议排名 + %1$d. %2$s — 覆盖率 %3$d%% + 大小写合并:%1$s + 已存在大小写不明确的变体 + 在每个可安装文件都有明确目标位置之前,自动应用将被阻止。 + 分享诊断信息 + 重新配置预览 + 新增 %1$d • 更改 %2$d • 移动 %3$d • 过期 %4$d + 选择软件包变体 + 我已检查这些游戏目录文件 + DLL、EXE 和加载器文件可能改变游戏的启动方式。仅当压缩包可信且预览与安装说明一致时才确认。 + 正在生成文件放置计划… + 重新应用缺失文件 + 验证并跟踪文件 + 已验证并跟踪文件,未进行任何更改。 + 无法验证和跟踪已安装的文件(%1$d 个问题) + 此放置方案不再生成该文件 + 恢复上一次部署 + 将当前文件放置计划替换为此模组上一次正常工作的计划。 + 正在恢复上一次部署… + 已恢复上一次部署。 + 无法恢复上一次部署(%1$d 个问题) + 搜索此文件夹 + 显示隐藏文件和文件夹 + 新建文件夹 + 在当前目标位置中选择文件夹名称。 + 文件夹名称 + 目前不会更改任何内容。仅在应用已审核的计划时创建文件夹。 + 使用文件夹 + 将创建 + 没有匹配的文件或文件夹 + 将添加 + 将替换 + 将备份 + 计划冲突 + 由此模组管理 + 由 %1$s 管理 + 部署后已更改 + 游戏文件或未管理文件 + 将接收文件 + 高级放置 + 隐藏高级放置 + 自动放置只是估计结果。请核对计划安装的文件和目标位置是否符合模组的安装说明。如果不符,请打开高级放置并选择自定义,手动指定位置。 + 在目标位置安装 + %1$s 的内容 + %1$s 文件夹及其内容 + 合并所选文件夹的内容 + 保留每个所选文件夹的名称 + (推荐) + 结果:%1$s + 这会产生 Data/Data 之类的重复文件夹名称。除非模组明确需要额外文件夹,否则请选择内容。 + 为何选择此放置方式? + 隐藏放置详情 + 使用此放置方式 + 解决 %1$d 个文件 + 切换到自定义放置并保留推荐映射,以便安全分配未解决的文件。 + 审核所有文件(%1$d) + 所有计划文件 + 搜索来源、目标或原因 + 全部(%1$d) + 没有计划文件与此筛选条件匹配 + 先前:%1$s + 目标:%1$s + 浏览目标 + 目标内容 + 已添加 + 已替换 + 已移动 + 已移除 + 已忽略 + 已阻止 + 未更改 + 上一页放置规则 + 下一页放置规则 + 放置规则 %1$d–%2$d,共 %3$d 条 从外部存储更快加载 可显著加快从外部存储启动游戏的速度,但可能导致部分游戏出现问题 禁用 libredirect @@ -2534,4 +2608,13 @@ %1$.1f°/秒 %1$d 毫秒 重置陀螺仪设置 + 检查放置方式 + 顶层文件夹保留其名称;根目录文件直接放入目标位置 + FOMOD 选择替换了 %1$d 个默认文件 + 模组顺序未更改。缺失或被修改的受管理文件:%1$d。请先检查安装状态并解决这些问题。 + 放置完成。总计:%1$d;已更新:%2$d;已是最新:%3$d;已备份:%4$d%5$s + 安装失败;已尝试安全回滚。错误:%1$d + 上次模组安装失败 + 该模组未被标记为已安装。对于能够安全核实的更改,已尝试回滚。请查看下方的文件错误,然后返回放置页面进行修正或重试。 + 文件错误:%1$d diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5728a26696..22b69a8def 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2000,8 +2000,6 @@ 模組中的資料夾/檔案 目標資料夾 安裝方法 - 為選擇建立一個資料夾 - 開:所選資料夾安裝到目標位置。關:僅放置其內容。 隱藏手動路徑 手動路徑 模組中的資料夾/檔案 @@ -2137,7 +2135,6 @@ 正在套用模組檔案 已套用 %1$d 個項目,備份 %2$d%3$s ; %1$d 個舊檔案保留在原處 - 套用時出現 %1$d 個錯誤 模組檔案已在套用中。 套用模組失敗 設定 FOMOD 安裝程式或在套用之前選擇自訂放置。 @@ -2456,6 +2453,83 @@ 已加入收藏 已將 %1$s 加入收藏 VR 重新整理頻率 + 放置檢查 + 已規劃 %1$d/%2$d 個檔案 • 已忽略 %3$d 個 • %4$d 個需要檢查 + 建議排名 + %1$d. %2$s — 涵蓋率 %3$d%% + 大小寫合併:%1$s + 已存在大小寫不明確的變體 + 在每個可安裝檔案都有明確目的地之前,自動套用將被封鎖。 + 分享診斷資訊 + 重新設定預覽 + 新增 %1$d • 變更 %2$d • 移動 %3$d • 過期 %4$d + 選擇軟體包變體 + 我已檢查這些遊戲目錄檔案 + DLL、EXE 和載入器檔案可能改變遊戲的啟動方式。僅在壓縮檔可信且預覽與安裝說明一致時確認。 + 正在建立檔案放置計畫… + 重新套用遺失檔案 + 驗證並追蹤檔案 + 已驗證並追蹤檔案,未進行任何變更。 + 無法驗證並追蹤已安裝的檔案(%1$d 個問題) + 此放置方案不再產生此檔案 + 還原上一次部署 + 將目前檔案放置計畫替換為此模組上一次正常運作的計畫。 + 正在還原上一次部署… + 已還原上一次部署。 + 無法還原上一次部署(%1$d 個問題) + 搜尋此資料夾 + 顯示隱藏檔案和資料夾 + 新增資料夾 + 在目前目標位置中選擇資料夾名稱。 + 資料夾名稱 + 目前不會變更任何內容。只有套用已檢閱的計畫時才會建立資料夾。 + 使用資料夾 + 將建立 + 沒有相符的檔案或資料夾 + 將新增 + 將取代 + 將備份 + 計畫衝突 + 由此模組管理 + 由 %1$s 管理 + 部署後已變更 + 遊戲檔案或未管理檔案 + 將接收檔案 + 進階放置 + 隱藏進階放置 + 自動放置只是估計結果。請核對預計安裝的檔案與目標位置是否符合模組的安裝說明。若不符合,請開啟進階放置並選擇自訂,手動指定位置。 + 在目標位置安裝 + %1$s 的內容 + %1$s 資料夾及其內容 + 合併所選資料夾的內容 + 保留每個所選資料夾的名稱 + (建議) + 結果:%1$s + 這會產生 Data/Data 之類的重複資料夾名稱。除非模組明確需要額外資料夾,否則請選擇內容。 + 為何選擇此放置方式? + 隱藏放置詳細資料 + 使用此放置方式 + 解決 %1$d 個檔案 + 切換到自訂放置並保留建議對應,以便安全指派未解決的檔案。 + 檢閱所有檔案(%1$d) + 所有計畫檔案 + 搜尋來源、目標或原因 + 全部(%1$d) + 沒有計畫檔案符合此篩選條件 + 先前:%1$s + 目標:%1$s + 瀏覽目標 + 目標內容 + 已新增 + 已取代 + 已移動 + 已移除 + 已忽略 + 已阻止 + 未變更 + 上一頁放置規則 + 下一頁放置規則 + 放置規則 %1$d–%2$d,共 %3$d 條 從外部儲存空間更快載入 可顯著加快從外部儲存空間啟動遊戲的速度,但可能導致部分遊戲出現問題 停用 libredirect @@ -2525,4 +2599,13 @@ %1$.1f°/秒 %1$d 毫秒 重設陀螺儀設定 + 檢查放置方式 + 頂層資料夾保留其名稱;根目錄檔案直接放入目標位置 + FOMOD 選項取代了 %1$d 個預設檔案 + 模組順序未變更。遺失或遭修改的受管理檔案:%1$d。請先檢查安裝狀態並解決這些問題。 + 放置完成。總計:%1$d;已更新:%2$d;已是最新:%3$d;已備份:%4$d%5$s + 安裝失敗;已嘗試安全回復。錯誤:%1$d + 上次模組安裝失敗 + 此模組未標記為已安裝。對於能安全確認的變更,已嘗試回復。請查看下方的檔案錯誤,然後返回放置頁面修正或重試。 + 檔案錯誤:%1$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3c50d70ed..4a70663dee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2004,6 +2004,7 @@ Related archive files are missing from the game folder: %1$s FOMOD installer Configure + Review placement %1$d step(s), %2$d option(s) %1$d required file mapping(s) Complete required FOMOD groups: %1$s @@ -2057,9 +2058,58 @@ Remove placement row Folder/file from the mod Destination Folder + Search this folder + Show hidden files and folders + New folder + Choose a folder name inside the current destination. + Folder name + Nothing is changed yet. The folder will be created only when the reviewed plan is applied. + Use folder + Will be created + No matching files or folders + Will add + Will replace + Will back up + Plan conflict + Managed by this mod + Managed by %1$s + Changed since deployment + Game or unmanaged file + Will receive files Install method - Create a folder for the selection - On: The selected folder is installed into the destination. Off: Only its contents are placed there. + Advanced placement + Hide advanced placement + Automatic placement is a best estimate. Check that the planned files and destinations match the mod’s installation instructions. If they do not, open Advanced placement and choose Custom to map them yourself. + At the destination, install + Top-level folders keep their names; root files go directly in the destination + Contents of %1$s + %1$s folder and its contents + Merge selected folder contents + Keep each selected folder name + \u0020(Recommended) + Result: %1$s + This creates a repeated folder name such as Data/Data. Choose contents unless the mod specifically requires the extra folder. + Why this placement? + Hide placement details + Use this placement + Resolve %1$d files + Switches to custom placement with the recommended mappings preserved so unresolved files can be assigned safely. + Review all files (%1$d) + All planned files + Search source, destination, or reason + All (%1$d) + No planned files match this filter + Previous: %1$s + Destination: %1$s + Browse destination + Destination contents + Added + Replaced + Moved + Removed + Ignored + Blocked + Unchanged Hide manual paths Manual paths Folders/files from the mod @@ -2140,12 +2190,39 @@ Mod order is already applying. Applying mod order. Large mod lists may take a while. Checking mod order + Mod order was not changed. Missing or modified managed files: %1$d. Run Install health and resolve them first. Fix plugin master warnings before applying order. Checking file conflicts Profile order has existing target files. Use Overwrite files and create backups for mods that should win conflicts. Storage is too low to apply mod order. Free at least %1$s more. Applying mod order Refreshing install health + Placement review + %1$d of %2$d files planned • %3$d ignored • %4$d need review + %1$d default file(s) replaced by your FOMOD choices + %1$s ← %2$s + Ranked suggestions + %1$d. %2$s — %3$d%% coverage + Case merge: %1$s + ambiguous case variants already exist + Automatic apply is blocked until every installable file has a proven destination. + Share diagnostic + Reconfiguration preview + %1$d added • %2$d changed • %3$d moved • %4$d stale + Choose a package variant + I reviewed these game-root files + DLL, EXE, and loader files can change how the game starts. Confirm only when this archive is trusted and the preview matches its installation instructions. + Building the file placement plan… + Reapply missing files + Verify and track files + Verified files are now tracked without changing them + Could not verify and track installed files (%1$d issue(s)) + No longer produced by this placement + Restore previous deployment + Returns this mod to its previous reviewed file plan. Externally modified files are preserved. + Restoring previous deployment + Previous deployment restored + Could not restore the previous deployment (%1$d issue(s)) Mod order applied%1$s%2$s Mod order applied with %1$d error(s)%2$s%3$s Failed to apply mod order @@ -2194,8 +2271,12 @@ Failed to resolve Nexus URL Applying mod files Applied %1$d item(s), backed up %2$d%3$s + Placement complete. Total: %1$d; updated: %2$d; already current: %3$d; backed up: %4$d%5$s ; %1$d old file(s) left in place - Applied with %1$d error(s) + Install failed; safe rollback attempted. Errors: %1$d + Last mod install failed + The mod was not marked as applied. Changes were rolled back where they could be verified safely. Review the file errors below, then return to Placement to correct or retry this mod. + File errors: %1$d Mod files are already being applied. Failed to apply mod Configure the FOMOD installer or choose Custom placement before applying. @@ -2483,6 +2564,9 @@ Conservative AdjustsGradual gradually with a focus on stability VR Refresh Rate + Previous placement-rule page + Next placement-rule page + Placement rules %1$d–%2$d of %3$d Simultaneous Sequence Sequence delay (ms) diff --git a/app/src/test/java/app/gamenative/db/dao/ModDaoLocalImportTest.kt b/app/src/test/java/app/gamenative/db/dao/ModDaoLocalImportTest.kt index b78cf8264b..5a1a032cd7 100644 --- a/app/src/test/java/app/gamenative/db/dao/ModDaoLocalImportTest.kt +++ b/app/src/test/java/app/gamenative/db/dao/ModDaoLocalImportTest.kt @@ -15,6 +15,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -87,6 +88,37 @@ class ModDaoLocalImportTest { assertNotNull(dao.getProfileInstallState(profile.profileId, install.installId)) } + @Test + fun profileUpsert_preservesEveryDependentInstallState() = runBlocking { + val profile = ModProfile( + profileId = "profile", + appId = APP_ID, + name = "Default", + ) + val first = localInstall("first", ModInstallStatus.APPLIED) + val second = localInstall("second", ModInstallStatus.APPLIED) + dao.upsertProfile(profile) + dao.upsertInstall(first) + dao.upsertInstall(second) + listOf(first, second).forEachIndexed { priority, install -> + dao.upsertProfileInstallState( + ModProfileInstallState( + profileId = profile.profileId, + installId = install.installId, + appId = APP_ID, + enabled = true, + priority = priority, + ), + ) + } + + dao.upsertProfile(profile.copy(active = true, updatedAt = profile.updatedAt + 1)) + + val states = dao.getProfileInstallStates(APP_ID, profile.profileId) + assertEquals(setOf("first", "second"), states.map { it.installId }.toSet()) + assertTrue(states.all { it.enabled }) + } + @Test fun installUpdate_doesNotRecreateDeletedRow() = runBlocking { val deleted = localInstall("local_deleted", ModInstallStatus.ERROR) diff --git a/app/src/test/java/app/gamenative/mods/AutomaticPlacementPlannerTest.kt b/app/src/test/java/app/gamenative/mods/AutomaticPlacementPlannerTest.kt new file mode 100644 index 0000000000..ad074d8733 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/AutomaticPlacementPlannerTest.kt @@ -0,0 +1,434 @@ +package app.gamenative.mods + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AutomaticPlacementPlannerTest { + @Test + fun bethesdaPlan_placesMixedLooseContentWithoutFlatteningDirectories() { + val entries = archive("Sounds/theme.xwm", "Scripts/menu.pex", "Example.esp", "readme.txt") + + val candidate = AutomaticPlacementPlanner.plan("Skyrim Special Edition", entries).recommended!! + + assertTrue(candidate.plan.isComplete) + assertEquals( + setOf("Data/Sounds/theme.xwm", "Data/Scripts/menu.pex", "Data/Example.esp"), + candidate.plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet(), + ) + assertEquals(PlannedFileStatus.INTENTIONALLY_IGNORED, candidate.plan.files.single { it.sourceRelativePath == "readme.txt" }.status) + } + + @Test + fun bethesdaPlan_ignoresDocumentationAndManagerMetadataButKeepsRuntimeText() { + val plan = AutomaticPlacementPlanner.plan( + "Skyrim Special Edition", + archive( + "Data/Interface/Translations/example_english.txt", + "BashTags/Example.txt", + "Docs/Example Readme + Credits.html", + "credits.html", + ), + ).recommended!!.plan + + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals( + PlannedFileStatus.PLACED, + plan.files.single { it.sourceRelativePath.contains("Translations") }.status, + ) + assertTrue( + plan.files.filterNot { it.sourceRelativePath.contains("Translations") } + .all { it.status == PlannedFileStatus.INTENTIONALLY_IGNORED }, + ) + } + + @Test + fun bethesdaPlan_stripsOneWrapperAndDataContainer() { + val candidate = AutomaticPlacementPlanner.plan( + "Fallout 4", + archive("Cool Mod/Data/meshes/rifle.nif", "Cool Mod/Data/textures/rifle.dds"), + ).recommended!! + + assertEquals( + setOf("Data/meshes/rifle.nif", "Data/textures/rifle.dds"), + candidate.plan.files.mapNotNull { it.targetRelativePath }.toSet(), + ) + assertTrue(candidate.plan.isComplete) + } + + @Test + fun automaticPlan_blocksUnexplainedInstallableFiles() { + val candidate = AutomaticPlacementPlanner.plan( + "Skyrim Special Edition", + archive("Sounds/theme.xwm", "Mystery/config.bin"), + ).recommended!! + + assertFalse(candidate.plan.isComplete) + assertEquals(PlannedFileStatus.UNSUPPORTED, candidate.plan.files.single { it.sourceRelativePath == "Mystery/config.bin" }.status) + assertTrue(candidate.plan.blockingIssues.isNotEmpty()) + } + + @Test + fun planningIsStableAcrossArchiveOrderAndManualFolderInferencePreservesContentFolder() { + val first = archive("Sounds/theme.xwm", "Scripts/menu.pex", "Example.esp") + val second = first.reversed() + + assertEquals( + AutomaticPlacementPlanner.plan("Skyrim Special Edition", first).recommended!!.plan.digest, + AutomaticPlacementPlanner.plan("Skyrim Special Edition", second).recommended!!.plan.digest, + ) + assertTrue(AutomaticPlacementPlanner.inferIncludeSourceDirectory(listOf("Sounds"), first, "Data")) + assertFalse(AutomaticPlacementPlanner.inferIncludeSourceDirectory(listOf("Data"), archive("Data/file.txt"), "Data")) + } + + @Test + fun ambiguousStructuralVariants_blockAutomaticChoiceAndKeepCommonFolderVisible() { + val result = AutomaticPlacementPlanner.plan( + "Skyrim Special Edition", + archive("Option A/Data/textures/x.dds", "Option B/Data/textures/x.dds", "Common/Data/scripts/y.pex"), + ) + + assertEquals(setOf("Option A", "Option B"), result.optionGroups.single().choices.map { it.sourceDirectory }.toSet()) + assertEquals(listOf("Common"), result.optionGroups.single().commonSourceDirectories) + assertFalse(result.recommended!!.plan.isComplete) + assertTrue(result.recommended!!.plan.blockingIssues.any { "variant" in it.lowercase() }) + + val group = result.optionGroups.single() + val selected = AutomaticPlacementPlanner.plan( + "Skyrim Special Edition", + archive("Option A/Data/textures/x.dds", "Option B/Data/textures/x.dds", "Common/Data/scripts/y.pex"), + selectedOptions = mapOf(group.stableId to "Option B"), + ).recommended!!.plan + assertTrue(selected.blockingIssues.toString(), selected.isComplete) + assertEquals( + setOf("Data/textures/x.dds", "Data/scripts/y.pex"), + selected.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet(), + ) + assertTrue(selected.files.any { it.sourceRelativePath.startsWith("Option A/") && it.status == PlannedFileStatus.INTENTIONALLY_IGNORED }) + } + + @Test + fun nestedStructuralVariants_areDetectedWithoutDependingOnFolderNames() { + val entries = archive( + "Package/Choices/Blue/Data/textures/x.dds", + "Package/Choices/Red/Data/textures/x.dds", + "Package/Choices/Common/Data/scripts/y.pex", + ) + val result = AutomaticPlacementPlanner.plan("Skyrim Special Edition", entries) + val group = result.optionGroups.single() + + assertEquals( + setOf("Package/Choices/Blue", "Package/Choices/Red"), + group.choices.mapTo(mutableSetOf()) { it.sourceDirectory }, + ) + assertEquals(listOf("Package/Choices/Common"), group.commonSourceDirectories) + assertFalse(result.recommended!!.plan.isComplete) + } + + @Test + fun mixedDataAndRootBinary_areSeparatedAndRootBinaryRequiresReview() { + val plan = AutomaticPlacementPlanner.plan( + "Skyrim Special Edition", + archive("Data/Scripts/x.pex", "dinput8.dll"), + ).recommended!!.plan + + assertEquals( + setOf("Data/Scripts/x.pex", "dinput8.dll"), + plan.files.filter { it.status == PlannedFileStatus.PLACED }.mapNotNull { it.targetRelativePath }.toSet(), + ) + assertFalse(plan.isComplete) + assertEquals(PlacementRisk.UNSAFE, plan.files.single { it.sourceRelativePath == "dinput8.dll" }.risk) + val approved = plan.withRiskyRootApproval(true) + assertTrue(approved.isComplete) + assertFalse(approved.withRiskyRootApproval(false).isComplete) + } + + @Test + fun frameworkRules_produceCompletePlansWithoutRegressingLegacyTargets() { + val cases = listOf( + Triple("Any Unity game", archive("BepInEx/plugins/Test.dll", "BepInEx/config/Test.cfg"), setOf("BepInEx/plugins/Test.dll", "BepInEx/config/Test.cfg")), + Triple("Another Unity game", archive("Mods/Test.dll", "UserData/settings.cfg"), setOf("Mods/Test.dll", "UserData/settings.cfg")), + Triple("Any Unreal game", archive("Content/Paks/Test.pak", "Content/Paks/Test.utoc"), setOf("Content/Paks/Test.pak", "Content/Paks/Test.utoc")), + Triple("Cyberpunk 2077", archive("archive/pc/mod/Test.archive", "r6/scripts/Test.reds"), setOf("archive/pc/mod/Test.archive", "r6/scripts/Test.reds")), + ) + + cases.forEach { (game, entries, targets) -> + val plan = AutomaticPlacementPlanner.plan(game, entries).recommended!!.plan + assertTrue("$game: ${plan.blockingIssues}", plan.isComplete) + assertEquals(targets, plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet()) + } + } + + @Test + fun existingGameFolders_outweighAnUnprovenSingleFolderGuess() { + val result = AutomaticPlacementPlanner.plan( + gameName = "Classic arena game", + entries = archive( + "Help/Map Readme.txt", + "Maps/CTF-Arena.ut2", + "Music/Arena.ogg", + "Screenshots/Arena.jpg", + ), + context = AutomaticPlacementContext( + defaultTargetRelativePath = "Sounds", + defaultTargetIsProven = false, + existingGameDirectories = setOf("Maps", "Music", "Sounds"), + ), + ) + val plan = result.recommended!!.plan + + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals( + setOf("Maps/CTF-Arena.ut2", "Music/Arena.ogg"), + plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet(), + ) + assertTrue( + plan.files.filter { it.sourceRelativePath.startsWith("Help/") || it.sourceRelativePath.startsWith("Screenshots/") } + .all { it.status == PlannedFileStatus.INTENTIONALLY_IGNORED }, + ) + } + + @Test + fun combinedRules_blockOneSourceMappedToDifferentDestinations() { + val result = AutomaticPlacementPlanner.plan( + gameName = "Classic game", + entries = archive("Mods/Plugin.dll"), + context = AutomaticPlacementContext( + defaultTargetRelativePath = "Packages", + defaultTargetIsProven = true, + existingGameDirectories = setOf("Mods"), + ), + ) + + val combined = result.candidates.single { it.id == "rules:combined-v1" } + + assertEquals(PlannedFileStatus.CONFLICTED, combined.plan.files.single().status) + assertTrue(combined.plan.blockingIssues.any { "multiple destinations" in it.lowercase() }) + } + + @Test + fun provenModDirectory_preservesPackageWrapperAndStripsOnlySelectedVariant() { + val entries = archive( + "CharacterEditor/v1/About/About.xml", + "CharacterEditor/v1/Assemblies/Editor.dll", + "CharacterEditor/v1.6/About/About.xml", + "CharacterEditor/v1.6/Assemblies/Editor.dll", + "CharacterEditor/Textures/Icon.png", + ) + val context = AutomaticPlacementContext( + defaultTargetRelativePath = "Mods", + defaultTargetIsProven = true, + ) + val initial = AutomaticPlacementPlanner.plan("Colony game", entries, context = context) + val optionGroup = initial.optionGroups.single() + val result = AutomaticPlacementPlanner.plan( + gameName = "Colony game", + entries = entries, + selectedOptions = mapOf(optionGroup.stableId to "CharacterEditor/v1.6"), + context = context, + ) + val plan = result.recommended!!.plan + + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals( + setOf( + "Mods/CharacterEditor/About/About.xml", + "Mods/CharacterEditor/Assemblies/Editor.dll", + "Mods/CharacterEditor/Textures/Icon.png", + ), + plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet(), + ) + assertTrue( + plan.files.filter { it.sourceRelativePath.startsWith("CharacterEditor/v1/") } + .all { it.status == PlannedFileStatus.INTENTIONALLY_IGNORED }, + ) + } + + @Test + fun provenModDirectory_treatsDifferentlyShapedVersionFoldersAsOneChoice() { + val entries = archive( + "CharacterEditor/About/About.xml", + "CharacterEditor/Defs/CharEditor.xml", + "CharacterEditor/Textures/Icon.png", + "CharacterEditor/v1.0/0Harmony.dll", + "CharacterEditor/v1.0/CharacterEditor.dll", + "CharacterEditor/v1.1/Assemblies/CharacterEditor.dll", + "CharacterEditor/v1.3/Assemblies/CharacterEditor.dll", + "CharacterEditor/v1.3/Defs/GradientHairMasks.xml", + "CharacterEditor/v1.6/Assemblies/CharacterEditor.dll", + "CharacterEditor/v1.6/Defs/LifeStageGiant.xml", + "CharacterEditor/v1.6/Gradients/GradientHairMasks.xml", + ) + val context = AutomaticPlacementContext( + defaultTargetRelativePath = "Mods", + defaultTargetIsProven = true, + ) + val initial = AutomaticPlacementPlanner.plan("RimWorld", entries, context = context) + val optionGroup = initial.optionGroups.single() + + assertEquals( + setOf( + "CharacterEditor/v1.0", + "CharacterEditor/v1.1", + "CharacterEditor/v1.3", + "CharacterEditor/v1.6", + ), + optionGroup.choices.mapTo(mutableSetOf()) { it.sourceDirectory }, + ) + assertEquals( + setOf("CharacterEditor/About", "CharacterEditor/Defs", "CharacterEditor/Textures"), + optionGroup.commonSourceDirectories.toSet(), + ) + + val plan = AutomaticPlacementPlanner.plan( + gameName = "RimWorld", + entries = entries, + selectedOptions = mapOf("previous-detector-id" to "CharacterEditor/v1.6"), + context = context, + ).recommended!!.plan + + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals( + setOf( + "Mods/CharacterEditor/About/About.xml", + "Mods/CharacterEditor/Defs/CharEditor.xml", + "Mods/CharacterEditor/Textures/Icon.png", + "Mods/CharacterEditor/Assemblies/CharacterEditor.dll", + "Mods/CharacterEditor/Defs/LifeStageGiant.xml", + "Mods/CharacterEditor/Gradients/GradientHairMasks.xml", + ), + plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet(), + ) + assertTrue( + plan.files.filter { "/v1." in it.sourceRelativePath && !it.sourceRelativePath.startsWith("CharacterEditor/v1.6/") } + .all { it.status == PlannedFileStatus.INTENTIONALLY_IGNORED }, + ) + } + + @Test + fun unrelatedPairOfNumberedFolders_isNotAssumedToBeAChoice() { + val result = AutomaticPlacementPlanner.plan( + gameName = "Numbered content", + entries = archive( + "Package/v1.0/Textures/First.dds", + "Package/v2.0/Sounds/Second.wav", + ), + ) + + assertTrue(result.optionGroups.isEmpty()) + } + + @Test + fun ordinaryContentNamespacesWithMatchingLayouts_areNotPackageVariants() { + val cases = listOf( + archive( + "Package/dungeons/cove/shared/room.darkest", + "Package/dungeons/crypts/shared/room.darkest", + "Package/dungeons/town/shared/room.darkest", + ), + archive( + "CreativeMode/interface/cheatingtable/window.config", + "CreativeMode/interface/furnituretable/window.config", + "CreativeMode/interface/spawningtable/window.config", + ), + ) + + cases.forEach { entries -> + assertTrue(GenericOptionSetDetector.detect(ModArchiveIndex.build(entries)).isEmpty()) + } + } + + @Test + fun structuralWrappersAroundAnInstallRoot_areStillPackageVariants() { + val result = AutomaticPlacementPlanner.plan( + gameName = "Modded game", + entries = archive( + "Blue/Data/textures/shared.dds", + "Red/Data/textures/shared.dds", + ), + ) + + assertEquals( + setOf("Blue", "Red"), + result.optionGroups.single().choices.mapTo(mutableSetOf()) { it.sourceDirectory }, + ) + } + + @Test + fun readmeTrees_areIgnoredAndNeverBecomePackageVariants() { + val entries = archive( + "Campaign Module/Module.ini", + "Campaign Module/troops.txt", + "Campaign Module/_README_PACKAGE/OPTIONAL_FONT_ENG/font.dds", + "Campaign Module/_README_PACKAGE/OPTIONAL_FONT_RUS_UKR_ENG/font.dds", + ) + val result = AutomaticPlacementPlanner.plan( + gameName = "Module game", + entries = entries, + context = AutomaticPlacementContext( + defaultTargetRelativePath = "Modules", + defaultTargetIsProven = true, + ), + ) + + assertTrue(result.optionGroups.isEmpty()) + val plan = result.recommended!!.plan + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals( + setOf("Campaign Module/Module.ini", "Campaign Module/troops.txt"), + plan.files.filter { it.status == PlannedFileStatus.PLACED }.mapTo(mutableSetOf()) { it.sourceRelativePath }, + ) + assertTrue( + plan.files.filter { "/_README_PACKAGE/" in it.sourceRelativePath } + .all { it.status == PlannedFileStatus.INTENTIONALLY_IGNORED }, + ) + } + + @Test + fun documentationPrefixes_doNotHideInstallableFoldersOrBinariesWithSimilarNames() { + val index = ModArchiveIndex.build( + archive( + "ManualTransmission/ManualTransmission.dll", + "ScreenshotsEnhanced/ScreenshotsEnhanced.esp", + "_README_PACKAGE/guide.txt", + "ReadMeFirst.txt", + ), + ) + + assertEquals( + ArchiveContentRole.INSTALLABLE, + index.files.single { it.displayPath.endsWith("ManualTransmission.dll") }.role, + ) + assertEquals( + ArchiveContentRole.INSTALLABLE, + index.files.single { it.displayPath.endsWith("ScreenshotsEnhanced.esp") }.role, + ) + assertTrue( + index.files.filter { it.displayPath.contains("README", ignoreCase = true) } + .all { it.role == ArchiveContentRole.DOCUMENTATION }, + ) + } + + @Test + fun provenTargetContainer_isMergedWithoutDuplicatingItsFolderName() { + val result = AutomaticPlacementPlanner.plan( + gameName = "Plugin game", + entries = archive("Mods/Example/About.xml", "Mods/Example/Runtime.dll"), + context = AutomaticPlacementContext( + defaultTargetRelativePath = "Mods", + defaultTargetIsProven = true, + ), + ) + val plan = result.recommended!!.plan + + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals( + setOf("Mods/Example/About.xml", "Mods/Example/Runtime.dll"), + plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet(), + ) + } + + private fun archive(vararg paths: String): List = + paths.map { ModArchiveEntry(it, directory = false, sizeBytes = 1L) } +} diff --git a/app/src/test/java/app/gamenative/mods/BethesdaPluginManagerTest.kt b/app/src/test/java/app/gamenative/mods/BethesdaPluginManagerTest.kt index a338ae6b00..48bc604205 100644 --- a/app/src/test/java/app/gamenative/mods/BethesdaPluginManagerTest.kt +++ b/app/src/test/java/app/gamenative/mods/BethesdaPluginManagerTest.kt @@ -1,5 +1,6 @@ package app.gamenative.mods +import app.gamenative.data.GameSource import app.gamenative.data.ModInstall import app.gamenative.data.ModInstallStatus import app.gamenative.data.ModPlacementMode @@ -37,6 +38,100 @@ class BethesdaPluginManagerTest { assertEquals(BethesdaGame.STARFIELD, BethesdaPluginManager.detectGame("Starfield")) } + @Test + fun pluginFiles_resolvesOnlyGogSkyrimSpecialEditionToStoreSpecificPath() { + assertPluginPath( + game = BethesdaGame.SKYRIM_SPECIAL_EDITION, + gameSource = GameSource.STEAM, + expectedDirectory = "Skyrim Special Edition", + ) + assertPluginPath( + game = BethesdaGame.SKYRIM_SPECIAL_EDITION, + gameSource = GameSource.GOG, + expectedDirectory = "Skyrim Special Edition GOG", + ) + assertPluginPath( + game = BethesdaGame.FALLOUT4, + gameSource = GameSource.GOG, + expectedDirectory = "Fallout4", + ) + } + + @Test + fun pluginFiles_readsLegacyGogStateUntilCorrectPathIsWritten() { + val winePrefix = winePrefix() + val localAppData = File(winePrefix, "drive_c/users/steamuser/AppData/Local") + val legacyFile = File(localAppData, "Skyrim Special Edition/plugins.txt").apply { + parentFile?.mkdirs() + writeText("# legacy comment\n*SkyUI_SE.esp\n*LegacyOnly.esp\n*External.esp\n") + } + File(legacyFile.parentFile, "loadorder.txt").writeText("SkyUI_SE.esp\nLegacyOnly.esp\nExternal.esp\n") + val targetFile = File(localAppData, "Skyrim Special Edition GOG/plugins.txt").apply { + parentFile?.mkdirs() + writeText("SkyUI_SE.esp\nExternal.esp\n*TargetOnly.esp\n# target comment\n") + } + val legacyText = legacyFile.readText() + + val pending = BethesdaPluginManager.pluginFiles( + winePrefix = winePrefix.absolutePath, + game = BethesdaGame.SKYRIM_SPECIAL_EDITION, + gameSource = GameSource.GOG, + )!! + + assertEquals(targetFile.absolutePath, pending.targetFile.absolutePath) + assertEquals(legacyFile.absolutePath, pending.stateFile.absolutePath) + + BethesdaPluginManager.updateManagedPluginsTxt( + file = pending.targetFile, + managedPlugins = listOf( + BethesdaPlugin("SkyUI_SE.esp", "skyui", "SkyUI", "", enabled = true, priority = 0), + ), + game = BethesdaGame.SKYRIM_SPECIAL_EDITION, + migrationSourceFile = pending.stateFile, + ) + + val migrated = BethesdaPluginManager.pluginFiles( + winePrefix = winePrefix.absolutePath, + game = BethesdaGame.SKYRIM_SPECIAL_EDITION, + gameSource = GameSource.GOG, + )!! + assertEquals(targetFile.absolutePath, migrated.stateFile.absolutePath) + assertEquals( + "External.esp\n*TargetOnly.esp\n# target comment\n*LegacyOnly.esp\n*SkyUI_SE.esp\n", + targetFile.readText(), + ) + assertEquals( + "External.esp\nTargetOnly.esp\nLegacyOnly.esp\nSkyUI_SE.esp\n", + File(targetFile.parentFile, "loadorder.txt").readText(), + ) + assertEquals(legacyText, legacyFile.readText()) + } + + @Test + fun pluginFiles_prefersExistingGogManagedState() { + val winePrefix = winePrefix() + val localAppData = File(winePrefix, "drive_c/users/steamuser/AppData/Local") + val legacyFile = File(localAppData, "Skyrim Special Edition/plugins.txt").apply { + parentFile?.mkdirs() + writeText("*Legacy.esp\n") + } + File(legacyFile.parentFile, "loadorder.txt").writeText("Legacy.esp\n") + val targetFile = File(localAppData, "Skyrim Special Edition GOG/plugins.txt").apply { + parentFile?.mkdirs() + writeText("*Gog.esp\n") + } + File(targetFile.parentFile, "loadorder.txt").writeText("Gog.esp\n") + + val files = BethesdaPluginManager.pluginFiles( + winePrefix = winePrefix.absolutePath, + game = BethesdaGame.SKYRIM_SPECIAL_EDITION, + gameSource = GameSource.GOG, + )!! + + assertEquals(targetFile.absolutePath, files.targetFile.absolutePath) + assertEquals(targetFile.absolutePath, files.stateFile.absolutePath) + } + @Test fun detectPlugins_readsPlannedPluginFilesAndEnabledState() = runBlocking { val install = install() @@ -83,6 +178,34 @@ class BethesdaPluginManagerTest { assertEquals(false, plugins.single().enabled) } + @Test + fun detectPlugins_usesRenamedTargetExtension() = runBlocking { + val install = install() + File(install.extractedPath, "Choice/Plugin.bin").apply { + parentFile?.mkdirs() + writeText("plugin") + } + val renamedRecipe = ModPlacementRecipe( + installId = install.installId, + sourceSubpath = "Choice/Plugin.bin", + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "Data", + targetFileName = "Renamed.esp", + mode = ModPlacementMode.OVERWRITE_COPY.name, + ) + + val plugins = BethesdaPluginManager.detectPlugins( + installs = listOf(install), + recipesByInstallId = mapOf(install.installId to listOf(renamedRecipe)), + prioritiesByInstallId = emptyMap(), + gameRootDir = gameDir, + winePrefix = "", + pluginsFile = null, + ) + + assertEquals(listOf("Renamed.esp"), plugins.map { it.fileName }) + } + @Test fun detectPlugins_canDefaultNewPluginsToEnabled() = runBlocking { val install = install() @@ -104,6 +227,49 @@ class BethesdaPluginManagerTest { assertEquals(true, plugins.single().enabled) } + @Test + fun detectPlugins_reusesAppliedOwnershipWithoutRebuildingArchivePlan() = runBlocking { + val install = install() + val source = File(install.extractedPath, "Choices/Cloaks.esp").apply { + parentFile?.mkdirs() + writeText("esp") + } + val target = File(gameDir, "Data/Cloaks.esp") + val ownership = ModOwnershipManifest( + installId = install.installId, + appId = install.appId, + planDigest = "owned", + files = listOf( + ModOwnedFile( + sourceRelativePath = source.relativeTo(File(install.extractedPath)).path, + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "Data/Cloaks.esp", + targetPath = target.absolutePath, + normalizedTargetKey = WindowsPathIdentity.absoluteKey(target), + mode = ModPlacementMode.OVERWRITE_COPY.name, + installedHash = "hash", + installedSize = source.length(), + installedMtime = source.lastModified(), + disposition = ModOwnedFileDisposition.CREATED, + ), + ), + ) + + val plugins = BethesdaPluginManager.detectPlugins( + installs = listOf(install), + recipesByInstallId = emptyMap(), + prioritiesByInstallId = mapOf(install.installId to 7), + gameRootDir = gameDir, + winePrefix = "", + pluginsFile = File(tempDir, "missing/plugins.txt"), + ownershipByInstallId = mapOf(install.installId to ownership), + defaultEnabled = true, + ) + + assertEquals("Cloaks.esp", plugins.single().fileName) + assertEquals(source.absolutePath, plugins.single().sourcePath) + } + @Test fun detectPlugins_ignoresReadyModsThatHaveNotBeenApplied() = runBlocking { val install = install().copy(status = ModInstallStatus.READY.name) @@ -496,6 +662,25 @@ class BethesdaPluginManagerTest { ) } + private fun winePrefix(): File = File(tempDir, "prefix").apply { + File(this, "drive_c/users/steamuser").mkdirs() + } + + private fun assertPluginPath( + game: BethesdaGame, + gameSource: GameSource, + expectedDirectory: String, + ) { + val winePrefix = winePrefix() + val files = BethesdaPluginManager.pluginFiles(winePrefix.absolutePath, game, gameSource)!! + val expected = File( + winePrefix, + "drive_c/users/steamuser/AppData/Local/$expectedDirectory/plugins.txt", + ) + assertEquals(expected.absolutePath, files.targetFile.absolutePath) + assertEquals(expected.absolutePath, files.stateFile.absolutePath) + } + private fun recipe(installId: String): ModPlacementRecipe = ModPlacementRecipe( installId = installId, diff --git a/app/src/test/java/app/gamenative/mods/FomodEnvironmentTest.kt b/app/src/test/java/app/gamenative/mods/FomodEnvironmentTest.kt new file mode 100644 index 0000000000..24e517ae61 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/FomodEnvironmentTest.kt @@ -0,0 +1,215 @@ +package app.gamenative.mods + +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.io.path.createTempDirectory +import org.junit.Assert.assertEquals +import org.junit.Test + +class FomodEnvironmentTest { + @Test + fun dependencies_preserveUnknownAndEvaluateSupportedFacts() { + val expression = FomodDependencyExpression( + fileDependencies = listOf(FomodFileDependency("Data/Required.dll", FomodRequiredFileState.ACTIVE)), + pluginDependencies = listOf(FomodPluginDependency("Example.esp", FomodRequiredFileState.ACTIVE)), + gameDependencies = listOf(FomodGameDependency("1.6.0")), + ) + + assertEquals(FomodFactState.UNKNOWN, expression.evaluate(emptyMap(), FomodEnvironmentSnapshot())) + assertEquals( + FomodFactState.TRUE, + expression.evaluate( + emptyMap(), + FomodEnvironmentSnapshot( + gameVersion = "1.6.1170", + fileFacts = mapOf("data/required.dll" to true), + presentPlugins = setOf("example.esp"), + activePlugins = setOf("example.esp"), + ), + ), + ) + } + + @Test + fun unknownConditional_blocksInsteadOfGuessing() { + val installer = FomodInstaller( + moduleName = "Dependencies", + requiredFiles = emptyList(), + steps = emptyList(), + conditionalFileInstalls = listOf( + FomodConditionalFileInstall( + FomodDependencyExpression( + fileDependencies = listOf(FomodFileDependency("Data/Maybe.dll", FomodRequiredFileState.ACTIVE)), + ), + listOf(FomodFileMapping("Maybe.dll", "Maybe.dll", 0, directory = false)), + ), + ), + ) + + val result = FomodSelectionEvaluator.evaluate(installer, emptySet()) + + assertTrue(result.mappings.isEmpty()) + assertTrue(result.blockingIssues.any { "unknown" in it.lowercase() }) + } + + @Test + fun unknownModuleGameVersion_warnsWithoutBlockingExplicitChoices() { + val installer = FomodInstaller( + moduleName = "Versioned installer", + requiredFiles = emptyList(), + steps = emptyList(), + moduleDependencies = FomodDependencyExpression( + gameDependencies = listOf(FomodGameDependency("1.6.629")), + ), + ) + + val result = FomodSelectionEvaluator.evaluate(installer, emptySet()) + + assertTrue(result.blockingIssues.isEmpty()) + assertTrue(result.warnings.any { "could not be verified" in it }) + } + + @Test + fun unknownOptionAvailability_warnsWithoutBlockingSelectedVersion() { + val installer = FomodInstaller( + moduleName = "Version-gated installer", + requiredFiles = emptyList(), + steps = listOf( + FomodStep( + name = "Main", + groups = listOf( + FomodGroup( + name = "DLL", + type = FomodGroupType.SELECT_EXACTLY_ONE, + plugins = listOf( + versionedPlugin("New game version", "New/Example.dll", "1.6.629"), + versionedPlugin("Old game version", "Old/Example.dll", "1.6.353"), + ), + ), + ), + ), + ), + ) + + val selected = setOf(FomodRecipeGenerator.pluginKey(0, 0, 0)) + val result = FomodSelectionEvaluator.evaluate(installer, selected) + + assertEquals(listOf("New/Example.dll"), result.mappings.map { it.mapping.source }) + assertTrue(result.blockingIssues.isEmpty()) + assertTrue(result.warnings.any { "explicit choices" in it }) + } + + @Test + fun environment_discoversScriptExtenderVersionAndDllArchitecture() { + val root = createTempDirectory("fomod-environment").toFile() + try { + File(root, "skse64_loader.exe").writeText("loader") + File(root, "skse64_2_02_06.dll").writeBytes(peHeader(0x8664)) + File(root, "Data/SKSE/Plugins/Test.dll").apply { + parentFile?.mkdirs() + writeBytes(peHeader(0x8664)) + } + val installer = FomodInstaller( + moduleName = "Environment", + requiredFiles = emptyList(), + steps = emptyList(), + moduleDependencies = FomodDependencyExpression( + fileDependencies = listOf(FomodFileDependency("SKSE/Plugins/Test.dll", FomodRequiredFileState.ACTIVE)), + ), + ) + + val snapshot = FomodEnvironmentSnapshotBuilder.build(installer, "Skyrim Special Edition", root) + + assertTrue(snapshot.scriptExtenders.getValue("skse").present) + assertEquals("2.2.6", snapshot.scriptExtenders.getValue("skse").version) + assertEquals(NativeBinaryArchitecture.X64, snapshot.nativeDllArchitectures["skse/plugins/test.dll"]) + } finally { + root.deleteRecursively() + } + } + + @Test + fun environment_honorsModernPluginMarkersAndLegacyUnmarkedLists() { + val root = createTempDirectory("fomod-plugin-state").toFile() + try { + File(root, "Data/Enabled.esp").apply { parentFile?.mkdirs(); writeText("enabled") } + File(root, "Data/Disabled.esp").writeText("disabled") + val installer = FomodInstaller( + moduleName = "Plugin state", + requiredFiles = emptyList(), + steps = emptyList(), + moduleDependencies = FomodDependencyExpression( + pluginDependencies = listOf( + FomodPluginDependency("Enabled.esp", FomodRequiredFileState.ACTIVE), + FomodPluginDependency("Disabled.esp", FomodRequiredFileState.INACTIVE), + ), + ), + ) + val pluginsFile = File(root, "plugins.txt") + pluginsFile.writeText("*Enabled.esp\nDisabled.esp\n") + + val modern = FomodEnvironmentSnapshotBuilder.build(installer, "Skyrim Special Edition", root, pluginsFile) + + assertEquals(setOf("enabled.esp"), modern.activePlugins) + assertEquals(setOf("enabled.esp", "disabled.esp"), modern.presentPlugins) + + pluginsFile.writeText("Enabled.esp\nDisabled.esp\n") + val legacy = FomodEnvironmentSnapshotBuilder.build(installer, "Skyrim Special Edition", root, pluginsFile) + + assertEquals(setOf("enabled.esp", "disabled.esp"), legacy.activePlugins) + } finally { + root.deleteRecursively() + } + } + + @Test + fun inspectedEmptyPluginState_provesMissingDependency() { + val root = createTempDirectory("fomod-empty-plugin-state").toFile() + try { + val dependency = FomodPluginDependency("Absent.esp", FomodRequiredFileState.MISSING) + val installer = FomodInstaller( + moduleName = "Missing plugin dependency", + requiredFiles = emptyList(), + steps = emptyList(), + moduleDependencies = FomodDependencyExpression(pluginDependencies = listOf(dependency)), + ) + + val snapshot = FomodEnvironmentSnapshotBuilder.build(installer, "Skyrim Special Edition", root) + + assertTrue(snapshot.pluginStateKnown) + assertEquals(FomodFactState.TRUE, snapshot.evaluate(dependency)) + assertEquals(FomodFactState.UNKNOWN, FomodEnvironmentSnapshot().evaluate(dependency)) + } finally { + root.deleteRecursively() + } + } + + private fun peHeader(machine: Int): ByteArray = ByteArray(512).also { bytes -> + bytes[0] = 'M'.code.toByte() + bytes[1] = 'Z'.code.toByte() + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(0x3c, 0x80) + putInt(0x80, 0x00004550) + putShort(0x84, machine.toShort()) + } + } + + private fun versionedPlugin(name: String, source: String, version: String) = FomodPlugin( + name = name, + description = "", + imagePath = "", + type = FomodPluginType.OPTIONAL, + files = listOf(FomodFileMapping(source, "SKSE/Plugins/Example.dll", 0, false)), + typePatterns = listOf( + FomodTypePattern( + dependencies = FomodDependencyExpression( + gameDependencies = listOf(FomodGameDependency(version)), + ), + type = FomodPluginType.RECOMMENDED, + ), + ), + ) + + private fun assertTrue(value: Boolean) = org.junit.Assert.assertTrue(value) +} diff --git a/app/src/test/java/app/gamenative/mods/FomodInstallerTest.kt b/app/src/test/java/app/gamenative/mods/FomodInstallerTest.kt index e31d15f55e..5e54b7a5a4 100644 --- a/app/src/test/java/app/gamenative/mods/FomodInstallerTest.kt +++ b/app/src/test/java/app/gamenative/mods/FomodInstallerTest.kt @@ -1,13 +1,16 @@ package app.gamenative.mods import app.gamenative.data.ModPlacementMode +import app.gamenative.data.ModInstall import app.gamenative.data.ModTargetRoot +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.File +import java.util.Locale import kotlin.io.path.createTempDirectory class FomodInstallerTest { @@ -29,6 +32,10 @@ class FomodInstallerTest { """ Example Installer + + + + @@ -67,6 +74,42 @@ class FomodInstallerTest { assertEquals(FomodPluginType.RECOMMENDED, plugin.type) assertEquals("2K", plugin.conditionFlags["TextureSize"]) assertEquals(2, plugin.files.size) + assertEquals("Data/Required.dll", installer.moduleDependencies.fileDependencies.single().file) + assertEquals("1.6.0", installer.moduleDependencies.gameDependencies.single().version) + } + + @Test + fun parse_isIndependentOfTheDeviceLanguage() { + val previousLocale = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")) + val moduleConfig = writeModuleConfig( + """ + + + + + + + + + + + + + + + + """.trimIndent(), + ) + + val installer = FomodParser.parse(moduleConfig) + + assertEquals(FomodGroupType.SELECT_EXACTLY_ONE, installer.steps.single().groups.single().type) + assertEquals(FomodPluginType.REQUIRED, installer.steps.single().groups.single().plugins.single().type) + } finally { + Locale.setDefault(previousLocale) + } } @Test @@ -108,7 +151,6 @@ class FomodInstallerTest { mode = ModPlacementMode.OVERWRITE_COPY.name, ) - assertTrue(result.unsupportedMappings.isEmpty()) assertEquals( listOf("Common->Data", "Option->Data/textures", "Plugins/Example.esp->Data"), result.recipes.map { "${it.sourceSubpath}->${it.targetRelativePath}" }, @@ -171,7 +213,7 @@ class FomodInstallerTest { } @Test - fun generate_reportsRenamedFileMappingsAsUnsupported() { + fun generate_preservesRenamedFileDestination() { val installer = FomodInstaller( moduleName = "Example", requiredFiles = listOf(FomodFileMapping("Plugins/Source.esp", "Renamed.esp", 0, directory = false)), @@ -184,8 +226,7 @@ class FomodInstallerTest { selectedPluginNames = emptySet(), ) - assertEquals(0, result.recipes.size) - assertEquals("Renamed.esp", result.unsupportedMappings.single().destination) + assertEquals("Renamed.esp", result.recipes.single().targetFileName) } @Test @@ -386,6 +427,225 @@ class FomodInstallerTest { assertEquals(listOf("PatchA"), result.recipes.map { it.sourceSubpath }) } + @Test + fun missingSelectedMapping_isAResolvableFileIssueWithoutADuplicateBlocker() { + val installer = FomodInstaller( + moduleName = "Missing source", + requiredFiles = listOf(FomodFileMapping("Missing.dll", "Missing.dll", priority = 0, directory = false)), + steps = emptyList(), + ) + + val plan = FomodRecipeGenerator.generateForPluginKeys( + installId = "missing", + installer = installer, + selectedPluginKeys = emptySet(), + extractedRoot = tempDir, + ).plan!! + + assertEquals(1, plan.unresolvedCount) + assertTrue(plan.blockingIssues.isEmpty()) + assertTrue(!plan.isComplete) + } + + @Test + fun equalPrioritySelectedBodyFiles_overrideRequiredDefaultsDuringMaterialization() = runBlocking { + val moduleConfig = writeModuleConfig( + """ + + Body installer fixture + + + + + + + Vanilla + + + + + + + + + """.trimIndent(), + ) + val relativeBody = "meshes/actors/character/character assets/femalebody_0.nif" + File(tempDir, "00 Required (Slim)/$relativeBody").apply { + parentFile?.mkdirs() + writeText("slim-default") + } + File(tempDir, "00 Required (Slim)/CalienteTools/base.osd").apply { + parentFile?.mkdirs() + writeText("required") + } + File(tempDir, "02 Vanilla/$relativeBody").apply { + parentFile?.mkdirs() + writeText("vanilla-selected") + } + val installer = FomodParser.parse(moduleConfig, tempDir) + val result = FomodRecipeGenerator.generateForPluginKeys( + installId = "body", + installer = installer, + selectedPluginKeys = setOf(FomodRecipeGenerator.pluginKey(0, 0, 0)), + extractedRoot = tempDir, + ) + val plan = result.plan!! + + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals( + "02 Vanilla/$relativeBody", + plan.files.single { it.targetRelativePath == "Data/$relativeBody" && it.status == PlannedFileStatus.PLACED } + .sourceRelativePath, + ) + assertEquals( + PlannedFileStatus.INTENTIONALLY_IGNORED, + plan.files.single { it.sourceRelativePath == "00 Required (Slim)/$relativeBody" }.status, + ) + + val game = File(tempDir, "game").apply { mkdirs() } + val install = ModInstall( + installId = "body", + appId = "game", + modName = "Body installer fixture", + fileName = "fixture.zip", + archivePath = "", + extractedPath = tempDir.absolutePath, + ) + val materialization = ModMaterializer.materializationPlan( + install = install, + recipes = result.recipes, + gameRootDir = game, + winePrefix = "", + reviewedPlan = plan, + ) + val applied = ModMaterializer.apply(install, materialization, File(tempDir, "backups"), allowOverwrite = true) + + assertTrue(applied.errors.toString(), applied.errors.isEmpty()) + assertEquals("vanilla-selected", File(game, "Data/$relativeBody").readText()) + assertEquals("required", File(game, "Data/CalienteTools/base.osd").readText()) + } + + @Test + fun mcmHelperShape_plansAndAppliesEverySelectedFile() = runBlocking { + val moduleConfig = writeModuleConfig( + """ + + MCM Helper fixture + + + + + + + + + + + + + + + + + + + + + + """.trimIndent(), + ) + listOf( + "Data/MCM/Config/SkyUI_SE/config.json", + "Data/MCM/Config/SkyUI_SE/settings.ini", + "Data/MCM/Settings/readme.txt", + "Data/Source/Scripts/SKI_ConfigMenu.psc", + "SkyrimSE/SKSE/Plugins/MCMHelper.dll", + "SkyrimSE/SKSE/Plugins/MCMHelper.pdb", + "SkyrimVR/SKSE/Plugins/MCMHelper.dll", + "Plugins/MCMHelper.esl", + "Plugins/MCMHelper.esp", + "BSA/MCMHelper.bsa", + "Loose/MCM/Config/SkyUI_SE/loose.json", + ).forEach { path -> + File(tempDir, path).apply { + parentFile?.mkdirs() + writeText(path) + } + } + val installer = FomodParser.parse(moduleConfig, tempDir) + val result = FomodRecipeGenerator.generateForPluginKeys( + installId = "mcm", + installer = installer, + selectedPluginKeys = setOf("0:0:0", "0:1:0", "0:2:0"), + extractedRoot = tempDir, + ) + val expected = setOf( + "Data/MCM/Config/SkyUI_SE/config.json", + "Data/MCM/Config/SkyUI_SE/settings.ini", + "Data/MCM/Settings/readme.txt", + "Data/Source/Scripts/SKI_ConfigMenu.psc", + "Data/SKSE/Plugins/MCMHelper.dll", + "Data/SKSE/Plugins/MCMHelper.pdb", + "Data/MCMHelper.esl", + "Data/MCMHelper.bsa", + ) + + assertTrue(result.plan!!.isComplete) + assertEquals( + expected, + result.plan.files.filter { it.status == PlannedFileStatus.PLACED }.map { it.targetRelativePath }.toSet(), + ) + + val game = File(tempDir, "game").apply { mkdirs() } + val install = ModInstall( + installId = "mcm", + appId = "game", + modName = "MCM Helper fixture", + fileName = "fixture.zip", + archivePath = "", + extractedPath = tempDir.absolutePath, + ) + val executionPlan = ModMaterializer.materializationPlan( + install = install, + recipes = result.recipes, + gameRootDir = game, + winePrefix = "", + reviewedPlan = result.plan, + ) + val applied = ModMaterializer.apply( + install = install, + plan = executionPlan, + backupRoot = File(tempDir, "backups"), + allowOverwrite = true, + ) + assertTrue(applied.errors.isEmpty()) + assertEquals(result.plan!!.placedCount, executionPlan.files.size) + assertEquals(result.plan.digest, executionPlan.reviewedPlan.digest) + assertEquals( + expected, + game.walkTopDown().filter { it.isFile } + .map { it.relativeTo(game).path.replace(File.separatorChar, '/') } + .toSet(), + ) + } + + @Test + fun generate_preservesDistinctDestinationNamesForTheSameSourceFile() { + val installer = FomodInstaller( + moduleName = "Renamed files", + requiredFiles = listOf( + FomodFileMapping("Shared/config.ini", "First.ini", 0, directory = false), + FomodFileMapping("Shared/config.ini", "Second.ini", 1, directory = false), + ), + steps = emptyList(), + ) + + val result = FomodRecipeGenerator.generate("install", installer, emptySet()) + + assertEquals(setOf("First.ini", "Second.ini"), result.recipes.mapTo(mutableSetOf()) { it.targetFileName }) + } + private fun writeModuleConfig(xml: String): File { val file = File(tempDir, "fomod/ModuleConfig.xml") file.parentFile?.mkdirs() diff --git a/app/src/test/java/app/gamenative/mods/ModArchiveIndexPerformanceTest.kt b/app/src/test/java/app/gamenative/mods/ModArchiveIndexPerformanceTest.kt new file mode 100644 index 0000000000..8cd9ce8ba8 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/ModArchiveIndexPerformanceTest.kt @@ -0,0 +1,97 @@ +package app.gamenative.mods + +import app.gamenative.data.ModInstall +import app.gamenative.data.ModPlacementMode +import app.gamenative.data.ModPlacementRecipe +import app.gamenative.data.ModTargetRoot +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.system.measureTimeMillis +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModArchiveIndexPerformanceTest { + @Test + fun fiftyThousandEntries_indexWithinInteractiveRegressionBudget() { + val entries = List(50_000) { index -> + ModArchiveEntry( + path = "Data/Textures/Set${index / 100}/texture$index.dds", + directory = false, + sizeBytes = 1, + ) + } + lateinit var archiveIndex: ModArchiveIndex + + val elapsed = measureTimeMillis { archiveIndex = ModArchiveIndex.build(entries) } + + assertEquals(50_000, archiveIndex.files.size) + assertEquals(100, archiveIndex.filesUnder("Data/Textures/Set42").size) + assertTrue("Indexing took ${elapsed}ms", elapsed < 3_000) + } + + @Test + fun fiftyThousandEntries_completePlanningWithinInteractiveRegressionBudget() { + val entries = List(50_000) { index -> + ModArchiveEntry( + path = "Data/Textures/Set${index / 100}/texture$index.dds", + directory = false, + sizeBytes = 1, + ) + } + lateinit var plan: ModInstallPlan + + val elapsed = measureTimeMillis { + plan = AutomaticPlacementPlanner.plan("Skyrim Special Edition", entries).recommended!!.plan + } + + assertTrue(plan.blockingIssues.toString(), plan.isComplete) + assertEquals(50_000, plan.placedCount) + val budgetMs = if (System.getenv("CI").equals("true", ignoreCase = true)) 15_000 else 5_000 + assertTrue("Planning took ${elapsed}ms (budget ${budgetMs}ms)", elapsed < budgetMs) + } + + @Test + fun twoThousandNestedFiles_materializeWithinGenerousRegressionBudget() { + val root = createTempDirectory("materialization_performance").toFile() + try { + val extracted = File(root, "extracted").apply { mkdirs() } + val game = File(root, "game").apply { mkdirs() } + repeat(2_000) { index -> + File(extracted, "Data/Textures/Set${index / 20}/texture$index.dds").apply { + parentFile?.mkdirs() + createNewFile() + } + } + val install = ModInstall( + installId = "performance", + appId = "STEAM_1", + nexusGameDomain = "game", + nexusModId = 1, + nexusFileId = 2, + modName = "Large tree", + fileName = "large.zip", + archivePath = File(root, "large.zip").absolutePath, + extractedPath = extracted.absolutePath, + ) + val recipe = ModPlacementRecipe( + installId = install.installId, + sourceSubpath = "Data", + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "Data", + mode = ModPlacementMode.OVERWRITE_COPY.name, + ) + lateinit var plan: ModMaterializationPlan + + val elapsed = measureTimeMillis { + plan = ModMaterializer.materializationPlan(install, listOf(recipe), game, "", captureTargetHashes = false) + } + + assertTrue(plan.errors.toString(), plan.isComplete) + assertEquals(2_000, plan.files.size) + assertTrue("Materialization planning took ${elapsed}ms", elapsed < 15_000) + } finally { + root.deleteRecursively() + } + } +} diff --git a/app/src/test/java/app/gamenative/mods/ModConfigurationDraftTest.kt b/app/src/test/java/app/gamenative/mods/ModConfigurationDraftTest.kt new file mode 100644 index 0000000000..b875bb8350 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/ModConfigurationDraftTest.kt @@ -0,0 +1,68 @@ +package app.gamenative.mods + +import app.gamenative.data.ModInstall +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ModConfigurationDraftTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun draftRoundTrip_isScopedToTheExactArchive_andCanBeCleared() { + val root = temporaryFolder.newFolder("cache") + val install = install("hash-one") + val draft = ModConfigurationDraft( + installId = install.installId, + archiveIdentity = ModConfigurationDraftStore.archiveIdentity(install), + placementChoice = "CUSTOM", + automaticOptions = mapOf("runtime" to "x64"), + fomodSelections = mapOf("0:0" to listOf("0:0:1")), + recipes = listOf(ModConfigurationRecipe(sourceSubpath = "Data", targetRelativePath = "Data")), + ) + + ModConfigurationDraftStore.write(root, draft) + + assertEquals(draft.copy(updatedAt = ModConfigurationDraftStore.read(root, install)!!.updatedAt), ModConfigurationDraftStore.read(root, install)) + assertNull(ModConfigurationDraftStore.read(root, install("different-hash"))) + ModConfigurationDraftStore.delete(root, install.installId) + assertNull(ModConfigurationDraftStore.read(root, install)) + } + + @Test + fun writeFailure_isBestEffortAndLeavesNoTemporaryFile() { + val invalidRoot = temporaryFolder.newFile("not-a-directory") + val draft = ModConfigurationDraft( + installId = "install", + archiveIdentity = "archive", + placementChoice = "AUTOMATIC", + ) + + assertFalse(ModConfigurationDraftStore.write(invalidRoot, draft)) + assertFalse(File(invalidRoot, "configuration/install.json.tmp").exists()) + } + + @Test + fun preparedFileReplacementFailure_preservesExistingTarget() { + val target = temporaryFolder.newFile("current.json").apply { writeText("valid") } + + assertTrue(runCatching { replaceWithPreparedFile(File(target.parentFile, "missing.tmp"), target) }.isFailure) + assertEquals("valid", target.readText()) + } + + private fun install(hash: String) = ModInstall( + installId = "install", + appId = "game", + modName = "Mod", + fileName = "mod.zip", + archivePath = File(temporaryFolder.root, "mod.zip").absolutePath, + extractedPath = File(temporaryFolder.root, "extracted").absolutePath, + archiveSha256 = hash, + ) +} diff --git a/app/src/test/java/app/gamenative/mods/ModConflictAnalyzerTest.kt b/app/src/test/java/app/gamenative/mods/ModConflictAnalyzerTest.kt index 953655b5a4..ca2f99100f 100644 --- a/app/src/test/java/app/gamenative/mods/ModConflictAnalyzerTest.kt +++ b/app/src/test/java/app/gamenative/mods/ModConflictAnalyzerTest.kt @@ -1,6 +1,7 @@ package app.gamenative.mods import app.gamenative.data.ModInstall +import app.gamenative.data.ModInstallStatus import app.gamenative.data.ModPlacementMode import app.gamenative.data.ModPlacementRecipe import app.gamenative.data.ModTargetRoot @@ -60,6 +61,52 @@ class ModConflictAnalyzerTest { assertEquals(true, report.participants.first().wins) } + @Test + fun analyze_treatsCaseVariantWindowsTargetsAsOneConflict() = runBlocking { + val first = install("first", "First", "first") + val second = install("second", "Second", "second") + File(first.extractedPath, "Data/Scripts/A.pex").apply { + parentFile?.mkdirs() + writeText("first") + } + File(second.extractedPath, "Data/scripts/a.pex").apply { + parentFile?.mkdirs() + writeText("second") + } + + val reports = ModConflictAnalyzer.analyze( + installs = listOf(first, second), + recipesByInstallId = mapOf(first.installId to listOf(recipe(first.installId)), second.installId to listOf(recipe(second.installId))), + prioritiesByInstallId = emptyMap(), + gameRootDir = gameDir, + winePrefix = "", + ) + + assertEquals(1, reports.size) + assertEquals(setOf("first", "second"), reports.single().participants.map { it.installId }.toSet()) + } + + @Test + fun analyze_reusesAppliedOwnershipWithoutRebuildingArchivePlans() = runBlocking { + val low = install("low", "Low", "low").copy(status = ModInstallStatus.APPLIED.name) + val high = install("high", "High", "high").copy(status = ModInstallStatus.APPLIED.name) + val target = File(gameDir, "Data/shared.txt") + + val reports = ModConflictAnalyzer.analyze( + installs = listOf(low, high), + recipesByInstallId = emptyMap(), + prioritiesByInstallId = mapOf("low" to 1, "high" to 2), + gameRootDir = gameDir, + winePrefix = "", + ownershipByInstallId = mapOf( + "low" to ownership(low, target), + "high" to ownership(high, target), + ), + ) + + assertEquals("high", reports.single().winnerInstallId) + } + private fun install(id: String, name: String, folder: String): ModInstall { val extracted = File(tempDir, folder).apply { mkdirs() } return ModInstall( @@ -83,4 +130,25 @@ class ModConflictAnalyzerTest { targetRelativePath = "Data", mode = ModPlacementMode.OVERWRITE_COPY.name, ) + + private fun ownership(install: ModInstall, target: File): ModOwnershipManifest = + ModOwnershipManifest( + installId = install.installId, + appId = install.appId, + planDigest = install.installId, + files = listOf( + ModOwnedFile( + sourceRelativePath = "Data/shared.txt", + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "Data/shared.txt", + targetPath = target.absolutePath, + normalizedTargetKey = WindowsPathIdentity.absoluteKey(target), + mode = ModPlacementMode.OVERWRITE_COPY.name, + installedHash = install.installId, + installedSize = 1L, + installedMtime = 1L, + disposition = ModOwnedFileDisposition.CREATED, + ), + ), + ) } diff --git a/app/src/test/java/app/gamenative/mods/ModDeploymentCoordinatorTest.kt b/app/src/test/java/app/gamenative/mods/ModDeploymentCoordinatorTest.kt new file mode 100644 index 0000000000..5a49ea3792 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/ModDeploymentCoordinatorTest.kt @@ -0,0 +1,43 @@ +package app.gamenative.mods + +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModDeploymentCoordinatorTest { + @Test + fun sameGameMutationsAreSerialized() = runBlocking { + val active = AtomicInteger() + val maximum = AtomicInteger() + + List(8) { + async { + ModDeploymentCoordinator.withGameLock("game") { + maximum.updateAndGet { previous -> maxOf(previous, active.incrementAndGet()) } + delay(5) + active.decrementAndGet() + } + } + }.awaitAll() + + assertEquals(1, maximum.get()) + } + + @Test + fun oneProfileTransactionCanCallNestedGameMutations() = runBlocking { + var nestedCompleted = false + + ModDeploymentCoordinator.withGameLock("game") { + ModDeploymentCoordinator.withGameLock("game") { + nestedCompleted = true + } + } + + assertTrue(nestedCompleted) + } +} diff --git a/app/src/test/java/app/gamenative/mods/ModDeploymentJournalTest.kt b/app/src/test/java/app/gamenative/mods/ModDeploymentJournalTest.kt new file mode 100644 index 0000000000..106e395932 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/ModDeploymentJournalTest.kt @@ -0,0 +1,107 @@ +package app.gamenative.mods + +import app.gamenative.data.ModPlacementMode +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ModDeploymentJournalTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun interruptedCheckpoints_reconcileToSafeExplicitStates() { + val expectations = mapOf( + ModDeploymentCheckpoint.PLANNED to ModDeploymentCheckpoint.ROLLED_BACK, + ModDeploymentCheckpoint.PREPARING to ModDeploymentCheckpoint.ROLLED_BACK, + ModDeploymentCheckpoint.APPLYING to ModDeploymentCheckpoint.RECOVERY_REQUIRED, + ModDeploymentCheckpoint.VERIFYING to ModDeploymentCheckpoint.RECOVERY_REQUIRED, + ModDeploymentCheckpoint.ROLLING_BACK to ModDeploymentCheckpoint.RECOVERY_REQUIRED, + ModDeploymentCheckpoint.ROLLED_BACK to ModDeploymentCheckpoint.ROLLED_BACK, + ModDeploymentCheckpoint.RECOVERY_REQUIRED to ModDeploymentCheckpoint.RECOVERY_REQUIRED, + ) + expectations.forEach { (interruptedAt, expected) -> + val root = temporaryFolder.newFolder(interruptedAt.name) + val initial = ModDeploymentJournalStore.begin(root, "install", "game", emptyPlan()) + if (interruptedAt != ModDeploymentCheckpoint.PLANNED) { + ModDeploymentJournalStore.checkpoint(root, initial, interruptedAt) + } + + assertEquals(expected, ModDeploymentJournalStore.reconcile(root).single().checkpoint) + } + } + + @Test + fun verifyingCheckpoint_commitsOnlyWhenOwnershipAndFilesMatch() { + val root = temporaryFolder.newFolder("verified") + val source = temporaryFolder.newFile("source").apply { writeText("expected") } + val target = temporaryFolder.newFile("target").apply { writeText("expected") } + val plan = ModMaterializationPlan( + installId = "install", + operations = emptyList(), + files = listOf( + ModPlannedFile( + installId = "install", + source = source, + target = target, + mode = ModPlacementMode.COPY, + targetRoot = "GAME_DIR", + sourceRelativePath = source.name, + targetRelativePath = target.name, + ), + ), + ) + val journal = ModDeploymentJournalStore.begin(root, "install", "game", plan) + ModDeploymentJournalStore.checkpoint(root, journal, ModDeploymentCheckpoint.VERIFYING) + ModOwnershipStore.write( + root, + ModOwnershipManifest( + installId = "install", + appId = "game", + planDigest = plan.digest, + files = listOf( + ModOwnedFile( + sourceRelativePath = source.name, + targetRoot = "GAME_DIR", + targetRelativePath = target.name, + targetPath = target.absolutePath, + normalizedTargetKey = WindowsPathIdentity.absoluteKey(target), + mode = ModPlacementMode.COPY.name, + installedHash = ModOwnershipStore.sha256(source), + installedSize = target.length(), + installedMtime = target.lastModified(), + disposition = ModOwnedFileDisposition.CREATED, + ), + ), + ), + ) + + assertEquals(ModDeploymentCheckpoint.COMMITTED, ModDeploymentJournalStore.reconcile(root).single().checkpoint) + target.writeText("external change") + assertTrue( + ModDeploymentVerifier.verify(ModOwnershipStore.read(root, "install")!!).issues.any { + it.type == + ModVerificationIssueType.MODIFIED + }, + ) + } + + @Test + fun delete_removesCurrentAndTemporaryJournalFiles() { + val root = temporaryFolder.newFolder("delete") + ModDeploymentJournalStore.begin(root, "install", "game", emptyPlan()) + val temporary = File(root, "journals/install.json.tmp").apply { writeText("partial") } + + ModDeploymentJournalStore.delete(root, "install") + + assertNull(ModDeploymentJournalStore.read(root, "install")) + assertFalse(temporary.exists()) + } + + private fun emptyPlan() = ModMaterializationPlan("install", emptyList(), emptyList()) +} diff --git a/app/src/test/java/app/gamenative/mods/ModInstallPlanContractTest.kt b/app/src/test/java/app/gamenative/mods/ModInstallPlanContractTest.kt new file mode 100644 index 0000000000..a6c2c9f9f2 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/ModInstallPlanContractTest.kt @@ -0,0 +1,129 @@ +package app.gamenative.mods + +import app.gamenative.data.ModTargetRoot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModInstallPlanContractTest { + @Test + fun plan_requiresEverySelectedFileToBePlacedOrExplained() { + val complete = plan(placed = listOf("Sounds/theme.xwm"), ignored = listOf("readme.txt")) + val partial = plan(placed = listOf("Sounds/theme.xwm"), unsupported = listOf("install.exe")) + + assertTrue(complete.isComplete) + assertEquals(1.0, complete.coverage, 0.0) + assertFalse(partial.isComplete) + assertEquals(1, partial.unresolvedCount) + } + + @Test + fun differentialPolicy_neverTradesCoverageForAReplacement() { + val baseline = plan(placed = listOf("Scripts/a.pex", "Plugin.esp")) + val worse = plan(placed = listOf("Scripts/a.pex"), unsupported = listOf("Plugin.esp")) + val better = plan(placed = listOf("Scripts/a.pex", "Plugin.esp", "Sounds/theme.xwm")) + + assertFalse(PlacementPlanRegressionPolicy.canReplace(baseline, worse)) + assertTrue(PlacementPlanRegressionPolicy.canReplace(baseline, better)) + } + + @Test + fun digest_isStableAcrossArchiveEnumerationOrder() { + val first = plan(placed = listOf("Scripts/a.pex", "Sounds/theme.xwm")) + val second = plan(placed = listOf("Sounds/theme.xwm", "Scripts/a.pex")) + + assertEquals(first.digest, second.digest) + } + + @Test + fun diagnosticSanitizer_removesCredentialsAndAbsolutePaths() { + val sanitized = ModDiagnosticSanitizer.text( + "C:\\Games\\Skyrim\\Data https://example.invalid/file?X-Amz-Credential=secret&X-Amz-Signature=123 " + + "/data/user/0/app/file https://user:password@example.invalid/file " + + "Authorization: Bearer header-secret apikey=key-secret", + ) + + assertFalse("secret" in sanitized) + assertFalse("password" in sanitized) + assertFalse("C:\\Games" in sanitized) + assertFalse("/data/user" in sanitized) + } + + @Test + fun healthManifest_includesSupportFactsWithoutLeakingPaths() { + val report = ModHealthReport( + issues = listOf( + ModHealthIssue( + severity = ModHealthSeverity.WARNING, + title = "Managed files changed", + detail = "C:\\Games\\Example\\Data\\changed.esp", + ), + ), + facts = listOf("ownership-producers=automatic@3:2", "cache=C:\\private\\mods"), + ) + + val manifest = report.sanitizedManifest() + + assertTrue("fact: ownership-producers=automatic@3:2" in manifest) + assertFalse("C:\\Games" in manifest) + assertFalse("C:\\private" in manifest) + } + + @Test + fun oneRiskPolicy_protectsExecutableRootsRegardlessOfPlanOrigin() { + val rootDll = file("dxgi.dll", PlannedFileStatus.PLACED, "FOMOD mapping").copy( + targetRelativePath = "dxgi.dll", + normalizedTargetKey = "game_dir:dxgi.dll", + origin = PlacementOrigin.FOMOD_OPTION, + ) + val dataDll = rootDll.copy( + sourceRelativePath = "MCMHelper.dll", + targetRelativePath = "Data/SKSE/Plugins/MCMHelper.dll", + normalizedTargetKey = "game_dir:data/skse/plugins/mcmhelper.dll", + ) + + val guarded = PlacementRiskPolicy.enforce(ModInstallPlan(listOf(rootDll, dataDll))) + + assertEquals(PlacementRisk.UNSAFE, guarded.files.first().risk) + assertEquals(PlacementRisk.SAFE, guarded.files.last().risk) + assertFalse(guarded.isComplete) + assertTrue(guarded.withRiskApproval(true).isComplete) + } + + private fun plan( + placed: List, + ignored: List = emptyList(), + unsupported: List = emptyList(), + ): ModInstallPlan = ModInstallPlan( + files = buildList { + placed.forEach { source -> + add(file(source, PlannedFileStatus.PLACED, "Selected by fixture rule")) + } + ignored.forEach { source -> + add(file(source, PlannedFileStatus.INTENTIONALLY_IGNORED, "Known documentation")) + } + unsupported.forEach { source -> + add(file(source, PlannedFileStatus.UNSUPPORTED, "Requires review", PlacementRisk.REVIEW)) + } + }, + blockingIssues = unsupported.map { "Unresolved installable file: $it" }, + ) + + private fun file( + source: String, + status: PlannedFileStatus, + reason: String, + risk: PlacementRisk = PlacementRisk.SAFE, + ): PlannedModFile = PlannedModFile( + sourceRelativePath = source, + targetRoot = ModTargetRoot.GAME_DIR.name.takeIf { status == PlannedFileStatus.PLACED }, + targetRelativePath = "Data/$source".takeIf { status == PlannedFileStatus.PLACED }, + normalizedTargetKey = "game_dir:data/${source.lowercase()}".takeIf { status == PlannedFileStatus.PLACED }, + status = status, + origin = PlacementOrigin.GAME_RULE, + sizeBytes = 1L, + reason = reason, + risk = risk, + ) +} diff --git a/app/src/test/java/app/gamenative/mods/ModMaterializerTest.kt b/app/src/test/java/app/gamenative/mods/ModMaterializerTest.kt index 30081fbf69..63bea8a67c 100644 --- a/app/src/test/java/app/gamenative/mods/ModMaterializerTest.kt +++ b/app/src/test/java/app/gamenative/mods/ModMaterializerTest.kt @@ -100,6 +100,44 @@ class ModMaterializerTest { assertEquals(1, result.skipped) } + @Test + fun overwriteCopy_mergesIncomingDirectoryIntoExistingWindowsCasing() = runBlocking { + File(extracted, "Data/scripts/example.pex").apply { + parentFile?.mkdirs() + writeText("compiled") + } + val existingScripts = File(gameDir, "Data/Scripts").apply { mkdirs() } + val install = install() + val placement = recipe( + sourceSubpath = "Data", + targetRelativePath = "Data", + mode = ModPlacementMode.OVERWRITE_COPY, + ) + + val result = ModMaterializer.apply( + install = install, + recipes = listOf(placement), + gameRootDir = gameDir, + winePrefix = "", + backupRoot = backupDir, + allowOverwrite = true, + ) + + assertTrue(result.errors.isEmpty()) + assertEquals("compiled", File(existingScripts, "example.pex").readText()) + assertEquals( + listOf("Scripts"), + File(gameDir, "Data").listFiles().orEmpty() + .filter { it.name.equals("scripts", ignoreCase = true) } + .map { it.name }, + ) + + assertTrue( + ModMaterializer.removeAppliedFiles(install, listOf(placement), gameDir, "").isEmpty(), + ) + assertFalse(File(existingScripts, "example.pex").exists()) + } + @Test fun scanConflicts_overwriteCopyIgnoresSameExistingFileAndNewFilesInExistingDirectory() = runBlocking { File(extracted, "Data/same.ini").apply { @@ -414,7 +452,7 @@ class ModMaterializerTest { } @Test - fun overwriteCopy_partialApplyFailureCanBeRolledBack() = runBlocking { + fun overwriteCopy_invalidSavedPlacementFailsBeforeWritingFiles() = runBlocking { File(extracted, "new.txt").writeText("new") val install = install() val recipes = listOf( @@ -431,18 +469,6 @@ class ModMaterializerTest { allowOverwrite = true, ) assertTrue(result.errors.isNotEmpty()) - assertEquals("new", File(gameDir, "new.txt").readText()) - - val restoreSkipped = ModMaterializer.restoreBackups(result.manifests) - val restoredTargets = result.manifests.map { it.targetPath }.filterNot { it in restoreSkipped }.toSet() - ModMaterializer.removeAppliedFiles( - install = install, - recipes = recipes, - gameRootDir = gameDir, - winePrefix = "", - restoredOverwriteTargets = restoredTargets, - ) - assertFalse(File(gameDir, "new.txt").exists()) } @@ -680,6 +706,52 @@ class ModMaterializerTest { assertEquals("linked original", File(linkedDir, "Old.txt").readText()) } + @Test + fun materializationPlan_expandsEveryDirectoryFileOnce() { + File(extracted, "Data/Scripts/A.pex").apply { + parentFile?.mkdirs() + writeText("a") + } + File(extracted, "Data/Textures/B.dds").apply { + parentFile?.mkdirs() + writeText("b") + } + + val plan = ModMaterializer.materializationPlan( + install = install(), + recipes = listOf( + recipe(mode = ModPlacementMode.OVERWRITE_COPY).copy( + sourceSubpath = "Data", + targetRelativePath = "Data", + ), + ), + gameRootDir = gameDir, + winePrefix = "", + ) + + assertTrue(plan.isComplete) + assertEquals(listOf("Data/Scripts/A.pex", "Data/Textures/B.dds"), plan.files.map { it.targetRelativePath }.sorted()) + assertEquals(2, plan.files.map { it.normalizedTargetKey }.distinct().size) + } + + @Test + fun onePlan_coalescesNewCaseVariantTargetDirectoriesBeforeApply() = runBlocking { + File(extracted, "first/A.pex").apply { parentFile?.mkdirs(); writeText("a") } + File(extracted, "second/B.pex").apply { parentFile?.mkdirs(); writeText("b") } + val recipes = listOf( + recipe(ModPlacementMode.OVERWRITE_COPY, "first", "Data/Scripts"), + recipe(ModPlacementMode.OVERWRITE_COPY, "second", "Data/scripts"), + ) + + val plan = ModMaterializer.materializationPlan(install(), recipes, gameDir, "") + val result = ModMaterializer.apply(install(), plan, backupDir, allowOverwrite = true) + + assertTrue(result.errors.toString(), result.errors.isEmpty()) + assertEquals(1, plan.operations.map { WindowsPathIdentity.absoluteKey(it.target.parentFile!!) }.distinct().size) + assertEquals(listOf("Scripts"), File(gameDir, "Data").listFiles().orEmpty().map { it.name }) + assertEquals(setOf("A.pex", "B.pex"), File(gameDir, "Data/Scripts").listFiles().orEmpty().map { it.name }.toSet()) + } + @Test fun restoreBackups_replacesMatchingSymlinkWithoutChangingLinkDestination() = runBlocking { File(extracted, "config.ini").writeText("modded") @@ -709,6 +781,212 @@ class ModMaterializerTest { assertEquals("modded", linkedFile.readText()) } + @Test + fun rollbackAppliedPlan_restoresExistingFilesAndRemovesOnlyNewPlanTargets() = runBlocking { + File(extracted, "Data/config.ini").apply { parentFile?.mkdirs(); writeText("modded") } + File(extracted, "Data/new.txt").writeText("new") + val existing = File(gameDir, "Data/config.ini").apply { parentFile?.mkdirs(); writeText("original") } + val created = File(gameDir, "Data/new.txt") + val plan = ModMaterializer.materializationPlan( + install(), + listOf(recipe(ModPlacementMode.OVERWRITE_COPY, "Data", "Data")), + gameDir, + "", + ) + val applied = ModMaterializer.apply(install(), plan, backupDir, allowOverwrite = true) + val restoreSkipped = ModMaterializer.restoreBackups(applied.manifests) + val restored = applied.manifests + .filter { it.backupPath.isNotBlank() && it.targetPath !in restoreSkipped } + .mapTo(mutableSetOf()) { it.targetPath } + + val rollbackSkipped = ModMaterializer.rollbackAppliedPlan(plan, restored) + + assertTrue(rollbackSkipped.toString(), rollbackSkipped.isEmpty()) + assertEquals("original", existing.readText()) + assertFalse(created.exists()) + } + + @Test + fun rollbackAppliedPlan_removesOwnedCopyDirectoryMarkerAndDirectory() = runBlocking { + File(extracted, "Package/file.txt").apply { + parentFile?.mkdirs() + writeText("payload") + } + val plan = ModMaterializer.materializationPlan( + install(), + listOf(recipe(ModPlacementMode.COPY, targetRelativePath = "Mods")), + gameDir, + "", + ) + val copiedDirectory = File(gameDir, "Mods/Package") + + val applied = ModMaterializer.apply(install(), plan, backupDir, allowOverwrite = false) + assertTrue(applied.errors.toString(), applied.errors.isEmpty()) + assertTrue(copiedDirectory.isDirectory) + + val rollbackSkipped = ModMaterializer.rollbackAppliedPlan(plan) + + assertTrue(rollbackSkipped.toString(), rollbackSkipped.isEmpty()) + assertFalse(copiedDirectory.exists()) + } + + @Test + fun reviewedPackageVariant_materializesInsideItsPreservedWrapper() = runBlocking { + val paths = listOf( + "CharacterEditor/v1/About/About.xml" to "old-version", + "CharacterEditor/v1/Assemblies/Editor.dll" to "old-version", + "CharacterEditor/v1.6/About/About.xml" to "selected-version", + "CharacterEditor/v1.6/Assemblies/Editor.dll" to "selected-version", + "CharacterEditor/Textures/Icon.png" to "common", + ) + paths.forEach { (path, contents) -> + File(extracted, path).apply { + parentFile?.mkdirs() + writeText(contents) + } + } + val entries = paths.map { (path, _) -> ModArchiveEntry(path, directory = false, sizeBytes = 1L) } + val context = AutomaticPlacementContext(defaultTargetRelativePath = "Mods", defaultTargetIsProven = true) + val initial = AutomaticPlacementPlanner.plan("Colony game", entries, context = context) + val group = initial.optionGroups.single() + val candidate = AutomaticPlacementPlanner.plan( + "Colony game", + entries, + selectedOptions = mapOf(group.stableId to "CharacterEditor/v1.6"), + context = context, + ).recommended!! + val recipes = candidate.drafts.map { draft -> + ModPlacementRecipe( + installId = "install", + sourceSubpath = draft.sourceSubpath, + targetRoot = draft.targetRoot, + targetRelativePath = draft.targetRelativePath, + mode = draft.mode, + includeSourceDirectory = draft.includeSourceDirectory, + ) + } + val plan = ModMaterializer.materializationPlan(install(), recipes, gameDir, "", reviewedPlan = candidate.plan) + val result = ModMaterializer.apply(install(), plan, backupDir, allowOverwrite = true) + + assertTrue(result.errors.toString(), result.errors.isEmpty()) + assertEquals("selected-version", File(gameDir, "Mods/CharacterEditor/Assemblies/Editor.dll").readText()) + assertEquals("common", File(gameDir, "Mods/CharacterEditor/Textures/Icon.png").readText()) + assertFalse(File(gameDir, "Mods/CharacterEditor/v1.6").exists()) + } + + @Test + fun missingTargetCheck_rejectsARegularFileInPlaceOfASymlink() { + File(extracted, "payload.txt").writeText("source") + File(gameDir, "linked.txt").writeText("replacement") + val appliedInstall = install().copy(status = ModInstallStatus.APPLIED.name) + + assertTrue( + NexusModManager.hasMissingAppliedTargets( + appliedInstall, + listOf(recipe(ModPlacementMode.SYMLINK, "payload.txt", "linked.txt")), + gameDir, + "", + ), + ) + } + + @Test + fun apply_refusesToMaterializeAnyPartOfAnIncompleteReviewedPlan() = runBlocking { + File(extracted, "ready.txt").writeText("ready") + val targetPath = "Data/ready.txt" + val reviewed = ModInstallPlan( + files = listOf( + PlannedModFile( + sourceRelativePath = "ready.txt", + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = targetPath, + normalizedTargetKey = ModTargetResolver.normalizedTargetKey(ModTargetRoot.GAME_DIR.name, targetPath), + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.MANUAL_RECIPE, + reason = "fixture", + ), + PlannedModFile( + sourceRelativePath = "unresolved.txt", + status = PlannedFileStatus.UNSUPPORTED, + origin = PlacementOrigin.MANUAL_RECIPE, + reason = "No destination was proven", + ), + ), + ) + val plan = ModMaterializer.materializationPlan(install(), emptyList(), gameDir, "", reviewedPlan = reviewed) + + val result = ModMaterializer.apply(install(), plan, backupDir, allowOverwrite = true) + + assertEquals(0, result.created) + assertTrue(result.errors.containsKey("unresolved.txt")) + assertFalse(File(gameDir, targetPath).exists()) + } + + @Test + fun repairMissingTargets_refusesAnIncompleteReviewedPlan() = runBlocking { + File(extracted, "ready.txt").writeText("ready") + val targetPath = "Data/ready.txt" + val reviewed = ModInstallPlan( + files = listOf( + PlannedModFile( + sourceRelativePath = "ready.txt", + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = targetPath, + normalizedTargetKey = ModTargetResolver.normalizedTargetKey(ModTargetRoot.GAME_DIR.name, targetPath), + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.MANUAL_RECIPE, + reason = "fixture", + ), + PlannedModFile( + sourceRelativePath = "unresolved.txt", + status = PlannedFileStatus.UNSUPPORTED, + origin = PlacementOrigin.MANUAL_RECIPE, + reason = "No destination was proven", + ), + ), + ) + + val result = ModMaterializer.repairMissingTargets( + install = install().copy(status = ModInstallStatus.APPLIED.name), + recipes = emptyList(), + gameRootDir = gameDir, + winePrefix = "", + reviewedPlan = reviewed, + ) + + assertEquals(0, result.created) + assertTrue(result.errors.containsKey("unresolved.txt")) + assertFalse(File(gameDir, targetPath).exists()) + } + + @Test + fun reviewedCustomAbsoluteTarget_usesTheCanonicalTargetIdentity() = runBlocking { + File(extracted, "asset.bin").writeText("payload") + val target = File(gameDir, "Custom/asset.bin") + val reviewed = ModInstallPlan( + files = listOf( + PlannedModFile( + sourceRelativePath = "asset.bin", + targetRoot = ModTargetRoot.CUSTOM_ABSOLUTE.name, + targetRelativePath = target.absolutePath, + normalizedTargetKey = ModTargetResolver.normalizedTargetKey( + ModTargetRoot.CUSTOM_ABSOLUTE.name, + target.absolutePath, + ), + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.MANUAL_RECIPE, + reason = "fixture", + ), + ), + ).withRiskApproval(true) + val plan = ModMaterializer.materializationPlan(install(), emptyList(), gameDir, "", reviewedPlan = reviewed) + + val result = ModMaterializer.apply(install(), plan, backupDir, allowOverwrite = true) + + assertTrue(result.errors.toString(), result.errors.isEmpty()) + assertEquals("payload", target.readText()) + } + private fun install() = ModInstall( installId = "install", appId = "STEAM_1", @@ -721,11 +999,15 @@ class ModMaterializerTest { extractedPath = extracted.absolutePath, ) - private fun recipe(mode: ModPlacementMode) = ModPlacementRecipe( + private fun recipe( + mode: ModPlacementMode, + sourceSubpath: String = "", + targetRelativePath: String = "", + ) = ModPlacementRecipe( installId = "install", - sourceSubpath = "", + sourceSubpath = sourceSubpath, targetRoot = ModTargetRoot.GAME_DIR.name, - targetRelativePath = "", + targetRelativePath = targetRelativePath, mode = mode.name, ) } diff --git a/app/src/test/java/app/gamenative/mods/ModOwnershipManifestTest.kt b/app/src/test/java/app/gamenative/mods/ModOwnershipManifestTest.kt new file mode 100644 index 0000000000..822993b426 --- /dev/null +++ b/app/src/test/java/app/gamenative/mods/ModOwnershipManifestTest.kt @@ -0,0 +1,307 @@ +package app.gamenative.mods + +import app.gamenative.data.ModPlacementMode +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ModOwnershipManifestTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun sidecarRoundTrip_andOverlayWinner_areDeterministic() { + val root = temporaryFolder.newFolder("cache") + val low = manifest("low", "Data/Scripts/X.pex", "one", priority = 10) + val high = manifest("high", "data/scripts/x.pex", "two", priority = 20) + ModOwnershipStore.write(root, low) + + assertEquals(low, ModOwnershipStore.read(root, low.installId)) + val overlay = ModProfileOverlayPlanner.build(listOf(high, low), mapOf("high" to 20, "low" to 10)) + assertEquals("high", overlay.targets.values.single().winner.installId) + assertEquals(1, overlay.conflicts.size) + assertTrue(overlay.targets.values.single().hasCaseCollision) + } + + @Test + fun overlayTransition_comparesDesiredWinnerAgainstTheCurrentDeployedWinner() { + val target = temporaryFolder.newFile("shared.txt").apply { writeText("low") } + val low = manifest("low", target, ModOwnershipStore.sha256(target), priority = 20) + val high = manifest("high", target, "different", priority = 10) + + val transition = ModProfileOverlayPlanner.transition( + listOf(low, high), + desiredPriorities = mapOf("low" to 10, "high" to 20), + ) + + assertEquals("low", transition.current.targets.values.single().winner.installId) + assertEquals("high", transition.desired.targets.values.single().winner.installId) + assertEquals(1, transition.changedWinnerKeys.size) + assertTrue(transition.requiresRebuild) + assertTrue(transition.safeToRebuild) + } + + @Test + fun overlayTransition_skipsContentVerificationWhenNoWinnerCanChange() { + val target = temporaryFolder.newFile("unchanged-order.txt").apply { writeText("current") } + val manifest = manifest("managed", target, "not-the-current-hash", priority = 10) + + val transition = ModProfileOverlayPlanner.transition( + listOf(manifest), + desiredPriorities = mapOf("managed" to 10), + ) + + assertFalse(transition.requiresRebuild) + assertTrue(transition.currentVerification.successful) + } + + @Test + fun overlayTransition_verifiesUnchangedTargetsBeforeRebuildingTheWholeOverlay() { + val shared = temporaryFolder.newFile("shared-rebuild.txt").apply { writeText("low") } + val unrelated = temporaryFolder.newFile("unrelated.txt").apply { writeText("owned") } + val low = manifest("low", shared, ModOwnershipStore.sha256(shared), priority = 20) + val high = manifest("high", shared, "different", priority = 10) + val other = manifest("other", unrelated, ModOwnershipStore.sha256(unrelated), priority = 5) + unrelated.appendText("-user edit") + + val transition = ModProfileOverlayPlanner.transition( + listOf(low, high, other), + desiredPriorities = mapOf("low" to 10, "high" to 20, "other" to 5), + ) + + assertTrue(transition.requiresRebuild) + assertFalse(transition.safeToRebuild) + assertEquals(listOf(unrelated.absolutePath), transition.currentVerification.issues.map { it.targetPath }) + } + + @Test + fun changedContentVerification_hashesOnlyWhenRecordedMetadataChanged() { + val target = temporaryFolder.newFile("metadata.txt").apply { writeText("owned") } + val manifest = manifest("managed", target, "not-the-current-hash", priority = 10).let { ownership -> + ownership.copy( + files = ownership.files.map { + it.copy(installedSize = target.length(), installedMtime = target.lastModified()) + }, + ) + } + + assertFalse(ModDeploymentVerifier.verify(manifest).successful) + assertTrue(ModDeploymentVerifier.verify(manifest, ModVerificationDepth.CHANGED_CONTENT).successful) + + target.appendText("-changed") + + assertFalse(ModDeploymentVerifier.verify(manifest, ModVerificationDepth.CHANGED_CONTENT).successful) + } + + @Test + fun presenceVerification_reportsMissingManagedTargetsWithoutMaterializingTheArchive() { + val missing = File(temporaryFolder.root, "missing.txt") + val manifest = manifest("managed", missing, "hash", priority = 10) + + assertEquals( + listOf(ModVerificationIssueType.MISSING), + ModDeploymentVerifier.verifyPresence(manifest).issues.map { it.type }, + ) + } + + @Test + fun staleVerification_checksOnlyFilesExplicitlyPreservedDuringDisable() { + val restoredTarget = temporaryFolder.newFile("restored.txt").apply { writeText("original") } + val preservedTarget = temporaryFolder.newFile("preserved.txt").apply { writeText("changed") } + val disabled = manifest("disabled", restoredTarget, "owned", priority = 1).copy( + state = ModOwnershipState.DISABLED, + files = listOf( + manifest("disabled", restoredTarget, "owned", priority = 1).files.single().copy(active = false), + manifest("disabled", preservedTarget, "owned", priority = 1).files.single().copy( + active = false, + disposition = ModOwnedFileDisposition.STALE_PRESERVED, + ), + ), + ) + + val findings = ModDeploymentVerifier.verifyStale(disabled).issues + + assertEquals(listOf(preservedTarget.absolutePath), findings.map { it.targetPath }) + } + + @Test + fun staleCleanup_removesOnlyUnchangedOwnedFiles() = runBlocking { + val targetRoot = temporaryFolder.newFolder("game") + val unchanged = File(targetRoot, "unchanged.txt").apply { writeText("owned") } + val modified = File(targetRoot, "modified.txt").apply { writeText("changed") } + val ownedHash = ModOwnershipStore.sha256(temporaryFolder.newFile("source.txt").apply { writeText("owned") }) + val manifest = ModOwnershipManifest( + installId = "install", + appId = "game", + planDigest = "digest", + files = listOf(unchanged, modified).map { target -> + ModOwnedFile( + sourceRelativePath = target.name, + targetRoot = "GAME_DIR", + targetRelativePath = target.name, + targetPath = target.absolutePath, + normalizedTargetKey = WindowsPathIdentity.absoluteKey(target), + mode = ModPlacementMode.COPY.name, + installedHash = ownedHash, + installedSize = 5, + installedMtime = 1, + disposition = ModOwnedFileDisposition.CREATED, + ) + }, + ) + + val result = ModOwnershipReconciler.removeOwnedFiles(manifest, emptyList()) + + assertFalse(unchanged.exists()) + assertTrue(modified.exists()) + assertEquals(listOf(modified.absolutePath), result.skippedPaths) + } + + @Test + fun reconfigurationDiff_reportsAddedMovedAndStaleOutputs() { + val previous = ModOwnershipManifest( + installId = "install", + appId = "game", + planDigest = "old", + files = listOf( + owned("A.txt", "Data/A.txt"), + owned("B.txt", "Data/B.txt"), + ), + ) + val next = ModInstallPlan( + files = listOf( + planned("A.txt", "Data/Moved/A.txt"), + planned("C.txt", "Data/C.txt"), + ), + producerId = "fixture", + ) + + val diff = ModOwnershipPlanDiffer.compare(previous, next) + + assertEquals(1, diff.added) + assertEquals(1, diff.moved) + assertEquals(1, diff.stale) + } + + @Test + fun ownershipDecisions_restoreTheReviewedPlan_andKeepOneUndoGeneration() { + val root = temporaryFolder.newFolder("history") + val first = manifest("install", "Data/First.txt", "one", priority = 1).copy( + decisions = listOf( + ModInstallDecision( + sourceRelativePath = "First.txt", + targetRoot = "GAME_DIR", + targetRelativePath = "Data/First.txt", + normalizedTargetKey = "game_dir:data/first.txt", + status = PlannedFileStatus.PLACED.name, + origin = PlacementOrigin.FOMOD_REQUIRED.name, + mode = ModPlacementMode.OVERWRITE_COPY.name, + priority = 4, + reason = "Required installer file", + outcome = "CREATED", + sizeBytes = 3, + evidence = listOf("requiredFiles"), + ), + ), + planProducerId = "fomod", + planProducerVersion = 2, + ) + val second = first.copy(planDigest = "second", files = first.files.map { it.copy(installedHash = "two") }) + ModOwnershipStore.write(root, first) + ModOwnershipStore.write(root, second) + + val restored = ModOwnershipStore.reviewedPlan(root, "install") + + assertEquals("fomod", restored?.producerId) + assertEquals("Data/First.txt", restored?.files?.single()?.targetRelativePath) + assertEquals(listOf("requiredFiles"), restored?.files?.single()?.evidence) + assertEquals(first, ModOwnershipStore.readPrevious(root, "install")) + } + + @Test + fun reviewedPlan_matchesRepeatedSourceByLogicalDestination() { + val manifest = ModOwnershipManifest( + installId = "install", + appId = "game", + planDigest = "digest", + files = listOf( + owned("Shared.bin", "Data/First.bin").copy(installedSize = 11), + owned("Shared.bin", "Data/Second.bin").copy(installedSize = 22), + ), + decisions = listOf("Data/Second.bin", "Data/First.bin").map { target -> + ModInstallDecision( + sourceRelativePath = "Shared.bin", + targetRoot = "GAME_DIR", + targetRelativePath = target, + normalizedTargetKey = WindowsPathIdentity.targetKey("GAME_DIR", target).orEmpty(), + status = PlannedFileStatus.PLACED.name, + origin = PlacementOrigin.FOMOD_REQUIRED.name, + mode = ModPlacementMode.OVERWRITE_COPY.name, + priority = 0, + reason = "fixture", + outcome = "CREATED", + ) + }, + ) + + val restored = manifest.reviewedPlanOrNull()!! + + assertEquals(listOf(22L, 11L), restored.files.map { it.sizeBytes }) + } + + private fun owned(source: String, target: String): ModOwnedFile = ModOwnedFile( + sourceRelativePath = source, + targetRoot = "GAME_DIR", + targetRelativePath = target, + targetPath = "C:/Game/$target", + normalizedTargetKey = WindowsPathIdentity.absoluteKey(File("C:/Game/$target")), + mode = ModPlacementMode.OVERWRITE_COPY.name, + installedHash = source, + installedSize = 1, + installedMtime = 1, + disposition = ModOwnedFileDisposition.CREATED, + ) + + private fun planned(source: String, target: String): PlannedModFile = PlannedModFile( + sourceRelativePath = source, + targetRoot = "GAME_DIR", + targetRelativePath = target, + normalizedTargetKey = WindowsPathIdentity.targetKey("GAME_DIR", target), + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.FOMOD_REQUIRED, + reason = "fixture", + ) + + private fun manifest(installId: String, targetPath: String, hash: String, priority: Int): ModOwnershipManifest { + val target = File("C:/Game/$targetPath") + return manifest(installId, target, hash, priority) + } + + private fun manifest(installId: String, target: File, hash: String, priority: Int): ModOwnershipManifest { + return ModOwnershipManifest( + installId = installId, + appId = "game", + planDigest = "digest-$installId", + files = listOf( + ModOwnedFile( + sourceRelativePath = "x.pex", + targetRoot = "GAME_DIR", + targetRelativePath = target.name, + targetPath = target.path, + normalizedTargetKey = WindowsPathIdentity.absoluteKey(target), + mode = ModPlacementMode.OVERWRITE_COPY.name, + installedHash = hash, + installedSize = target.takeIf(File::isFile)?.length() ?: 1, + installedMtime = target.takeIf(File::isFile)?.lastModified() ?: 1, + disposition = ModOwnedFileDisposition.OVERWROTE, + priority = priority, + ), + ), + ) + } +} diff --git a/app/src/test/java/app/gamenative/mods/ModTargetResolverTest.kt b/app/src/test/java/app/gamenative/mods/ModTargetResolverTest.kt index 199d52280d..b981a4c9f4 100644 --- a/app/src/test/java/app/gamenative/mods/ModTargetResolverTest.kt +++ b/app/src/test/java/app/gamenative/mods/ModTargetResolverTest.kt @@ -5,6 +5,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import java.io.File @@ -87,6 +88,76 @@ class ModTargetResolverTest { assertEquals(File(gameDir, "Data/Textures").canonicalFile, resolved) } + @Test + fun resolve_reusesExistingDirectoryCasing() { + val scripts = File(gameDir, "Data/Scripts").apply { mkdirs() } + + val resolved = ModTargetResolver.resolve( + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "data/scripts", + gameRootDir = gameDir, + winePrefix = winePrefix.absolutePath, + ) + + assertEquals(scripts.canonicalFile, resolved) + } + + @Test + fun inspectPlan_reportsCaseMergeWithoutChangingThePlan() { + File(gameDir, "Data/Scripts").mkdirs() + val plan = ModInstallPlan( + files = listOf( + PlannedModFile( + sourceRelativePath = "scripts/example.pex", + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "Data/scripts/example.pex", + normalizedTargetKey = "GAME_DIR:data/scripts/example.pex", + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.GAME_RULE, + reason = "fixture", + ), + ), + ) + + val inspection = ModTargetResolver.inspectPlan(plan, ModTargetResolver.roots(gameDir, winePrefix.absolutePath)) + + assertTrue(inspection.caseMerges.any { it.contains("scripts -> Scripts") }) + assertTrue(inspection.ambiguousPaths.isEmpty()) + } + + @Test + fun inspectPlan_reusesPlannedDirectoryCasing() { + val plan = ModInstallPlan( + files = listOf( + plannedFile("Data/Scripts/First.pex"), + plannedFile("data/scripts/Second.pex"), + ), + ) + + val inspection = ModTargetResolver.inspectPlan(plan, ModTargetResolver.roots(gameDir, winePrefix.absolutePath)) + + assertTrue(inspection.caseMerges.any { it.contains("scripts -> Scripts") }) + assertTrue(inspection.ambiguousPaths.isEmpty()) + } + + @Test + fun resolve_blocksAmbiguousExistingCaseVariants() { + File(gameDir, "Data/Scripts").mkdirs() + File(gameDir, "Data/scripts").mkdirs() + assumeTrue( + File(gameDir, "Data").listFiles().orEmpty().count { it.name.equals("scripts", ignoreCase = true) } == 2, + ) + + val resolved = ModTargetResolver.resolve( + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "Data/SCRIPTS", + gameRootDir = gameDir, + winePrefix = winePrefix.absolutePath, + ) + + assertNull(resolved) + } + @Test fun resolve_normalizesWindowsSeparatorsInCustomAbsolutePath() { val inside = File(gameDir, "Data/Textures").apply { mkdirs() } @@ -120,4 +191,14 @@ class ModTargetResolverTest { assertEquals(File(emptyPrefix, "drive_c/users/steamuser").canonicalFile, userHome.canonicalFile) assertTrue(userHome.isDirectory) } + + private fun plannedFile(path: String) = PlannedModFile( + sourceRelativePath = path, + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = path, + normalizedTargetKey = ModTargetResolver.normalizedTargetKey(ModTargetRoot.GAME_DIR.name, path), + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.GAME_RULE, + reason = "fixture", + ) } diff --git a/app/src/test/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpersTest.kt b/app/src/test/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpersTest.kt index 55e5f2dd77..fdfe6ad61e 100644 --- a/app/src/test/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpersTest.kt +++ b/app/src/test/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpersTest.kt @@ -2,6 +2,11 @@ package app.gamenative.ui.component.dialog import app.gamenative.data.ModInstall import app.gamenative.data.ModInstallStatus +import app.gamenative.data.ModPlacementRecipe +import app.gamenative.mods.NexusCollectionFile +import app.gamenative.mods.ModDeploymentVerification +import app.gamenative.mods.ModProfileOverlay +import app.gamenative.mods.ModProfileOverlayTransition import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow @@ -9,6 +14,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -70,6 +76,91 @@ class NexusModsDialogHelpersTest { assertEquals("PROFILE_DISABLED", install.profileStatus(enabledInProfile = false)) } + @Test + fun recipeRoundTrip_preservesFomodDestinationFileName() { + val recipe = ModPlacementRecipe( + installId = "mcm-helper", + sourceSubpath = "Data/MCM/Config/SkyUI_SE/config.json", + targetRelativePath = "Data/MCM/Config/SkyUI_SE", + targetFileName = "config.json", + ) + + assertEquals(recipe.targetFileName, recipe.toDraft().targetFileName) + assertEquals(recipe.targetFileName, recipe.toDraft().toRecipe(recipe.installId).targetFileName) + } + + @Test + fun unresolvedSources_requireAnExplicitDestinationWithoutDroppingExistingMappings() { + val current = listOf(RecipeDraft(sourceSubpath = "Data", targetRelativePath = "Data")) + + val drafts = draftsWithUnresolvedSources(current, listOf("Docs/readme.txt", "Docs/readme.txt"), RecipeDraft()) + + assertEquals(2, drafts.size) + assertEquals(current.single(), drafts.first()) + assertEquals("Docs/readme.txt", drafts.last().sourceSubpath) + assertEquals("", drafts.last().targetRoot) + } + + @Test + fun placementErrors_showTheUsefulPathTail() { + assertEquals( + "Mods/CharacterEditor/v1.6/CharacterEditor.dll", + compactPlacementErrorPath("/data/user/0/app/game/Mods/CharacterEditor/v1.6/CharacterEditor.dll"), + ) + } + + @Test + fun collectionEmbeddedMetadata_avoidsSeparateModInfoLookup() { + val collectionFile = NexusCollectionFile( + gameDomain = "skyrimspecialedition", + modId = 123L, + fileId = 456L, + modName = "Cloaks", + fileName = "Cloaks-456.7z", + version = "1.2.1", + sizeBytes = 42L, + ) + + assertEquals("Cloaks", collectionFile.toEmbeddedNexusModInfo()?.name) + assertEquals("Cloaks-456.7z", collectionFile.toFallbackNexusFile()?.fileName) + } + + @Test + fun applyOrder_doesNotRebuildOrReapplyManagedConflictsWhenOrderIsAlreadyCurrent() { + val overlay = ModProfileOverlay(emptyMap(), emptyList()) + val unchanged = ModProfileOverlayTransition( + current = overlay, + desired = overlay, + changedWinnerKeys = emptyList(), + currentVerification = ModDeploymentVerification(emptyList()), + ) + val applied = install(status = ModInstallStatus.APPLIED) + + assertFalse(requiresManagedOverlayRebuild(setOf(applied.installId), setOf(applied.installId), unchanged)) + assertFalse( + shouldApplyProfileInstall( + install = applied, + hasActiveOwnership = true, + hasConflict = true, + needsAssetRepair = false, + hasMissingTarget = false, + ), + ) + } + + @Test + fun applyOrder_rebuildsManagedOverlayOnlyWhenAValidatedWinnerChanges() { + val overlay = ModProfileOverlay(emptyMap(), emptyList()) + val changed = ModProfileOverlayTransition( + current = overlay, + desired = overlay, + changedWinnerKeys = listOf("data/shared.txt"), + currentVerification = ModDeploymentVerification(emptyList()), + ) + + assertTrue(requiresManagedOverlayRebuild(setOf("low", "high"), setOf("low", "high"), changed)) + } + private fun install(status: ModInstallStatus): ModInstall = ModInstall( installId = "install", diff --git a/app/src/test/java/app/gamenative/ui/component/dialog/PlacementWorkspaceModelsTest.kt b/app/src/test/java/app/gamenative/ui/component/dialog/PlacementWorkspaceModelsTest.kt new file mode 100644 index 0000000000..b2c2c63f25 --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/component/dialog/PlacementWorkspaceModelsTest.kt @@ -0,0 +1,253 @@ +package app.gamenative.ui.component.dialog + +import app.gamenative.data.ModPlacementMode +import app.gamenative.data.ModTargetRoot +import app.gamenative.mods.ModArchiveEntry +import app.gamenative.mods.ModInstallPlan +import app.gamenative.mods.ModOwnedFile +import app.gamenative.mods.ModOwnedFileDisposition +import app.gamenative.mods.ModOwnershipManifest +import app.gamenative.mods.ModPlanChange +import app.gamenative.mods.ModPlanChangeType +import app.gamenative.mods.ModReconfigurationDiff +import app.gamenative.mods.PlacementOrigin +import app.gamenative.mods.PlannedFileStatus +import app.gamenative.mods.PlannedModFile +import app.gamenative.mods.ResolvedModTargetRoot +import app.gamenative.mods.WindowsPathIdentity +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class PlacementWorkspaceModelsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun layoutChoice_isHiddenWhenItCannotChangeTheResult() { + val entries = listOf(ModArchiveEntry("readme.txt", directory = false, sizeBytes = 1)) + + assertFalse(placementLayoutModel(RecipeDraft(), entries).visible) + assertFalse(placementLayoutModel(RecipeDraft(sourceSubpath = "readme.txt"), entries).visible) + assertFalse(placementLayoutModel(RecipeDraft(sourceSubpath = "Data", targetFileName = "file.txt"), entries).visible) + } + + @Test + fun placementDraftPage_boundsLargeRuleSets() { + assertEquals(0 until 20, placementDraftPage(6_654, 0).let { it.startIndex until it.endIndexExclusive }) + val last = placementDraftPage(6_654, Int.MAX_VALUE) + assertEquals(332, last.pageIndex) + assertEquals(6_640 until 6_654, last.startIndex until last.endIndexExclusive) + } + + @Test + fun layoutChoice_explainsFolderAndContentsResults() { + val entries = listOf(ModArchiveEntry("Data/file.txt", directory = false, sizeBytes = 1)) + val contents = placementLayoutModel( + RecipeDraft(sourceSubpath = "Data", targetRelativePath = "Data"), + entries, + ) + val folder = placementLayoutModel( + RecipeDraft(sourceSubpath = "Data", targetRelativePath = "Data", includeSourceDirectory = true), + entries, + ) + + assertEquals("Data/", contents.resultExample) + assertEquals("Data/Data/", folder.resultExample) + assertTrue(folder.duplicateFolderWarning) + } + + @Test + fun everythingSelection_explainsThatThePackageFolderIsPreserved() { + val layout = placementLayoutModel( + RecipeDraft(targetRelativePath = "Mods"), + listOf(ModArchiveEntry("CharacterEditor/About/About.xml", directory = false, sizeBytes = 1)), + ) + + assertTrue(layout.visible) + assertFalse(layout.editable) + assertEquals("Mods/CharacterEditor/", layout.resultExample) + } + + @Test + fun virtualFolderName_rejectsTraversalReservedAndSeparators() { + assertTrue(validVirtualDestinationFolderName("New Mods")) + assertFalse(validVirtualDestinationFolderName("../escape")) + assertFalse(validVirtualDestinationFolderName("Data/Plugins")) + assertFalse(validVirtualDestinationFolderName("CON")) + } + + @Test + fun virtualDestinationFolder_rejectsAnExistingFile() { + val parent = temporaryFolder.newFolder("destination-parent") + File(parent, "existing").writeText("file") + File(parent, "directory").mkdir() + + assertFalse(validVirtualDestinationFolder(parent, "existing")) + assertTrue(validVirtualDestinationFolder(parent, "new folder")) + assertTrue(validVirtualDestinationFolder(parent, "directory")) + } + + @Test + fun destinationBrowser_showsFilesAndClassifiesPlanAndOwnership() { + val rootDir = temporaryFolder.newFolder("game") + val dataDir = File(rootDir, "Data").apply { mkdirs() } + val replace = File(dataDir, "replace.txt").apply { writeText("old") } + val managed = File(dataDir, "managed.txt").apply { writeText("managed") } + File(dataDir, ".hidden.txt").writeText("hidden") + val root = ResolvedModTargetRoot(ModTargetRoot.GAME_DIR, "Game", rootDir) + val plan = ModInstallPlan( + files = listOf( + planned("Data/replace.txt", ModPlacementMode.OVERWRITE_COPY), + planned("Data/new.txt", ModPlacementMode.COPY), + ), + ) + val ownership = ModOwnershipManifest( + installId = "selected", + appId = "app", + planDigest = "digest", + files = listOf( + ModOwnedFile( + sourceRelativePath = "managed.txt", + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = "Data/managed.txt", + targetPath = managed.absolutePath, + normalizedTargetKey = WindowsPathIdentity.absoluteKey(managed), + mode = ModPlacementMode.COPY.name, + installedHash = "hash", + installedSize = managed.length(), + installedMtime = managed.lastModified(), + disposition = ModOwnedFileDisposition.CREATED, + ), + ), + ) + + val entries = destinationBrowserEntries(dataDir, root, plan, listOf(ownership), "selected", false, "") + val replaceEntry = entries.single { it.file == replace } + val managedEntry = entries.single { it.file == managed } + + assertTrue(DestinationEntryImpact.WILL_REPLACE in replaceEntry.impacts) + assertTrue(DestinationEntryImpact.WILL_BACK_UP in replaceEntry.impacts) + assertTrue(DestinationEntryImpact.MANAGED_BY_THIS_MOD in managedEntry.impacts) + assertTrue(entries.none { it.file.name.startsWith('.') }) + } + + @Test + fun destinationBrowser_searchesAndIncludesVirtualFolder() { + val rootDir = temporaryFolder.newFolder("search-game") + File(rootDir, "Data").mkdirs() + File(rootDir, "BepInEx").mkdirs() + val root = ResolvedModTargetRoot(ModTargetRoot.GAME_DIR, "Game", rootDir) + + val entries = destinationBrowserEntries( + rootDir, + root, + null, + emptyList(), + "selected", + false, + "mod", + virtualFolderName = "Mods", + ) + + assertEquals(listOf("Mods"), entries.map { it.file.name }) + assertTrue(entries.single().virtual) + } + + @Test + fun destinationBrowser_doesNotMatchPlansWithoutTargetKeys() { + val rootDir = temporaryFolder.newFolder("unresolved-game") + val unmanaged = File(rootDir, "unmanaged.bin").apply { writeText("game") } + val root = ResolvedModTargetRoot(ModTargetRoot.GAME_DIR, "Game", rootDir) + val unresolvedPlan = ModInstallPlan( + files = listOf( + PlannedModFile( + sourceRelativePath = "unknown.bin", + status = PlannedFileStatus.CONFLICTED, + origin = PlacementOrigin.GAME_RULE, + reason = "No target", + ), + ), + ) + + val entry = destinationBrowserEntries( + rootDir, + root, + unresolvedPlan, + emptyList(), + "selected", + false, + "", + ).single { it.file == unmanaged } + + assertFalse(DestinationEntryImpact.PLAN_CONFLICT in entry.impacts) + assertTrue(DestinationEntryImpact.GAME_OR_UNMANAGED in entry.impacts) + } + + @Test + fun reviewRows_groupPlanAndReconfigurationChanges() { + val rootDir = temporaryFolder.newFolder("review-game") + File(rootDir, "Data/existing.txt").apply { parentFile?.mkdirs(); writeText("old") } + val root = ResolvedModTargetRoot(ModTargetRoot.GAME_DIR, "Game", rootDir) + val plan = ModInstallPlan( + files = listOf( + planned("Data/new.txt"), + planned("Data/existing.txt"), + planned("Data/moved.txt"), + PlannedModFile( + sourceRelativePath = "docs/readme.txt", + status = PlannedFileStatus.INTENTIONALLY_IGNORED, + origin = PlacementOrigin.GAME_RULE, + reason = "Documentation", + ), + PlannedModFile( + sourceRelativePath = "unknown/file.bin", + status = PlannedFileStatus.UNSUPPORTED, + origin = PlacementOrigin.GAME_RULE, + reason = "No safe destination", + ), + ), + ) + val diff = ModReconfigurationDiff( + listOf( + ModPlanChange(ModPlanChangeType.ADDED, "Data/new.txt", newTarget = "GAME_DIR:data/new.txt"), + ModPlanChange(ModPlanChangeType.ADDED, "Data/existing.txt", newTarget = "GAME_DIR:data/existing.txt"), + ModPlanChange(ModPlanChangeType.MOVED, "Data/moved.txt", "GAME_DIR:old.txt", "GAME_DIR:data/moved.txt"), + ModPlanChange(ModPlanChangeType.STALE, "Data/removed.txt", "GAME_DIR:data/removed.txt"), + ), + ) + + val categories = placementReviewRows( + plan, + diff, + listOf(root), + staleReason = "No longer produced by this placement", + ).groupingBy { it.category }.eachCount() + + assertEquals(1, categories[PlacementReviewCategory.ADDED]) + assertEquals(1, categories[PlacementReviewCategory.REPLACED]) + assertEquals(1, categories[PlacementReviewCategory.MOVED]) + assertEquals(1, categories[PlacementReviewCategory.REMOVED]) + assertEquals(1, categories[PlacementReviewCategory.IGNORED]) + assertEquals(1, categories[PlacementReviewCategory.BLOCKED]) + } + + private fun planned( + relative: String, + mode: ModPlacementMode = ModPlacementMode.COPY, + ) = PlannedModFile( + sourceRelativePath = relative, + targetRoot = ModTargetRoot.GAME_DIR.name, + targetRelativePath = relative, + normalizedTargetKey = WindowsPathIdentity.targetKey(ModTargetRoot.GAME_DIR.name, relative), + status = PlannedFileStatus.PLACED, + origin = PlacementOrigin.GAME_RULE, + mode = mode.name, + sizeBytes = 1, + reason = "Test", + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39e75efb83..7f730a3ae6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ coreKtx = "1.15.0" # https://mvnrepository.com/artifact/androidx.core/core-ktx coroutines = "1.10.2" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core dagger-hilt = "2.55" # https://mvnrepository.com/artifact/com.google.dagger/hilt-android dataStore = "1.1.2" # https://mvnrepository.com/artifact/androidx.datastore/datastore-preferences -espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espresso/espresso-core +espressoCore = "3.7.0" # https://mvnrepository.com/artifact/androidx.test.espresso/espresso-core feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose @@ -59,6 +59,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room-runtime" } androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room-runtime" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room-runtime" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room-runtime" } # TODO: Remove 'version' once 1.8.0 is rolled into stable BOM, see https://developer.android.com/jetpack/androidx/releases/compose-ui # This fixes `Placement happened before lookahead` crash when animating lists items.