Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8a3a79ce3 | |||
| 0e2fec8c01 | |||
| cb60d768df | |||
| 8751a72176 | |||
| c260ebd566 | |||
| 17d12fef6a | |||
| 97fc2dc872 | |||
| a1ea34bc01 | |||
| 314af37b3d | |||
| 877db194c2 | |||
| c4c15dbada | |||
| 301525e198 | |||
| fa8f0d7443 | |||
| 1fdef1bcc8 | |||
| 53ba636258 | |||
| a25f5f602e | |||
| b9370eb2d5 | |||
| 7c6fba9b3f | |||
| 4eb8324056 | |||
| 6cb78c91fa | |||
| a9184acd23 | |||
| fae1979e04 | |||
| 36f2f0c2f9 | |||
| 35d4eccdfd | |||
| a81f67536f | |||
| ce68860175 | |||
| becc41a5f0 |
@@ -23,3 +23,4 @@
|
|||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
data/
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
name: Build & Push Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Trigger on pushes to main or release branches, and on manual workflow dispatch
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'release/**'
|
||||||
|
- 'beta/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Convert to lowercase
|
||||||
|
id: github_repository_to_lowercase
|
||||||
|
run: |
|
||||||
|
# Grab the value (you can also use `${{ github.ref }}`, `${{ secrets.MY_SECRET }}`, etc.)
|
||||||
|
raw_value="${{ github.repository }}"
|
||||||
|
# Convert to lower case
|
||||||
|
lower_value=$(echo "$raw_value" | tr '[:upper:]' '[:lower:]')
|
||||||
|
# Export it to the workflow environment
|
||||||
|
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
|
||||||
|
# If you want to use it as an output of this step:
|
||||||
|
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
|
||||||
|
- name: Convert ref to buildx safe value
|
||||||
|
id: docker_tag_from_ref
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Grab the raw ref
|
||||||
|
REF="${{ github.ref }}"
|
||||||
|
|
||||||
|
# Strip the "refs/*/" prefix (refs/heads/, refs/tags/…)
|
||||||
|
TAG=${REF#refs/*/}
|
||||||
|
|
||||||
|
# Replace characters that Docker tags disallow
|
||||||
|
# * "/" → "-"
|
||||||
|
# * ":" → "-"
|
||||||
|
# * Any other non‑alphanumeric / . / _ / - → "-"
|
||||||
|
TAG=${TAG//\//-}
|
||||||
|
TAG=${TAG//:/-}
|
||||||
|
TAG=${TAG//[^a-zA-Z0-9._-]/-}
|
||||||
|
|
||||||
|
# (Optional) force lower‑case – Docker tags are case‑sensitive,
|
||||||
|
# but many people prefer lower‑case
|
||||||
|
TAG=${TAG,,}
|
||||||
|
|
||||||
|
# Export to the action's output
|
||||||
|
echo "docker-tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. Checkout repository
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # needed for git rev‑parse and tag generation
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. Set up Docker Buildx (optional, but recommended for multi‑arch)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. Log in to the Gitea container registry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY_HOST }} # e.g. registry.example.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }} # e.g. admin
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }} # e.g. <api‑token>
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. Build the Docker image
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Build image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: TinfoilVibeServer/Dockerfile
|
||||||
|
# do not push yet
|
||||||
|
push: false
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||||
|
build-args: |
|
||||||
|
# Add any build args here
|
||||||
|
# ARG_NAME=VALUE
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. Push the image to the registry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Push image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: TinfoilVibeServer/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 6. (Optional) Clean up local Docker cache
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Docker system prune
|
||||||
|
run: docker system prune -f
|
||||||
|
if: ${{ always() }}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 7. Output useful info
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Show pushed image tags
|
||||||
|
run: |
|
||||||
|
echo "Pushed image tags:"
|
||||||
|
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}"
|
||||||
|
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}"
|
||||||
|
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Convert to lowercase
|
||||||
|
id: github_repository_to_lowercase
|
||||||
|
run: |
|
||||||
|
# Grab the value (you can also use `${{ github.ref }}`, `${{ secrets.MY_SECRET }}`, etc.)
|
||||||
|
raw_value="${{ github.repository }}"
|
||||||
|
# Convert to lower case
|
||||||
|
lower_value=$(echo "$raw_value" | tr '[:upper:]' '[:lower:]')
|
||||||
|
# Export it to the workflow environment
|
||||||
|
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
|
||||||
|
# If you want to use it as an output of this step:
|
||||||
|
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
|
||||||
|
- name: Convert ref to buildx safe value
|
||||||
|
id: docker_tag_from_ref
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Grab the raw ref
|
||||||
|
REF="${{ github.ref }}"
|
||||||
|
|
||||||
|
# Strip the "refs/*/" prefix (refs/heads/, refs/tags/…)
|
||||||
|
TAG=${REF#refs/*/}
|
||||||
|
|
||||||
|
# Replace characters that Docker tags disallow
|
||||||
|
# * "/" → "-"
|
||||||
|
# * ":" → "-"
|
||||||
|
# * Any other non‑alphanumeric / . / _ / - → "-"
|
||||||
|
TAG=${TAG//\//-}
|
||||||
|
TAG=${TAG//:/-}
|
||||||
|
TAG=${TAG//[^a-zA-Z0-9._-]/-}
|
||||||
|
|
||||||
|
# (Optional) force lower‑case – Docker tags are case‑sensitive,
|
||||||
|
# but many people prefer lower‑case
|
||||||
|
TAG=${TAG,,}
|
||||||
|
|
||||||
|
# Export to the action's output
|
||||||
|
echo "docker-tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: TinfoilVibeServer/Dockerfile
|
||||||
|
# do not push yet
|
||||||
|
push: false
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ steps.docker_tag_from_ref.outputs.docker-tag }}
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
||||||
|
build-args: |
|
||||||
|
# Add any build args here
|
||||||
|
# ARG_NAME=VALUE
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
@@ -3,3 +3,10 @@ obj/
|
|||||||
/packages/
|
/packages/
|
||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
|
TinfoilVibeServer.sln.DotSettings.user
|
||||||
|
data/*
|
||||||
|
!.data/.gitkeep
|
||||||
|
**/*.local.json
|
||||||
|
TinfoilVibeServer/config/prod.keys
|
||||||
|
TinfoilVibeServer/data/*
|
||||||
|
!TinfoilVibeServer/data/.gitkeep
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="TinfoilVibeServer: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||||
|
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/TinfoilVibeServer/TinfoilVibeServer.csproj" />
|
||||||
|
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
|
||||||
|
<option name="LAUNCH_PROFILE_NAME" value="http" />
|
||||||
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
|
<option name="USE_MONO" value="0" />
|
||||||
|
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||||
|
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||||
|
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||||
|
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||||
|
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||||
|
<option name="AUTO_ATTACH_CHILDREN" value="0" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="Build" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="TinfoilVibeServer/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker WSL">
|
||||||
|
<deployment type="dockerfile">
|
||||||
|
<settings>
|
||||||
|
<option name="imageTag" value="gitea.ecenshu.net/ecenshu/tinfoilvibeserver:dev" />
|
||||||
|
<option name="containerName" value="tinfoilvibeserver" />
|
||||||
|
<option name="contextFolderPath" value="." />
|
||||||
|
<option name="envVars">
|
||||||
|
<list>
|
||||||
|
<DockerEnvVarImpl>
|
||||||
|
<option name="name" value="uid" />
|
||||||
|
<option name="value" value="1034" />
|
||||||
|
</DockerEnvVarImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="portBindings">
|
||||||
|
<list>
|
||||||
|
<DockerPortBindingImpl>
|
||||||
|
<option name="containerPort" value="8080" />
|
||||||
|
<option name="hostIp" value="127.0.0.1" />
|
||||||
|
<option name="hostPort" value="8080" />
|
||||||
|
</DockerPortBindingImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="showCommandPreview" value="true" />
|
||||||
|
<option name="sourceFilePath" value="TinfoilVibeServer/Dockerfile" />
|
||||||
|
<option name="volumeBindings">
|
||||||
|
<list>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/app/data" />
|
||||||
|
<option name="hostPath" value="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\bin\Debug\net9.0\data" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/app/config" />
|
||||||
|
<option name="hostPath" value="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\bin\Debug\net9.0\config" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/roms_cold" />
|
||||||
|
<option name="hostPath" value="Z:\downloads\roms\switch" />
|
||||||
|
<option name="readOnly" value="true" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/roms_hot" />
|
||||||
|
<option name="hostPath" value="Z:\imgs\roms\Switch" />
|
||||||
|
<option name="readOnly" value="true" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</settings>
|
||||||
|
</deployment>
|
||||||
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -3,6 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41999D26-405C-4DAB-8991-CBA992117C84}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41999D26-405C-4DAB-8991-CBA992117C84}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
compose.yaml = compose.yaml
|
compose.yaml = compose.yaml
|
||||||
|
compose.overide.yaml = compose.overide.yaml
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String></wpf:ResourceDictionary>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ROM/@EntryIndexedValue">ROM</s:String></wpf:ResourceDictionary>
|
||||||
@@ -3,75 +3,125 @@
|
|||||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAbstractWritableArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F22ddbc5388b085f3bf3bfcf15c58a6938df55f53cc27a762c1e0d3e974da87_003FAbstractWritableArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAbstractWritableArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F22ddbc5388b085f3bf3bfcf15c58a6938df55f53cc27a762c1e0d3e974da87_003FAbstractWritableArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAppContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F574233347f5bab7d6529a8c4744fb8a213de24439a69fd5c4316ea962144_003FAppContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAppContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F574233347f5bab7d6529a8c4744fb8a213de24439a69fd5c4316ea962144_003FAppContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArchiveFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Feb8857ee63bd2e1fe34fa06c5137343348c6a0dc69a8b6e8d966786f2fe285f7_003FArchiveFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArchiveReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff0a43ad18bad5a87cbf2acea8abe9bb9b0a35a54aaabd6ab2dfea29281543_003FArchiveReader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArchiveReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff0a43ad18bad5a87cbf2acea8abe9bb9b0a35a54aaabd6ab2dfea29281543_003FArchiveReader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArgumentNullException_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F79b4b5d07e8c5ac3de172b75667c6bded8e1fa6f42f36d8f6dc2f9e3d568_003FArgumentNullException_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArgumentNullException_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F79b4b5d07e8c5ac3de172b75667c6bded8e1fa6f42f36d8f6dc2f9e3d568_003FArgumentNullException_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArrayMemoryPool_002EArrayMemoryPoolBuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d472c6e66d6b1c7ae84d34d6a2e85aeee5ad6861eabf9ea9b5e85f7595c96cf_003FArrayMemoryPool_002EArrayMemoryPoolBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArrayMemoryPool_002EArrayMemoryPoolBuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d472c6e66d6b1c7ae84d34d6a2e85aeee5ad6861eabf9ea9b5e85f7595c96cf_003FArrayMemoryPool_002EArrayMemoryPoolBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAssert_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F182ec85d25b779c7118bca2890ca95ae8dd32868c52272faee4a3a304f624b_003FAssert_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAssert_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F182ec85d25b779c7118bca2890ca95ae8dd32868c52272faee4a3a304f624b_003FAssert_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABinaryReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F6759c9e86085d2e4796e66824d1e6bcde85215244243079466ada44a6cf0_003FBinaryReader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7ee725ee96f111d37690c58fee67515d5d1706bf9892196531efff8346adf_003FBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7ee725ee96f111d37690c58fee67515d5d1706bf9892196531efff8346adf_003FBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationToken_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2565b9d99fdde488bc7801b84387b2cc864959cfb63212e1ff576fc9c6bb7e_003FCancellationToken_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACnmt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fb0_003Fa6f99852_003FCnmt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACnmt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fb0_003Fa6f99852_003FCnmt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6fa05d7dbf2e454ce78b261b422234da1f1ccedee678fd997e5ef4afe6df6e_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6fa05d7dbf2e454ce78b261b422234da1f1ccedee678fd997e5ef4afe6df6e_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACryptoUtil_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd4_003F032ece9d_003FCryptoUtil_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACryptoUtil_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd4_003F032ece9d_003FCryptoUtil_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultHttpContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6af28599e71724c5fc6a617444e2f60b5d57dfdc5be0df4ba43ccfc36977_003FDefaultHttpContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultHttpContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6af28599e71724c5fc6a617444e2f60b5d57dfdc5be0df4ba43ccfc36977_003FDefaultHttpContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe5d623ea960f2c3c9fda144954d339f8d4cd3dad6dd8bd4ab96093a010ab_003FDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe5d623ea960f2c3c9fda144954d339f8d4cd3dad6dd8bd4ab96093a010ab_003FDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEncoding_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F1675dc7b710feeeb3e0bc8728be8a947537155c199480fb23b776e81d459_003FEncoding_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F53b0d531c06faf86bf2ae111a9a2dbce4c52a9153feb9966ade60289c71bf52_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F53b0d531c06faf86bf2ae111a9a2dbce4c52a9153feb9966ade60289c71bf52_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExpressionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd785b86024a6dfc7a0de13179d2769fe20f85a33e39e7bfc8dfffba6a44a44_003FExpressionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExpressionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd785b86024a6dfc7a0de13179d2769fe20f85a33e39e7bfc8dfffba6a44a44_003FExpressionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F85a5735906a3a06f39a1422a28e0353e3e317f2d923dcc5731fef07dd436f9_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F85a5735906a3a06f39a1422a28e0353e3e317f2d923dcc5731fef07dd436f9_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fae_003Fb2d28c5c_003FFileStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEnumerator_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F326755fc341d349c24999b4c209d69fbe4317313d563859abe51f4ded75c97b_003FFileSystemEnumerator_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEnumerator_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F326755fc341d349c24999b4c209d69fbe4317313d563859abe51f4ded75c97b_003FFileSystemEnumerator_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEventArgs_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36b5c2553dd4c6dccec26adf9d5ab4ff493763447f46751b6775ba38a832_003FFileSystemEventArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEventArgs_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36b5c2553dd4c6dccec26adf9d5ab4ff493763447f46751b6775ba38a832_003FFileSystemEventArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9c93a119672fff4fc4a8405c22a67b8153942a238a226de1266ccc65c652d936_003FFileSystemInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashSet_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9f36f47319b32af838b2a5d575986ff83918e428931b1ed44ad662b6bc8a_003FHashSet_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashSet_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9f36f47319b32af838b2a5d575986ff83918e428931b1ed44ad662b6bc8a_003FHashSet_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHostingAbstractionsHostExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F41efb8821245d1097e7a593356b9bd4e26a16282ac228833317de7645a9d81_003FHostingAbstractionsHostExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHostingAbstractionsHostExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F41efb8821245d1097e7a593356b9bd4e26a16282ac228833317de7645a9d81_003FHostingAbstractionsHostExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHost_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcfb8165d037ea5062589e7be2a322d53397b1abadab026bb319132b6a24556_003FHost_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHost_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcfb8165d037ea5062589e7be2a322d53397b1abadab026bb319132b6a24556_003FHost_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4cfeb8b377bc81e1fbb5f7d7a02492cb6ac23e88c8c9d7155944f0716f3d4b_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4cfeb8b377bc81e1fbb5f7d7a02492cb6ac23e88c8c9d7155944f0716f3d4b_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fca01a4e200c84eee8955133d13d81dc8c0e00_003F1a_003F8011ac3b_003FIArchiveEntryExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fca01a4e200c84eee8955133d13d81dc8c0e00_003F1a_003F8011ac3b_003FIArchiveEntryExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe82cab4cb05dfe89c48ab345f842025771adcc674cc9ede9c399369f8ab8748_003FIArchiveEntryExtensions_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe82cab4cb05dfe89c48ab345f842025771adcc674cc9ede9c399369f8ab8748_003FIArchiveEntryExtensions_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F482647feefe97ea0467efea59b1ba1d33b2766c65db7d194aa8ba179edd65_003FIArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F9b_003Fef91f762_003FIFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F9b_003Fef91f762_003FIFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F9b_003Fef91f762_003FIFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F1e_003F73490ea0_003FIStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonConverterOfT_002EReadCore_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa7e99f3da3fc9d80c1949ce87d548d992a745092d1c720f44952dbbd144437f_003FJsonConverterOfT_002EReadCore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonConverterOfT_002EReadCore_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa7e99f3da3fc9d80c1949ce87d548d992a745092d1c720f44952dbbd144437f_003FJsonConverterOfT_002EReadCore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5ef7f3c9db445621211faddebc3c1c9bb48a942f1b8cba4caa2501466f85f_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5ef7f3c9db445621211faddebc3c1c9bb48a942f1b8cba4caa2501466f85f_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002EWrite_002EString_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd49cb1d7ce3d716547551bbff70eb2084ebe04c491a927531363631f3e46330_003FJsonSerializer_002EWrite_002EString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002EWrite_002EString_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd49cb1d7ce3d716547551bbff70eb2084ebe04c491a927531363631f3e46330_003FJsonSerializer_002EWrite_002EString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALazyReadOnlyCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F41c2eb564782dcbbd4dbb34c6329c22a7c59cf2e50ce14fc51e348d258bac1_003FLazyReadOnlyCollection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALazy_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fafcc64c3432daf776b0c75429fdb3c6b938b876c6daf5d81eee485f119428_003FLazy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALazy_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fafcc64c3432daf776b0c75429fdb3c6b938b876c6daf5d81eee485f119428_003FLazy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AListeningStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F633629cdc36745a741a359d1d83ca239ddb3ac7c7dd49ce6b63025acd5f5e8e2_003FListeningStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AListeningStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F633629cdc36745a741a359d1d83ca239ddb3ac7c7dd49ce6b63025acd5f5e8e2_003FListeningStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AManualResetEventSlim_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff620c361a4199e3e4ceb5b7d542ea2b32cb76c577fb91a2bc16d15c49879ab8_003FManualResetEventSlim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AManualResetEventSlim_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff620c361a4199e3e4ceb5b7d542ea2b32cb76c577fb91a2bc16d15c49879ab8_003FManualResetEventSlim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryCacheExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feabafafbeed23dab31f6a8184bdb1fe5a4c2237f39efe53885612b2ccdd3cd_003FMemoryCacheExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryCacheExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feabafafbeed23dab31f6a8184bdb1fe5a4c2237f39efe53885612b2ccdd3cd_003FMemoryCacheExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryMarshal_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F948494fffd27a44b5c55f2f85972545a315f73df6c6278f9e692cbbad86b0_003FMemoryMarshal_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryMarshal_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F948494fffd27a44b5c55f2f85972545a315f73df6c6278f9e692cbbad86b0_003FMemoryMarshal_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbb20bcb6cf86be25708bd683fe862b76c8c4497abe68566865702178cf5e1c_003FMemory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F69ba5438e03a0e3a5d2f133f37405f9e32f9e0abb1fbe0a8cb7145514ff85f_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F69ba5438e03a0e3a5d2f133f37405f9e32f9e0abb1fbe0a8cb7145514ff85f_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMock_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F337268366a89be4dda7d47b37063fd21841972237539397997c82c4f924b_003FMock_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMock_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F337268366a89be4dda7d47b37063fd21841972237539397997c82c4f924b_003FMock_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff5c5fabf10b2751774ff136491ac41fbc0932fbd1315879ac29f2d13e2ad24b_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff5c5fabf10b2751774ff136491ac41fbc0932fbd1315879ac29f2d13e2ad24b_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff5c5fabf10b2751774ff136491ac41fbc0932fbd1315879ac29f2d13e2ad24b_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMultiVolumeReadOnlyStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F53a828451e96fc5fe4fce5b95fb79b85beefa0be2da557dff1be93f39f6e6_003FMultiVolumeReadOnlyStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANcaHeader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F9e_003Fdaa64f0f_003FNcaHeader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANca_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F8b_003F8c92e4d1_003FNca_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANxFileStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F35_003F56be8607_003FNxFileStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANxFileStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F35_003F56be8607_003FNxFileStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANxFileStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F35_003F56be8607_003FNxFileStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObject_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9a6b1457cbcf17db31a383ba49ef9bcc786cf3ef77146d997eee499b27a46d_003FObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObject_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9a6b1457cbcf17db31a383ba49ef9bcc786cf3ef77146d997eee499b27a46d_003FObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObject_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9a6b1457cbcf17db31a383ba49ef9bcc786cf3ef77146d997eee499b27a46d_003FObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOrderedEnumerable_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F3e15db288878a5c586c176f46b93b148490778ac79eb4529070555aa87415_003FOrderedEnumerable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOrderedEnumerable_002ESpeedOpt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff152b848b1c419edb4c8e8d52d371fe584aca143db23ee711341209a6ceb_003FOrderedEnumerable_002ESpeedOpt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemCore_00604_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F42_003Ff49d31db_003FPartitionFileSystemCore_00604_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemCore_00604_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F42_003Ff49d31db_003FPartitionFileSystemCore_00604_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemCore_00604_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F42_003Ff49d31db_003FPartitionFileSystemCore_00604_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F12_003Ff913ca40_003FPartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F12_003Ff913ca40_003FPartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F84_003F3a6a0a14_003FPartitionFileSystemMetaCore_00603_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F84_003F3a6a0a14_003FPartitionFileSystemMetaCore_00603_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F84_003F3a6a0a14_003FPartitionFileSystemMetaCore_00603_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F94_003F00e38e6a_003FPartitionFileSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F94_003F00e38e6a_003FPartitionFileSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F94_003F00e38e6a_003FPartitionFileSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8bfc57955751d07e3fad13bb97d5f66af412d7a34486ec29ad916547ec8ce6b_003FPath_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8bfc57955751d07e3fad13bb97d5f66af412d7a34486ec29ad916547ec8ce6b_003FPath_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandomAccess_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cb442cc26c0b982afc257bb62f18d1554630732b2b8245e97107fb51974d_003FRandomAccess_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandomAccess_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cb442cc26c0b982afc257bb62f18d1554630732b2b8245e97107fb51974d_003FRandomAccess_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntryFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8fbd22ad2b9537e1af2f53d1f62a875fd9ed2e2781cd7a3fb7828c5ba2edce3_003FRarArchiveEntryFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc9e95c2dd9e75e585f636790bc74e1484a536fc029954c307fe585a3822129_003FRarArchiveEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc9e95c2dd9e75e585f636790bc74e1484a536fc029954c307fe585a3822129_003FRarArchiveEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc9e95c2dd9e75e585f636790bc74e1484a536fc029954c307fe585a3822129_003FRarArchiveEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveVolumeFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F416daa6b333a5ecff282ed559ac81071113ee993ebcdc662c5cf488d7072575b_003FRarArchiveVolumeFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveVolumeFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F416daa6b333a5ecff282ed559ac81071113ee993ebcdc662c5cf488d7072575b_003FRarArchiveVolumeFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F897247968e1ee1cdab3bf5f342eab3992b66d159a27a99ebb88a7593da4aeb_003FRarArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F897247968e1ee1cdab3bf5f342eab3992b66d159a27a99ebb88a7593da4aeb_003FRarArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F48a2e7ca6b54673c580a84a5f84c86d7a8cf2b199df9ca68655d9734e95_003FRarEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F48a2e7ca6b54673c580a84a5f84c86d7a8cf2b199df9ca68655d9734e95_003FRarEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F48a2e7ca6b54673c580a84a5f84c86d7a8cf2b199df9ca68655d9734e95_003FRarEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarHeaderFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F138ca59251bcbcaf4d5e59bd2ce24c21ebcfd721aeb51ff82a71a7151d72ced_003FRarHeaderFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc35d82af548651a2b21dec4154692cd38619ed1ae76afd1aae437738cde798_003FRarStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc35d82af548651a2b21dec4154692cd38619ed1ae76afd1aae437738cde798_003FRarStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc35d82af548651a2b21dec4154692cd38619ed1ae76afd1aae437738cde798_003FRarStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarVolume_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa2e3d55c2c2cc4d9fc7547b9fe71b8dc6b2b1d2732938296845f16e9d3e532_003FRarVolume_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReadOnlySpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F47bfed48817fad7d8e1a89bf3530e4be7277b022a9c7477c5a243031605a5f_003FReadOnlySpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReadOnlySpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F47bfed48817fad7d8e1a89bf3530e4be7277b022a9c7477c5a243031605a5f_003FReadOnlySpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReadOnlySpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F47bfed48817fad7d8e1a89bf3530e4be7277b022a9c7477c5a243031605a5f_003FReadOnlySpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F117275599da316d4e45c62326f3097e45f8b5b17d8fe17bbcf9ca86b0819b16_003FResourceInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F117275599da316d4e45c62326f3097e45f8b5b17d8fe17bbcf9ca86b0819b16_003FResourceInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResult_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F13_003Faf246217_003FResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeHelpers_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F54c7fa9daf3ab775e3ebabc3f3948a6e2a484886fa1196f813b98e17e36aa49_003FRuntimeHelpers_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeHelpers_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F54c7fa9daf3ab775e3ebabc3f3948a6e2a484886fa1196f813b98e17e36aa49_003FRuntimeHelpers_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6ef8e7d549f6bfb5b9eafaaa0ff48d8cbfc564f32be26323b5f60e63aef4dd1_003FSafeFileHandle_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6ef8e7d549f6bfb5b9eafaaa0ff48d8cbfc564f32be26323b5f60e63aef4dd1_003FSafeFileHandle_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASeekableZipHeaderFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc5f3e4d3cedb261911afb1e191b36ed580a783fd7ee73cba3f33d8c6feeb_003FSeekableZipHeaderFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASemaphoreSlim_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9769f0874b89d9a174c39e6c977aef6d979daefa49c7f3c239ef268493ea12_003FSemaphoreSlim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASha256PartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F0a_003Fcb570a3a_003FSha256PartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASha256PartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fe1_003Ff97991f9_003FSha256PartitionFileSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpCompressStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F94cd926a492d1f137d5d538f98f52a3cba4f258bf7f15dcd4ac79daddea9d_003FSharpCompressStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpSevenZipExtractor_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb6d4f34f86579010d492ef5548eca93df749a4e4634ba9c3dee6fbd6c6d74_003FSharpSevenZipExtractor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpSevenZipExtractor_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb6d4f34f86579010d492ef5548eca93df749a4e4634ba9c3dee6fbd6c6d74_003FSharpSevenZipExtractor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fad60445d5abe6c4fac32d6378f89f1eeba9e73cbdf09554352c1546a8314194_003FSourceStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpanHelpers_002EByteMemOps_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6169f1bb3c3c365c1f886cb89adaa3d156367d5b9d3e238bb46901cffce27_003FSpanHelpers_002EByteMemOps_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpanHelpers_002EByteMemOps_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6169f1bb3c3c365c1f886cb89adaa3d156367d5b9d3e238bb46901cffce27_003FSpanHelpers_002EByteMemOps_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Faa74b2b4e9988ce29825f798c3ef4254a9bde18b81c60754798e520d5445a27_003FSpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Faa74b2b4e9988ce29825f798c3ef4254a9bde18b81c60754798e520d5445a27_003FSpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Faa74b2b4e9988ce29825f798c3ef4254a9bde18b81c60754798e520d5445a27_003FSpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2854ce6d56c18d0d837d3a3ef9c4f2c7c77691fa3528c8394986ac7ce7719_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2854ce6d56c18d0d837d3a3ef9c4f2c7c77691fa3528c8394986ac7ce7719_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStorageExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F01_003F633a1d70_003FStorageExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStorageStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa2_003F35b2a1db_003FStorageStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa6_003F13a0d744_003FStreamStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa6_003F13a0d744_003FStreamStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa6_003F13a0d744_003FStreamStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126_003FStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126_003FStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126_003FStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASubStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fc1_003F4e56d301_003FSubStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATaskAwaitAdapter_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd3c187e0b88b1a56f2518fb9f4b5ee5ed6e9116d93a507969a932c16801033_003FTaskAwaitAdapter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATaskAwaitAdapter_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd3c187e0b88b1a56f2518fb9f4b5ee5ed6e9116d93a507969a932c16801033_003FTaskAwaitAdapter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8789586b575d44528c2aa18853f6eb96f3e8f8d3f4fa7eb9d6a56b54b49d7_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8789586b575d44528c2aa18853f6eb96f3e8f8d3f4fa7eb9d6a56b54b49d7_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThreadPoolWorkQueue_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F478cd37a9cf5552e58e069a6aafb2a112b89b262faef3cfb5054d65b1ea95345_003FThreadPoolWorkQueue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThreadPoolWorkQueue_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F478cd37a9cf5552e58e069a6aafb2a112b89b262faef3cfb5054d65b1ea95345_003FThreadPoolWorkQueue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@@ -83,13 +133,27 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f4af9413b11943bfdd164b58f2c3faa476fb6ffee3bb2d63ea7882b139c1_003FThrowHelper_002ESerialization_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f4af9413b11943bfdd164b58f2c3faa476fb6ffee3bb2d63ea7882b139c1_003FThrowHelper_002ESerialization_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATitleVersion_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa9_003F226f6bd5_003FTitleVersion_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATitleVersion_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa9_003F226f6bd5_003FTitleVersion_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AToCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F439c4ee753b23e743cc14119593bc889751f9eb0b38997577d8e4c47c4fed_003FToCollection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AToCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F439c4ee753b23e743cc14119593bc889751f9eb0b38997577d8e4c47c4fed_003FToCollection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AToCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F439c4ee753b23e743cc14119593bc889751f9eb0b38997577d8e4c47c4fed_003FToCollection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AType_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cde391207de75962d7bacb899ca2bd3985c86911b152d185b58999a422bf0_003FType_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AType_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cde391207de75962d7bacb899ca2bd3985c86911b152d185b58999a422bf0_003FType_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUniqueRef_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa0_003F631946a0_003FUniqueRef_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUniqueRef_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa0_003F631946a0_003FUniqueRef_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack15_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fad78a77fe5aefabfc0b472fc617df3f42822b80df4d1ebbb3e6bf744ad6f6_003FUnpack15_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4c375bcb6a2d2378855cad1b1c7cfe7ca1448866f1e8af44226775b5f75df86_003FUnpack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4c375bcb6a2d2378855cad1b1c7cfe7ca1448866f1e8af44226775b5f75df86_003FUnpack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F4c375bcb6a2d2378855cad1b1c7cfe7ca1448866f1e8af44226775b5f75df86_003FUnpack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnsafeHelpers_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd1_003Fc59f91c2_003FUnsafeHelpers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnsafeHelpers_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd1_003Fc59f91c2_003FUnsafeHelpers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUriExt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F25edf3e734d0c76e87b29a9954b8b4a7383648a69396554742e5529205e2dd7_003FUriExt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUriSyntax_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F7ed82aa0f48a6bf284b4aba7c70aff35142349e44fb4f6caec3d71611f9929_003FUriSyntax_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtilities_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F57_003F0c15ef69_003FUtilities_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtility_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3ea3ed6216d2412ac7c33016ad940618bcfbcafe1633dc26832be514633b4_003FUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtility_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3ea3ed6216d2412ac7c33016ad940618bcfbcafe1633dc26832be514633b4_003FUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWaitHandle_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcab54c7c1f8d9b3da5f7878e39faba52467f16d0899a2c4b10086cb2ef73f2b_003FWaitHandle_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciHeader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fbd_003F5ce70040_003FXciHeader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F04_003F4e8815da_003FXciPartition_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F04_003F4e8815da_003FXciPartition_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F04_003F4e8815da_003FXciPartition_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXci_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd8_003F1d4b8551_003FXci_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F3bba989a68c21c129712261a87d71e882f467a6767a2dd561e88915c5424bba_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd17281d585b64ae689c9f9dc982ec39d77a84e88eca8aecaa9e18643aa213822_003FZipFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
|
||||||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
||||||
<Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" />
|
<Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" />
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
using System.Collections.Concurrent;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using TinfoilVibeServer.Services;
|
using TinfoilVibeServer.Services;
|
||||||
using TinfoilVibeServer.Utilities;
|
using TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
@@ -12,7 +20,7 @@ public interface IAuthStore
|
|||||||
void Dispose();
|
void Dispose();
|
||||||
bool TryValidate(string username,
|
bool TryValidate(string username,
|
||||||
string password,
|
string password,
|
||||||
int? uid,
|
string? uid,
|
||||||
string ip,
|
string ip,
|
||||||
out string? error);
|
out string? error);
|
||||||
|
|
||||||
@@ -29,83 +37,106 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
{
|
{
|
||||||
private readonly ILogger<AuthStore> _logger;
|
private readonly ILogger<AuthStore> _logger;
|
||||||
private readonly ConfigManager _configManager;
|
private readonly ConfigManager _configManager;
|
||||||
|
private readonly IHostEnvironment _env;
|
||||||
|
|
||||||
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
|
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
|
||||||
public readonly ConcurrentDictionary<string, List<int>> Fingerprints = new();
|
public readonly ConcurrentDictionary<string, List<string>> Fingerprints = new();
|
||||||
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
|
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
|
||||||
private readonly HashSet<string> BlacklistIPs = new();
|
private readonly HashSet<string> _blacklistIPs = new();
|
||||||
|
|
||||||
private readonly object _sync = new();
|
private readonly Lock _sync = new();
|
||||||
private readonly FileSystemWatcher _credentialsWatcher;
|
private readonly FileSystemWatcher _credentialsWatcher;
|
||||||
|
private readonly FileSystemWatcher _blacklistWatcher;
|
||||||
|
|
||||||
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager)
|
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager, IHostEnvironment env)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configManager = configManager;
|
_configManager = configManager;
|
||||||
|
_env = env;
|
||||||
|
|
||||||
LoadAll();
|
LoadAll();
|
||||||
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, env);
|
||||||
var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile);
|
var directoryName = Path.GetDirectoryName(Path.Combine(credentialsFilePath));
|
||||||
_credentialsWatcher = new FileSystemWatcher
|
_credentialsWatcher = new FileSystemWatcher
|
||||||
{
|
{
|
||||||
Path = (!string.IsNullOrEmpty(directoryName)) ? directoryName : AppContext.BaseDirectory,
|
Path = (!string.IsNullOrEmpty(directoryName)) ? directoryName : AppContext.BaseDirectory,
|
||||||
Filter = Path.GetFileName(_configManager.Settings.CredentialsFile),
|
Filter = Path.GetFileName(credentialsFilePath) ?? throw new InvalidOperationException(),
|
||||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
||||||
};
|
};
|
||||||
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
|
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
|
||||||
_credentialsWatcher.EnableRaisingEvents = true;
|
_credentialsWatcher.EnableRaisingEvents = true;
|
||||||
|
_blacklistWatcher = new FileSystemWatcher
|
||||||
|
{
|
||||||
|
Path = (!string.IsNullOrEmpty(directoryName)) ? directoryName : AppContext.BaseDirectory,
|
||||||
|
Filter = Path.GetFileName(_configManager.Settings?.BlacklistFile) ?? throw new InvalidOperationException(),
|
||||||
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
||||||
|
};
|
||||||
|
_blacklistWatcher.Changed += (_, _) => OnBlacklistChanged();
|
||||||
|
_blacklistWatcher.EnableRaisingEvents = true;
|
||||||
|
_logger.LogInformation("Started watching credentials file {File}", credentialsFilePath);
|
||||||
|
_logger.LogInformation("Started watching blacklist file {File}", Path.Combine(env.ContentRootPath, "data", Path.GetFileName(_configManager.Settings?.BlacklistFile) ?? throw new InvalidOperationException()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetermineCredentialsPath(string? settingsCredentialsFile, IHostEnvironment env)
|
||||||
|
{
|
||||||
|
if (settingsCredentialsFile == null) return Path.Combine("app","data","credentials.json");
|
||||||
|
return Path.IsPathRooted(settingsCredentialsFile) ? settingsCredentialsFile : Path.Combine(env.ContentRootPath,"data",settingsCredentialsFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_credentialsWatcher?.Dispose();
|
_credentialsWatcher.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Loading helpers
|
#region Loading helpers
|
||||||
|
|
||||||
private void LoadAll()
|
private void LoadAll()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile);
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
|
||||||
|
_logger.LogInformation("Loading authentication data from {File}", credentialsFilePath);
|
||||||
// credentials
|
// credentials
|
||||||
if (File.Exists(_configManager.Settings.CredentialsFile))
|
if (File.Exists(credentialsFilePath))
|
||||||
{
|
{
|
||||||
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
|
var txt = File.ReadAllText(credentialsFilePath);
|
||||||
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
||||||
foreach (var kv in dict)
|
foreach (var kv in dict)
|
||||||
Credentials[kv.Key] = kv.Value;
|
Credentials[kv.Key] = kv.Value;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.CredentialsFile)));
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings?.CredentialsFile ?? throw new InvalidOperationException())));
|
||||||
}
|
}
|
||||||
|
|
||||||
// fingerprints
|
// fingerprints
|
||||||
if (File.Exists(_configManager.Settings.FingerprintsFile))
|
if (File.Exists(_configManager.Settings?.FingerprintsFile))
|
||||||
{
|
{
|
||||||
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
|
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
|
||||||
var dict = JsonSerializer.Deserialize<Dictionary<string, List<int>>>(txt)!;
|
var dict = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(txt)!;
|
||||||
foreach (var kv in dict)
|
foreach (var kv in dict)
|
||||||
Fingerprints[kv.Key] = kv.Value;
|
Fingerprints[kv.Key] = kv.Value;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (_configManager.Settings?.FingerprintsFile != null)
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile)));
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// blacklist
|
// blacklist
|
||||||
if (File.Exists(_configManager.Settings.BlacklistFile))
|
if (File.Exists(_configManager.Settings?.BlacklistFile))
|
||||||
{
|
{
|
||||||
var txt = File.ReadAllText(_configManager.Settings.BlacklistFile);
|
var txt = File.ReadAllText(_configManager.Settings.BlacklistFile);
|
||||||
var arr = JsonSerializer.Deserialize<string[]>(txt)!;
|
var arr = JsonSerializer.Deserialize<string[]>(txt)!;
|
||||||
foreach (var ip in arr)
|
foreach (var ip in arr)
|
||||||
BlacklistIPs.Add(ip);
|
_blacklistIPs.Add(ip);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (_configManager.Settings?.BlacklistFile != null)
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
|
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
|
||||||
Credentials.Count, Fingerprints.Count, BlacklistIPs.Count);
|
Credentials.Count, Fingerprints.Count, _blacklistIPs.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -122,17 +153,28 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnBlacklistChanged()
|
||||||
|
{
|
||||||
|
// Small debounce – the file may still be locked by the editor.
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(200);
|
||||||
|
LoadAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ReloadCredentials()
|
private void ReloadCredentials()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_configManager.Settings.CredentialsFile))
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
|
||||||
|
if (!File.Exists(credentialsFilePath))
|
||||||
{
|
{
|
||||||
_logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile);
|
_logger.LogError("Credentials file {File} does not exist", credentialsFilePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
|
var txt = File.ReadAllText(credentialsFilePath);
|
||||||
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
||||||
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -164,7 +206,7 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile);
|
_logger.LogError(ex, "Failed to reload credentials from {File}", credentialsFilePath);
|
||||||
// ignore – malformed JSON or IO error – keep old state
|
// ignore – malformed JSON or IO error – keep old state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,11 +215,7 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
#region Authentication logic
|
#region Authentication logic
|
||||||
|
|
||||||
public bool TryValidate(string username,
|
public bool TryValidate(string username, string password, string? uid, string ip, out string? error)
|
||||||
string password,
|
|
||||||
int? uid,
|
|
||||||
string ip,
|
|
||||||
out string? error)
|
|
||||||
{
|
{
|
||||||
error = null;
|
error = null;
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -190,14 +228,6 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
PersistCredentials();
|
PersistCredentials();
|
||||||
_logger.LogInformation("Created new user {Username} (verified={Verified})", username, cred.Verified);
|
_logger.LogInformation("Created new user {Username} (verified={Verified})", username, cred.Verified);
|
||||||
|
|
||||||
var list = Fingerprints.GetOrAdd(username, _ => new List<int>());
|
|
||||||
if (uid.HasValue && !list.Contains(uid.Value))
|
|
||||||
{
|
|
||||||
if (list.Count < cred.AllowedUidCount)
|
|
||||||
list.Add(uid.Value);
|
|
||||||
PersistFingerprints();
|
|
||||||
}
|
|
||||||
|
|
||||||
error = "User not verified";
|
error = "User not verified";
|
||||||
IncrementFailed(username, ip);
|
IncrementFailed(username, ip);
|
||||||
return false;
|
return false;
|
||||||
@@ -218,15 +248,15 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uid.HasValue)
|
if (uid != null)
|
||||||
{
|
{
|
||||||
var list = Fingerprints.GetOrAdd(username, _ => new List<int>());
|
var list = Fingerprints.GetOrAdd(username, _ => new List<string>());
|
||||||
|
|
||||||
if (!list.Contains(uid.Value))
|
if (!list.Contains(uid))
|
||||||
{
|
{
|
||||||
if (list.Count < cred.AllowedUidCount)
|
if (list.Count < cred.AllowedUidCount)
|
||||||
{
|
{
|
||||||
list.Add(uid.Value);
|
list.Add(uid);
|
||||||
PersistFingerprints();
|
PersistFingerprints();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -237,6 +267,11 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No Fingerprint given during authentication for {Username} from {IP}", username, ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
FailedAttempts[username] = 0;
|
FailedAttempts[username] = 0;
|
||||||
return true;
|
return true;
|
||||||
@@ -245,24 +280,31 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
public int IncrementFailed(string username, string ip)
|
public int IncrementFailed(string username, string ip)
|
||||||
{
|
{
|
||||||
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
|
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
|
||||||
FailedAttempts[username] = newCount;
|
FailedAttempts[username] = newCount;
|
||||||
}
|
|
||||||
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
|
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
|
||||||
|
|
||||||
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
|
_logger.LogCritical("Settings not set to determine failed login counts");
|
||||||
|
return int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
if (newCount < _configManager.Settings.MaxFailedAttempts + 1) return newCount;
|
if (newCount < _configManager.Settings.MaxFailedAttempts + 1) return newCount;
|
||||||
|
|
||||||
BlacklistIPs.Add(ip);
|
_blacklistIPs.Add(ip);
|
||||||
PersistBlacklist();
|
PersistBlacklist();
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
FailedAttempts[username] = 0;
|
FailedAttempts[username] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
|
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
|
||||||
return newCount;
|
return newCount;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -280,38 +322,61 @@ public class AuthStore : IDisposable, IAuthStore
|
|||||||
|
|
||||||
private void PersistCredentials()
|
private void PersistCredentials()
|
||||||
{
|
{
|
||||||
|
var credentialsFilePath = DetermineCredentialsPath(_configManager.Settings?.CredentialsFile, _env);
|
||||||
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(_configManager.Settings.CredentialsFile, json);
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
|
_logger.LogCritical("Credentials file not set, cannot persist");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.WriteAllText(credentialsFilePath, json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistFingerprints()
|
private void PersistFingerprints()
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
|
_logger.LogCritical("Fingerprint file not set, cannot persist");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
|
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void PersistBlacklist()
|
private void PersistBlacklist()
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(_blacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
if (_configManager.Settings == null)
|
||||||
|
{
|
||||||
|
_logger.LogCritical("Blacklist file not set, cannot persist");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
File.WriteAllText(_configManager.Settings.BlacklistFile, json);
|
File.WriteAllText(_configManager.Settings.BlacklistFile, json);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Blacklist helpers
|
#region Blacklist helpers
|
||||||
|
|
||||||
public bool IsIPBlacklisted(string ipAddress)
|
public bool IsIPBlacklisted(string ipAddress)
|
||||||
{
|
{
|
||||||
return BlacklistIPs.Contains(ipAddress);
|
return _blacklistIPs.Contains(ipAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool UnbanIp(string ipAddress)
|
public bool UnbanIp(string ipAddress)
|
||||||
{
|
{
|
||||||
return BlacklistIPs.Remove(ipAddress);
|
return _blacklistIPs.Remove(ipAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool BlacklistActive()
|
public bool BlacklistActive()
|
||||||
{
|
{
|
||||||
return BlacklistIPs.Count > 0;
|
return _blacklistIPs.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class CancelableFileResult : FileResult
|
|||||||
/// Allows you to set a suggested download name.
|
/// Allows you to set a suggested download name.
|
||||||
/// It will be sent in a “Content‑Disposition” header.
|
/// It will be sent in a “Content‑Disposition” header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? FileDownloadName { get; set; }
|
public new string? FileDownloadName { get; set; }
|
||||||
|
|
||||||
public override async Task ExecuteResultAsync(ActionContext context)
|
public override async Task ExecuteResultAsync(ActionContext context)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,33 +1,15 @@
|
|||||||
using System;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using SharpCompress.Readers;
|
using SharpCompress.Readers;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
using TinfoilVibeServer.Services;
|
using TinfoilVibeServer.Services;
|
||||||
|
using TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Controllers;
|
namespace TinfoilVibeServer.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/")]
|
[Route("/")]
|
||||||
public sealed class IndexController : ControllerBase
|
public sealed class IndexController(ISnapshotService snapshotService, IndexBuilderService indexBuilderService) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISnapshotService _snapshotService;
|
|
||||||
private readonly TitleDatabaseService _titleDb;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly IndexBuilderService _indexBuilderService;
|
|
||||||
|
|
||||||
public IndexController(ISnapshotService snapshotService,
|
|
||||||
TitleDatabaseService titleDb,
|
|
||||||
IConfiguration configuration, IndexBuilderService indexBuilderService)
|
|
||||||
{
|
|
||||||
_snapshotService = snapshotService;
|
|
||||||
_titleDb = titleDb;
|
|
||||||
_configuration = configuration;
|
|
||||||
_indexBuilderService = indexBuilderService;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// GET /
|
// GET /
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@@ -40,10 +22,10 @@ public sealed class IndexController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (HttpContext.Request.Headers.CacheControl == "no-cache")
|
if (HttpContext.Request.Headers.CacheControl == "no-cache")
|
||||||
{
|
{
|
||||||
_indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
|
indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
var index = _indexBuilderService.Build(HttpContext);
|
var index = indexBuilderService.Build(HttpContext);
|
||||||
|
|
||||||
return Ok(index);
|
return Ok(index);
|
||||||
}
|
}
|
||||||
@@ -75,7 +57,7 @@ public sealed class IndexController : ControllerBase
|
|||||||
var titleId = match.Groups["id"].Value.ToUpperInvariant();
|
var titleId = match.Groups["id"].Value.ToUpperInvariant();
|
||||||
|
|
||||||
// ---- 2️⃣ Find the file that contains this TitleId ------------
|
// ---- 2️⃣ Find the file that contains this TitleId ------------
|
||||||
var entry = _snapshotService.GetSnapshot().Files
|
var entry = snapshotService.GetSnapshot().Files
|
||||||
.FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; });
|
.FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; });
|
||||||
|
|
||||||
if (entry == null)
|
if (entry == null)
|
||||||
@@ -84,7 +66,7 @@ public sealed class IndexController : ControllerBase
|
|||||||
// ---- 3️⃣ If the file is a normal NSP → send it ----------------
|
// ---- 3️⃣ If the file is a normal NSP → send it ----------------
|
||||||
|
|
||||||
if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase)
|
if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase)
|
||||||
&& !entry.Path.Contains(_snapshotService.GetArchivePathSeparator()))
|
&& !entry.Path.Contains(snapshotService.GetArchivePathSeparator()))
|
||||||
{
|
{
|
||||||
if (System.IO.File.Exists(entry.Path))
|
if (System.IO.File.Exists(entry.Path))
|
||||||
{
|
{
|
||||||
@@ -94,7 +76,7 @@ public sealed class IndexController : ControllerBase
|
|||||||
FileMode.Open,
|
FileMode.Open,
|
||||||
FileAccess.Read,
|
FileAccess.Read,
|
||||||
FileShare.Read,
|
FileShare.Read,
|
||||||
bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB
|
bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB
|
||||||
useAsync: true); // <‑‑ VERY important for scalability
|
useAsync: true); // <‑‑ VERY important for scalability
|
||||||
|
|
||||||
// 2️⃣ Return a cancellation‑aware result
|
// 2️⃣ Return a cancellation‑aware result
|
||||||
@@ -116,7 +98,7 @@ public sealed class IndexController : ControllerBase
|
|||||||
if (IsInsideArchive(entry.Path))
|
if (IsInsideArchive(entry.Path))
|
||||||
{
|
{
|
||||||
// Example: file is inside an archive – use ArchiveHandler
|
// Example: file is inside an archive – use ArchiveHandler
|
||||||
var innerFileName = entry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last();
|
var innerFileName = entry.Path.Split(snapshotService.GetArchivePathSeparator()).Last();
|
||||||
var stream = StreamFromArchive(entry, titleId, out var streamContainer);
|
var stream = StreamFromArchive(entry, titleId, out var streamContainer);
|
||||||
|
|
||||||
if (stream == null)
|
if (stream == null)
|
||||||
@@ -149,7 +131,7 @@ public sealed class IndexController : ControllerBase
|
|||||||
// (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp"
|
// (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp"
|
||||||
// would be inside an archive). For simplicity we only check
|
// would be inside an archive). For simplicity we only check
|
||||||
// for common archive extensions.
|
// for common archive extensions.
|
||||||
var filePath = path.Split(_snapshotService.GetArchivePathSeparator()).First();
|
var filePath = path.Split(snapshotService.GetArchivePathSeparator()).First();
|
||||||
return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
|
return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
|
||||||
filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) ||
|
filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) ||
|
||||||
filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase);
|
filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -163,9 +145,9 @@ public sealed class IndexController : ControllerBase
|
|||||||
private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer)
|
private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer)
|
||||||
{
|
{
|
||||||
// Example: file is inside an archive – use ArchiveHandler
|
// Example: file is inside an archive – use ArchiveHandler
|
||||||
var archivePath = fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).First();
|
var archivePath = fileEntry.Path.Split(snapshotService.GetArchivePathSeparator()).First();
|
||||||
_snapshotService.GetArchiveName(titleId);
|
snapshotService.GetArchiveName(titleId);
|
||||||
var innerFileName = Path.GetFileName(fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last());
|
var innerFileName = Path.GetFileName(fileEntry.Path.Split(snapshotService.GetArchivePathSeparator()).Last());
|
||||||
|
|
||||||
// Use SharpCompress to open the archive and find the entry.
|
// Use SharpCompress to open the archive and find the entry.
|
||||||
// Only the 3 archive types we support are handled.
|
// Only the 3 archive types we support are handled.
|
||||||
@@ -212,44 +194,4 @@ public sealed class IndexController : ControllerBase
|
|||||||
streamContainer = null;
|
streamContainer = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DependentStream : Stream
|
|
||||||
{
|
|
||||||
private readonly Stream _innerStream;
|
|
||||||
private readonly IDisposable? _parentContainer;
|
|
||||||
|
|
||||||
public DependentStream(Stream innerStream, IDisposable? parentContainer)
|
|
||||||
{
|
|
||||||
_innerStream = innerStream;
|
|
||||||
_parentContainer = parentContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Flush() => _innerStream.Flush();
|
|
||||||
|
|
||||||
public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);
|
|
||||||
|
|
||||||
public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);
|
|
||||||
|
|
||||||
public override void SetLength(long value) => _innerStream.SetLength(value);
|
|
||||||
|
|
||||||
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
|
|
||||||
|
|
||||||
|
|
||||||
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanRead => _innerStream.CanRead;
|
|
||||||
public override bool CanSeek => _innerStream.CanSeek;
|
|
||||||
public override bool CanWrite => _innerStream.CanWrite;
|
|
||||||
public override long Length => _innerStream.Length;
|
|
||||||
public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; }
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
_parentContainer?.Dispose();
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,24 +4,17 @@ using TinfoilVibeServer.Authentication;
|
|||||||
namespace TinfoilVibeServer.Middleware;
|
namespace TinfoilVibeServer.Middleware;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal Basic‑Auth middleware that also checks UID, failure counters and a blacklist.
|
/// Minimal Basic‑Auth middleware that also checks UID, failure counters, and a blacklist.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BasicAuthMiddleware
|
public sealed class BasicAuthMiddleware(RequestDelegate next)
|
||||||
{
|
{
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
|
|
||||||
public BasicAuthMiddleware(RequestDelegate next)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger)
|
public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger)
|
||||||
{
|
{
|
||||||
// ------------- 1) Bypass auth for every path except “/” ----------------
|
// ------------- 1) Bypass auth for every path except “/” ----------------
|
||||||
// PathString is a struct – compare its value directly.
|
// PathString is a struct – compare its value directly.
|
||||||
if (!context.Request.Path.Equals("/", StringComparison.Ordinal))
|
if (!context.Request.Path.Equals("/", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
await _next(context);
|
await next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +35,7 @@ public sealed class BasicAuthMiddleware
|
|||||||
{
|
{
|
||||||
logger.LogWarning("Missing Authorization header from {IP}", ip);
|
logger.LogWarning("Missing Authorization header from {IP}", ip);
|
||||||
Challenge(context);
|
Challenge(context);
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
logger.LogTrace("Sent 401 challenge to client");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +43,14 @@ public sealed class BasicAuthMiddleware
|
|||||||
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
Challenge(context);
|
Challenge(context);
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
logger.LogTrace("Sent 401 challenge to client");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string decoded;
|
string decoded;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var b64 = authHeader[6..].Trim();
|
var b64 = authHeader.Substring(6).Trim();
|
||||||
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -79,12 +72,9 @@ public sealed class BasicAuthMiddleware
|
|||||||
var password = parts[1];
|
var password = parts[1];
|
||||||
|
|
||||||
// 3) UID header (optional)
|
// 3) UID header (optional)
|
||||||
int? uid = null;
|
string? uid = null;
|
||||||
if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
|
if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
|
||||||
{
|
uid = uidHeader.FirstOrDefault();
|
||||||
if (int.TryParse(uidHeader.ToString(), out var parsedUid))
|
|
||||||
uid = parsedUid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Validate
|
// 4) Validate
|
||||||
if (!store.TryValidate(username, password, uid, ip, out var error))
|
if (!store.TryValidate(username, password, uid, ip, out var error))
|
||||||
@@ -99,7 +89,7 @@ public sealed class BasicAuthMiddleware
|
|||||||
// Authentication succeeded – attach username for downstream handlers if needed
|
// Authentication succeeded – attach username for downstream handlers if needed
|
||||||
context.Items["User"] = username;
|
context.Items["User"] = username;
|
||||||
logger.LogInformation("User {User} authenticated successfully (UID={UID})", username, uid);
|
logger.LogInformation("User {User} authenticated successfully (UID={UID})", username, uid);
|
||||||
await _next(context);
|
await next(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Challenge(HttpContext ctx)
|
private static void Challenge(HttpContext ctx)
|
||||||
|
|||||||
@@ -10,6 +10,5 @@ public sealed record AppSettings(
|
|||||||
string CredentialsFile,
|
string CredentialsFile,
|
||||||
string FingerprintsFile,
|
string FingerprintsFile,
|
||||||
string BlacklistFile,
|
string BlacklistFile,
|
||||||
int MaxFailedAttempts,
|
int MaxFailedAttempts
|
||||||
string KeySetFile
|
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using TinfoilVibeServer.Services;
|
using System.Collections.Generic;
|
||||||
|
using TinfoilVibeServer.Services;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
@@ -8,6 +9,6 @@ namespace TinfoilVibeServer.Models;
|
|||||||
public sealed record FileEntry(
|
public sealed record FileEntry(
|
||||||
string Path, // nsp or archive path
|
string Path, // nsp or archive path
|
||||||
long Size, // size of nsp or full archive
|
long Size, // size of nsp or full archive
|
||||||
string Hash, // SHA‑256 hex of first NCA of first NCP in NSP or archive
|
string? Hash, // SHA‑256 hex of first NCA of first NCP in NSP or archive
|
||||||
List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path
|
List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path
|
||||||
);
|
);
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
|
|
||||||
public class IndexBuilderSettings
|
public class IndexBuilderSettings
|
||||||
{
|
{
|
||||||
|
public string CacheFilePath { get; set; } = "indexcache.json";
|
||||||
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
|
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
|
||||||
|
public ICollection<string>? IndexDirectories { get; set; }
|
||||||
|
|
||||||
|
public string? LoginMessage { get; set; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using LibHac.Ncm;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO returned by the extractor – contains all data the snapshot needs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NcaMetadataWithHash
|
||||||
|
{
|
||||||
|
public string TitleId { get; }
|
||||||
|
public string ApplicationTitle { get; set; }
|
||||||
|
public int Version { get; }
|
||||||
|
|
||||||
|
public ContentMetaType ContentMetaType { get; set; }
|
||||||
|
public string? Hash { get; }
|
||||||
|
|
||||||
|
public NcaMetadataWithHash(string titleId, string applicationTitle, int version,
|
||||||
|
ContentMetaType contentMetaType, string? hash = null)
|
||||||
|
{
|
||||||
|
TitleId = titleId;
|
||||||
|
ApplicationTitle = applicationTitle;
|
||||||
|
Version = version;
|
||||||
|
ContentMetaType = contentMetaType;
|
||||||
|
Hash = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,44 @@
|
|||||||
using System.ComponentModel;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
public sealed class SnapshotOptions : INotifyPropertyChanged
|
public sealed class SnapshotOptions : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
private List<string> _rootDirectories = new();
|
private List<string> _rootDirectories = [];
|
||||||
|
|
||||||
public List<string> RootDirectories
|
public List<string> RootDirectories
|
||||||
{
|
{
|
||||||
get => _rootDirectories;
|
get => _rootDirectories;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (_rootDirectories != value)
|
if (_rootDirectories.Except(value) == Array.Empty<string>()) return;
|
||||||
{
|
|
||||||
_rootDirectories = value;
|
_rootDirectories = value;
|
||||||
OnPropertyChanged(nameof(RootDirectories));
|
OnPropertyChanged(nameof(RootDirectories));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
private List<string> _archiveExtensions = new();
|
private List<string> _archiveExtensions = new();
|
||||||
|
|
||||||
public List<string> ArchiveExtensions
|
public List<string> ArchiveExtensions
|
||||||
{
|
{
|
||||||
get => _archiveExtensions;
|
get => _archiveExtensions;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (_archiveExtensions != value)
|
if (_archiveExtensions.Except(value) == Array.Empty<string>()) return;
|
||||||
{
|
|
||||||
_archiveExtensions = value;
|
_archiveExtensions = value;
|
||||||
OnPropertyChanged(nameof(_archiveExtensions));
|
OnPropertyChanged(nameof(_archiveExtensions));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
private List<string> _romExtensions = new();
|
private List<string> _romExtensions = new();
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "No ROM extension specified")]
|
||||||
public List<string> RomExtensions
|
public List<string> RomExtensions
|
||||||
{
|
{
|
||||||
get => _romExtensions;
|
get => _romExtensions;
|
||||||
@@ -43,7 +51,9 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
|
private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
public TimeSpan CacheTtl
|
public TimeSpan CacheTtl
|
||||||
{
|
{
|
||||||
get => _cacheTtl;
|
get => _cacheTtl;
|
||||||
@@ -71,6 +81,7 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string _snapshotBackupFile = "snapshot.bak";
|
private string _snapshotBackupFile = "snapshot.bak";
|
||||||
|
|
||||||
public string SnapshotBackupFile
|
public string SnapshotBackupFile
|
||||||
{
|
{
|
||||||
get => _snapshotBackupFile;
|
get => _snapshotBackupFile;
|
||||||
@@ -82,7 +93,33 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region FileSystemWatcher options
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many times to retry when the snapshot file is locked.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRetryCount { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base debounce timeout (ms) used by the file‑watcher.
|
||||||
|
/// The retry interval will be this value multiplied by <see cref="RetryMultiplier"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int DebounceTimeoutMs { get; set; } = 500; // existing value – keep it
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multiplier used to compute the retry wait time: `wait = DebounceTimeoutMs * RetryMultiplier`.
|
||||||
|
/// </summary>
|
||||||
|
public double RetryMultiplier { get; set; } = 1.5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional custom path to the snapshot file; the service will try to write there.
|
||||||
|
/// </summary>
|
||||||
|
public string SnapshotFilePath { get; set; } = "snapshots/latest.snapshot";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
private void OnPropertyChanged(string propertyName) =>
|
private void OnPropertyChanged(string propertyName) =>
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TinfoilVibeServer.Authentication;
|
using TinfoilVibeServer.Authentication;
|
||||||
using TinfoilVibeServer.Middleware;
|
using TinfoilVibeServer.Middleware;
|
||||||
@@ -9,31 +18,59 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddConsole();
|
builder.Logging.AddConsole();
|
||||||
builder.Logging.AddDebug();
|
builder.Logging.AddDebug();
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// 1) Configuration – read appsettings.json once and expose it via
|
|
||||||
// ConfigManager (reloads on file change)
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.Configure<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
|
var dataRoot = builder.Configuration["CONFIG_ROOT"] ?? "/app/config/";
|
||||||
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
// 1️⃣ Load the embedded default
|
||||||
builder.Services.Configure<SnapshotOptions>(builder.Configuration.GetSection("Snapshot"));
|
var defaultResource = typeof(Program).Assembly
|
||||||
builder.Services.AddSingleton<ConfigManager>();
|
.GetManifestResourceStream("TinfoilVibeServer.appsettings.default.json")!; // adjust namespace
|
||||||
builder.Services.AddSingleton<INSPExtractor, NSPExtractor>(sp =>
|
var defaultConfig = JsonDocument.Parse(defaultResource).RootElement;
|
||||||
|
|
||||||
|
// 2️⃣ Try to write the file if it doesn't exist
|
||||||
|
var configPath = Path.Combine(dataRoot, "appsettings.json");
|
||||||
|
if (!File.Exists(configPath))
|
||||||
{
|
{
|
||||||
var config = sp.GetRequiredService<ConfigManager>();
|
// write the embedded JSON straight to disk
|
||||||
var logger = sp.GetRequiredService<ILogger<INSPExtractor>>();
|
try
|
||||||
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
|
{
|
||||||
return new NSPExtractor(keySet, logger);
|
File.WriteAllText(configPath, defaultConfig.GetRawText());
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
var tempFactory = LoggerFactory.Create(loggingBuilder =>
|
||||||
|
{
|
||||||
|
loggingBuilder.AddConsole();
|
||||||
|
loggingBuilder.AddDebug();
|
||||||
});
|
});
|
||||||
|
var logger = tempFactory.CreateLogger<Program>();
|
||||||
|
logger.LogError(e, "Failed to write default config file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddJsonFile(Path.Combine(dataRoot,"appsettings.json"), optional: false, reloadOnChange: true)
|
||||||
|
.AddJsonFile(Path.Combine(dataRoot,$"appsettings.{builder.Environment.EnvironmentName}.json"), optional: true, reloadOnChange: true)
|
||||||
|
.AddJsonFile(Path.Combine(dataRoot,$"appsettings.{builder.Environment.EnvironmentName}.local.json"), optional: true, reloadOnChange: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.AddCommandLine(args).Build();
|
||||||
|
builder.Configuration.AddConfiguration(config);
|
||||||
|
builder.Services.AddOptions();
|
||||||
|
builder.Services.Configure<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
|
||||||
|
builder.Services.Configure<NSPExtractorOptions>(builder.Configuration.GetSection("NSPExtractor"));
|
||||||
|
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
||||||
|
builder.Services.Configure<IndexBuilderSettings>(builder.Configuration.GetSection("IndexBuilder"));
|
||||||
|
builder.Services.AddOptions<SnapshotOptions>().Bind(builder.Configuration.GetSection("Snapshot")).ValidateOnStart();
|
||||||
|
builder.Services.AddSingleton<ConfigManager>();
|
||||||
|
builder.Services.AddSingleton<INSPExtractor, NSPExtractor>();
|
||||||
builder.Services.AddSingleton<SnapshotService>();
|
builder.Services.AddSingleton<SnapshotService>();
|
||||||
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(sp => sp.GetRequiredService<SnapshotService>());
|
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(sp => sp.GetRequiredService<SnapshotService>());
|
||||||
builder.Services.AddSingleton<IAuthStore, AuthStore>();
|
builder.Services.AddSingleton<IAuthStore, AuthStore>();
|
||||||
builder.Services.AddSingleton<TitleDatabaseService>();
|
builder.Services.AddSingleton<TitleDatabaseService>();
|
||||||
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
|
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
|
||||||
builder.Services.AddSingleton<IndexBuilderService>();
|
builder.Services.AddSingleton<IndexBuilderService>();
|
||||||
|
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
|
||||||
builder.Services.AddHostedService<SnapshotService>(provider => provider.GetRequiredService<SnapshotService>());
|
builder.Services.AddHostedService<SnapshotService>(provider => provider.GetRequiredService<SnapshotService>());
|
||||||
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
||||||
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
|
|
||||||
builder.Services.AddControllers(); // add MVC
|
builder.Services.AddControllers(); // add MVC
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist)
|
// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist)
|
||||||
@@ -54,7 +91,7 @@ app.MapGet("/debug", () => new SnapshotService(
|
|||||||
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
|
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
|
||||||
app.Services.GetRequiredService<INSPExtractor>(),
|
app.Services.GetRequiredService<INSPExtractor>(),
|
||||||
app.Services.GetRequiredService<IArchiveHandler>(),
|
app.Services.GetRequiredService<IArchiveHandler>(),
|
||||||
app.Services.GetRequiredService<ILogger<SnapshotService>>())
|
app.Services.GetRequiredService<ILogger<SnapshotService>>(), app.Services.GetRequiredService<IHostEnvironment>())
|
||||||
.GetSnapshot());
|
.GetSnapshot());
|
||||||
app.Lifetime.ApplicationStarted.Register(() =>
|
app.Lifetime.ApplicationStarted.Register(() =>
|
||||||
app.Services.GetRequiredService<ILogger<Program>>().LogInformation("Application started. Listening on {Urls}", string.Join(", ", app.Urls)));
|
app.Services.GetRequiredService<ILogger<Program>>().LogInformation("Application started. Listening on {Urls}", string.Join(", ", app.Urls)));
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://192.168.1.145:80;http://tinfoil.localhost:8080;http://tinfoil.ecenshu.net",
|
"applicationUrl": "http://192.168.1.145:8080;http://tinfoil.localhost:8081;http://tinfoil.ecenshu.net",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"CONFIG_ROOT": "./config/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.IO.Compression;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using SharpCompress.Archives;
|
using SharpCompress.Archives;
|
||||||
using SharpCompress.Archives.Zip;
|
|
||||||
using SharpCompress.Archives.Rar;
|
using SharpCompress.Archives.Rar;
|
||||||
using SharpCompress.Archives.SevenZip;
|
using SharpCompress.Archives.SevenZip;
|
||||||
using SharpCompress.Common;
|
using SharpCompress.Common;
|
||||||
@@ -97,7 +100,7 @@ public sealed class ArchiveHandler : IArchiveHandler
|
|||||||
using var archive = SevenZipArchive.Open(path);
|
using var archive = SevenZipArchive.Open(path);
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
{
|
{
|
||||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
if (!entry.IsDirectory && entry.Key != null && IsRomArchive(entry.Key))
|
||||||
{
|
{
|
||||||
var temp = Path.GetTempFileName();
|
var temp = Path.GetTempFileName();
|
||||||
entry.WriteToFile(temp);
|
entry.WriteToFile(temp);
|
||||||
@@ -114,18 +117,29 @@ public sealed class ArchiveHandler : IArchiveHandler
|
|||||||
var entryCount = 0;
|
var entryCount = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// todo: handle and skip multipart archives
|
using var romArchive = new RomArchiveReader(path);
|
||||||
using var archive = RarArchive.Open(path);
|
var archiveEntries = romArchive.GetEntries().ToList();
|
||||||
entryCount = archive.Entries.Count;
|
//using var archive = RarArchive.Open(path);
|
||||||
foreach (var entry in archive.Entries)
|
entryCount = archiveEntries.Count;
|
||||||
{
|
foreach (var archiveEntry in archiveEntries)
|
||||||
if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue;
|
|
||||||
{
|
{
|
||||||
|
var archiveEntryName = archiveEntry.Name;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, true);
|
Stream? wrapper = null;
|
||||||
var title = _nspExtractor.ExtractFromStream(streamWrapper);
|
|
||||||
if (title != null) titles.Add((entry.Key, entry.Size, title));
|
wrapper = new RewindableStream(archiveEntry.Stream, () =>
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Rewinding archive entry {ArchiveEntry}", archiveEntryName);
|
||||||
|
return romArchive.GetEntries().First(romArchiveEntry => romArchiveEntry.Name == archiveEntryName).Stream;
|
||||||
|
},10*1024*1024, archiveEntry.Stream.Length);
|
||||||
|
//wrapper = new SeekableBufferedStream(archiveEntry.Stream, archiveEntry.Stream.Length, 10*1024*1024, true);
|
||||||
|
var title = _nspExtractor.ExtractFromStream(wrapper);
|
||||||
|
if (title != null)
|
||||||
|
{
|
||||||
|
titles.Add((archiveEntry.Name, archiveEntry.Stream.Length, title));
|
||||||
|
}
|
||||||
|
wrapper?.Dispose();
|
||||||
}
|
}
|
||||||
catch (IncompleteArchiveException incompleteArchiveException)
|
catch (IncompleteArchiveException incompleteArchiveException)
|
||||||
{
|
{
|
||||||
@@ -144,7 +158,6 @@ public sealed class ArchiveHandler : IArchiveHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
if (titles.Count > 0 && titles.Count == entryCount)
|
if (titles.Count > 0 && titles.Count == entryCount)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.IO;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
using LibHac.Common.Keys;
|
using LibHac.Common.Keys;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
@@ -11,17 +13,16 @@ namespace TinfoilVibeServer.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConfigManager
|
public class ConfigManager
|
||||||
{
|
{
|
||||||
public AppSettings Settings { get; private set; }
|
public AppSettings? Settings { get; private set; }
|
||||||
|
public event Action<AppSettings?>? OnChange;
|
||||||
public event Action<AppSettings>? OnChange;
|
|
||||||
|
|
||||||
private readonly string _configPath;
|
private readonly string _configPath;
|
||||||
private readonly FileSystemWatcher _watcher;
|
private readonly FileSystemWatcher _watcher;
|
||||||
private readonly object _sync = new();
|
private readonly Lock _sync = new();
|
||||||
|
|
||||||
public ConfigManager()
|
public ConfigManager()
|
||||||
{
|
{
|
||||||
_configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
_configPath = Path.Combine(AppContext.BaseDirectory, "config", "appsettings.json");
|
||||||
Load();
|
Load();
|
||||||
|
|
||||||
_watcher = new FileSystemWatcher
|
_watcher = new FileSystemWatcher
|
||||||
@@ -39,41 +40,19 @@ public class ConfigManager
|
|||||||
if (!File.Exists(_configPath))
|
if (!File.Exists(_configPath))
|
||||||
{
|
{
|
||||||
Settings = new AppSettings(
|
Settings = new AppSettings(
|
||||||
RootDirectories: Array.Empty<string>(),
|
RootDirectories: [],
|
||||||
WhitelistExtensions: Array.Empty<string>(),
|
WhitelistExtensions: [],
|
||||||
RomExtensions: Array.Empty<string>(),
|
RomExtensions: [],
|
||||||
CredentialsFile: "credentials.json",
|
CredentialsFile: "credentials.json",
|
||||||
FingerprintsFile: "fingerprints.json",
|
FingerprintsFile: "fingerprints.json",
|
||||||
BlacklistFile: "blacklist.json",
|
BlacklistFile: "blacklist.json",
|
||||||
MaxFailedAttempts: 5,
|
MaxFailedAttempts: 5
|
||||||
KeySetFile: "keys.bin"
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var txt = File.ReadAllText(_configPath);
|
var txt = File.ReadAllText(_configPath);
|
||||||
Settings = JsonSerializer.Deserialize<AppSettings>(txt, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
Settings = JsonSerializer.Deserialize<AppSettings>(txt, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||||
|
|
||||||
// --- Load the KeySet --------------------------------------------
|
|
||||||
if (!string.IsNullOrWhiteSpace(Settings.KeySetFile))
|
|
||||||
{
|
|
||||||
var keyFilePath = Path.Combine(AppContext.BaseDirectory, Settings.KeySetFile);
|
|
||||||
if (File.Exists(keyFilePath))
|
|
||||||
{
|
|
||||||
// LibHac provides a static helper to load a key‑set file.
|
|
||||||
// If the file is not found or corrupt, we simply keep the
|
|
||||||
// default (empty) key set – the app will throw later
|
|
||||||
// when a title requires a missing key.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(keyFilePath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
KeySetHolder.KeySet = new KeySet(); // fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Reload()
|
private void Reload()
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
using System.Security.Cryptography;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using LibHac.Ncm;
|
using LibHac.Ncm;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
@@ -11,29 +20,35 @@ namespace TinfoilVibeServer.Services;
|
|||||||
// *** NEW ***
|
// *** NEW ***
|
||||||
public sealed class IndexBuilderService: IHostedService
|
public sealed class IndexBuilderService: IHostedService
|
||||||
{
|
{
|
||||||
private const string CacheFileName = "indexcache.json";
|
private readonly IOptionsMonitor<IndexBuilderSettings> _options;
|
||||||
|
|
||||||
private readonly IOptions<IndexBuilderSettings> _options;
|
|
||||||
private readonly ISnapshotService _snapshotService;
|
private readonly ISnapshotService _snapshotService;
|
||||||
private readonly TitleDatabaseService _titleDb;
|
private readonly TitleDatabaseService _titleDb;
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly ILogger<IndexBuilderService> _logger;
|
private readonly ILogger<IndexBuilderService> _logger;
|
||||||
private readonly string _cachePath;
|
private readonly IHostEnvironment _hostEnvironment;
|
||||||
|
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
public IndexBuilderService(
|
public IndexBuilderService(
|
||||||
IOptions<IndexBuilderSettings> options,
|
IOptionsMonitor<IndexBuilderSettings> options,
|
||||||
ISnapshotService snapshotService,
|
ISnapshotService snapshotService,
|
||||||
TitleDatabaseService titleDb,
|
TitleDatabaseService titleDb,
|
||||||
IConfiguration configuration,
|
ILogger<IndexBuilderService> logger,
|
||||||
ILogger<IndexBuilderService> logger)
|
IHostEnvironment hostEnvironment)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_snapshotService = snapshotService;
|
_snapshotService = snapshotService;
|
||||||
_titleDb = titleDb;
|
_titleDb = titleDb;
|
||||||
_configuration = configuration;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName);
|
_hostEnvironment = hostEnvironment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetermineCachePath(IOptionsMonitor<IndexBuilderSettings> options, IHostEnvironment environment)
|
||||||
|
{
|
||||||
|
if (Path.IsPathRooted(options.CurrentValue.CacheFilePath))
|
||||||
|
{
|
||||||
|
return options.CurrentValue.CacheFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(environment.ContentRootPath, "data", options.CurrentValue.CacheFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IndexDto Build(HttpContext httpContext)
|
public IndexDto Build(HttpContext httpContext)
|
||||||
@@ -60,10 +75,9 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
// 3️⃣ Build new index from snapshot entries
|
// 3️⃣ Build new index from snapshot entries
|
||||||
var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase));
|
var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase));
|
||||||
|
|
||||||
var directories = _configuration.GetSection("Directories")
|
var directories = _options.CurrentValue.IndexDirectories ?? Array.Empty<string>();
|
||||||
.Get<string[]>() ?? Array.Empty<string>();
|
|
||||||
|
|
||||||
var success = _configuration["SuccessMessage"] ?? string.Empty;
|
var success = _options.CurrentValue.LoginMessage ?? string.Empty;
|
||||||
|
|
||||||
var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success);
|
var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success);
|
||||||
|
|
||||||
@@ -90,7 +104,7 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000;
|
var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000;
|
||||||
var patchOrApp = title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
|
var patchOrApp = title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
|
||||||
if (title.ContentMetaType == ContentMetaType.Patch ||
|
if (title.ContentMetaType == ContentMetaType.Patch ||
|
||||||
title.ContentMetaType == ContentMetaType.AddOnContent)
|
title.ContentMetaType == ContentMetaType.AddOnContent || (title.ContentMetaType == ContentMetaType.Application && name == "Unknown"))
|
||||||
{
|
{
|
||||||
// Patch should use the application title name
|
// Patch should use the application title name
|
||||||
name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle)
|
name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle)
|
||||||
@@ -102,21 +116,21 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
// if still unknown, its probably a demo, use filename extraction
|
// if still unknown, its probably a demo, use filename extraction
|
||||||
if (name == "Unknown")
|
if (name == "Unknown")
|
||||||
{
|
{
|
||||||
var match = Regex.Match(Path.GetFileNameWithoutExtension(e.Path), "^(.+?)\\s*(?:\\[.*)?$",
|
var match = Regex.Match(Path.GetFileNameWithoutExtension(e.Path), "^(.+?)\\s*(?:\\[.*)?$", RegexOptions.None);
|
||||||
RegexOptions.None);
|
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
{
|
{
|
||||||
name = match.Groups[1].Value;
|
name = match.Groups[1].Value;
|
||||||
_logger.LogInformation("Name not found for {TitleId}, using filename: {Name}", titleId,
|
_logger.LogInformation("Name not found for {TitleId}, using filename: {Name}", titleId, name);
|
||||||
name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileName = Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
|
var fileName = Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
|
||||||
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
|
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
|
||||||
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
|
var isWellFormed = Uri.TryCreate(url, UriKind.Absolute, out var parsedUri);
|
||||||
|
|
||||||
|
if (isWellFormed && parsedUri != null)
|
||||||
{
|
{
|
||||||
fileDtos.Add(new FileDto(url, e.Size));
|
fileDtos.Add(new FileDto(parsedUri.AbsoluteUri, e.Size));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -133,18 +147,28 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
|
|
||||||
private IndexCache? LoadCache()
|
private IndexCache? LoadCache()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_cachePath)) return null;
|
var cachePath = DetermineCachePath(_options, _hostEnvironment);
|
||||||
|
if (!File.Exists(cachePath)) return null;
|
||||||
_lock.Wait();
|
_lock.Wait();
|
||||||
var json = File.ReadAllText(_cachePath);
|
var json = File.ReadAllText(cachePath);
|
||||||
_lock.Release();
|
_lock.Release();
|
||||||
_logger.LogInformation("Loaded index cache from {Path}", _cachePath);
|
_logger.LogInformation("Loaded index cache from {Path}", cachePath);
|
||||||
return JsonSerializer.Deserialize<IndexCache>(json);
|
return JsonSerializer.Deserialize<IndexCache>(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistCache(string snapshotHash, IndexDto index)
|
private void PersistCache(string snapshotHash, IndexDto index)
|
||||||
{
|
{
|
||||||
|
var cachePath = DetermineCachePath(_options, _hostEnvironment);
|
||||||
var cache = new IndexCache(snapshotHash, index);
|
var cache = new IndexCache(snapshotHash, index);
|
||||||
File.WriteAllText(_cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true}));
|
File.WriteAllText(cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true}));
|
||||||
|
}
|
||||||
|
public void InvalidateIndex(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var cachePath = DetermineCachePath(_options, _hostEnvironment);
|
||||||
|
if (!File.Exists(cachePath)) return;
|
||||||
|
|
||||||
|
File.Delete(cachePath);
|
||||||
|
_logger.LogInformation("Index cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
||||||
@@ -161,27 +185,17 @@ public sealed class IndexBuilderService: IHostedService
|
|||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var url = new Uri(_options.Value.ApiBaseUrl);
|
var url = new Uri(_options.CurrentValue.ApiBaseUrl);
|
||||||
var host = url.Host;
|
var host = url.Host;
|
||||||
Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}});
|
Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}});
|
||||||
_snapshotService.SnapshotRebuilt += InvalidateIndex;
|
_snapshotService.SnapshotRebuilt += InvalidateIndex;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InvalidateIndex(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (!File.Exists(_cachePath)) return;
|
|
||||||
|
|
||||||
File.Delete(_cachePath);
|
|
||||||
_logger.LogInformation("Index cache cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_snapshotService.SnapshotRebuilt -= InvalidateIndex;
|
_snapshotService.SnapshotRebuilt -= InvalidateIndex;
|
||||||
return Task.CompletedTask; // nothing special to do on shutdown
|
return Task.CompletedTask; // nothing special to do on shutdown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
using System.Security.Cryptography;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using LibHac.Common;
|
using LibHac.Common;
|
||||||
using LibHac.Fs;
|
using LibHac.Fs;
|
||||||
using LibHac.Fs.Fsa;
|
using LibHac.Fs.Fsa;
|
||||||
@@ -9,6 +14,13 @@ using LibHac.Common.Keys;
|
|||||||
using LibHac.Ncm;
|
using LibHac.Ncm;
|
||||||
using LibHac.Tools.Fs;
|
using LibHac.Tools.Fs;
|
||||||
using LibHac.Tools.Ncm;
|
using LibHac.Tools.Ncm;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
using TinfoilVibeServer.Utilities;
|
||||||
|
using NcaHeader = LibHac.Tools.FsSystem.NcaUtils.NcaHeader;
|
||||||
|
using Path = System.IO.Path;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services
|
namespace TinfoilVibeServer.Services
|
||||||
{
|
{
|
||||||
@@ -32,13 +44,49 @@ namespace TinfoilVibeServer.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NSPExtractor : INSPExtractor
|
public sealed class NSPExtractor : INSPExtractor
|
||||||
{
|
{
|
||||||
private readonly KeySet _keySet;
|
private KeySet? _keySet;
|
||||||
private readonly ILogger<INSPExtractor> _logger;
|
|
||||||
|
|
||||||
public NSPExtractor(KeySet keySet, ILogger<INSPExtractor> logger)
|
public KeySet? KeySet
|
||||||
{
|
{
|
||||||
_keySet = keySet;
|
get
|
||||||
|
{
|
||||||
|
if (_keySet != null) return _keySet;
|
||||||
|
if (_options.CurrentValue.KeyFile == null) return null;
|
||||||
|
var dataRoot = _environment.ContentRootPath ?? "/app/config";
|
||||||
|
if (Path.IsPathRooted(_options.CurrentValue.KeyFile))
|
||||||
|
{
|
||||||
|
_keySet = ExternalKeyReader.ReadKeyFile(_options.CurrentValue.KeyFile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_keySet = ExternalKeyReader.ReadKeyFile(Path.Combine(dataRoot, "config", _options.CurrentValue.KeyFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
return _keySet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IOptionsMonitor<NSPExtractorOptions> _options;
|
||||||
|
private readonly ILogger<INSPExtractor> _logger;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
|
|
||||||
|
public NSPExtractor(IOptionsMonitor<NSPExtractorOptions> options, ILogger<INSPExtractor> logger, IHostEnvironment environment)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_options.OnChange(o =>
|
||||||
|
{
|
||||||
|
if (o.KeyFile == null)
|
||||||
|
{
|
||||||
|
_logger?.LogInformation("No KeySet specified, skipping key validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(o.KeyFile))
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("KeySet file {KeyFile} does not exist", o.KeyFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_environment = environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -55,6 +103,8 @@ namespace TinfoilVibeServer.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public NcaMetadataWithHash? ExtractFromStream(Stream stream)
|
public NcaMetadataWithHash? ExtractFromStream(Stream stream)
|
||||||
{
|
{
|
||||||
|
if (KeySet == null) return null;
|
||||||
|
|
||||||
if (!stream.CanSeek) return null;
|
if (!stream.CanSeek) return null;
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
@@ -67,17 +117,25 @@ namespace TinfoilVibeServer.Services
|
|||||||
|
|
||||||
if (IsXciFileSystem(stream))
|
if (IsXciFileSystem(stream))
|
||||||
{
|
{
|
||||||
var xci = new Xci(_keySet, storage);
|
var xci = new Xci(KeySet, storage);
|
||||||
List<DirectoryEntryEx> ncaEntries;
|
|
||||||
if (xci.HasPartition(XciPartitionType.Secure))
|
if (xci.HasPartition(XciPartitionType.Secure))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Processing as XCI");
|
_logger.LogInformation("Processing as XCI");
|
||||||
var partition = xci.OpenPartition(XciPartitionType.Secure);
|
using var partition = xci.OpenPartition(XciPartitionType.Secure);
|
||||||
ncaEntries = partition
|
// Find the smallest file
|
||||||
|
var hashEntry = partition.EnumerateEntries("*", SearchOptions.Default)
|
||||||
|
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
|
||||||
|
using var hashFileRef = new UniqueRef<IFile>();
|
||||||
|
partition.OpenFile(ref hashFileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
using var hashFile = hashFileRef.Release();
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
using var hashStream = hashFile.AsStream();
|
||||||
|
var hash = sha256.ComputeHash(hashStream);
|
||||||
|
|
||||||
|
List<DirectoryEntryEx> ncaEntries = partition
|
||||||
.EnumerateEntries("*.cnmt.nca", SearchOptions.RecurseSubdirectories)
|
.EnumerateEntries("*.cnmt.nca", SearchOptions.RecurseSubdirectories)
|
||||||
.Where(e => e.Type == DirectoryEntryType.File)
|
.Where(e => e.Type == DirectoryEntryType.File)
|
||||||
.ToList();
|
.ToList();
|
||||||
byte[]? hash = null;
|
|
||||||
foreach (var dirEntry in ncaEntries)
|
foreach (var dirEntry in ncaEntries)
|
||||||
{
|
{
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
using var fileRef = new UniqueRef<IFile>();
|
||||||
@@ -85,14 +143,7 @@ namespace TinfoilVibeServer.Services
|
|||||||
using var ncaFile = fileRef.Release();
|
using var ncaFile = fileRef.Release();
|
||||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
using var ncaFileStorage = new FileStorage(ncaFile);
|
||||||
|
|
||||||
var nca = new Nca(_keySet, ncaFileStorage);
|
var nca = new Nca(KeySet, ncaFileStorage);
|
||||||
if (hash == null)
|
|
||||||
{
|
|
||||||
// Hash the *first* NCA stream – the stream we just opened
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
using var ncaStream = ncaFile.AsStream();
|
|
||||||
hash = sha256.ComputeHash(ncaStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta)
|
if (nca.Header.ContentType != NcaContentType.Meta)
|
||||||
continue; // only the meta NCA contains title metadata
|
continue; // only the meta NCA contains title metadata
|
||||||
@@ -100,7 +151,7 @@ namespace TinfoilVibeServer.Services
|
|||||||
string titleId = nca.Header.TitleId.ToString("X16");
|
string titleId = nca.Header.TitleId.ToString("X16");
|
||||||
var (contentMetaType, applicationTitleId, titleVersion) = GetMetaData(nca);
|
var (contentMetaType, applicationTitleId, titleVersion) = GetMetaData(nca);
|
||||||
|
|
||||||
_logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", titleId, titleVersion);
|
_logger.LogInformation("Meta NCA found – TitleId={TitleId} ApplicationTitleId={ApplicationTitleId} Version={Version}", titleId, applicationTitleId.ToStrId(), titleVersion);
|
||||||
// XCI can never be a patch?
|
// XCI can never be a patch?
|
||||||
return new NcaMetadataWithHash(titleId, applicationTitleId.ToString("X16"), titleVersion.Major, ContentMetaType.Application, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
|
return new NcaMetadataWithHash(titleId, applicationTitleId.ToString("X16"), titleVersion.Major, ContentMetaType.Application, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
|
||||||
}
|
}
|
||||||
@@ -112,16 +163,29 @@ namespace TinfoilVibeServer.Services
|
|||||||
|
|
||||||
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
|
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
|
||||||
{
|
{
|
||||||
|
if (KeySet == null) return null;
|
||||||
|
|
||||||
List<DirectoryEntryEx> ncaEntries;
|
List<DirectoryEntryEx> ncaEntries;
|
||||||
_logger.LogInformation("Processing as NSP");
|
_logger.LogInformation("Processing as NSP");
|
||||||
var partition = new PartitionFileSystem();
|
using var partition = new PartitionFileSystem();
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
partition.Initialize(storage).ThrowIfFailure();
|
||||||
// Find the first *.nca that contains the meta header
|
// Find the first *.nca that contains the meta header
|
||||||
|
var hashEntry = partition
|
||||||
|
.EnumerateEntries("*", SearchOptions.Default)
|
||||||
|
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
|
||||||
|
// Hash the *first* NCA stream – the stream we just opened
|
||||||
|
using var hashRef = new UniqueRef<IFile>();
|
||||||
|
partition.OpenFile(ref hashRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
using var hashFile = hashRef.Release();
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
using var hashStream = hashFile.AsStream();
|
||||||
|
var hash = sha256.ComputeHash(hashStream);
|
||||||
|
|
||||||
ncaEntries = partition
|
ncaEntries = partition
|
||||||
.EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
|
.EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
|
||||||
.Where(e => e.Type == DirectoryEntryType.File)
|
.Where(e => e.Type == DirectoryEntryType.File)
|
||||||
.ToList();
|
.ToList();
|
||||||
byte[]? hash = null;
|
|
||||||
foreach (var dirEntry in ncaEntries)
|
foreach (var dirEntry in ncaEntries)
|
||||||
{
|
{
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
using var fileRef = new UniqueRef<IFile>();
|
||||||
@@ -129,14 +193,8 @@ namespace TinfoilVibeServer.Services
|
|||||||
using var ncaFile = fileRef.Release();
|
using var ncaFile = fileRef.Release();
|
||||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
using var ncaFileStorage = new FileStorage(ncaFile);
|
||||||
|
|
||||||
var nca = new Nca(_keySet, ncaFileStorage);
|
var nca = new Nca(KeySet, ncaFileStorage);
|
||||||
if (hash == null)
|
|
||||||
{
|
|
||||||
// Hash the *first* NCA stream – the stream we just opened
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
using var ncaStream = ncaFile.AsStream();
|
|
||||||
hash = sha256.ComputeHash(ncaStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta)
|
if (nca.Header.ContentType != NcaContentType.Meta)
|
||||||
continue; // only the meta NCA contains title metadata
|
continue; // only the meta NCA contains title metadata
|
||||||
@@ -183,8 +241,8 @@ namespace TinfoilVibeServer.Services
|
|||||||
if (!stream.CanSeek) return false;
|
if (!stream.CanSeek) return false;
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
var storage = new StreamStorage(stream, true);
|
using var storage = new StreamStorage(stream, true);
|
||||||
var partition = new PartitionFileSystem();
|
using var partition = new PartitionFileSystem();
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
partition.Initialize(storage).ThrowIfFailure();
|
||||||
_logger.LogInformation("PFS0 found");
|
_logger.LogInformation("PFS0 found");
|
||||||
return true;
|
return true;
|
||||||
@@ -196,17 +254,19 @@ namespace TinfoilVibeServer.Services
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsXciFileSystem(Stream stream)
|
private bool IsXciFileSystem(Stream stream)
|
||||||
{
|
{
|
||||||
|
if (KeySet == null) return false;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!stream.CanSeek) return false;
|
if (!stream.CanSeek) return false;
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
using var storage = new StreamStorage(stream, true);
|
||||||
var storage = new StreamStorage(stream, true);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var xciBlock = new Xci(_keySet, storage);
|
var xciBlock = new Xci(KeySet, storage);
|
||||||
_logger.LogInformation("XCI found");
|
_logger.LogInformation("XCI found");
|
||||||
return xciBlock.HasPartition(XciPartitionType.Secure);
|
return xciBlock.HasPartition(XciPartitionType.Secure);
|
||||||
}
|
}
|
||||||
@@ -214,6 +274,7 @@ namespace TinfoilVibeServer.Services
|
|||||||
{
|
{
|
||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
@@ -225,41 +286,60 @@ namespace TinfoilVibeServer.Services
|
|||||||
|
|
||||||
public string ExtractHashFromStream(Stream nspStream)
|
public string ExtractHashFromStream(Stream nspStream)
|
||||||
{
|
{
|
||||||
if (!IsPfs0FileSystem(nspStream))
|
if (KeySet == null) return string.Empty;
|
||||||
|
var seekableStream = !nspStream.CanSeek ? new SeekableBufferedStream(nspStream, nspStream.Length) : nspStream;
|
||||||
|
|
||||||
|
var isNsp = IsPfs0FileSystem(seekableStream);
|
||||||
|
var isXci = false;
|
||||||
|
|
||||||
|
if (!isNsp)
|
||||||
|
{
|
||||||
|
isXci = IsXciFileSystem(seekableStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNsp && !isXci)
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
nspStream.Seek(0, SeekOrigin.Begin);
|
seekableStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
using var storage = new StreamStorage(nspStream, true);
|
using var storage = new StreamStorage(seekableStream, true);
|
||||||
var partition = new PartitionFileSystem();
|
if (isXci)
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
|
||||||
|
|
||||||
// Find the first *.nca that contains the meta header
|
|
||||||
var ncaEntries = partition
|
|
||||||
.EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
|
|
||||||
.Where(e => e.Type == DirectoryEntryType.File)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var dirEntry in ncaEntries)
|
|
||||||
{
|
{
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
var xci = new Xci(KeySet, storage);
|
||||||
partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
using IFileSystem partition = xci.OpenPartition(XciPartitionType.Secure);
|
||||||
using var ncaFile = fileRef.Release();
|
var hashEntry = partition
|
||||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
.EnumerateEntries("*", SearchOptions.Default)
|
||||||
|
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10*1024*1024);
|
||||||
|
|
||||||
|
using var fileRef = new UniqueRef<IFile>();
|
||||||
|
partition.OpenFile(ref fileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
using var ncaFile = fileRef.Release();
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
using var ncaStream = ncaFile.AsStream();
|
||||||
|
var hash = sha256.ComputeHash(ncaStream);
|
||||||
|
var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||||
|
_logger.LogInformation("Computed first‑stream hash {Hash}", extractHashFromStream);
|
||||||
|
return extractHashFromStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNsp)
|
||||||
|
{
|
||||||
|
using var partition = new PartitionFileSystem();
|
||||||
|
partition.Initialize(storage).ThrowIfFailure();
|
||||||
|
var hashEntry = partition
|
||||||
|
.EnumerateEntries("*", SearchOptions.Default)
|
||||||
|
.First(e => e.Type == DirectoryEntryType.File && e.Size < 10 * 1024 * 1024);
|
||||||
|
|
||||||
|
using var fileRef = new UniqueRef<IFile>();
|
||||||
|
partition.OpenFile(ref fileRef.Ref, hashEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
|
using var ncaFile = fileRef.Release();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nca = new Nca(_keySet, ncaFileStorage);
|
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta)
|
|
||||||
continue; // only the meta NCA contains title metadata
|
|
||||||
|
|
||||||
// Hash the *first* NCA stream – the stream we just opened
|
|
||||||
using var ncaStream = ncaFile.AsStream();
|
using var ncaStream = ncaFile.AsStream();
|
||||||
using var sha256 = SHA256.Create();
|
using var sha256 = SHA256.Create();
|
||||||
var hash = sha256.ComputeHash(ncaStream);
|
var hash = sha256.ComputeHash(ncaStream);
|
||||||
var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||||
_logger.LogInformation("Computed first‑stream hash {Hash} for {TitleId}", extractHashFromStream,
|
_logger.LogInformation("Computed first‑stream hash {Hash}", extractHashFromStream);
|
||||||
nca.Header.TitleId);
|
|
||||||
return extractHashFromStream;
|
return extractHashFromStream;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
@@ -267,30 +347,13 @@ namespace TinfoilVibeServer.Services
|
|||||||
_logger.LogError("Failed to extract NSP: {Exception}", e.Message);
|
_logger.LogError("Failed to extract NSP: {Exception}", e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public class NSPExtractorOptions
|
||||||
/// DTO returned by the extractor – contains all data the snapshot needs.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NcaMetadataWithHash
|
|
||||||
{
|
{
|
||||||
public string TitleId { get; }
|
public string? KeyFile { get; set; }
|
||||||
public string ApplicationTitle { get; set; }
|
|
||||||
public int Version { get; }
|
|
||||||
|
|
||||||
public ContentMetaType ContentMetaType { get; set; }
|
|
||||||
public string Hash { get; }
|
|
||||||
|
|
||||||
public NcaMetadataWithHash(string titleId, string applicationTitle, int version,
|
|
||||||
ContentMetaType contentMetaType, string hash)
|
|
||||||
{
|
|
||||||
TitleId = titleId;
|
|
||||||
ApplicationTitle = applicationTitle;
|
|
||||||
Version = version;
|
|
||||||
ContentMetaType = contentMetaType;
|
|
||||||
Hash = hash;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,14 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using SharpCompress.Archives;
|
using SharpCompress.Archives;
|
||||||
|
using SharpCompress.Archives.Rar;
|
||||||
using SharpCompress.Common;
|
using SharpCompress.Common;
|
||||||
|
using SharpCompress.IO;
|
||||||
|
using SharpCompress.Readers;
|
||||||
|
using TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services
|
namespace TinfoilVibeServer.Services
|
||||||
{
|
{
|
||||||
@@ -14,22 +20,26 @@ namespace TinfoilVibeServer.Services
|
|||||||
public sealed class RomArchiveReader : IDisposable
|
public sealed class RomArchiveReader : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ZipArchive? _zipArchive;
|
private readonly ZipArchive? _zipArchive;
|
||||||
private readonly IArchive? _sharpArchive;
|
private readonly IArchive? _archive;
|
||||||
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
|
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
|
||||||
|
private readonly ICollection<Stream>? _partStreams;
|
||||||
|
|
||||||
public RomArchiveReader(string path) : this(File.OpenRead(path), path) { }
|
public RomArchiveReader(string path)
|
||||||
|
{
|
||||||
|
_archive = DetectAndWrap(path);
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens an archive from a stream.
|
/// Opens an archive from a stream.
|
||||||
/// The *fileName* parameter is only used to decide which archive format
|
/// The *fileName* parameter is only used to decide which archive format
|
||||||
/// to open; it can be <c>null</c> if the caller already knows the format.
|
/// to open; it can be <c>null</c> if the caller already knows the format.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RomArchiveReader(Stream stream, string? fileName = null)
|
public RomArchiveReader(string path, Stream stream)
|
||||||
{
|
{
|
||||||
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
||||||
|
|
||||||
var ext = fileName?.ToLowerInvariant() ?? string.Empty;
|
var fileInfo = new FileInfo(path);
|
||||||
|
|
||||||
switch (ext)
|
switch (fileInfo.Extension)
|
||||||
{
|
{
|
||||||
case ".zip":
|
case ".zip":
|
||||||
// System.IO.Compression can use the stream directly
|
// System.IO.Compression can use the stream directly
|
||||||
@@ -51,14 +61,61 @@ namespace TinfoilVibeServer.Services
|
|||||||
{
|
{
|
||||||
_archiveStream = stream;
|
_archiveStream = stream;
|
||||||
}
|
}
|
||||||
_sharpArchive = ArchiveFactory.Open(_archiveStream);
|
|
||||||
|
_archive = ArchiveFactory.Open(_archiveStream, new ReaderOptions() { LeaveStreamOpen = false, BufferSize = 10 * 1024 * 1024});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new NotSupportedException($"Archive type '{ext}' is not supported.");
|
throw new NotSupportedException($"Archive type '{fileInfo.Extension}' is not supported.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect whether the file is a multi‑part RAR and wrap it if necessary
|
||||||
|
private static IArchive DetectAndWrap(string path)
|
||||||
|
{
|
||||||
|
string ext = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
|
||||||
|
if (ext == ".rar" || ext == ".r00" || ext == ".r01" || ext == ".r02")
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(path)!;
|
||||||
|
var fileName = Path.GetFileName(path);
|
||||||
|
|
||||||
|
// ----- 1️⃣ Determine the base name (everything before the first ".rar" or ".partNN") -----
|
||||||
|
string baseName = MultiPartRarHelper.GetBaseNameForRarVolume(fileName);
|
||||||
|
|
||||||
|
// Any file that ends with .rar or .rNN could be the start of a multi‑part set
|
||||||
|
// Let MultiPartRarStream decide which parts belong together.
|
||||||
|
var volumes = MultiPartRarHelper.DiscoverVolumes(dir, baseName);
|
||||||
|
if (volumes.Count is 0 or 1)
|
||||||
|
{
|
||||||
|
return ArchiveFactory.Open(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
var streams = new List<Stream>(volumes.Count);
|
||||||
|
foreach (var volume in volumes)
|
||||||
|
{
|
||||||
|
streams.Add(new FileStream(volume, FileMode.Open, FileAccess.Read, FileShare.Read));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ArchiveFactory.Open(streams, new ReaderOptions { LeaveStreamOpen = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal single‑file archive (zip, 7z, single‑rar, etc.)
|
||||||
|
using var archiveStream = File.OpenRead(path);
|
||||||
|
return ArchiveFactory.Open(archiveStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Stream? GetPart(int arg)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream? GetEntryStream(string entryName)
|
||||||
|
{
|
||||||
|
var entry = _archive?.Entries
|
||||||
|
.Single(e => e.Key != null && e.Key.Equals(entryName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return entry?.OpenEntryStream();
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerates every file entry inside the archive.
|
/// Enumerates every file entry inside the archive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -71,19 +128,29 @@ namespace TinfoilVibeServer.Services
|
|||||||
if (entry.FullName.EndsWith("/", StringComparison.Ordinal))
|
if (entry.FullName.EndsWith("/", StringComparison.Ordinal))
|
||||||
continue; // skip directories
|
continue; // skip directories
|
||||||
|
|
||||||
// ZipArchiveEntry.Open returns a seekable stream that must be disposed by the caller
|
|
||||||
yield return new RomArchiveEntry(entry.FullName, entry.Open());
|
yield return new RomArchiveEntry(entry.FullName, entry.Open());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (_sharpArchive != null)
|
else if (_archive != null)
|
||||||
{
|
{
|
||||||
foreach (var entry in _sharpArchive.Entries)
|
foreach (var entry in _archive.Entries)
|
||||||
{
|
{
|
||||||
if (entry.IsDirectory)
|
if (entry.IsDirectory)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// SharpCompress gives us a stream that must be disposed by the caller
|
// SharpCompress gives us a stream that must be disposed by the caller
|
||||||
yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
|
if (!entry.Archive.IsComplete)
|
||||||
|
{
|
||||||
|
if (entry.Key != null)
|
||||||
|
{
|
||||||
|
var entryStream = GetEntryStream(entry.Key);
|
||||||
|
if (entryStream != null) yield return new RomArchiveEntry(entry.Key, entryStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (entry.Key != null) yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +166,7 @@ namespace TinfoilVibeServer.Services
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_zipArchive?.Dispose();
|
_zipArchive?.Dispose();
|
||||||
_sharpArchive?.Dispose();
|
_archive?.Dispose();
|
||||||
_archiveStream?.Dispose();
|
_archiveStream?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,72 @@
|
|||||||
using System.Collections.Concurrent;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
using TinfoilVibeServer.Utilities;
|
using TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
namespace TinfoilVibeServer.Services;
|
||||||
public interface ISnapshotService
|
public interface ISnapshotService
|
||||||
{
|
{
|
||||||
event EventHandler SnapshotRebuilt; // raised after a rebuild
|
event EventHandler SnapshotRebuilt; // event raised after a rebuild
|
||||||
void RebuildSnapshot();
|
void RebuildSnapshot();
|
||||||
|
Task RebuildSnapshotAsync(CancellationToken cancellationToken = default);
|
||||||
SnapshotService.ROMSnapshot GetSnapshot();
|
SnapshotService.ROMSnapshot GetSnapshot();
|
||||||
|
|
||||||
Task AddToSnapshotAsync(FileEntry entry);
|
Task AddToSnapshotAsync(FileEntry entry);
|
||||||
Task BuildSnapshotAsync();
|
Task BuildSnapshotAsync(CancellationToken cancellationToken = default);
|
||||||
void GetArchiveName(string titleId);
|
void GetArchiveName(string titleId);
|
||||||
char GetArchivePathSeparator();
|
char GetArchivePathSeparator();
|
||||||
|
void Start();
|
||||||
|
void Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
/// Watches a folder for changes and rebuilds a snapshot when the first change after a debounce window occurs.
|
||||||
|
/// While a rebuild is in progress, subsequent file changes are ignored (they will be processed once the current
|
||||||
|
/// rebuild finishes and a new debounce window starts).
|
||||||
/// only re‑processes a file if its hash changed.
|
/// only re‑processes a file if its hash changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
|
public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
|
||||||
{
|
{
|
||||||
#region FileSystemWatcher
|
#region FileSystemWatcher
|
||||||
private readonly List<FileSystemWatcher> _watchers = new();
|
|
||||||
|
/* ==============================================================
|
||||||
|
* 1️⃣ FileSystemWatcher
|
||||||
|
* ============================================================== */
|
||||||
|
private readonly List<FileSystemWatcher> _watchers = [];
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Snapshot options & helpers
|
||||||
|
/* ==============================================================
|
||||||
|
* 2️⃣ Snapshot options & helpers
|
||||||
|
* ============================================================== */
|
||||||
private readonly SnapshotOptions _options;
|
private readonly SnapshotOptions _options;
|
||||||
private readonly INSPExtractor _nspExtractor;
|
private readonly INSPExtractor _nspExtractor;
|
||||||
private readonly IArchiveHandler _archiveHandler;
|
private readonly IArchiveHandler _archiveHandler;
|
||||||
private readonly ILogger<SnapshotService> _logger;
|
private readonly ILogger<SnapshotService> _logger;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
private readonly string _jsonPath;
|
private readonly string _jsonPath;
|
||||||
private readonly string _snapshotPath;
|
private readonly string _snapshotPath;
|
||||||
private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new();
|
private readonly ConcurrentDictionary<string, SnapshotEntry> _cache = new();
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, string> _hashCache = new();
|
private readonly ConcurrentDictionary<string, string> _hashCache = new();
|
||||||
|
|
||||||
// Archive full path -> FileEntry.Path
|
// Archive full path -> FileEntry.Path
|
||||||
private readonly ConcurrentDictionary<string, string> _archiveLookup = new();
|
private readonly ConcurrentDictionary<string, string> _archiveLookup = new();
|
||||||
|
|
||||||
// hash -> file size
|
// hash -> file size
|
||||||
private readonly ConcurrentDictionary<string, long> _sizeLookup = new();
|
private readonly ConcurrentDictionary<string, long> _sizeLookup = new();
|
||||||
private readonly IMemoryCache _debouncerCache;
|
private readonly IMemoryCache _debouncerCache;
|
||||||
@@ -46,60 +74,103 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
public event EventHandler? SnapshotRebuilding;
|
public event EventHandler? SnapshotRebuilding;
|
||||||
|
|
||||||
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1, 1);
|
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1, 1);
|
||||||
private const char ArchivePathSeparator = '|';
|
|
||||||
public char GetArchivePathSeparator() => ArchivePathSeparator;
|
|
||||||
|
|
||||||
|
private const char ArchivePathSeparator = '|';
|
||||||
|
|
||||||
|
// Cache key used to keep the debounce flag
|
||||||
|
private const string DebounceKey = "SnapshotService.IsDebouncing";
|
||||||
|
private const string BuildKey = "SnapshotService.IsBuilding";
|
||||||
|
private CancellationTokenSource _cancellation = new();
|
||||||
|
private Task? _currentBuildTask;
|
||||||
|
|
||||||
|
public char GetArchivePathSeparator() => ArchivePathSeparator;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
/* ==============================================================
|
||||||
|
* 3️⃣ Build‑time guard
|
||||||
|
* ============================================================== */
|
||||||
|
/// <summary>
|
||||||
|
/// Allows only one rebuild at a time.
|
||||||
|
/// </summary>
|
||||||
|
private readonly SemaphoreSlim _buildLock = new(1, 1);
|
||||||
|
|
||||||
|
/* ==============================================================
|
||||||
|
* 4️⃣ Constructor
|
||||||
|
* ============================================================== */
|
||||||
public SnapshotService(
|
public SnapshotService(
|
||||||
IMemoryCache debouncerCache,
|
IMemoryCache debouncerCache,
|
||||||
IOptionsMonitor<SnapshotOptions> options,
|
IOptionsMonitor<SnapshotOptions> options,
|
||||||
INSPExtractor nspExtractor,
|
INSPExtractor nspExtractor,
|
||||||
IArchiveHandler archiveHandler,
|
IArchiveHandler archiveHandler,
|
||||||
ILogger<SnapshotService> logger)
|
ILogger<SnapshotService> logger,
|
||||||
|
IHostEnvironment environment
|
||||||
|
)
|
||||||
{
|
{
|
||||||
_options = options.CurrentValue;
|
_options = options.CurrentValue;
|
||||||
_debouncerCache = debouncerCache;
|
_debouncerCache = debouncerCache;
|
||||||
_nspExtractor = nspExtractor;
|
_nspExtractor = nspExtractor;
|
||||||
_archiveHandler = archiveHandler;
|
_archiveHandler = archiveHandler;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
|
_environment = environment;
|
||||||
|
_jsonPath = !Path.IsPathRooted(_options.SnapshotFile) ? Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotFile) : _options.SnapshotFile;
|
||||||
|
|
||||||
// Debounce timer for persisting snapshot
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath) ?? throw new InvalidOperationException()));
|
||||||
long debounceTime = 200;
|
|
||||||
var entryOptions = new MemoryCacheEntryOptions()
|
|
||||||
.SetSlidingExpiration(TimeSpan.FromSeconds(debounceTime)).RegisterPostEvictionCallback((key, value, reason,
|
|
||||||
state) =>
|
|
||||||
{
|
|
||||||
|
|
||||||
_logger.LogInformation("Should persist the snapshot {Key}, {Reason}", key, reason);
|
|
||||||
}); // <‑‑ sliding!
|
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath)));
|
|
||||||
if (!File.Exists(_jsonPath))
|
if (!File.Exists(_jsonPath))
|
||||||
{
|
{
|
||||||
_snapshotFileSemaphore.Wait();
|
_snapshotFileSemaphore.Wait();
|
||||||
File.WriteAllText(_jsonPath, "[]");
|
File.WriteAllText(_jsonPath, "[]");
|
||||||
_snapshotFileSemaphore.Release();
|
_snapshotFileSemaphore.Release();
|
||||||
}
|
}
|
||||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
|
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath)));
|
_snapshotPath = !Path.IsPathRooted(_options.SnapshotBackupFile) ? Path.Combine(Path.DirectorySeparatorChar.ToString(), "app", "data", _options.SnapshotBackupFile) : _options.SnapshotBackupFile;
|
||||||
|
|
||||||
|
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath) ?? throw new InvalidOperationException()));
|
||||||
|
|
||||||
// 1️⃣ Register for *property* changes
|
// 1️⃣ Register for *property* changes
|
||||||
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
options.OnChange((snapshotOptions, _) => { _options.RootDirectories = snapshotOptions.RootDirectories; });
|
||||||
|
_options.PropertyChanged += (_, e) => OnOptionsChanged(e.PropertyName);
|
||||||
|
|
||||||
|
if (_options.RootDirectories.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No directories set to watch for ROMS/Archives");
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var path in _options.RootDirectories)
|
foreach (var path in _options.RootDirectories)
|
||||||
{
|
{
|
||||||
AddWatchDirectory(path);
|
AddWatchDirectory(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#region Public API
|
||||||
|
|
||||||
|
public void Start() => _watchers.ForEach(watcher => watcher.EnableRaisingEvents = true);
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
foreach (var fileSystemWatcher in _watchers)
|
||||||
|
{
|
||||||
|
fileSystemWatcher.EnableRaisingEvents = false;
|
||||||
|
}
|
||||||
|
_cancellation.Cancel();
|
||||||
|
try { _currentBuildTask?.Wait(); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
// --------- Private helpers ---------
|
// --------- Private helpers ---------
|
||||||
private void OnOptionsChanged(string propertyName)
|
private void OnOptionsChanged(string? propertyName)
|
||||||
{
|
|
||||||
if (propertyName == nameof(SnapshotOptions.RootDirectories))
|
|
||||||
{
|
{
|
||||||
|
if (propertyName != nameof(SnapshotOptions.RootDirectories)) return;
|
||||||
|
|
||||||
|
_logger.LogInformation("Root directories changed, rebuilding snapshot");
|
||||||
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
||||||
foreach (var watcher in fileSystemWatchers)
|
var systemWatchers = fileSystemWatchers.ToList();
|
||||||
|
foreach (var watcher in systemWatchers)
|
||||||
{
|
{
|
||||||
watcher.EnableRaisingEvents = false;
|
RemoveWatchDirectory(watcher.Path);
|
||||||
watcher.Dispose();
|
|
||||||
_watchers.Remove(watcher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
||||||
@@ -109,16 +180,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
foreach (var newWatchedDirectory in newWatchedDirectories)
|
foreach (var newWatchedDirectory in newWatchedDirectories)
|
||||||
{
|
{
|
||||||
AddWatchDirectory(newWatchedDirectory);
|
AddWatchDirectory(newWatchedDirectory);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BuildSnapshotAsync(); // rebuild everything
|
_ = BuildSnapshotAsync(_cancellation.Token); // rebuild everything
|
||||||
PersistSnapshotAsync();
|
PersistSnapshotAsync(_cancellation.Token);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#region FileSystemWatcher
|
#region FileSystemWatcher helpers
|
||||||
|
|
||||||
|
/* ==============================================================
|
||||||
|
* 5️⃣ FileSystemWatcher helpers
|
||||||
|
* ============================================================== */
|
||||||
private void AddWatchDirectory(string path)
|
private void AddWatchDirectory(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path)) return;
|
if (!Directory.Exists(path)) return;
|
||||||
@@ -151,11 +224,42 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
|
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
|
||||||
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
|
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuild the snapshot, if rebuild in process, cancel it and restart
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileSystemEventArgs"></param>
|
||||||
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
||||||
{
|
{
|
||||||
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
// If a rebuild is already underway, ignore the event
|
||||||
|
if (_currentBuildTask is { IsCompleted: false })
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"File system event {ChangeType} on {Path} triggered, but build is in progress, skipping snapshot update",
|
||||||
|
fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a rebuild only if we’re not already debouncing
|
||||||
|
if (_debouncerCache.TryGetValue(DebounceKey, out bool isDebouncing) && isDebouncing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// If a rebuild is in progress, ignore the event immediately
|
||||||
|
if (_buildLock.CurrentCount == 0) // lock held by a rebuild
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"File system event {ChangeType} on {Path} triggered, restart Build Task on next completed entry",
|
||||||
|
fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
|
||||||
|
_cancellation.Cancel();
|
||||||
|
_buildLock.Wait();
|
||||||
|
_buildLock.Release();
|
||||||
|
_cancellation.Dispose();
|
||||||
|
_cancellation = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
CancellationTokenSource cts = new();
|
||||||
|
|
||||||
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
||||||
//.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
|
.AddExpirationToken(new CancellationChangeToken(cts.Token))
|
||||||
.SetValue(fileSystemEventArgs)
|
.SetValue(fileSystemEventArgs)
|
||||||
.SetOptions(new MemoryCacheEntryOptions
|
.SetOptions(new MemoryCacheEntryOptions
|
||||||
{
|
{
|
||||||
@@ -164,60 +268,27 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
new PostEvictionCallbackRegistration
|
new PostEvictionCallbackRegistration
|
||||||
{
|
{
|
||||||
EvictionCallback =
|
EvictionCallback =
|
||||||
(key, value, reason, state) =>
|
(_, _, reason, _) =>
|
||||||
{
|
{
|
||||||
if (reason != EvictionReason.Expired) return;
|
if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired)) return;
|
||||||
|
|
||||||
if (value is FileSystemEventArgs args)
|
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
||||||
{
|
// Kick off the rebuild asynchronously
|
||||||
if (IsFileLocked(args.FullPath))
|
_currentBuildTask = RebuildSnapshotAsync(_cancellation.Token);
|
||||||
{
|
|
||||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath);
|
|
||||||
using var rebounce = _debouncerCache.CreateEntry(args.FullPath)
|
|
||||||
.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
|
|
||||||
.SetValue(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RebuildSnapshot();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
|
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||||
|
|
||||||
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
||||||
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
|
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff"));
|
||||||
}
|
|
||||||
private static bool IsFileLocked(string filePath)
|
|
||||||
{
|
|
||||||
FileStream? stream = null;
|
|
||||||
var file = new FileInfo(filePath);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
stream?.Close();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int DebounceMs = 400;
|
private const int DebounceMs = 400;
|
||||||
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true };
|
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true };
|
||||||
private int SnapshotFileLockTimeout { get; } = 1000;
|
private int SnapshotFileLockTimeout { get; } = 1000;
|
||||||
|
|
||||||
private void DebounceElapsed()
|
|
||||||
{
|
|
||||||
UpdateSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Snapshot logic
|
#region Snapshot logic
|
||||||
@@ -225,9 +296,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
public Task AddToSnapshotAsync(FileEntry entry)
|
public Task AddToSnapshotAsync(FileEntry entry)
|
||||||
{
|
{
|
||||||
// Update lookup tables
|
// Update lookup tables
|
||||||
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, entry.Titles);
|
if (entry.Hash == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path);
|
||||||
|
|
||||||
|
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles);
|
||||||
_hashCache[entry.Hash] = entry.Path;
|
_hashCache[entry.Hash] = entry.Path;
|
||||||
_sizeLookup[entry.Hash] = entry.Size;
|
_sizeLookup[entry.Hash] = entry.Size;
|
||||||
|
|
||||||
if (entry.Path.Contains(ArchivePathSeparator))
|
if (entry.Path.Contains(ArchivePathSeparator))
|
||||||
{
|
{
|
||||||
var filename = entry.Path.Split(ArchivePathSeparator)[0];
|
var filename = entry.Path.Split(ArchivePathSeparator)[0];
|
||||||
@@ -236,111 +316,138 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
foreach (var ncaMetadataWithHash in entry.Titles)
|
foreach (var ncaMetadataWithHash in entry.Titles)
|
||||||
{
|
{
|
||||||
|
if (ncaMetadataWithHash.Hash == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
|
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
|
||||||
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
|
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
|
||||||
_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
|
//_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist snapshot to disk
|
// Persist snapshot to disk
|
||||||
PersistSnapshotAsync();
|
PersistSnapshotAsync();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==============================================================
|
||||||
|
* 6️⃣ Snapshot build / persistence helpers
|
||||||
|
* ============================================================== */
|
||||||
/// Builds _cache and _hashCache based on directory configuration
|
/// Builds _cache and _hashCache based on directory configuration
|
||||||
public Task BuildSnapshotAsync()
|
/// <param name="cancellationToken"></param>
|
||||||
|
public async Task BuildSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Acquire the rebuild lock – if we cannot, skip this build.
|
||||||
|
if (!await _buildLock.WaitAsync(0, cancellationToken))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("BuildSnapshotAsync called while rebuild in progress, ignoring.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Building snapshot");
|
_logger.LogInformation("Building snapshot");
|
||||||
var index = LoadSnapshotIndex();
|
var index = LoadSnapshotIndex();
|
||||||
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
||||||
var fileInfo = new FileInfo(_snapshotPath);
|
var fileInfo = new FileInfo(_snapshotPath);
|
||||||
bool snapshotVerified = true;
|
var snapshotVerified = fileInfo.Exists;
|
||||||
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
||||||
{
|
{
|
||||||
if (index.Count != 0)
|
if (index.Count != 0)
|
||||||
{
|
{
|
||||||
// directory may have been added with older roms, verify that the snapshot is still up to date
|
|
||||||
foreach (var dir in _options.RootDirectories)
|
foreach (var dir in _options.RootDirectories)
|
||||||
{
|
{
|
||||||
// check first entry is in index
|
// Snapshot is older than the latest modified file in the directory
|
||||||
var entry = BuildSnapshot(dir).FirstOrDefault();
|
try
|
||||||
if (entry != null)
|
|
||||||
{
|
{
|
||||||
if (!index.TryGetValue(entry.Path, out var cached))
|
var lastOrDefault = BuildSnapshot(dir, cancellationToken).LastOrDefault();
|
||||||
|
if (lastOrDefault != null && !index.TryGetValue(lastOrDefault.Path, out _))
|
||||||
{
|
{
|
||||||
snapshotVerified = false;
|
snapshotVerified = false;
|
||||||
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
|
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException operationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Build Cancelled while building snapshot from directory {Directory}: {Message}", dir, operationCanceledException.Message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshotVerified)
|
if (!snapshotVerified)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Snapshot is up to date");
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Snapshot is up to date but index is empty");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
|
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
|
||||||
var entries = new List<FileEntry>();
|
var entries = new List<FileEntry>();
|
||||||
|
|
||||||
var snapshotChanged = false;
|
|
||||||
foreach (var dir in _options.RootDirectories)
|
foreach (var dir in _options.RootDirectories)
|
||||||
{
|
{
|
||||||
_ = Task.Run(() =>
|
foreach (var entry in BuildSnapshot(dir, cancellationToken))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Rebuilding directory {Directory}", dir);
|
if (entry != null) entries.Add(entry);
|
||||||
var buildSnapshot = BuildSnapshot(dir);
|
}
|
||||||
var fileEntries = buildSnapshot.ToList();
|
|
||||||
snapshotChanged = snapshotChanged || fileEntries.Count != 0;
|
|
||||||
entries.AddRange(fileEntries.Where(entry => entry != null)!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the entire snapshot
|
var currentHash = ComputeSnapshotHash(entries);
|
||||||
ComputeSnapshotHash(entries);
|
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0)
|
||||||
if (snapshotChanged)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Snapshot rebuilt");
|
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
|
||||||
|
await PersistSnapshotAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_buildLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void GetArchiveName(string titleId)
|
public void GetArchiveName(string titleId)
|
||||||
{
|
{
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns List of FileEntry that do not have a hash in the cache
|
// Returns List of FileEntry that do not have a hash in the cache
|
||||||
// Each entry that has not been added to the lookup table is added to the cache
|
// Each entry that has not been added to the lookup table is added to the cache
|
||||||
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
|
private IEnumerable<FileEntry?> BuildSnapshot(string dir, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
FileEntry entry;
|
var processedFiles = new HashSet<string>();
|
||||||
|
|
||||||
if (!Directory.Exists(dir)) yield break;
|
if (!Directory.Exists(dir)) yield break;
|
||||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).OrderBy(file =>
|
||||||
{
|
{
|
||||||
string hash = string.Empty;
|
var fileInfo = new FileInfo(file);
|
||||||
|
return fileInfo.LastWriteTimeUtc;
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
string hash;
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||||
|
|
||||||
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (_cache.ContainsKey(file) || _hashCache.ContainsKey(hash))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) extract title if applicable
|
// 3) extract title if applicable
|
||||||
var titles = new List<(string, long, NcaMetadataWithHash)>();
|
var titles = new List<(string, long, NcaMetadataWithHash)>();
|
||||||
if (_options.RomExtensions.Contains(ext))
|
if (_options.RomExtensions.Contains(ext))
|
||||||
{
|
{
|
||||||
|
var fileInfo = new FileInfo(file);
|
||||||
|
var ncaMetadataWithHash = fileInfo.GetNcaMetadataWithHash();
|
||||||
|
if (ncaMetadataWithHash != null)
|
||||||
|
{
|
||||||
|
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
|
||||||
|
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
|
||||||
|
AddToSnapshotAsync(fileEntryFromFileName);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return fileEntryFromFileName;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
using var nspStream = File.OpenRead(file);
|
using var nspStream = File.OpenRead(file);
|
||||||
|
_logger.LogDebug("Extracting hash for {File}", file);
|
||||||
hash = ComputeFirstStreamHash(nspStream);
|
hash = ComputeFirstStreamHash(nspStream);
|
||||||
|
|
||||||
if (_hashCache.ContainsKey(hash))
|
if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -349,10 +456,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
var title = _nspExtractor.ExtractFromStream(nspStream);
|
var title = _nspExtractor.ExtractFromStream(nspStream);
|
||||||
if (title != null)
|
if (title != null)
|
||||||
{
|
{
|
||||||
var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]);
|
var romEntry = new FileEntry(file, nspStreamLength, hash, [title]);
|
||||||
AddToSnapshotAsync(archiveEntry);
|
AddToSnapshotAsync(romEntry);
|
||||||
titles.Add((title.TitleId, nspStreamLength, title));
|
titles.Add((title.TitleId, nspStreamLength, title));
|
||||||
yield return archiveEntry;
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return romEntry;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -360,16 +469,34 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
if (_options.ArchiveExtensions.Contains(ext))
|
if (_options.ArchiveExtensions.Contains(ext))
|
||||||
{
|
{
|
||||||
if (_archiveLookup.ContainsKey(file)) continue;
|
if (_archiveLookup.ContainsKey(file)) continue;
|
||||||
hash = ComputeFirstStreamHash(file);
|
if (processedFiles.Contains(file)) continue;
|
||||||
if (_hashCache.ContainsKey(hash))
|
_logger.LogDebug("Extracting hash for {File}", file);
|
||||||
|
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||||
|
hash = ComputeFirstStreamHashAsync(file, cancellationToken).Result;
|
||||||
|
stopwatch.Stop();
|
||||||
|
_logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds);
|
||||||
|
if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return null;
|
yield return null;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
|
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
|
titlesEnumerable = TryExtractTitleInfosWithRetryAsync(file, cancellationToken).Result;
|
||||||
|
// if it was multipart, add multiparts to processedFiles
|
||||||
|
var directoryName = Path.GetDirectoryName(file);
|
||||||
|
if (directoryName != null)
|
||||||
|
{
|
||||||
|
var baseName = MultiPartRarHelper.GetBaseNameForRarVolume(Path.GetFileName(file));
|
||||||
|
var discoverVolumes = MultiPartRarHelper.DiscoverVolumes(directoryName, baseName);
|
||||||
|
if (discoverVolumes.Count > 1)
|
||||||
|
{
|
||||||
|
processedFiles.UnionWith(discoverVolumes);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@@ -383,17 +510,13 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
{
|
{
|
||||||
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
|
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
|
||||||
AddToSnapshotAsync(archiveEntry);
|
AddToSnapshotAsync(archiveEntry);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return archiveEntry;
|
yield return archiveEntry;
|
||||||
}
|
}
|
||||||
/*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList());
|
|
||||||
AddToSnapshotAsync(fileEntry);
|
|
||||||
yield return fileEntry;*/
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (titles.Count == 0)
|
if (titles.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -402,93 +525,125 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash);
|
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash);
|
||||||
yield return new FileEntry(file, titles.Select((tuple, i) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, i) => tuple.Item3).ToList());
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return new FileEntry(file, titles.Select((tuple, _) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, _) => tuple.Item3).ToList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ComputeFirstStreamHash(Stream nspStream)
|
private async Task<IEnumerable<(string, long, NcaMetadataWithHash)>?> TryExtractTitleInfosWithRetryAsync(string file, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _nspExtractor.ExtractHashFromStream(nspStream);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stopwatch2 = Stopwatch.StartNew();
|
||||||
|
var titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
|
||||||
|
stopwatch2.Stop();
|
||||||
|
_logger.LogDebug("Extracted title infos for {File} in {Time}ms", file, stopwatch2.ElapsedMilliseconds);
|
||||||
|
return titlesEnumerable;
|
||||||
|
}
|
||||||
|
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
||||||
|
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.",
|
||||||
|
attempt + 1, file, delay);
|
||||||
|
await Task.Delay(delay, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSnapshot() => BuildSnapshotAsync();
|
private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
IEnumerable<FileEntry> GetEntries()
|
private string ComputeFirstStreamHash(Stream nspStream) => _nspExtractor.ExtractHashFromStream(nspStream);
|
||||||
|
|
||||||
|
private IEnumerable<FileEntry> GetEntries()
|
||||||
{
|
{
|
||||||
foreach (var snapshotEntry in _cache)
|
foreach (var kv in _cache.OrderByDescending(pair => pair.Value.LastModified))
|
||||||
{
|
yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash);
|
||||||
_sizeLookup.TryGetValue(snapshotEntry.Value.Hash, out var size);
|
|
||||||
var fileEntry = new FileEntry(snapshotEntry.Key, snapshotEntry.Value.Size, snapshotEntry.Value.Hash, snapshotEntry.Value.NcaMetadataWithHash);
|
|
||||||
yield return fileEntry;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
private Task PersistSnapshotAsync()
|
private Task PersistSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_debouncerCache.TryGetValue(_jsonPath, out var value))
|
if (_debouncerCache.TryGetValue(_jsonPath, out _))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
|
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var entries = GetEntries().ToList();
|
||||||
|
|
||||||
|
var newHash = ComputeSnapshotHash(entries);
|
||||||
var snapshot = GetSnapshot();
|
var snapshot = GetSnapshot();
|
||||||
var entries = GetEntries();
|
|
||||||
var fileEntries = entries.ToList();
|
|
||||||
var newHash = ComputeSnapshotHash(fileEntries);
|
|
||||||
if (snapshot.Hash == newHash) return Task.CompletedTask;
|
if (snapshot.Hash == newHash) return Task.CompletedTask;
|
||||||
|
|
||||||
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
var cancellationTokenSource = new CancellationTokenSource();
|
||||||
using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
|
using var cacheEntry = _debouncerCache.CreateEntry(_jsonPath)
|
||||||
debouncedPersistence.SlidingExpiration = TimeSpan.FromMilliseconds(DebounceMs);
|
.AddExpirationToken(new CancellationChangeToken(cancellationTokenSource.Token))
|
||||||
debouncedPersistence.Value = fileEntries;
|
.SetValue(entries)
|
||||||
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
|
.SetOptions(new MemoryCacheEntryOptions
|
||||||
{
|
{
|
||||||
EvictionCallback = (key, entriesCallback, reason, state) =>
|
PostEvictionCallbacks =
|
||||||
{
|
{
|
||||||
if (entriesCallback is IEnumerable<FileEntry> entriesToPersist && key is string filePath)
|
new PostEvictionCallbackRegistration
|
||||||
{
|
{
|
||||||
|
EvictionCallback = (key, value, reason, _) =>
|
||||||
|
{
|
||||||
|
if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired))
|
||||||
|
return;
|
||||||
|
var filePath = (string)key;
|
||||||
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
|
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
|
||||||
{
|
{
|
||||||
if (IsFileLocked(filePath))
|
try
|
||||||
|
{
|
||||||
|
if (FileLockHelper.IsFileLocked(filePath))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
File.WriteAllText(filePath,
|
File.WriteAllText(filePath, JsonSerializer.Serialize(value, _jsonSerializerOptions));
|
||||||
JsonSerializer.Serialize(entriesToPersist, _jsonSerializerOptions));
|
_logger.LogInformation("Persisted snapshot to {FilePath}", filePath);
|
||||||
_snapshotFileSemaphore.Release();
|
|
||||||
_logger.LogInformation("Persisted snapshot");
|
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
finally
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Failed to persist file {FilePath} due to timeout", filePath);
|
_snapshotFileSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static string ComputeHash(string filePath)
|
private static string ComputeHash(string filePath)
|
||||||
{
|
{
|
||||||
using var sha = SHA256.Create();
|
using var sha = SHA256.Create();
|
||||||
using var stream = File.OpenRead(filePath);
|
using var stream = File.OpenRead(filePath);
|
||||||
var hash = sha.ComputeHash(stream);
|
var hash = SHA256.HashData(stream);
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
return Convert.ToHexStringLower(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(entries);
|
var json = JsonSerializer.Serialize(entries);
|
||||||
using var sha = SHA256.Create();
|
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json));
|
||||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
|
return Convert.ToHexStringLower(hash);
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// From filesystem cache, load each entry and build the lookups
|
/// From filesystem cache, load each entry and build the lookups
|
||||||
|
/// Check for duplicate hashes
|
||||||
|
/// Check for nonexistent entries against filesystem
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private Dictionary<string, FileEntry> LoadSnapshotIndex()
|
private Dictionary<string, FileEntry> LoadSnapshotIndex()
|
||||||
@@ -504,33 +659,69 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
// Reindex the cache
|
// Reindex the cache
|
||||||
foreach (var fileEntry in entries)
|
foreach (var fileEntry in entries)
|
||||||
{
|
{
|
||||||
|
if (fileEntry.Hash == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Entry {Path} has no hash", fileEntry.Path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (_hashCache.TryGetValue(fileEntry.Hash, out var value))
|
if (_hashCache.TryGetValue(fileEntry.Hash, out var value))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
|
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nspOrArchivePath = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
||||||
|
if (!File.Exists(nspOrArchivePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Nonexistent entry found: {Path}", fileEntry.Path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileContainedInRootDirectories = false;
|
||||||
|
foreach (var optionsRootDirectory in _options.RootDirectories)
|
||||||
|
{
|
||||||
|
if (fileEntry.Path.StartsWith(optionsRootDirectory))
|
||||||
|
{
|
||||||
|
fileContainedInRootDirectories = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileContainedInRootDirectories)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Entry {Path} is not contained in any root directory", fileEntry.Path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
|
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
|
||||||
{
|
{
|
||||||
if (fileEntry.Path.Contains(ArchivePathSeparator))
|
if (fileEntry.Path.Contains(ArchivePathSeparator))
|
||||||
{
|
{
|
||||||
|
var fileInfo = new FileInfo(fileEntry.Path.Split(ArchivePathSeparator)[0]);
|
||||||
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
||||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
// ReSharper disable once RedundantSuppressNullableWarningExpression
|
||||||
|
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileInfo.LastWriteTimeUtc, fileEntry.Titles!);
|
||||||
_archiveLookup[filename] = fileEntry.Path;
|
_archiveLookup[filename] = fileEntry.Path;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
var fileInfo = new FileInfo(fileEntry.Path);
|
||||||
|
// ReSharper disable once RedundantSuppressNullableWarningExpression
|
||||||
|
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileInfo.LastWriteTimeUtc, fileEntry.Titles!);
|
||||||
fileEntries.TryAdd(fileEntry.Path, fileEntry);
|
fileEntries.TryAdd(fileEntry.Path, fileEntry);
|
||||||
_hashCache[fileEntry.Hash] = fileEntry.Path;
|
_hashCache[fileEntry.Hash] = fileEntry.Path;
|
||||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||||
if (fileEntry.Titles == null) continue;
|
if (fileEntry.Titles == null) continue;
|
||||||
foreach (var ncaMetadataWithHash in fileEntry.Titles)
|
foreach (var ncaMetadataWithHash in fileEntry.Titles)
|
||||||
{
|
{
|
||||||
|
if (ncaMetadataWithHash.Hash == null) continue;
|
||||||
_hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path;
|
_hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Loaded snapshot index {Count} entries", fileEntries.Count);
|
||||||
return fileEntries;
|
return fileEntries;
|
||||||
}
|
}
|
||||||
catch (ArgumentException e)
|
catch (ArgumentException e)
|
||||||
@@ -540,8 +731,21 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public void RebuildSnapshot()
|
public void RebuildSnapshot()
|
||||||
|
{
|
||||||
|
RebuildSnapshotAsync().Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RebuildSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Fast path: if we already have the lock, just log and exit.
|
||||||
|
if (!await _buildLock.WaitAsync(0, cancellationToken))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("RebuildSnapshot called while a rebuild is already in progress, ignoring.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// 1️⃣ Flush the old in‑memory snapshot
|
// 1️⃣ Flush the old in‑memory snapshot
|
||||||
_cache.Clear();
|
_cache.Clear();
|
||||||
@@ -551,10 +755,17 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
//_failedAttempts.Clear(); // if you keep per‑user counters
|
//_failedAttempts.Clear(); // if you keep per‑user counters
|
||||||
|
|
||||||
// 2️⃣ Re‑build from disk again
|
// 2️⃣ Re‑build from disk again
|
||||||
BuildSnapshotAsync().Wait(); // synchronous – we already own the lock
|
_buildLock.Release();
|
||||||
PersistSnapshotAsync().Wait(); // same
|
await BuildSnapshotAsync(cancellationToken);
|
||||||
|
await PersistSnapshotAsync(cancellationToken);
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_buildLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public ROMSnapshot GetSnapshot()
|
public ROMSnapshot GetSnapshot()
|
||||||
@@ -591,20 +802,37 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
return new ROMSnapshot();
|
return new ROMSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region IDisposable
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
foreach (var watcher in _watchers)
|
Stop();
|
||||||
|
foreach (var fileSystemWatcher in _watchers)
|
||||||
{
|
{
|
||||||
watcher.Dispose();
|
fileSystemWatcher.Dispose();
|
||||||
}
|
}
|
||||||
|
_cancellation.Dispose();
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
private sealed record SnapshotEntry(string Path, string Hash, long Size, List<NcaMetadataWithHash> NcaMetadataWithHash);
|
/// <summary>
|
||||||
|
/// Represents a single ROM/archive entry in the snapshot cache.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record SnapshotEntry(string Path, string Hash, long Size, DateTime LastModified, List<NcaMetadataWithHash> NcaMetadataWithHash);
|
||||||
|
|
||||||
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
||||||
|
|
||||||
private string ComputeFirstStreamHash(string filePath)
|
private async Task<string> ComputeFirstStreamHashAsync(string filePath, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (FileLockHelper.IsFileLocked(filePath))
|
||||||
|
{
|
||||||
|
throw new IOException("File is locked");
|
||||||
|
}
|
||||||
|
|
||||||
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
||||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
||||||
@@ -618,25 +846,37 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
var first = reader.GetEntries().FirstOrDefault();
|
var first = reader.GetEntries().FirstOrDefault();
|
||||||
if (first == null) return ComputeFullHash(filePath);
|
if (first == null) return ComputeFullHash(filePath);
|
||||||
|
|
||||||
using var firstStream = first.Stream;
|
//using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true);
|
||||||
var hash = _nspExtractor.ExtractHashFromStream(firstStream);
|
await using var rewindableWrapper = new RewindableStream(first.Stream, () => { return reader.GetEntries().FirstOrDefault().Stream; }, 10 * 1024 * 1024, first.Stream.Length);
|
||||||
|
var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper);
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// On error, fall back to the full file hash
|
// On error, fall back to the full file hash
|
||||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
||||||
|
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.",
|
||||||
|
attempt + 1, filePath, delay);
|
||||||
|
await Task.Delay(delay, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
throw new IOException($"Failed to compute hash for {filePath} after {_options.MaxRetryCount} attempts");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeFullHash(string filePath)
|
private static string ComputeFullHash(string filePath)
|
||||||
@@ -649,8 +889,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
public class ROMSnapshot
|
public class ROMSnapshot
|
||||||
{
|
{
|
||||||
public string? Hash { get; set; }
|
public string? Hash { get; init; }
|
||||||
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
|
public IReadOnlyList<FileEntry> Files { get; init; } = new List<FileEntry>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
@@ -658,10 +898,13 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
_logger.LogInformation("Starting snapshot service");
|
_logger.LogInformation("Starting snapshot service");
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await BuildSnapshotAsync();
|
await ValidateSnapshotAsync(cancellationToken);
|
||||||
await PersistSnapshotAsync();
|
_currentBuildTask = BuildSnapshotAsync(_cancellation.Token);
|
||||||
|
await _currentBuildTask.WaitAsync(_cancellation.Token);
|
||||||
|
await PersistSnapshotAsync(_cancellation.Token);
|
||||||
}, cancellationToken); // initial scan
|
}, cancellationToken); // initial scan
|
||||||
new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
/*var timer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);*/
|
||||||
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
@@ -24,9 +33,7 @@ public sealed class TitleDatabaseService : IHostedService
|
|||||||
private readonly IOptionsMonitor<TitleDbOptions> _options;
|
private readonly IOptionsMonitor<TitleDbOptions> _options;
|
||||||
private readonly ILogger<TitleDatabaseService> _logger;
|
private readonly ILogger<TitleDatabaseService> _logger;
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly INSPExtractor _nspExtractor;
|
|
||||||
private readonly string _cacheFolder; // Where the JSON is cached.
|
private readonly string _cacheFolder; // Where the JSON is cached.
|
||||||
private readonly List<string> _rootDirectories; // directories that contain game files
|
|
||||||
|
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly ISnapshotService _snapshotService;
|
private readonly ISnapshotService _snapshotService;
|
||||||
@@ -50,7 +57,6 @@ public sealed class TitleDatabaseService : IHostedService
|
|||||||
/// directories that contain the NSP files.
|
/// directories that contain the NSP files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TitleDatabaseService(
|
public TitleDatabaseService(
|
||||||
IConfiguration configuration,
|
|
||||||
IOptionsMonitor<TitleDbOptions> options,
|
IOptionsMonitor<TitleDbOptions> options,
|
||||||
ILogger<TitleDatabaseService> logger,
|
ILogger<TitleDatabaseService> logger,
|
||||||
ISnapshotService snapshotService,
|
ISnapshotService snapshotService,
|
||||||
@@ -62,11 +68,10 @@ public sealed class TitleDatabaseService : IHostedService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_snapshotService = snapshotService;
|
_snapshotService = snapshotService;
|
||||||
_httpFactory = httpFactory;
|
_httpFactory = httpFactory;
|
||||||
_nspExtractor = nspExtractor;
|
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
|
||||||
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-cache");
|
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "data", "titledb-cache");
|
||||||
_rootDirectories = new List<string>
|
new List<string>
|
||||||
{
|
{
|
||||||
// You can extend this list – it is the set of directories that
|
// You can extend this list – it is the set of directories that
|
||||||
// are scanned when the service starts up.
|
// are scanned when the service starts up.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.10" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||||
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
||||||
@@ -20,14 +22,23 @@
|
|||||||
<Content Include="..\.dockerignore">
|
<Content Include="..\.dockerignore">
|
||||||
<Link>.dockerignore</Link>
|
<Link>.dockerignore</Link>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Update="appsettings.Development.json">
|
|
||||||
<DependentUpon>appsettings.json</DependentUpon>
|
|
||||||
</Content>
|
|
||||||
<Content Remove="obj\**" />
|
<Content Remove="obj\**" />
|
||||||
<AdditionalFiles Include="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll">
|
<AdditionalFiles Include="..\Dependencies\LibHac.dll">
|
||||||
<Link>LibHac.dll</Link>
|
<Link>LibHac.dll</Link>
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</AdditionalFiles>
|
</AdditionalFiles>
|
||||||
|
<Content Update="Config\appsettings.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Update="Config\appsettings.development.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Update="Config\appsettings.local.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Update="Config\appsettings.production.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -49,6 +60,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Remove="obj\**" />
|
<EmbeddedResource Remove="obj\**" />
|
||||||
|
<EmbeddedResource Include="appsettings.default.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@TinfoilVibeServer_HostAddress = http://localhost:5253
|
@TinfoilVibeServer_HostAddress = http://tinfoil.localhost/
|
||||||
|
|
||||||
GET {{TinfoilVibeServer_HostAddress}}/weatherforecast/
|
GET {{TinfoilVibeServer_HostAddress}}/
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
|
public class DependentStream(Stream innerStream, IDisposable? parentContainer) : Stream
|
||||||
|
{
|
||||||
|
public override void Flush() => innerStream.Flush();
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count) => innerStream.Read(buffer, offset, count);
|
||||||
|
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin);
|
||||||
|
|
||||||
|
public override void SetLength(long value) => innerStream.SetLength(value);
|
||||||
|
|
||||||
|
public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count);
|
||||||
|
|
||||||
|
|
||||||
|
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return innerStream.CopyToAsync(destination, bufferSize, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanRead => innerStream.CanRead;
|
||||||
|
public override bool CanSeek => innerStream.CanSeek;
|
||||||
|
public override bool CanWrite => innerStream.CanWrite;
|
||||||
|
public override long Length => innerStream.Length;
|
||||||
|
public override long Position { get => innerStream.Position; set => innerStream.Position = value; }
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
parentContainer?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
|
public static class FileLockHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a file is locked (open by another process).
|
||||||
|
/// Works on Windows and Unix (Linux / macOS).
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsFileLocked(string filePath, ILogger? logger = null)
|
||||||
|
{
|
||||||
|
// Quick sanity check: the file must exist
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.Open(filePath,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None);
|
||||||
|
// If we get here, the file is not locked.
|
||||||
|
stream.Close();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (IOException ioEx)
|
||||||
|
{
|
||||||
|
// On Windows, error code 32 (sharing violation) or 33 (lock violation)
|
||||||
|
// On Unix, EINVAL or EACCES
|
||||||
|
// The .NET exception already hides the native error, so we just log it.
|
||||||
|
logger?.LogDebug(ioEx, "File '{Path}' is locked.", filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException uaEx)
|
||||||
|
{
|
||||||
|
logger?.LogDebug(uaEx, "File '{Path}' access denied (likely locked).", filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Unexpected: we conservatively say it’s locked
|
||||||
|
logger?.LogError(ex, "Unexpected error while checking lock on '{Path}'. Assuming locked.", filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
namespace TinfoilVibeServer.Utilities;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
public static class FileSystemExtensions
|
public static class FileSystemExtensions
|
||||||
{
|
{
|
||||||
@@ -110,10 +116,9 @@ public static class FileSystemExtensions
|
|||||||
/// <exception cref="ArgumentException">Thrown if <paramref name="path"/> is empty or contains only whitespace.</exception>
|
/// <exception cref="ArgumentException">Thrown if <paramref name="path"/> is empty or contains only whitespace.</exception>
|
||||||
/// <exception cref="UnauthorizedAccessException">Thrown if the caller does not have permission.</exception>
|
/// <exception cref="UnauthorizedAccessException">Thrown if the caller does not have permission.</exception>
|
||||||
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
|
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
|
||||||
public static void EnsureDirectoryExists(string path)
|
public static void EnsureDirectoryExists(string? path)
|
||||||
{
|
{
|
||||||
if (path is null)
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
throw new ArgumentNullException(nameof(path));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
throw new ArgumentException("Path must not be empty or whitespace.", nameof(path));
|
throw new ArgumentException("Path must not be empty or whitespace.", nameof(path));
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
|
|
||||||
public static class IdHelper
|
public static class IdHelper
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a multipart RAR as a single, seekable stream.
|
||||||
|
/// Supports:
|
||||||
|
/// • myGame.rar (single volume)
|
||||||
|
/// • myGame.r00 / r01 … (RAR 4.x style)
|
||||||
|
/// • myGame.part01.rar … (WinRAR “part” style)
|
||||||
|
/// </summary>
|
||||||
|
public static class MultiPartRarHelper
|
||||||
|
{
|
||||||
|
public static string GetBaseNameForRarVolume(string fileName)
|
||||||
|
{
|
||||||
|
string baseName = string.Empty;
|
||||||
|
// Multipart – remove the ".rNN" or ".partNN" suffix
|
||||||
|
var m = Regex.Match(fileName,
|
||||||
|
@"^(?<base>.+?)(\.r\d\d|\.part\d\d)\.rar$",
|
||||||
|
RegexOptions.IgnoreCase);
|
||||||
|
if (!m.Success)
|
||||||
|
{
|
||||||
|
if (fileName.EndsWith(".rar", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Single‑volume archive – just drop the suffix
|
||||||
|
baseName = fileName.Substring(0, fileName.Length - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
baseName = m.Groups["base"].Value;
|
||||||
|
}
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the list of files that belong to the same multipart set.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> DiscoverVolumes(string dir, string baseName)
|
||||||
|
{
|
||||||
|
// Pattern: <base>.(rar | rNN | partNN.rar)
|
||||||
|
string pattern =
|
||||||
|
$@"^{Regex.Escape(baseName)}(\.rar|\.r\d\d|\.part\d\d\.rar)$";
|
||||||
|
|
||||||
|
return Directory.GetFiles(dir)
|
||||||
|
.Where(f => Regex.IsMatch(Path.GetFileName(f), pattern, RegexOptions.IgnoreCase))
|
||||||
|
.OrderBy(f => GetPartNumber(Path.GetFileName(f), baseName))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gives each file a numeric key that guarantees the correct order.
|
||||||
|
/// 0 → .rar (first volume)
|
||||||
|
/// 1 → .r00 or .part01
|
||||||
|
/// 2 → .r01 or .part02
|
||||||
|
/// … and so on.
|
||||||
|
/// </summary>
|
||||||
|
private static int GetPartNumber(string fileName, string baseName)
|
||||||
|
{
|
||||||
|
// 1️⃣ ".rar" → 0
|
||||||
|
if (fileName.Equals($"{baseName}.rar", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// 2️⃣ ".rNN" or ".partNN.rar"
|
||||||
|
var m = Regex.Match(fileName,
|
||||||
|
$@"\.r(?<num>\d\d)$|\.part(?<num>\d\d)\.rar$",
|
||||||
|
RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
int partNum = int.Parse(m.Groups["num"].Value);
|
||||||
|
return partNum + 1; // so r00 → 1, r01 → 2; part01 → 1, part02 → 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ unknown pattern – treat as first part
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using LibHac.Ncm;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
|
public static class NcaMetadataWithHashHelper
|
||||||
|
{
|
||||||
|
public static NcaMetadataWithHash? GetNcaMetadataWithHash(this FileInfo fileInfo)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(fileInfo.Name, @"^(.+)\[(\w{16})\]\[v(\d{1,7})\]\[(\w+).*\]\.nsp$");
|
||||||
|
if (!match.Success) return null;
|
||||||
|
var titleId = match.Groups[2].Value;
|
||||||
|
var applicationTitle = titleId;
|
||||||
|
//var applicationTitle = match.Groups[1].Value.Trim();
|
||||||
|
var version = int.Parse(match.Groups[3].Value) / 0x10000;
|
||||||
|
var nspType = match.Groups[4].Value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"base" => ContentMetaType.Application,
|
||||||
|
"update" => ContentMetaType.Patch,
|
||||||
|
"dlc" => ContentMetaType.AddOnContent,
|
||||||
|
_ => ContentMetaType.Patch
|
||||||
|
};
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(fileInfo.FullName);
|
||||||
|
var hash = SHA256.HashData(bytes);
|
||||||
|
|
||||||
|
return new NcaMetadataWithHash(titleId, applicationTitle, version, nspType, Convert.ToBase64String(hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a non‑seekable stream so that it can be read and seeked.
|
||||||
|
/// The wrapper keeps a small circular buffer of recently read data.
|
||||||
|
/// When the caller seeks outside the buffered range the wrapper
|
||||||
|
/// disposes the current stream, obtains a new instance via a
|
||||||
|
/// supplied factory, and reads forward from the start again.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RewindableStream : Stream
|
||||||
|
{
|
||||||
|
private readonly Func<Stream> _reopenFactory; // function that returns a fresh stream instance
|
||||||
|
private readonly int _bufferLimit; // maximum bytes to keep in memory
|
||||||
|
|
||||||
|
private Stream _source; // the current underlying stream
|
||||||
|
private MemoryStream _buffer; // holds the cached bytes
|
||||||
|
private long _bufferStart; // absolute position in the source of the first byte in _buffer
|
||||||
|
private long _position; // current read position in the virtual stream
|
||||||
|
private long? _length; // cached length once we discover it (null = unknown)
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new seekable wrapper.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">The initial non‑seekable stream.</param>
|
||||||
|
/// <param name="reopenFactory">
|
||||||
|
/// Factory that returns a *new* instance of the underlying stream.
|
||||||
|
/// It is called whenever we need to seek beyond the cached range.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="bufferLimit">
|
||||||
|
/// The maximum number of bytes to keep cached in memory.
|
||||||
|
/// Older data will be discarded as new data is read. Typical value: 64 KB.
|
||||||
|
/// </param>
|
||||||
|
public RewindableStream(
|
||||||
|
Stream source,
|
||||||
|
Func<Stream> reopenFactory,
|
||||||
|
int bufferLimit = 64 * 1024, long? length = null)
|
||||||
|
{
|
||||||
|
if (source == null) throw new ArgumentNullException(nameof(source));
|
||||||
|
if (!source.CanRead) throw new ArgumentException("Source stream must be readable", nameof(source));
|
||||||
|
if (reopenFactory == null) throw new ArgumentNullException(nameof(reopenFactory));
|
||||||
|
if (bufferLimit <= 0) throw new ArgumentOutOfRangeException(nameof(bufferLimit));
|
||||||
|
if (length.HasValue && length.Value < 0) throw new ArgumentOutOfRangeException(nameof(length));
|
||||||
|
_length = null; // unknown until we discover it
|
||||||
|
if (length.HasValue) _length = length;
|
||||||
|
_source = source;
|
||||||
|
_reopenFactory = reopenFactory;
|
||||||
|
_bufferLimit = bufferLimit;
|
||||||
|
_buffer = new MemoryStream();
|
||||||
|
_bufferStart = 0;
|
||||||
|
_position = 0;
|
||||||
|
_disposed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Stream overrides
|
||||||
|
|
||||||
|
public override bool CanRead => !_disposed && _source.CanRead;
|
||||||
|
public override bool CanSeek => true; // we expose seek behaviour
|
||||||
|
public override bool CanWrite => false; // read‑only wrapper
|
||||||
|
public override long Length
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
return _length.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override long Position
|
||||||
|
{
|
||||||
|
get => _position;
|
||||||
|
set => Seek(value, SeekOrigin.Begin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Flush()
|
||||||
|
{
|
||||||
|
// Nothing to do – read‑only wrapper.
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (buffer == null) throw new ArgumentNullException(nameof(buffer));
|
||||||
|
if (offset < 0 || count < 0 || offset + count > buffer.Length)
|
||||||
|
throw new ArgumentOutOfRangeException();
|
||||||
|
|
||||||
|
if (count == 0) return 0;
|
||||||
|
|
||||||
|
int totalRead = 0;
|
||||||
|
while (count > 0)
|
||||||
|
{
|
||||||
|
// Make sure the requested range is buffered.
|
||||||
|
EnsureBufferedUpTo(_position + count - 1).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// How many bytes can we copy from the buffer?
|
||||||
|
long bufferEnd = _bufferStart + _buffer.Length;
|
||||||
|
long available = bufferEnd - _position;
|
||||||
|
if (available <= 0)
|
||||||
|
{
|
||||||
|
// We are at EOF – nothing more to read.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int toCopy = (int)Math.Min(count, available);
|
||||||
|
_buffer.Position = _position - _bufferStart;
|
||||||
|
int read = _buffer.Read(buffer, offset, toCopy);
|
||||||
|
|
||||||
|
offset += read;
|
||||||
|
count -= read;
|
||||||
|
totalRead += read;
|
||||||
|
_position += read;
|
||||||
|
|
||||||
|
if (read == 0) break; // EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override long Seek(long offset, SeekOrigin origin)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
long newPos;
|
||||||
|
|
||||||
|
if (origin == SeekOrigin.Begin)
|
||||||
|
{
|
||||||
|
newPos = offset;
|
||||||
|
}
|
||||||
|
else if (origin == SeekOrigin.Current)
|
||||||
|
{
|
||||||
|
newPos = _position + offset;
|
||||||
|
}
|
||||||
|
else if (origin == SeekOrigin.End)
|
||||||
|
{
|
||||||
|
// We need the length first.
|
||||||
|
EnsureLengthAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
newPos = _length.Value + offset;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPos < 0)
|
||||||
|
throw new IOException("Cannot seek to a negative position.");
|
||||||
|
|
||||||
|
// If the new position lies outside our cached range, we must
|
||||||
|
// restart the underlying stream and read forward again.
|
||||||
|
if (newPos < _bufferStart || newPos > _bufferStart + _buffer.Length)
|
||||||
|
{
|
||||||
|
ReopenFromStart(); // resets _source, _buffer, etc.
|
||||||
|
_position = newPos; // restore the requested position
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_position = newPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that we actually have bytes buffered up to the new position
|
||||||
|
// (unless we are at the very end – in which case the call will just
|
||||||
|
// return as we hit EOF).
|
||||||
|
EnsureBufferedUpTo(_position).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
return _position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SetLength(long value)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException("SetLength is not supported on RewindableStream.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(byte[] buffer, int offset, int count)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException("Write is not supported on RewindableStream.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helper methods
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures that the buffer contains data up to the specified absolute position.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureBufferedUpTo(long position)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (position < _bufferStart) return; // we already have data before our buffer.
|
||||||
|
|
||||||
|
// Read from the underlying stream until we have buffered up to 'position'
|
||||||
|
// or until EOF.
|
||||||
|
while (_bufferStart + _buffer.Length <= position)
|
||||||
|
{
|
||||||
|
int toRead = (int)Math.Min(_bufferLimit,
|
||||||
|
position - (_bufferStart + _buffer.Length) + 1);
|
||||||
|
|
||||||
|
// Allocate a temporary buffer
|
||||||
|
byte[] temp = new byte[toRead];
|
||||||
|
int read = await _source.ReadAsync(temp, 0, temp.Length, CancellationToken.None);
|
||||||
|
|
||||||
|
if (read == 0) // EOF
|
||||||
|
{
|
||||||
|
// Store the final length if we don't already know it.
|
||||||
|
if (!_length.HasValue)
|
||||||
|
{
|
||||||
|
_length = _bufferStart + _buffer.Length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append to our circular buffer
|
||||||
|
_buffer.Position = _buffer.Length; // move to end
|
||||||
|
_buffer.Write(temp, 0, read);
|
||||||
|
|
||||||
|
// Trim if we exceeded the buffer limit.
|
||||||
|
if (_buffer.Length > _bufferLimit)
|
||||||
|
{
|
||||||
|
long excess = _buffer.Length - _bufferLimit;
|
||||||
|
byte[] remaining = new byte[_buffer.Length - excess];
|
||||||
|
_buffer.Position = excess;
|
||||||
|
_buffer.Read(remaining, 0, remaining.Length);
|
||||||
|
|
||||||
|
_buffer = new MemoryStream();
|
||||||
|
_buffer.Write(remaining, 0, remaining.Length);
|
||||||
|
_bufferStart += excess; // first byte in new buffer is now further ahead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reopens the underlying stream by disposing the current instance
|
||||||
|
/// and calling the factory again.
|
||||||
|
/// </summary>
|
||||||
|
private void ReopenFromStart()
|
||||||
|
{
|
||||||
|
_source.Dispose();
|
||||||
|
_source = _reopenFactory();
|
||||||
|
|
||||||
|
_buffer.SetLength(0);
|
||||||
|
_bufferStart = 0;
|
||||||
|
_position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to discover the length of the underlying source if it supports it.
|
||||||
|
/// If the source does not expose a length we will read to the end once.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureLengthAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_length.HasValue) return;
|
||||||
|
|
||||||
|
if (_source.CanSeek)
|
||||||
|
{
|
||||||
|
long current = _source.Position;
|
||||||
|
_length = _source.Length;
|
||||||
|
_source.Position = current;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// We need to read until EOF to determine the length
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
byte[] temp = new byte[_bufferLimit];
|
||||||
|
int read = await _source.ReadAsync(temp, 0, temp.Length, cancellationToken);
|
||||||
|
if (read == 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_length = _bufferStart + _buffer.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(RewindableStream));
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region IDisposable
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (!_disposed && disposing)
|
||||||
|
{
|
||||||
|
_source?.Dispose();
|
||||||
|
_buffer?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
using System.Buffers;
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Utilities;
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
@@ -25,7 +30,7 @@ public sealed class SeekableBufferedStream : Stream
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly List<BufferBlock> _blocks = new();
|
private readonly List<BufferBlock> _blocks = new();
|
||||||
private readonly long _specifiedLength = 0;
|
private readonly long _specifiedLength;
|
||||||
private long _bufferedLength; // total number of bytes buffered so far
|
private long _bufferedLength; // total number of bytes buffered so far
|
||||||
private long _position; // current logical position in the stream
|
private long _position; // current logical position in the stream
|
||||||
private bool _eof; // true when the source stream has been exhausted
|
private bool _eof; // true when the source stream has been exhausted
|
||||||
@@ -67,6 +72,13 @@ public sealed class SeekableBufferedStream : Stream
|
|||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeekableBufferedStream.cs – Add IAsyncDisposable support
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region helpers
|
#region helpers
|
||||||
@@ -244,6 +256,7 @@ public sealed class SeekableBufferedStream : Stream
|
|||||||
bytesRead += toCopy;
|
bytesRead += toCopy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
return bytesRead;
|
return bytesRead;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"RootDirectories": [ "D:\\Cloud\\Git\\TinfoilWebServer\\TinfoilWebServer.Test\\data", "Z:\\imgs\\roms\\Switch" ]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
|
||||||
|
"CredentialsFile": "/app/data/credentials.json",
|
||||||
|
"FingerprintsFile": "/app/data/fingerprints.json",
|
||||||
|
"BlacklistFile": "/app/data/blacklist.json",
|
||||||
|
"MaxFailedAttempts": 5,
|
||||||
|
"Snapshot" : {
|
||||||
|
"RootDirectories": [ ],
|
||||||
|
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
|
||||||
|
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
||||||
|
"CacheTtl": 60,
|
||||||
|
"SnapshotFile": "/app/data/snapshot.json",
|
||||||
|
"SnapshotBackupFile": "/app/data/snapshot.bak"
|
||||||
|
},
|
||||||
|
"NSPExtractor": {
|
||||||
|
"KeyFile": "/app/config/prod.keys"
|
||||||
|
},
|
||||||
|
"IndexBuilder": {
|
||||||
|
"ApiBaseUrl": "http://tinfoil.localhost:8080",
|
||||||
|
"IndexDirectories": [
|
||||||
|
"https://url1",
|
||||||
|
"sdmc:/url2",
|
||||||
|
"http://url3"
|
||||||
|
],
|
||||||
|
"Success" : "Welcome to Tinfoil Vibe Server!"
|
||||||
|
},
|
||||||
|
"TitleDb": {
|
||||||
|
"CountryCode": "AU",
|
||||||
|
"Language": "en",
|
||||||
|
"TtlSeconds" : 90
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
|
|
||||||
"KeySetFile": "prod.keys",
|
|
||||||
"CredentialsFile": "credentials.json",
|
|
||||||
"FingerprintsFile": "fingerprints.json",
|
|
||||||
"BlacklistFile": "blacklist.json",
|
|
||||||
"MaxFailedAttempts": 5,
|
|
||||||
"Snapshot" : {
|
|
||||||
"RootDirectories": [ "Z:\\downloads\\roms\\switch", "Z:\\imgs\\roms\\Switch" ],
|
|
||||||
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
|
|
||||||
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
|
||||||
"CacheTtl": 60,
|
|
||||||
"SnapshotFile": "index.tfl",
|
|
||||||
"SnapshotBackupFile": "snapshot.bin"
|
|
||||||
},
|
|
||||||
"IndexBuilder": {
|
|
||||||
"ApiBaseUrl": "http://tinfoil.localhost:80"
|
|
||||||
},
|
|
||||||
"TitleDb": {
|
|
||||||
"CountryCode": "AU",
|
|
||||||
"Language": "en",
|
|
||||||
"TtlSeconds" : 90,
|
|
||||||
"SnapshotFile" : "snapshot.json"
|
|
||||||
},
|
|
||||||
|
|
||||||
"IndexDirectories": [
|
|
||||||
"https://url1",
|
|
||||||
"sdmc:/url2",
|
|
||||||
"http://url3"
|
|
||||||
],
|
|
||||||
"Success" : "Welcome to Tinfoil Vibe Server!"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
|
||||||
|
"CredentialsFile": "/app/data/credentials.json",
|
||||||
|
"FingerprintsFile": "/app/data/fingerprints.json",
|
||||||
|
"BlacklistFile": "/app/data/blacklist.json",
|
||||||
|
"MaxFailedAttempts": 5,
|
||||||
|
"Snapshot" : {
|
||||||
|
"RootDirectories": [ ],
|
||||||
|
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
|
||||||
|
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
||||||
|
"CacheTtl": 60,
|
||||||
|
"SnapshotFile": "/app/data/snapshot.json",
|
||||||
|
"SnapshotBackupFile": "/app/data/snapshot.bak"
|
||||||
|
},
|
||||||
|
"NSPExtractor": {
|
||||||
|
"KeyFile": "/app/config/prod.keys"
|
||||||
|
},
|
||||||
|
"IndexBuilder": {
|
||||||
|
"ApiBaseUrl": "http://tinfoil.localhost:8080",
|
||||||
|
"IndexDirectories": [
|
||||||
|
"https://url1",
|
||||||
|
"sdmc:/url2",
|
||||||
|
"http://url3"
|
||||||
|
],
|
||||||
|
"Success" : "Welcome to Tinfoil Vibe Server!"
|
||||||
|
},
|
||||||
|
"TitleDb": {
|
||||||
|
"CountryCode": "AU",
|
||||||
|
"Language": "en",
|
||||||
|
"TtlSeconds" : 90
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"System": "Information",
|
||||||
|
"Microsoft": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Hosting.Internal;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
using TinfoilVibeServer.Authentication;
|
using TinfoilVibeServer.Authentication;
|
||||||
@@ -20,7 +21,8 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_loggerMock = new Mock<ILogger<AuthStore>>();
|
_loggerMock = new Mock<ILogger<AuthStore>>();
|
||||||
// Assume Settings is static and can be patched for tests
|
// Assume Settings is static and can be patched for tests
|
||||||
MockConfigManager = new Mock<ConfigManager>();
|
MockConfigManager = new Mock<ConfigManager>();
|
||||||
_authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object);
|
var env = new Mock<HostingEnvironment>();
|
||||||
|
_authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object, env.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mock<ConfigManager> MockConfigManager { get; set; }
|
public Mock<ConfigManager> MockConfigManager { get; set; }
|
||||||
@@ -39,7 +41,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
var fprs = _authStore.Fingerprints.Count;
|
var fprs = _authStore.Fingerprints.Count;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
|
//Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
|
||||||
Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
|
Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
|
||||||
|
|
||||||
_loggerMock.Verify(
|
_loggerMock.Verify(
|
||||||
@@ -59,7 +61,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
var newUser = "newuser";
|
var newUser = "newuser";
|
||||||
var ip = "127.0.0.1";
|
var ip = "127.0.0.1";
|
||||||
var password = "";
|
var password = "";
|
||||||
var uid = null as int?;
|
var uid = "";
|
||||||
// Act
|
// Act
|
||||||
var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred);
|
var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_middleware = new BasicAuthMiddleware(_next);
|
_middleware = new BasicAuthMiddleware(_next);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpContext CreateContext(string authHeader = "", string ip = "127.0.0.1", int? uid = null)
|
private HttpContext CreateContext(string authHeader = "", string ip = "127.0.0.1", string uid = "")
|
||||||
{
|
{
|
||||||
var ctx = new DefaultHttpContext();
|
var ctx = new DefaultHttpContext();
|
||||||
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);
|
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);
|
||||||
@@ -54,6 +54,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var ctx = CreateContext();
|
var ctx = CreateContext();
|
||||||
|
ctx.Request.Path = new PathString("/");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
|
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
|
||||||
@@ -73,7 +74,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var ctx = CreateContext("Basic dXNlcjpwYXNz");
|
var ctx = CreateContext("Basic dXNlcjpwYXNz");
|
||||||
|
ctx.Request.Path = new PathString("/");
|
||||||
_authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true);
|
_authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -90,7 +91,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
// Arrange
|
// Arrange
|
||||||
var user = "alice";
|
var user = "alice";
|
||||||
var pw = "secret";
|
var pw = "secret";
|
||||||
var uid = 1234;
|
var uid = "1234";
|
||||||
var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}";
|
var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}";
|
||||||
|
|
||||||
var ip = "127.0.0.1";
|
var ip = "127.0.0.1";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.IO;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LibHac.Ncm;
|
using LibHac.Ncm;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Hosting.Internal;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
@@ -44,6 +45,7 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_loggerMock = new Mock<ILogger<SnapshotService>>();
|
_loggerMock = new Mock<ILogger<SnapshotService>>();
|
||||||
_archiveHander = new Mock<IArchiveHandler>();
|
_archiveHander = new Mock<IArchiveHandler>();
|
||||||
_nspExtractorMock = new Mock<INSPExtractor>();
|
_nspExtractorMock = new Mock<INSPExtractor>();
|
||||||
|
var hostEnv = new Mock<HostingEnvironment>();
|
||||||
var memoryCacheOptions = Options.Create(new MemoryCacheOptions());
|
var memoryCacheOptions = Options.Create(new MemoryCacheOptions());
|
||||||
_memoryCache = new MemoryCache(memoryCacheOptions);
|
_memoryCache = new MemoryCache(memoryCacheOptions);
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ namespace TinfoilVibeServerTest.Tests
|
|||||||
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
|
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
|
||||||
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
|
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
|
||||||
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
|
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
|
||||||
_service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object);
|
|
||||||
|
_service = new SnapshotService(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object, hostEnv.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
[TearDown]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# docker-compose.override.yml
|
||||||
|
services:
|
||||||
|
tinfoilvibeserver:
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=error
|
||||||
|
- APP_MODE=production
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # expose on host port 80
|
||||||
+18
-9
@@ -1,13 +1,22 @@
|
|||||||
services:
|
version: "3.9"
|
||||||
consoleapp1:
|
|
||||||
image: consoleapp1
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ConsoleApp1/Dockerfile
|
|
||||||
|
|
||||||
|
services:
|
||||||
tinfoilvibeserver:
|
tinfoilvibeserver:
|
||||||
image: tinfoilvibeserver
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: TinfoilVibeServer
|
||||||
dockerfile: TinfoilVibeServer/Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: gitea.ecenshu.net/ecenshu/tinfoilvibeserver:latest
|
||||||
|
container_name: tinfoilvibeserver
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production # .NET‑specific
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL} # just a double‑check, uses .env value
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./config:/app/config
|
||||||
|
ports:
|
||||||
|
- ":80"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user