diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ba2ce86..1c2bff55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build SubathonManager (Windows) +name: Build SubathonManager on: push: @@ -23,12 +23,39 @@ on: required: false default: 'false' type: boolean + build_windows: + description: 'Avalonia: build Windows (win-x64)' + required: false + default: true + type: boolean + build_linux: + description: 'Avalonia: build Linux (linux-x64)' + required: false + default: true + type: boolean + build_macos_arm: + description: 'Avalonia: build macOS Apple Silicon (osx-arm64)' + required: false + default: true + type: boolean + build_macos_intel: + description: 'Avalonia: build macOS Intel (osx-x64)' + required: false + default: true + type: boolean jobs: test: # if: github.event_name != 'pull_request' || github.actor != 'dependabot[bot]' # reconsider when more tests name: Run Tests & Generate Coverage runs-on: ubuntu-latest + outputs: + line_rate: ${{ steps.covstats.outputs.line_rate }} + branch_rate: ${{ steps.covstats.outputs.branch_rate }} + tests_total: ${{ steps.covstats.outputs.tests_total }} + tests_passed: ${{ steps.covstats.outputs.tests_passed }} + tests_failed: ${{ steps.covstats.outputs.tests_failed }} + tests_skipped: ${{ steps.covstats.outputs.tests_skipped }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -36,8 +63,8 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' - + dotnet-version: '10.0.x' + - name: Cache NuGet packages uses: actions/cache@v6 with: @@ -45,23 +72,38 @@ jobs: key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} restore-keys: | nuget-${{ runner.os }}- - + - name: Restore dependencies run: dotnet restore SubathonManager.Tests/SubathonManager.Tests.csproj -p:EnableWindowsTargeting=true - + - name: Run tests run: | dotnet test SubathonManager.Tests/SubathonManager.Tests.csproj \ --configuration Release \ --logger "console;verbosity=detailed" \ + --logger "trx;LogFileName=test-results.trx" \ --collect:"XPlat Code Coverage" \ --results-directory ./coverage \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="**/Migrations/**;**/*.g.cs" - + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="**/Migrations/**;**/*.g.cs;**/SubathonManager.UI/**" + + - name: List raw coverage files + if: always() + shell: bash + run: ls -la coverage/**/coverage.cobertura.xml || true + + - name: Merge coverage reports + if: always() + uses: danielpalme/ReportGenerator-GitHub-Action@5 + with: + reports: coverage/**/coverage.cobertura.xml + targetdir: coverage-merged + reporttypes: Cobertura + - name: Code Coverage Summary Report + if: always() uses: irongut/CodeCoverageSummary@v1.3.0 with: - filename: coverage/**/coverage.cobertura.xml + filename: coverage-merged/Cobertura.xml fail_below_min: false hide_branch_rate: false hide_complexity: true @@ -69,32 +111,114 @@ jobs: format: markdown output: both thresholds: '60 80' - - build: - if: github.event_name != 'pull_request' || github.actor != 'dependabot[bot]' - env: - RUNTIME: win-x64 - name: Build Windows App - runs-on: windows-latest + + - name: Extract test & coverage totals + id: covstats + if: always() + shell: bash + run: | + shopt -s globstar nullglob + + line_rate=""; branch_rate="" + if [ -f coverage-merged/Cobertura.xml ]; then + line_rate=$(sed -n 's/.*]*line-rate="\([0-9.]*\)".*/\1/p' coverage-merged/Cobertura.xml | head -n1) + branch_rate=$(sed -n 's/.*]*branch-rate="\([0-9.]*\)".*/\1/p' coverage-merged/Cobertura.xml | head -n1) + fi + pct() { [ -z "$1" ] && echo "n/a" || awk -v r="$1" 'BEGIN{printf "%.1f%%", r*100}'; } + echo "line_rate=$(pct "$line_rate")" >> "$GITHUB_OUTPUT" + echo "branch_rate=$(pct "$branch_rate")" >> "$GITHUB_OUTPUT" + + total=0; passed=0; failed=0; skipped=0 + for f in coverage/**/*.trx; do + counters=$(tr -d '\n' < "$f" | sed -n 's/.*]*\)>.*/\1/p') + [ -z "$counters" ] && continue + get() { + local v + v=$(printf ' %s' "$counters" | sed -n "s/.*[ \"]$1=\"\([0-9]*\)\".*/\1/p") + printf '%s' "${v:-0}" + } + total=$((total + $(get total))) + passed=$((passed + $(get passed))) + failed=$((failed + $(get failed))) + skipped=$((skipped + $(get notExecuted))) + done + { + echo "tests_total=$total" + echo "tests_passed=$passed" + echo "tests_failed=$failed" + echo "tests_skipped=$skipped" + } >> "$GITHUB_OUTPUT" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: code-coverage-results.md + retention-days: 3 + if-no-files-found: ignore + + avalonia-matrix: + name: Compute Avalonia build matrix + if: > + github.event_name == 'workflow_dispatch' + || (github.event_name == 'push' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v'))) needs: test + runs-on: ubuntu-latest outputs: - artifact_failed: ${{ steps.artifact-upload.outcome != 'success' }} - version: ${{ steps.vars.outputs.version }} - filename: ${{ steps.vars.outputs.filename }} - nextcloud_url: ${{ steps.nextcloud-fallback.outcome == 'success' && steps.nextcloud-fallback.outputs.SHAREABLE_URL || '' }} - artifact_name: SubathonManager_${{env.RUNTIME}}_${{steps.vars.outputs.version}} + matrix: ${{ steps.set.outputs.matrix }} + has_targets: ${{ steps.set.outputs.has_targets }} steps: - - name: Set Path Registry + - name: Build matrix from inputs + id: set + shell: bash + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + WIN="${{ inputs.build_windows }}" + LINUX="${{ inputs.build_linux }}" + ARM="${{ inputs.build_macos_arm }}" + INTEL="${{ inputs.build_macos_intel }}" + else + WIN=true; LINUX=true; ARM=true; INTEL=true + fi + + include=() + [ "$WIN" = "true" ] && include+=('{"os":"windows-latest","runtime":"win-x64","single":"true"}') + [ "$LINUX" = "true" ] && include+=('{"os":"ubuntu-latest","runtime":"linux-x64","single":"true"}') + [ "$ARM" = "true" ] && include+=('{"os":"macos-latest","runtime":"osx-arm64","single":"false"}') + [ "$INTEL" = "true" ] && include+=('{"os":"macos-latest","runtime":"osx-x64","single":"false"}') + + joined=$(IFS=,; echo "${include[*]}") + echo "matrix={\"include\":[$joined]}" >> "$GITHUB_OUTPUT" + if [ ${#include[@]} -eq 0 ]; then + echo "has_targets=false" >> "$GITHUB_OUTPUT" + else + echo "has_targets=true" >> "$GITHUB_OUTPUT" + fi + echo "Selected matrix: {\"include\":[$joined]}" + + build-avalonia: + name: Build Avalonia App (${{ matrix.runtime }}) + if: needs.avalonia-matrix.outputs.has_targets == 'true' + needs: [test, avalonia-matrix] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.avalonia-matrix.outputs.matrix) }} + runs-on: ${{ matrix.os }} + steps: + - name: Set Path Registry (Windows long paths) + if: matrix.os == 'windows-latest' shell: pwsh run: Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 1 - + - name: Checkout repository uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Cache NuGet packages uses: actions/cache@v6 @@ -105,103 +229,356 @@ jobs: nuget-${{ runner.os }}- - name: Restore dependencies - run: dotnet restore SubathonManager.sln + run: dotnet restore SubathonManager.UI/SubathonManager.UI.csproj - - name: Determine version and filename - id: vars + - name: Set copyright year + shell: bash + run: echo "CopyrightYear=$(date +%Y)" >> $GITHUB_ENV + + - name: Determine version + id: avars shell: bash run: | - RUNTIME=${{ env.RUNTIME }} - if [ "${GITHUB_REF_NAME}" = "main" ]; then + FORCE_NIGHTLY="${{ github.event.inputs.force_nightly }}" + if [ "${GITHUB_REF_NAME}" = "main" ] || [ "$FORCE_NIGHTLY" = "true" ]; then echo "version=nightly-${GITHUB_SHA}" >> $GITHUB_OUTPUT + echo "suffix=nightly" >> $GITHUB_OUTPUT elif [ "${GITHUB_EVENT_NAME}" = "pull_request" ] || [ "${GITHUB_REF_NAME}" = "dev" ]; then echo "version=${GITHUB_SHA}" >> $GITHUB_OUTPUT + echo "suffix=${GITHUB_SHA}" >> $GITHUB_OUTPUT else SAFE_REF=${GITHUB_REF_NAME//\//-} echo "version=${SAFE_REF}" >> $GITHUB_OUTPUT + echo "suffix=${SAFE_REF}" >> $GITHUB_OUTPUT fi - - if [ "${GITHUB_REF_NAME}" = "main" ]; then - echo "filename=SubathonManager_${RUNTIME}_nightly.zip" >> $GITHUB_OUTPUT - elif [ "${GITHUB_EVENT_NAME}" = "pull_request" ] || [ "${GITHUB_REF_NAME}" = "dev" ]; then - echo "filename=SubathonManager_${RUNTIME}_${GITHUB_SHA}.zip" >> $GITHUB_OUTPUT - else - SAFE_REF=${GITHUB_REF_NAME//\//-} - echo "filename=SubathonManager_${RUNTIME}_${SAFE_REF}.zip" >> $GITHUB_OUTPUT - fi - if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - CLEAN_VERSION="${GITHUB_REF_NAME#v}" - echo "file_version=$CLEAN_VERSION" >> $GITHUB_OUTPUT + echo "file_version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT else echo "file_version=0.0.0.0" >> $GITHUB_OUTPUT fi - - - name: Set copyright year - shell: bash - run: | - echo "CopyrightYear=$(date +%Y)" >> $GITHUB_ENV - + - name: Inject telemetry key shell: bash run: | - sed -i 's/BUILD_TELEMETRY_KEY/${{ secrets.TELEMETRY_KEY }}/g' \ - SubathonManager.Services/TelemetryService.cs + sed -i.bak 's/BUILD_TELEMETRY_KEY/${{ secrets.TELEMETRY_KEY }}/g' \ + SubathonManager.Services/TelemetryService.cs + rm -f SubathonManager.Services/TelemetryService.cs.bak - - name: Build project + - name: Publish Avalonia (${{ matrix.runtime }}) run: > - dotnet publish SubathonManager.UI/SubathonManager.UI.csproj - -c Release - -r ${{ env.RUNTIME }} - --self-contained true - -p:PublishSingleFile=true - -p:IncludeAllContentForSelfExtract=true - -p:PublishTrimmed=false - -p:FileVersion=${{ steps.vars.outputs.file_version }} - -p:AssemblyVersion=${{ steps.vars.outputs.file_version }} - -p:InformationalVersion=${{ steps.vars.outputs.version }} + dotnet publish SubathonManager.UI/SubathonManager.UI.csproj + -c Release + -r ${{ matrix.runtime }} + --self-contained true + -p:PublishSingleFile=${{ matrix.single }} + -p:IncludeNativeLibrariesForSelfExtract=${{ matrix.single }} + -p:PublishTrimmed=false + -p:FileVersion=${{ steps.avars.outputs.file_version }} + -p:AssemblyVersion=${{ steps.avars.outputs.file_version }} + -p:InformationalVersion=${{ steps.avars.outputs.version }} -p:Copyright="© 2025–${{ env.CopyrightYear }} WolfwithSword and contributors" - -o build + -o build-avalonia + + - name: Copy presets, resources folder and license into build output + shell: bash + run: | + mkdir -p build-avalonia/presets build-avalonia/resources + cp -R presets/. build-avalonia/presets/ + cp -R resources/. build-avalonia/resources/ + cp LICENSE build-avalonia/ + rm -f build-avalonia/*.pdb + + - name: Generate hicolor icon sizes (linux) + if: matrix.runtime == 'linux-x64' + shell: bash + run: | + set -euo pipefail + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y imagemagick + fi + IM=$(command -v magick || command -v convert) + mkdir -p build-avalonia/Assets + for size in 48 64 128 256; do + "$IM" assets/icon.png -resize "${size}x${size}" \ + -background none -gravity center -extent "${size}x${size}" \ + "build-avalonia/Assets/icon_${size}.png" + done + ls -l build-avalonia/Assets/icon_*.png + + - name: Bundle Linux association helper (linux) + if: matrix.runtime == 'linux-x64' + shell: bash + run: | + mkdir -p build-avalonia/linux + cp external/linux/install.sh external/linux/subathonmanager-smo.xml build-avalonia/linux/ + chmod +x build-avalonia/linux/install.sh + + - name: Zip build (linux) + if: matrix.runtime == 'linux-x64' + shell: bash + run: | + cd build-avalonia + zip -r ../SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip . - - name: Copy presets folder and license into build output + - name: Assemble macOS .app bundle and zip (macOS only) + if: startsWith(matrix.runtime, 'osx') + shell: bash run: | - mkdir -Force build\presets - xcopy presets build\presets /E /I /Y - copy LICENSE build\ - rm build\*.pdb + chmod +x external/macos/make-app-bundle.sh + bash external/macos/make-app-bundle.sh build-avalonia bundle external/macos/Info.plist assets/icon.png SubathonManager "${{ steps.avars.outputs.file_version }}" + cp external/macos/README.txt bundle/ + cd bundle + zip -ry ../SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip SubathonManager.app READ-ME-FIRST.txt + + - name: Zip build (windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: Compress-Archive -Path build-avalonia/* -DestinationPath SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip + + - name: Resolve Nextcloud upload path + shell: bash + run: | + if [[ "${{ matrix.runtime }}" == osx-* ]]; then + echo "NC_UPLOAD_PATH=bundle" >> "$GITHUB_ENV" + else + echo "NC_UPLOAD_PATH=build-avalonia" >> "$GITHUB_ENV" + fi + + - name: Decide artifact storage + shell: bash + run: | + EVENT="${GITHUB_EVENT_NAME}" + TARGET="${{ github.event.inputs.upload_target }}" + PRIMARY=nextcloud + ALLOW_FALLBACK=false + if [ "$EVENT" = "workflow_dispatch" ] && [ "$TARGET" = "github" ]; then + PRIMARY=github; ALLOW_FALLBACK=false + elif [ "$EVENT" = "workflow_dispatch" ] && [ "$TARGET" = "nextcloud" ]; then + PRIMARY=nextcloud; ALLOW_FALLBACK=false + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + PRIMARY=github; ALLOW_FALLBACK=true + else + PRIMARY=nextcloud; ALLOW_FALLBACK=false + fi + echo "PRIMARY=$PRIMARY" >> "$GITHUB_ENV" + echo "ALLOW_FALLBACK=$ALLOW_FALLBACK" >> "$GITHUB_ENV" + echo "Storage: primary=$PRIMARY fallback=$ALLOW_FALLBACK" - name: Upload build artifact (GitHub) - if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.upload_target == 'github' || github.event.inputs.upload_target == 'default')) || - ((github.ref_type == 'tag' && github.ref != 'refs/tags/nightly') || github.ref_name == 'main') && github.ref_name != 'dev' }} + id: gh + if: ${{ env.PRIMARY == 'github' }} + continue-on-error: ${{ env.ALLOW_FALLBACK == 'true' }} uses: actions/upload-artifact@v7 - id: artifact-upload - continue-on-error: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.upload_target == 'github') }} with: - name: SubathonManager_${{env.RUNTIME}}_${{steps.vars.outputs.version}} - path: build + name: SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }} + path: SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip retention-days: 3 - name: Upload build artifact (Nextcloud) - id: nextcloud-fallback - if: ${{ github.event_name == 'pull_request' - || (github.event_name == 'workflow_dispatch' && github.event.inputs.upload_target == 'nextcloud') - || (steps.artifact-upload.outcome != 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.upload_target != 'github')) }} + id: nc + if: ${{ env.PRIMARY == 'nextcloud' || steps.gh.outcome == 'failure' }} uses: trympet/nextcloud-artifacts-action@v2 continue-on-error: false with: - name: SubathonManager_${{env.RUNTIME}}_${{steps.vars.outputs.version}} - path: build + name: SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }} + path: ${{ env.NC_UPLOAD_PATH }} nextcloud-url: ${{ vars.NEXTCLOUD_URL }} nextcloud-username: ${{ vars.NEXTCLOUD_USERNAME }} nextcloud-password: ${{ secrets.NEXTCLOUD_PASSWORD }} + - name: Record Nextcloud location + if: ${{ steps.nc.outcome == 'success' }} + shell: bash + run: | + echo "SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}|${{ steps.nc.outputs.SHAREABLE_URL }}" \ + > "ncmeta-${{ matrix.runtime }}.txt" + + - name: Upload Nextcloud manifest + if: ${{ steps.nc.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: ncmeta-${{ matrix.runtime }} + path: ncmeta-${{ matrix.runtime }}.txt + retention-days: 3 + + - name: Record build status + if: always() + shell: bash + env: + GH_URL: ${{ steps.gh.outputs.artifact-url }} + NC_URL: ${{ steps.nc.outputs.SHAREABLE_URL }} + run: | + STATUS="${{ job.status }}" + STORAGE="-"; URL="" + if [ "${{ steps.gh.outcome }}" = "success" ] && [ -n "$GH_URL" ]; then + STORAGE="GitHub"; URL="$GH_URL" + elif [ "${{ steps.nc.outcome }}" = "success" ] && [ -n "$NC_URL" ]; then + STORAGE="Nextcloud"; URL="$NC_URL" + fi + printf '%s|%s|%s|%s\n' \ + "${{ matrix.runtime }}" "$STATUS" "$STORAGE" "$URL" \ + > "buildmeta-${{ matrix.runtime }}.txt" + cat "buildmeta-${{ matrix.runtime }}.txt" + + - name: Upload build status manifest + if: always() + uses: actions/upload-artifact@v7 + with: + name: buildmeta-${{ matrix.runtime }} + path: buildmeta-${{ matrix.runtime }}.txt + retention-days: 3 + + build-pr: + name: PR Build (win-x64) + if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' + needs: test + runs-on: windows-latest + steps: + - name: Set Path Registry (Windows long paths) + shell: pwsh + run: Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 1 + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore dependencies + run: dotnet restore SubathonManager.UI/SubathonManager.UI.csproj + + - name: Publish Avalonia (win-x64) + run: > + dotnet publish SubathonManager.UI/SubathonManager.UI.csproj + -c Release + -r win-x64 + --self-contained true + -p:PublishSingleFile=false + -p:PublishTrimmed=false + -o build-pr + + - name: Copy presets, resources folder and license into build output + shell: bash + run: | + mkdir -p build-pr/presets build-pr/resources + cp -R presets/. build-pr/presets/ + cp -R resources/. build-pr/resources/ + cp LICENSE build-pr/ + rm -f build-pr/*.pdb + + - name: Upload PR build artifact + uses: actions/upload-artifact@v7 + with: + name: SubathonManager_pr-${{ github.event.pull_request.number }}_win-x64 + path: build-pr + retention-days: 2 + + summary: + name: Build Summary + runs-on: ubuntu-latest + needs: [test, avalonia-matrix, build-avalonia, build-pr, release] + if: always() + steps: + - name: Download build status manifests + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: buildmeta-* + path: buildmeta + merge-multiple: true + + - name: Download coverage report + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: coverage-report + path: coverage-report + + - name: Render summary + shell: bash + run: | + icon() { + case "$1" in + success) echo "✅ Success" ;; + failure) echo "❌ Failed" ;; + cancelled) echo "⚠️ Cancelled" ;; + skipped) echo "⏭️ Skipped" ;; + *) echo "❔ $1" ;; + esac + } + + { + echo "# SubathonManager Build Summary" + echo + echo "**Ref:** \`${GITHUB_REF_NAME}\`  •  **Commit:** \`${GITHUB_SHA:0:8}\`  •  **Trigger:** \`${GITHUB_EVENT_NAME}\`" + echo + echo "## Jobs" + echo + echo "| Job | Result |" + echo "| --- | --- |" + echo "| Tests & Coverage | $(icon "${{ needs.test.result }}") |" + echo "| Builds | $(icon "${{ needs['build-avalonia'].result }}") |" + echo "| PR build (win-x64) | $(icon "${{ needs['build-pr'].result }}") |" + echo "| Release | $(icon "${{ needs.release.result }}") |" + echo + echo "## Tests" + echo + echo "| Total | Passed | Failed | Skipped |" + echo "| --- | --- | --- | --- |" + echo "| ${{ needs.test.outputs.tests_total || 'n/a' }} | ✅ ${{ needs.test.outputs.tests_passed || '0' }} | ❌ ${{ needs.test.outputs.tests_failed || '0' }} | ⏭️ ${{ needs.test.outputs.tests_skipped || '0' }} |" + echo + echo "## Coverage" + echo + echo "| Metric | Value |" + echo "| --- | --- |" + echo "| Line rate | ${{ needs.test.outputs.line_rate || 'n/a' }} |" + echo "| Branch rate | ${{ needs.test.outputs.branch_rate || 'n/a' }} |" + echo + if [ -f coverage-report/code-coverage-results.md ]; then + echo "
Per-assembly coverage" + echo + cat coverage-report/code-coverage-results.md + echo + echo "
" + echo + fi + echo "## Platform Builds" + echo + echo "| Platform | Result | Storage | Artifact |" + echo "| --- | --- | --- | --- |" + shopt -s nullglob + found=0 + for f in buildmeta/*.txt; do + found=1 + IFS='|' read -r RUNTIME STATUS STORAGE URL < "$f" + if [ -n "$URL" ]; then + LINK="[Download]($URL)" + else + LINK="-" + fi + echo "| \`${RUNTIME}\` | $(icon "$STATUS") | ${STORAGE:-–} | ${LINK} |" + done + if [ "$found" -eq 0 ]; then + echo "| - | no platform builds ran | - | - |" + fi + echo + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + } >> "$GITHUB_STEP_SUMMARY" release: - env: - RUNTIME: win-x64 name: Create Release runs-on: ubuntu-latest - needs: build + needs: [test, build-avalonia] outputs: release_ok: ${{ steps.create_release.outcome == 'success' }} if: > @@ -216,88 +593,105 @@ jobs: sparse-checkout: | external sparse-checkout-cone-mode: false - - - name: Download build artifact (GitHub) - if: needs.build.outputs.artifact_failed == 'false' && github.event.inputs.upload_target != 'nextcloud' + + - name: Install tooling + run: | + sudo apt-get update + sudo apt-get install -y imagemagick zip unzip curl + + - name: Download GitHub build artifacts + continue-on-error: true uses: actions/download-artifact@v8 with: - name: ${{ needs.build.outputs.artifact_name }} - path: build/ + pattern: SubathonManager_* + path: dist + merge-multiple: true - - name: Download build artifact (Nextcloud) - if: needs.build.outputs.artifact_failed == 'true' && github.event.inputs.upload_target != 'github' + - name: Download Nextcloud manifests + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: ncmeta-* + path: ncmeta + merge-multiple: true + + - name: Resolve Nextcloud artifacts + shell: bash run: | - echo "Downloading release zip from Nextcloud fallback..." - mkdir build - curl -L "${{ needs.build.outputs.nextcloud_url }}/download?path=&files=${{ needs.build.outputs.artifact_name }}.zip" \ - -o "build/${{ needs.build.outputs.filename }}" - mv build/${{ needs.build.outputs.filename }} ./ - - - name: Zip release files - if: needs.build.outputs.artifact_failed == 'false' && github.event.inputs.upload_target != 'nextcloud' + mkdir -p dist + shopt -s nullglob + for f in ncmeta/*.txt; do + NAME="$(cut -d'|' -f1 "$f")" + URL="$(cut -d'|' -f2 "$f")" + [ -z "$NAME" ] && continue + [ -z "$URL" ] && continue + if [ -f "dist/${NAME}.zip" ]; then + echo "Already have ${NAME}.zip from GitHub"; continue + fi + echo "Fetching ${NAME}.zip from Nextcloud..." + curl -fL "${URL}/download?path=&files=${NAME}.zip" -o outer.zip + rm -rf nctmp && mkdir nctmp + unzip -o outer.zip -d nctmp || true + if [ -f "nctmp/${NAME}.zip" ]; then + mv "nctmp/${NAME}.zip" "dist/${NAME}.zip" + else + cp outer.zip "dist/${NAME}.zip" + fi + rm -rf nctmp outer.zip + done + echo "Release assets:"; ls -la dist || true + + - name: Ensure at least one build is present + shell: bash run: | - cd build - zip -r ../${{ needs.build.outputs.filename }} . - echo "${{ needs.build.outputs.filename }} successfully zipped" - + if ! ls dist/*.zip >/dev/null 2>&1; then + echo "ERROR: no build zips found (GitHub or Nextcloud)"; exit 1 + fi + - name: Fetch SB Extension shell: pwsh run: | New-Item -ItemType Directory -Force -Path "external/streamerbot" | Out-Null - + Invoke-WebRequest ` -Uri "https://sb-assets.wolfwithsword.com/extensions/subathonmanager/latest/subathonmanager-extension.sb" ` -OutFile "external/streamerbot/SubathonManager_Extension.sb" - - - name: Move files - run: | - if [ -f "build/${{ needs.build.outputs.filename }}" ]; then - mv build/${{ needs.build.outputs.filename }} ./ - fi - if [ ! -f "${{ needs.build.outputs.filename }}" ]; then - echo "ERROR: ZIP not found!" - exit 1 - fi - cp external/streamerbot/*.sb ./ || true - - name: Install ImageMagick - run: | - sudo apt-get update - sudo apt-get install -y imagemagick zip - + - name: Stage streamerbot extension + run: cp external/streamerbot/*.sb ./ || true + - name: Download icon shell: bash - run: | - curl -L "https://assets.subathonmanager.app/docs/icon.png" -o icon.png - + run: curl -L "https://assets.subathonmanager.app/docs/icon.png" -o icon.png + - name: Package Stream Deck plugin shell: bash run: | set -e - + sdplugin="external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin" mkdir -p "$sdplugin/images" - + for spec in pluginIcon:28 categoryIcon:28 actionIcon:20 keyIcon:72; do name="${spec%%:*}" size="${spec##*:}" - + convert icon.png -resize "${size}x${size}" "$sdplugin/images/${name}.png" convert icon.png -resize "$((size*2))x$((size*2))" "$sdplugin/images/${name}@2x.png" done - + ( cd external/streamdeck zip -r ../../SubathonManager_StreamDeck.streamDeckPlugin com.wolfwithsword.subathonmanager.sdPlugin ) - + - name: Create GitHub Release id: create_release if: startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/tags/nightly' && (github.event_name != 'workflow_dispatch' || github.event.inputs.force_nightly != 'true') uses: softprops/action-gh-release@v3 with: files: | - ${{ needs.build.outputs.filename }} + dist/*.zip *.sb *.streamDeckPlugin env: @@ -328,7 +722,7 @@ jobs: body: "Latest build from main branch." prerelease: true files: | - ${{ needs.build.outputs.filename }} + dist/*.zip *.sb *.streamDeckPlugin token: ${{ secrets.GITHUB_TOKEN }} @@ -374,12 +768,12 @@ jobs: ] } EOF - + curl -X POST \ -H "Content-Type: application/json" \ -d @payload.json \ "$WEBHOOK_URL" - + curl -X POST \ -H "Content-Type: application/json" \ -d @payload.json \ diff --git a/.github/workflows/wiki-drawio-upload.yml b/.github/workflows/wiki-drawio-upload.yml index 2dc0a75f..e1b95d07 100644 --- a/.github/workflows/wiki-drawio-upload.yml +++ b/.github/workflows/wiki-drawio-upload.yml @@ -19,6 +19,18 @@ jobs: name: Update Wiki Documentation runs-on: ubuntu-latest steps: + - name: Normalize release tag + id: version + run: | + TAG="${{ inputs.release_tag || 'nightly' }}" + + if [[ "$TAG" == "nightly" || "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" + else + echo "Invalid release tag '$TAG', using nightly." + echo "release_tag=nightly" >> "$GITHUB_OUTPUT" + fi + - name: Check if wiki repository exists id: wiki-check run: | @@ -76,22 +88,29 @@ jobs: if git diff --cached --quiet; then echo "No changes detected, skipping commit." else - git commit -m "Update Subathon_Manager.drawio for release ${{ inputs.release_tag || 'nightly' }}" + git commit -m "Update Subathon_Manager.drawio for release ${{ steps.version.outputs.release_tag }}" git push fi - + outputs: + release_tag: ${{ steps.version.outputs.release_tag }} trigger-docs-release: runs-on: ubuntu-latest needs: update - if: ${{ (inputs.release_tag || 'nightly') != 'nightly' }} + if: ${{ needs.update.result == 'success' && needs.version.outputs.release_tag != 'nightly' && needs.version.outputs.release_tag != '' }} steps: - name: Dispatch docs release uses: actions/github-script@v9 + env: + VERSION: ${{ needs.version.outputs.release_tag }} with: github-token: ${{ secrets.DOCS_DISPATCH_TOKEN }} script: | - const version = context.payload.inputs?.release_tag || 'nightly'; + const version = process.env.VERSION; + if (!version || version === 'nightly') { + core.setFailed(`Refusing to dispatch a docs release for version '${version}'.`); + return; + } await github.rest.repos.createDispatchEvent({ owner: 'WolfwithSword', repo: 'SubathonManager-docs', diff --git a/.gitignore b/.gitignore index 7b2d8393..ace32f34 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,18 @@ riderModule.iml exports/ imports/ +cache/ + *.smo +*.smw +*.smwc + +presets/** +!presets/ +!presets/**/ +!presets/**/*.smw +!presets/**/*.smwc -# If we set this up !.idea/codeStyles/ !.idea/inspectionProfiles/ !.idea/runConfigurations/ diff --git a/SubathonManager.Core/AppServices.cs b/SubathonManager.Core/AppServices.cs index d89a01fb..bf0c2e54 100644 --- a/SubathonManager.Core/AppServices.cs +++ b/SubathonManager.Core/AppServices.cs @@ -1,7 +1,7 @@ using Updatum; using System.Reflection; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; namespace SubathonManager.Core { @@ -26,9 +26,22 @@ public static string AppVersion { AssetExtensionFilter = "zip", CurrentVersion = GetVersion(), - FetchOnlyLatestRelease = true + FetchOnlyLatestRelease = true, + AssetRegexPattern = GetAssetRegexPattern(), }; + private static string GetAssetRegexPattern() + { + string rid = + OperatingSystem.IsWindows() ? "win-x64" : + OperatingSystem.IsLinux() ? "linux-x64" : + OperatingSystem.IsMacOS() + ? (RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64") + : string.Empty; + + return string.IsNullOrEmpty(rid) ? string.Empty : $@"(?i).*{rid}.*\.zip$"; + } + private static Version GetVersion() { if (string.IsNullOrWhiteSpace(AppVersion)) @@ -96,13 +109,7 @@ public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILog try { - Process.Start(new ProcessStartInfo - { - FileName = Config.AppFolder, - UseShellExecute = true, - Verb = "open" - }); - await AppUpdater.InstallUpdateAsync(asset, true, UpdatumManager.NoRunAfterUpgradeToken); + await AppUpdater.InstallUpdateAsync(asset, true); return true; } catch (Exception ex) diff --git a/SubathonManager.Core/Enums/SubathonPromptType.cs b/SubathonManager.Core/Enums/SubathonPromptType.cs index 8b216a2b..cd22b627 100644 --- a/SubathonManager.Core/Enums/SubathonPromptType.cs +++ b/SubathonManager.Core/Enums/SubathonPromptType.cs @@ -73,7 +73,11 @@ public static SubathonPromptSubType[] GetValidSubTypes( return type.GetValidSubTypes(); if (filterEventType.IsSubscription()) + { + if (filterEventType == SubathonEventType.YouTubeGiftMembership) // no tier selection + return [SubathonPromptSubType.Default]; return [SubathonPromptSubType.Default, SubathonPromptSubType.ByTier]; + } if (filterEventType.IsOrder()) { // makeship and juniper only ever track unit counts between polls, but do it diff diff --git a/SubathonManager.Core/Enums/WidgetType.cs b/SubathonManager.Core/Enums/WidgetType.cs index 86807663..c57be4b5 100644 --- a/SubathonManager.Core/Enums/WidgetType.cs +++ b/SubathonManager.Core/Enums/WidgetType.cs @@ -7,6 +7,29 @@ public enum WidgetType Video = 2 } +public enum WidgetContextAction +{ + ToggleVisibility, + Clone, + Delete, + ResetScale, + Refresh +} + +public enum WidgetDisplayKind +{ + Widget,// + UnpackedWidget,// + Image, + Video +} + +public enum WidgetCatalogSource +{ + Imported, + Preset +} + public static class WidgetTypeHelper { private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index 6bcfca0d..2b494f33 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -22,7 +22,8 @@ public static class SubathonEvents public static event Action>? SubathonValuesPatched; public static event Action? SubathonTotalsUpdated; - + public static event Action? SubscriptionTotalsUpdated; + public static event Action? PromptRunStarted; public static event Action? PromptRunUpdate; public static event Action? PromptRunProgressUpdated; @@ -34,6 +35,11 @@ public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) SubathonTotalsUpdated?.Invoke(totals); } + public static void RaiseSubscriptionTotalsUpdated(SubscriptionTotals totals) + { + SubscriptionTotalsUpdated?.Invoke(totals); + } + public static void RaisePromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) => PromptRunStarted?.Invoke(run, prompt); diff --git a/SubathonManager.Core/Events/WidgetEvents.cs b/SubathonManager.Core/Events/WidgetEvents.cs index f0abbdb7..360b0f60 100644 --- a/SubathonManager.Core/Events/WidgetEvents.cs +++ b/SubathonManager.Core/Events/WidgetEvents.cs @@ -1,4 +1,5 @@ -using SubathonManager.Core.Models; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Events; @@ -9,6 +10,12 @@ public static class WidgetEvents public static event Action? WidgetScaleUpdated; public static event Action? WidgetSizeUpdated; public static event Action? SelectEditorWidget; + public static event Action? WidgetActionRequested; + + public static void RaiseWidgetAction(Guid widgetId, WidgetContextAction action) + { + WidgetActionRequested?.Invoke(widgetId, action); + } public static void RaisePositionUpdated(Widget widget) { diff --git a/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs new file mode 100644 index 00000000..be1e5920 --- /dev/null +++ b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs @@ -0,0 +1,30 @@ +namespace SubathonManager.Core.Interfaces; + +public interface IWidgetFileSystem +{ + bool Exists(string path); + string? ReadAllText(string path); + byte[]? ReadAllBytes(string path); + bool IsPacked(string path); + string? GetRealFilePath(string path); + IEnumerable EnumerateFiles(string directory); +} + +public sealed class DiskWidgetFileSystem : IWidgetFileSystem +{ + public bool Exists(string path) => File.Exists(path); + public string? ReadAllText(string path) => File.Exists(path) ? File.ReadAllText(path) : null; + public byte[]? ReadAllBytes(string path) => File.Exists(path) ? File.ReadAllBytes(path) : null; + public bool IsPacked(string path) => false; + public string? GetRealFilePath(string path) => File.Exists(path) ? path : null; + + public IEnumerable EnumerateFiles(string directory) => + Directory.Exists(directory) + ? Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + : []; +} + +public static class WidgetFiles +{ + public static IWidgetFileSystem Current { get; set; } = new DiskWidgetFileSystem(); +} diff --git a/SubathonManager.Core/Models/StateValue.cs b/SubathonManager.Core/Models/StateValue.cs index 55e85f48..24caa1c6 100644 --- a/SubathonManager.Core/Models/StateValue.cs +++ b/SubathonManager.Core/Models/StateValue.cs @@ -14,4 +14,9 @@ public class StateValue public static class StateKeys { public const string WheelSpinsOwed = "WheelSpinsOwed"; + + public const string WidgetBrowserCollapsed = "WidgetBrowserCollapsed"; + public const string WidgetBrowserSkipDeleteConfirm = "WidgetBrowserSkipDeleteConfirm"; + public const string WidgetBrowserAllVersions = "WidgetBrowserAllVersions"; + public const string EditorPreviewLightBg = "EditorPreviewLightBg"; } diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index dfa7f455..0ba46370 100644 --- a/SubathonManager.Core/Models/Widget.cs +++ b/SubathonManager.Core/Models/Widget.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; @@ -155,7 +156,11 @@ WidgetVariableType.StringList or } else if (((WidgetVariableType?)Type).IsFileVariable()) { - if (!Value.StartsWith("./") && !string.IsNullOrWhiteSpace(Value)) + if (ResourcePaths.IsResourceUrl(Value)) + { + sb.Append($"\"{Value}\""); + } + else if (!Value.StartsWith("./") && !string.IsNullOrWhiteSpace(Value)) { sb.Append($"\"externalPath/{Value}\""); } @@ -300,36 +305,51 @@ public void ScanCssVariables() CssVariables = ExtractCssVariablesFromFiles(); } - public List ExtractCssVariablesFromFiles() + public List GetLinkedCssPaths() { - var extractedVars = new List(); + var paths = new List(); - if (!File.Exists(HtmlPath)) - return extractedVars; + var html = WidgetFiles.Current.ReadAllText(HtmlPath); + if (html == null) + return paths; - string htmlContent = File.ReadAllText(HtmlPath); string baseDir = Path.GetDirectoryName(HtmlPath)!; - var cssMatches = CssLinkRegex().Matches(htmlContent); - - foreach (Match match in cssMatches) + foreach (Match match in CssLinkMatches(html)) { string cssFile = match.Groups[1].Value; string cssPath = Path.IsPathRooted(cssFile) ? cssFile : Path.Combine(baseDir, cssFile); - if (!File.Exists(cssPath)) + if (!WidgetFiles.Current.Exists(cssPath)) + continue; + if (paths.Contains(cssPath, StringComparer.OrdinalIgnoreCase)) continue; - string cssContent = File.ReadAllText(cssPath); + paths.Add(cssPath); + } + + return paths; + } + + public static MatchCollection CssLinkMatches(string htmlContent) => CssLinkRegex().Matches(htmlContent); + + public List ExtractCssVariablesFromFiles() + { + var extractedVars = new List(); + + foreach (var cssPath in GetLinkedCssPaths()) + { + string? cssContent = WidgetFiles.Current.ReadAllText(cssPath); + if (cssContent == null) continue; var varMatches = CssVarRegex().Matches(cssContent); string metaPath = cssPath + ".json"; Dictionary>? varTypes = null; - if (File.Exists(metaPath)) + var metaJson = WidgetFiles.Current.ReadAllText(metaPath); + if (metaJson != null) { - var metaJson = File.ReadAllText(metaPath); varTypes = JsonSerializer.Deserialize>>(metaJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = false }); } diff --git a/SubathonManager.Core/Models/WidgetCatalogEntry.cs b/SubathonManager.Core/Models/WidgetCatalogEntry.cs new file mode 100644 index 00000000..73280e61 --- /dev/null +++ b/SubathonManager.Core/Models/WidgetCatalogEntry.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Models; + +[ExcludeFromCodeCoverage] +public class WidgetCatalogEntry +{ + [Key] + public int Id { get; set; } + + public string PackPath { get; set; } = string.Empty; + public long FileSize { get; set; } + public long FileModifiedTicks { get; set; } + + public WidgetCatalogSource Source { get; set; } = WidgetCatalogSource.Imported; + + public string PackId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public string Group { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Entry { get; set; } = string.Empty; + public string DocsUrl { get; set; } = string.Empty; + public string Tags { get; set; } = string.Empty; + public string PreviewImage { get; set; } = string.Empty; + public string PreviewCachePath { get; set; } = string.Empty; + + public DateTime LastSeenUtc { get; set; } + + public float ScaleX { get; set; } = 1; + public float ScaleY { get; set; } = 1; +} diff --git a/SubathonManager.Core/Objects/SubscriptionTotals.cs b/SubathonManager.Core/Objects/SubscriptionTotals.cs new file mode 100644 index 00000000..2d21ae14 --- /dev/null +++ b/SubathonManager.Core/Objects/SubscriptionTotals.cs @@ -0,0 +1,20 @@ +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Objects; + +public class SubscriptionTotals +{ + public int SubTotal { get; init; } = 0; + public Dictionary SubTotalByEvent { get; init; } = new(); + + public Dictionary> SubTotalByEventTier { get; init; } = new(); + + public SubscriptionSimulatedTotals Simulated { get; init; } = new(); +} + +public class SubscriptionSimulatedTotals +{ + public int SubTotal { get; init; } = 0; + public Dictionary SubTotalByEvent { get; init; } = new(); + public Dictionary> SubTotalByEventTier { get; init; } = new(); +} diff --git a/SubathonManager.Core/Objects/WidgetMeta.cs b/SubathonManager.Core/Objects/WidgetMeta.cs index 63a85b65..7ad96841 100644 --- a/SubathonManager.Core/Objects/WidgetMeta.cs +++ b/SubathonManager.Core/Objects/WidgetMeta.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Serialization; using SubathonManager.Core.Enums; @@ -13,6 +14,7 @@ public class WidgetMeta public Dictionary Vars { get; set; } = new(); } +[ExcludeFromCodeCoverage] public class WidgetMetaVar { [JsonIgnore] diff --git a/SubathonManager.Core/ResourcePaths.cs b/SubathonManager.Core/ResourcePaths.cs new file mode 100644 index 00000000..3a4a5633 --- /dev/null +++ b/SubathonManager.Core/ResourcePaths.cs @@ -0,0 +1,150 @@ +using System.Text.RegularExpressions; + +namespace SubathonManager.Core; + +public static partial class ResourcePaths +{ + public const string UrlPrefix = "/resources/"; + public const string BundleFolder = "resources"; + + public static string Root => Path.GetFullPath("./resources"); + public static readonly string[] DefaultFolders = ["images", "images/logos", "audio"]; // think of more later? + + public static void EnsureCreated() + { + try + { + Directory.CreateDirectory(Root); + foreach (var folder in DefaultFolders) + Directory.CreateDirectory(Path.Combine(Root, folder.Replace('/', Path.DirectorySeparatorChar))); + } + catch { /**/ } + } + + public static List EnumerateRelative() + { + try + { + string root = Root; + if (!Directory.Exists(root)) return []; + + return Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(root, f).Replace('\\', '/')) + .Where(rel => !rel.StartsWith('.') && !rel.Contains("/.")) + .OrderBy(rel => rel, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch { return []; } + } + + public static IEnumerable FindReferences(string? text) + { + if (string.IsNullOrEmpty(text)) yield break; + + foreach (Match m in ReferenceRegex().Matches(text)) + { + string? rel = NormalizeReference(m.Groups[1].Value); + if (rel != null) yield return rel; + } + } + + public static string RewriteReferences(string text, Func prefixFor) + { + if (string.IsNullOrEmpty(text)) return text; + + return ReferenceRegex().Replace(text, m => + { + string? rel = NormalizeReference(m.Groups[1].Value); + if (rel == null) return m.Value; + + string? prefix = prefixFor(rel); + return prefix == null ? m.Value : prefix + m.Groups[1].Value; + }); + } + + private static string? NormalizeReference(string captured) + { + string rel = captured.Split('?')[0].Split('#')[0].Trim('/'); + if (rel.Length == 0) return null; + + try { return Uri.UnescapeDataString(rel); } + catch { return rel; } + } + + public static string? RelativeFromUrl(string? value) + { + if (!IsResourceUrl(value)) return null; + string rel = value!.Replace('\\', '/')[UrlPrefix.Length..].Split('?')[0].Split('#')[0].Trim('/'); + return rel.Length == 0 ? null : rel; + } + + public static bool IsResourceUrl(string? value) + => !string.IsNullOrWhiteSpace(value) + && value.Replace('\\', '/').StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase); + + public static string? ToResourceUrl(string? localPath) + { + if (string.IsNullOrWhiteSpace(localPath)) return null; + if (IsResourceUrl(localPath)) return localPath.Replace('\\', '/'); + + try + { + string root = Root; + string full = Path.GetFullPath(localPath); + + if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return null; + + string relative = full[(root.Length + 1)..].Replace('\\', '/').Trim('/'); + return relative.Length == 0 ? null : UrlPrefix + relative; + } + catch { return null; } + } + + public static string? ToLocalPath(string? resourceUrl) + { + if (!IsResourceUrl(resourceUrl)) return null; + + try + { + string relative = resourceUrl!.Replace('\\', '/')[UrlPrefix.Length..].Trim('/'); + string root = Root; + string full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + + return full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + ? full + : null; + } + catch { return null; } + } + + [GeneratedRegex(@"(?)]+)", RegexOptions.IgnoreCase)] + private static partial Regex ReferenceRegex(); + + public static string? ResolveRequestPath(string? requestPath) + { + if (string.IsNullOrWhiteSpace(requestPath)) return null; + + string relative = requestPath.Split('?')[0]; + if (relative.StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase)) + relative = relative[UrlPrefix.Length..]; + relative = relative.Trim('/'); + + if (relative.Length == 0) return null; + + try + { + relative = Uri.UnescapeDataString(relative); + if (relative.IndexOfAny(Path.GetInvalidPathChars()) >= 0) return null; + + string root = Root; + string full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + + if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return null; + + return File.Exists(full) ? full : null; + } + catch { return null; } + } +} diff --git a/SubathonManager.Core/SafeFileName.cs b/SubathonManager.Core/SafeFileName.cs new file mode 100644 index 00000000..4b9db491 --- /dev/null +++ b/SubathonManager.Core/SafeFileName.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace SubathonManager.Core; + +public static class SafeFileName +{ + public const string DefaultReplacement = "_"; + public const int MaxLength = 255; + + private static readonly HashSet InvalidChars = BuildInvalidChars(); + + private static HashSet BuildInvalidChars() + { + var chars = new HashSet("\"<>|:*?\\/"); + for (char c = (char)0; c <= (char)31; c++) chars.Add(c); + chars.Add((char)127); + foreach (char c in Path.GetInvalidFileNameChars()) chars.Add(c); + + return chars; + } + + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + public static bool IsInvalidChar(char value) => InvalidChars.Contains(value); + + public static bool IsSafe([NotNullWhen(true)] string? value) + => !string.IsNullOrWhiteSpace(value) && string.Equals(Sanitize(value), value, StringComparison.Ordinal); + + public static string Sanitize(string? value, string replacement = DefaultReplacement, + string fallback = "") + { + if (string.IsNullOrWhiteSpace(value)) return fallback; + + var sb = new StringBuilder(value.Length); + foreach (char c in value.Trim()) + { + if (IsInvalidChar(c)) sb.Append(replacement); + else sb.Append(c); + } + + string result = sb.ToString().TrimEnd(' ', '.'); + + if (result.Length > MaxLength) result = result[..MaxLength].TrimEnd(' ', '.'); + if (result.Length == 0) return fallback; + + return IsReserved(result) ? DefaultReplacement + result : result; + } + + private static bool IsReserved(string name) + { + int dot = name.IndexOf('.'); + return ReservedNames.Contains(dot < 0 ? name : name[..dot]); + } +} diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 9e7695db..1759e031 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -18,6 +18,7 @@ public static class Utils private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> ConnectionDetails = new(); public static string? PendingOverlayImportPath { get; set; } + public static string? PendingWidgetPackImportPath { get; set; } public static OAuthCallback? PendingOAuthCallback { get; set; } public static IEnumerable GetAllConnections() diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 37ff276e..d5cc5af1 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -42,7 +42,9 @@ public class AppDbContext : DbContext public DbSet JuniperProducts { get; set; } public DbSet StateValues { get; set; } - + + public DbSet WidgetCatalogEntries => Set(); + public AppDbContext(DbContextOptions options) : base(options) { @@ -170,6 +172,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasIndex(s => new { s.SiteId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(e => e.PackPath) + .IsUnique(); + modelBuilder.Entity() .Property(p => p.ProductId) .HasConversion(v => v.ToString(), v => System.Numerics.BigInteger.Parse(v)) diff --git a/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.Designer.cs b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.Designer.cs new file mode 100644 index 00000000..613c5372 --- /dev/null +++ b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.Designer.cs @@ -0,0 +1,1098 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260729160320_addWidgetCatalog")] + partial class addWidgetCatalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.cs b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.cs new file mode 100644 index 00000000..66802cf2 --- /dev/null +++ b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class addWidgetCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WidgetCatalogEntries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PackPath = table.Column(type: "TEXT", nullable: false), + FileSize = table.Column(type: "INTEGER", nullable: false), + FileModifiedTicks = table.Column(type: "INTEGER", nullable: false), + Source = table.Column(type: "INTEGER", nullable: false), + PackId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Author = table.Column(type: "TEXT", nullable: false), + Group = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "TEXT", nullable: false), + Entry = table.Column(type: "TEXT", nullable: false), + DocsUrl = table.Column(type: "TEXT", nullable: false), + Tags = table.Column(type: "TEXT", nullable: false), + PreviewImage = table.Column(type: "TEXT", nullable: false), + PreviewCachePath = table.Column(type: "TEXT", nullable: false), + LastSeenUtc = table.Column(type: "TEXT", nullable: false), + ScaleX = table.Column(type: "REAL", nullable: false), + ScaleY = table.Column(type: "REAL", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WidgetCatalogEntries", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_WidgetCatalogEntries_PackPath", + table: "WidgetCatalogEntries", + column: "PackPath", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WidgetCatalogEntries"); + } + } +} diff --git a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs index 9c1a0d3a..1e76a4ea 100644 --- a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs @@ -813,6 +813,82 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Widgets"); }); + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => { b.HasOne("SubathonManager.Core.Models.Widget", "Widget") diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index d641fa9d..54629a6e 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -7,7 +7,11 @@ using Microsoft.EntityFrameworkCore; using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Data.Overlays; +using SubathonManager.Data.Widgets; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data; @@ -16,7 +20,9 @@ public static class OverlayPorter { private const int SegmentHashLength = 4; private const string ExternalFolder = "_external"; - private const string ManifestFileName = "overlay.json"; + private const string PacksFolder = "packs"; + private const string ManifestFileName = OverlayPackInstaller.ManifestFileName; + public const string FormatVersion = "2"; private static readonly JsonSerializerOptions SerializeOptions = new JsonSerializerOptions { @@ -26,10 +32,12 @@ public static class OverlayPorter #region EXPORT - public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, HashSet? excludedZipEntries = null, string version = "1", string appVersion = "") + public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, + HashSet? excludedZipEntries = null, string version = "1", string appVersion = "", + string author = "", List? tags = null) { var widgets = route.Widgets.ToList(); - var plan = BuildExportPlan(widgets); + var plan = BuildExportPlan(widgets, excludedZipEntries); await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); @@ -37,16 +45,27 @@ public static async Task ExportRouteAsync(Route route, string outputPath, string var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var (srcFile, zipEntry) in plan.FileCopies) { - if (!File.Exists(srcFile)) continue; + if (!WidgetFiles.Current.Exists(srcFile)) continue; if (!seen.Add(zipEntry)) continue; if (excludedZipEntries != null && excludedZipEntries.Contains(zipEntry)) continue; - archive.CreateEntryFromFile(srcFile, zipEntry, CompressionLevel.Optimal); + + if (File.Exists(srcFile)) + { + await archive.CreateEntryFromFileAsync(srcFile, zipEntry, CompressionLevel.Optimal); + continue; + } + + var bytes = WidgetFiles.Current.ReadAllBytes(srcFile); + if (bytes == null) continue; + var packedEntry = archive.CreateEntry(zipEntry, CompressionLevel.Optimal); + await using var packedStream = await packedEntry.OpenAsync(); + await packedStream.WriteAsync(bytes); } - var manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion); + var manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion, author, tags); var manifestJson = JsonSerializer.Serialize(manifest, SerializeOptions); var manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal); - await using var manifestStream = manifestEntry.Open(); + await using var manifestStream = await manifestEntry.OpenAsync(); await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson)); OpenExportFolder(outputPath); } @@ -70,9 +89,20 @@ private static void OpenExportFolder(string outputPath) catch {/**/} } - private static ExportPlan BuildExportPlan(List widgets) + public const string ResourcesFolder = "_resources"; + + private static ExportPlan BuildExportPlan(List widgets, HashSet? excludedZipEntries = null) { var plan = new ExportPlan(); + bool IsKept(string zipEntry) => excludedZipEntries?.Contains(zipEntry) != true; + + foreach (var rel in ResourcePaths.EnumerateRelative()) + { + string zipEntry = $"{ResourcesFolder}/{rel}"; + if (!IsKept(zipEntry)) continue; + if (ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel) is { } source) + plan.FileCopies.Add((source, zipEntry)); + } var widgetRoots = widgets.Select(w => w.GetPath()).ToList(); var zipRoots = GetZipWidgetRoots(widgetRoots); @@ -86,17 +116,26 @@ private static ExportPlan BuildExportPlan(List widgets) plan.WidgetFolderMap[widget.Id] = zipWidgetRoot; - if (widget.Type.IsAsset()) + bool isPacked = WidgetPackPaths.TryResolve(widget.HtmlPath, out var packFile, out var packEntry, + out var packId, out var packVersion); + + if (isPacked) { - if (File.Exists(widget.HtmlPath)) + string packZipPath = $"{PacksFolder}/{packId}/{packVersion}{WidgetPackPaths.PackExtension}"; + plan.FileCopies.Add((packFile, packZipPath)); + plan.WidgetPacks[widget.Id] = new PackReference(packId, packVersion, packEntry, packZipPath); + } + else if (widget.Type.IsAsset()) + { + if (WidgetFiles.Current.Exists(widget.HtmlPath)) { string fileName = Path.GetFileName(widget.HtmlPath); plan.FileCopies.Add((widget.HtmlPath, $"{zipWidgetRoot}/{fileName}")); } } - else if (Directory.Exists(widgetRoot)) + else { - foreach (var file in Directory.EnumerateFiles(widgetRoot, "*", SearchOption.AllDirectories)) + foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) { string relative = Path.GetRelativePath(widgetRoot, file).Replace('\\', '/'); plan.FileCopies.Add((file, $"{zipWidgetRoot}/{relative}")); @@ -108,6 +147,16 @@ private static ExportPlan BuildExportPlan(List widgets) if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; + if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } resourceRel) + { + if (!IsKept($"{ResourcesFolder}/{resourceRel}")) continue; + string upToRoot = string.Concat( + Enumerable.Repeat("../", zipWidgetRoot.Split('/').Length)); + SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, + $"{upToRoot}{ResourcesFolder}/{resourceRel}"); + continue; + } + bool isAbsolute = !jsVar.Value.StartsWith("./") && !jsVar.Value.StartsWith("../") && Path.IsPathRooted(jsVar.Value); if (!isAbsolute) continue; @@ -135,7 +184,8 @@ private static ExportPlan BuildExportPlan(List widgets) return plan; } - private static JsonElement BuildManifest(Route route, List widgets, ExportPlan plan, string exportName, string version = "1", string appVersion = "") + private static JsonElement BuildManifest(Route route, List widgets, ExportPlan plan, string exportName, + string version = "1", string appVersion = "", string author = "", List? tags = null) { if (string.IsNullOrWhiteSpace(appVersion)) appVersion = AppServices.AppVersion; var widgetList = widgets.Select(w => @@ -151,11 +201,16 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo return new { name = v.Name, value, type = v.Type }; }); + var pack = plan.WidgetPacks.TryGetValue(w.Id, out var packRef) + ? new { id = packRef.PackId, version = packRef.Version, entry = packRef.Entry, file = packRef.ZipPath } + : null; + return new { id = w.Id, name = w.Name, htmlPath = htmlZipRelPath, + pack, type = w.Type.ToString(), position = new { x = w.X, y = w.Y, z = w.Z }, size = new { width = w.Width, height = w.Height }, @@ -169,13 +224,18 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo var obj = new { + // formatversion helps determine some behaviour from old stuff version = version, + format_version = FormatVersion, app_version = appVersion, exported_at = DateTime.UtcNow, route = new { id = route.Id, name = exportName, + author = author, + overlay_version = version, + tags = tags ?? new List(), resolution = new { width = route.Width, height = route.Height }, created = route.CreatedTimestamp, updated = route.UpdatedTimestamp @@ -202,8 +262,17 @@ public static async Task ImportRouteAsync( string archiveName = Path.GetFileNameWithoutExtension(smoPath); string extractDir = Path.Combine(extractRoot, SanitizeName(archiveName)); Directory.CreateDirectory(extractDir); - ZipFile.ExtractToDirectory(smoPath, extractDir, overwriteFiles: true); + await ZipFile.ExtractToDirectoryAsync(smoPath, extractDir, overwriteFiles: true); + return await ImportExtractedRouteAsync(extractDir, factory, archiveName); + } + + public static async Task ImportExtractedRouteAsync( + string extractDir, + IDbContextFactory factory, + string fallbackName, + string? routeNameOverride = null) + { string manifestPath = Path.Combine(extractDir, ManifestFileName); if (!File.Exists(manifestPath)) return ImportResult.Fail("overlay.json not found in archive"); @@ -214,57 +283,98 @@ public static async Task ImportRouteAsync( await using var db = await factory.CreateDbContextAsync(); var routeEl = root.GetProperty("route"); - string routeName = routeEl.GetProperty("name").GetString() ?? archiveName; - - var existingWidgetPaths = new HashSet( - db.Widgets.Select(w => w.HtmlPath), - StringComparer.OrdinalIgnoreCase); + string routeName = routeNameOverride + ?? routeEl.GetProperty("name").GetString() + ?? fallbackName; var manifestWidgets = root.GetProperty("widgets").EnumerateArray() - .Select(wEl => + .Select(wEl => ResolveManifestWidget(wEl, extractDir)) + .ToList(); + + var existing = db.Widgets + .Select(w => new { w.Id, w.HtmlPath, w.RouteId }) + .ToList() + .Select(w => new { - string htmlZipRelPath = wEl.GetProperty("htmlPath").GetString() ?? ""; - string htmlAbsPath = Path.GetFullPath( - Path.Combine(extractDir, htmlZipRelPath.Replace('/', Path.DirectorySeparatorChar))); - return (wEl, htmlAbsPath); + w.Id, + w.HtmlPath, + w.RouteId, + PackFolder = WidgetPackPaths.Resolve(w.HtmlPath)?.PackFolderStr }) .ToList(); - bool anyWidgetExists = manifestWidgets.Any(w => existingWidgetPaths.Contains(w.htmlAbsPath)); + Guid? matchedRouteId = null; + foreach (var mw in manifestWidgets) + { + var hit = existing.FirstOrDefault(w => + mw.PackFolder != null + ? string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) + : string.Equals(w.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase)); + + if (hit == null) continue; + matchedRouteId = hit.RouteId; + break; + } + + if (matchedRouteId == null) + { + string baseName = OverlayPackPaths.BaseRouteName(routeName); + if (!string.IsNullOrWhiteSpace(baseName)) + { + matchedRouteId = db.Routes + .Select(r => new { r.Id, r.Name }) + .ToList() + .FirstOrDefault(r => string.Equals( + OverlayPackPaths.BaseRouteName(r.Name), + baseName, StringComparison.OrdinalIgnoreCase)) + ?.Id; + } + } - Route? route; + Route? route = null; bool routeIsNew; - if (anyWidgetExists) + if (matchedRouteId != null) { - string? matchedPath = manifestWidgets - .Select(w => w.htmlAbsPath) - .FirstOrDefault(p => existingWidgetPaths.Contains(p)); - route = await db.Routes .Include(r => r.Widgets).ThenInclude(w => w.JsVariables) .Include(r => r.Widgets).ThenInclude(w => w.CssVariables) - .FirstOrDefaultAsync(r => r.Widgets.Any(w => w.HtmlPath == matchedPath)); - - if (route == null) { route = BuildNewRoute(routeEl, routeName); routeIsNew = true; } - else routeIsNew = false; + .FirstOrDefaultAsync(r => r.Id == matchedRouteId); } - else + + if (route == null) { route = BuildNewRoute(routeEl, routeName); routeIsNew = true; } + else + { + routeIsNew = false; + } var newWidgets = new List(); var newCssVariables = new List(); var newJsVariables = new List(); + var repointedWidgets = new List(); - foreach (var (wEl, htmlAbsPath) in manifestWidgets) + foreach (var mw in manifestWidgets) { - string widgetExtractFolder = Path.GetDirectoryName(htmlAbsPath)!; + var wEl = mw.Element; + string htmlAbsPath = mw.HtmlAbsPath; + + string widgetExtractFolder = mw.PackFolder != null + ? mw.OverlayFolder + : Path.GetDirectoryName(htmlAbsPath)!; + Widget? existingWidget = routeIsNew ? null - : route.Widgets.FirstOrDefault(w => - string.Equals(w.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)); + : route.Widgets.FirstOrDefault(w => MatchesManifestWidget(w, mw)); + + if (existingWidget != null && + !string.Equals(existingWidget.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)) + { + existingWidget.HtmlPath = htmlAbsPath; + repointedWidgets.Add(existingWidget); + } if (existingWidget == null) { @@ -301,7 +411,8 @@ public static async Task ImportRouteAsync( widget.CssVariables.Add(v); } foreach (var v in wEl.GetProperty("jsVariables").EnumerateArray().Select(jsEl => - BuildJsVariable(jsEl, widget.Id, widgetExtractFolder)).OfType()) + BuildJsVariable(jsEl, widget.Id, widgetExtractFolder, mw.PackFolder != null)) + .OfType()) { widget.JsVariables.Add(v); } @@ -325,31 +436,103 @@ public static async Task ImportRouteAsync( { string? name = jsEl.TryGetProperty("name", out var n) ? n.GetString() : null; if (name == null || existingJsNames.Contains(name)) continue; - var v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder); + var v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder, mw.PackFolder != null); if (v != null) newJsVariables.Add(v); } } } + var repointedIds = new HashSet(repointedWidgets.Select(w => w.Id)); + foreach (var mw in manifestWidgets.Where(m => m.PackFolder != null)) + { + var strays = existing.Where(w => + string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) && + !string.Equals(w.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase) && + !repointedIds.Contains(w.Id)); + + foreach (var stray in strays) + { + var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == stray.Id); + if (tracked == null || !repointedIds.Add(tracked.Id)) continue; + tracked.HtmlPath = mw.HtmlAbsPath; + repointedWidgets.Add(tracked); + } + } + + bool renameMerged = !routeIsNew && !string.Equals(route!.Name, routeName, StringComparison.Ordinal); + return new ImportResult { Route = routeIsNew ? route : null, NewWidgets = newWidgets, NewCssVariables = newCssVariables, NewJsVariables = newJsVariables, - RouteIsNew = routeIsNew + RepointedWidgets = repointedWidgets, + RouteIsNew = routeIsNew, + MergedRouteId = routeIsNew ? null : route!.Id, + MergedRouteName = renameMerged ? routeName : null }; } - private static Route BuildNewRoute(JsonElement routeEl, string fallbackName) => new Route + private record ManifestWidget(JsonElement Element, string HtmlAbsPath, string OverlayFolder, string? PackFolder); + + private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extractDir) + { + string htmlZipRelPath = wEl.TryGetProperty("htmlPath", out var hp) ? hp.GetString() ?? "" : ""; + + string overlayPath = Path.GetFullPath( + Path.Combine(extractDir, htmlZipRelPath.Replace('/', Path.DirectorySeparatorChar))); + string overlayFolder = Path.GetDirectoryName(overlayPath) ?? extractDir; + + if (!wEl.TryGetProperty("pack", out var packEl) || packEl.ValueKind != JsonValueKind.Object) + return new ManifestWidget(wEl, overlayPath, overlayFolder, null); + + string file = packEl.TryGetProperty("file", out var f) ? f.GetString() ?? "" : ""; + if (string.IsNullOrWhiteSpace(file)) + return new ManifestWidget(wEl, overlayPath, overlayFolder, null); + + string smwPath = Path.GetFullPath( + Path.Combine(extractDir, file.Replace('/', Path.DirectorySeparatorChar))); + + var packManifest = WidgetPackInstaller.ReadManifest(smwPath); + string entry = packManifest?.Entry ?? string.Empty; + if (string.IsNullOrWhiteSpace(entry)) + entry = packEl.TryGetProperty("entry", out var en) ? en.GetString() ?? "" : ""; + + if (string.IsNullOrWhiteSpace(entry) || !File.Exists(smwPath)) + return new ManifestWidget(wEl, overlayPath, overlayFolder, null); + + string mountRoot = Path.Combine( + Path.GetDirectoryName(smwPath)!, + Path.GetFileNameWithoutExtension(smwPath)); + + string packFolder = Path.GetDirectoryName(smwPath)!; + string htmlPath = WidgetPackPaths.EntryPathIn(mountRoot, entry); + + return new ManifestWidget(wEl, htmlPath, overlayFolder, packFolder); + } + + private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) + { + if (mw.PackFolder != null) + { + var location = WidgetPackPaths.Resolve(widget.HtmlPath); + return location != null && + string.Equals(location.PackFolderStr, mw.PackFolder, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(widget.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase); + } + + private static Route BuildNewRoute(JsonElement routeEl, string routeName) => new Route { Id = Guid.NewGuid(), - Name = routeEl.TryGetProperty("name", out var n) ? n.GetString() ?? fallbackName : fallbackName, + Name = routeName, Width = routeEl.GetProperty("resolution").GetProperty("width").GetInt32(), Height = routeEl.GetProperty("resolution").GetProperty("height").GetInt32(), }; - private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder) + private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder, bool isPacked) { var v = JsVariable.FromJson(jsEl, widgetId); if (v == null) return null; @@ -364,9 +547,18 @@ public static async Task ImportRouteAsync( resolvedValue = resolvedValue.Replace('\\', '/'); string normWidgetFolder = widgetFolder.Replace('\\', '/').TrimEnd('/') + "/"; - v.Value = resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase) - ? "./" + resolvedValue[normWidgetFolder.Length..] - : resolvedValue; + if (!resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase)) + { + v.Value = resolvedValue; + return v; + } + + string relative = resolvedValue[normWidgetFolder.Length..]; + bool bundledByOverlay = isPacked && + relative.StartsWith(ExternalFolder + "/", + StringComparison.OrdinalIgnoreCase); + + v.Value = bundledByOverlay ? resolvedValue : "./" + relative; return v; } @@ -466,33 +658,39 @@ private static string HashSegment(string segment) return Convert.ToHexString(bytes)[..SegmentHashLength].ToLowerInvariant(); } - private static string SanitizeName(string name) - { - var invalid = Path.GetInvalidFileNameChars(); - return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); - } + private static string SanitizeName(string name) => SafeFileName.Sanitize(name); #endregion #region TYPES public class ImportResult { public Route? Route { get; init; } - public List NewWidgets { get; init; } = new(); - public List NewCssVariables { get; init; } = new(); - public List NewJsVariables { get; init; } = new(); + public List NewWidgets { get; init; } = []; + public List NewCssVariables { get; init; } = []; + public List NewJsVariables { get; init; } = []; public bool RouteIsNew { get; init; } public bool Failed { get; init; } public string? FailReason { get; init; } + + public List RepointedWidgets { get; init; } = []; + public Guid? MergedRouteId { get; init; } + public string? MergedRouteName { get; init; } + public bool HasAnythingNew => - RouteIsNew || NewWidgets.Count > 0 || NewCssVariables.Count > 0 || NewJsVariables.Count > 0; + RouteIsNew || NewWidgets.Count > 0 || NewCssVariables.Count > 0 || NewJsVariables.Count > 0 + || RepointedWidgets.Count > 0 + || (MergedRouteName != null && MergedRouteId != null); public static ImportResult Fail(string reason) => new ImportResult { Failed = true, FailReason = reason }; } + private record PackReference(string PackId, string Version, string Entry, string ZipPath); + private class ExportPlan { public Dictionary WidgetFolderMap { get; } = new(); // debug helper - public List<(string Src, string ZipEntry)> FileCopies { get; } = new(); + public Dictionary WidgetPacks { get; } = new(); + public List<(string Src, string ZipEntry)> FileCopies { get; } = []; public Dictionary> VariableRewrites { get; } = new(); } #endregion diff --git a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs new file mode 100644 index 00000000..00b5d5ea --- /dev/null +++ b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs @@ -0,0 +1,132 @@ +using System.IO.Compression; +using System.Text.Json; + +namespace SubathonManager.Data.Overlays; + +public class OverlayManifest +{ + public string FormatVersion { get; init; } = "1"; + public string AppVersion { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Version { get; init; } = "1.0.0"; + public List Tags { get; init; } = []; +} + +public static class OverlayPackInstaller +{ + public const string ManifestFileName = "overlay.json"; + public record InstalledOverlay(OverlayManifest Manifest, string ArchiveFile, string UnpackDir); + + public static OverlayManifest? ReadManifest(string smoPath) + { + try + { + using var stream = new FileStream(smoPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + using var reader = new StreamReader(entry.Open()); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return Parse(doc.RootElement, smoPath); + } + catch + { + return null; + } + } + + public static InstalledOverlay? Install(string smoPath) + { + var manifest = ReadManifest(smoPath); + if (manifest == null) return null; + + string archiveTarget = OverlayPackPaths.ArchiveFile(manifest.Author, manifest.Name, manifest.Version); + string unpackDir = OverlayPackPaths.UnpackDir(manifest.Author, manifest.Name); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(archiveTarget)!); + + if (!string.Equals(Path.GetFullPath(smoPath), Path.GetFullPath(archiveTarget), + StringComparison.OrdinalIgnoreCase)) + File.Copy(smoPath, archiveTarget, overwrite: true); + + Directory.CreateDirectory(unpackDir); + ExtractSafely(archiveTarget, unpackDir); + } + catch + { + return null; + } + + return new InstalledOverlay(manifest, archiveTarget, unpackDir); + } + + private static void ExtractSafely(string archivePath, string targetDir) + { + string root = Path.GetFullPath(targetDir) + Path.DirectorySeparatorChar; + + using var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; // directory marker + + string relative = entry.FullName.Replace('\\', '/').TrimStart('/'); + string destination = Path.GetFullPath( + Path.Combine(targetDir, relative.Replace('/', Path.DirectorySeparatorChar))); + + if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) continue; + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + entry.ExtractToFile(destination, overwrite: true); + } + } + + private static OverlayManifest Parse(JsonElement root, string smoPath) + { + var route = root.TryGetProperty("route", out var r) ? r : default; + string name = Str(route, "name"); + + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smoPath); + + string version = Str(route, "overlay_version"); + if (string.IsNullOrWhiteSpace(version)) version = Str(root, "version"); + if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; + + return new OverlayManifest + { + FormatVersion = Str(root, "format_version"), + AppVersion = Str(root, "app_version"), + Name = name, + Author = Str(route, "author"), + Version = version, + Tags = StrList(route, "tags") + }; + } + + private static string Str(JsonElement el, string name) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && + v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; + + private static List StrList(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || + !el.TryGetProperty(name, out var v) || + v.ValueKind != JsonValueKind.Array) + return []; + + return v.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + } +} diff --git a/SubathonManager.Data/Overlays/OverlayPackPaths.cs b/SubathonManager.Data/Overlays/OverlayPackPaths.cs new file mode 100644 index 00000000..6c7a7f2f --- /dev/null +++ b/SubathonManager.Data/Overlays/OverlayPackPaths.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; +using SubathonManager.Core; +using SubathonManager.Data.Widgets; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Overlays; + +public static partial class OverlayPackPaths +{ + public const string OverlayExtension = ".smo"; + public const string UnpackFolderName = "unpack"; + public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "overlays")); + + public static string OverlayRoot(string author, string name) + { + string authorFolder = WidgetPackPaths.Slug(author); + if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; + + string nameFolder = WidgetPackPaths.Slug(name); + if (string.IsNullOrEmpty(nameFolder)) nameFolder = "overlay"; + + return Path.Combine(ImportsRoot, authorFolder, nameFolder); + } + + public static string ArchiveFile(string author, string name, string version) + { + string versionFile = SanitizeSegment(version); + if (string.IsNullOrEmpty(versionFile)) versionFile = "1.0.0"; + return Path.Combine(OverlayRoot(author, name), versionFile + OverlayExtension); + } + + public static string UnpackDir(string author, string name) + => Path.Combine(OverlayRoot(author, name), UnpackFolderName); + + public static List ImportedVersions(string author, string name) + { + string folder = OverlayRoot(author, name); + if (!Directory.Exists(folder)) return []; + + try + { + return Directory.EnumerateFiles(folder, "*" + OverlayExtension) + .Select(Path.GetFileNameWithoutExtension) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .ToList(); + } + catch + { + return []; + } + } + + public static string RouteName(string name, string version) + { + string trimmed = (name ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(trimmed)) trimmed = "Imported Overlay"; + + string display = WidgetPackPaths.DisplayVersion(version); + return string.IsNullOrWhiteSpace(display) ? trimmed : $"{trimmed} v{display}"; + } + + public static string BaseRouteName(string? routeName) + { + return string.IsNullOrWhiteSpace(routeName) ? string.Empty : + VersionSuffixRegex().Replace(routeName.Trim(), string.Empty).Trim(); + } + + [GeneratedRegex(@"\s+v\d[\w.\-]*$", RegexOptions.IgnoreCase)] + private static partial Regex VersionSuffixRegex(); + + public static string BuildFileName(string author, string name, string version) + { + var parts = new[] { author, name, version } + .Select(p => SanitizeSegment(p).Replace(' ', '-')) + .Where(p => !string.IsNullOrWhiteSpace(p)); + + string joined = string.Join('_', parts); + return (string.IsNullOrWhiteSpace(joined) ? "overlay" : joined) + OverlayExtension; + } + + private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); +} diff --git a/SubathonManager.Data/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index 9d2f8040..72f0fd82 100644 --- a/SubathonManager.Data/WidgetEntityHelper.cs +++ b/SubathonManager.Data/WidgetEntityHelper.cs @@ -1,5 +1,4 @@ -using System.Reflection; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,6 +6,7 @@ using System.Text.RegularExpressions; using SubathonManager.Core.Events; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Core; using SubathonManager.Core.Objects; @@ -250,26 +250,14 @@ public void SyncJsVariables(Widget widget) public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) { var jsonPath = htmlpath + ".json"; - if (!File.Exists(jsonPath)) - { - var html = File.ReadAllText(htmlpath); - Dictionary data = GetMetaDataHtml(html); - try - { - File.WriteAllText(jsonPath, JsonSerializer.Serialize(ConvertHtmlMetaToJsonMeta(data), JsonOptions)); - _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); - return new WidgetMeta(); - } - - } + if (!EnsureMetaSidecar(htmlpath, jsonPath, out var packedFallback)) + return packedFallback; + try { - var json = File.ReadAllText(jsonPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); + var json = WidgetFiles.Current.ReadAllText(jsonPath); + if (json != null) + return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); } catch (Exception ex) { @@ -279,36 +267,31 @@ public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) return new WidgetMeta(); } - public async Task ExtractWidgetMetadata(string htmlpath) + public Task ExtractWidgetMetadata(string htmlpath) + => Task.FromResult(ExtractWidgetMetadataSync(htmlpath)); + + private bool EnsureMetaSidecar(string htmlpath, string jsonPath, out WidgetMeta packedFallback) { - var jsonPath = htmlpath + ".json"; - if (!File.Exists(jsonPath)) - { - var html = await File.ReadAllTextAsync(htmlpath); - Dictionary data = GetMetaDataHtml(html); - try - { - await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(ConvertHtmlMetaToJsonMeta(data), JsonOptions)); - _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); - return new WidgetMeta(); - } - } - + packedFallback = new WidgetMeta(); + + if (WidgetFiles.Current.Exists(jsonPath)) return true; + if (WidgetFiles.Current.IsPacked(htmlpath)) return false; + + var html = WidgetFiles.Current.ReadAllText(htmlpath); + if (html == null) return false; + try { - var json = await File.ReadAllTextAsync(jsonPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); + var meta = ConvertHtmlMetaToJsonMeta(GetMetaDataHtml(html)); + File.WriteAllText(jsonPath, JsonSerializer.Serialize(meta, JsonOptions)); + _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); + return true; } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to read widget meta JSON at {Path}", jsonPath); + _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); + return false; } - - return new WidgetMeta(); } internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) diff --git a/SubathonManager.Data/WidgetPorter.cs b/SubathonManager.Data/WidgetPorter.cs new file mode 100644 index 00000000..ad78360f --- /dev/null +++ b/SubathonManager.Data/WidgetPorter.cs @@ -0,0 +1,673 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Data.Widgets; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data; + +public static partial class WidgetPorter +{ + public const string ManifestFileName = WidgetPackInstaller.ManifestFileName; + public const string ContentFolder = "content"; + public const string ExternalFolder = "_external"; + public const string SharedFolder = "_shared"; + public const string FormatVersion = "1"; + + private static readonly JsonSerializerOptions MetaOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() } + }; + + private static readonly JsonSerializerOptions ManifestOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() } + }; + + #region TYPES + + public enum SmwEntryKind + { + Manifest, + Entry, + Css, + Meta, + Asset, + External + } + + public class SmwEntry + { + public required string ZipEntry { get; init; } + public string? AbsSource { get; init; } + public SmwEntryKind Kind { get; init; } + public bool DefaultSelected { get; set; } + public bool Locked { get; init; } + public Func? Generator { get; init; } + public bool InUse { get; init; } + public string? UsageHint { get; init; } + } + + public class SmwExportOptions + { + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Group { get; init; } = string.Empty; + + public string Version { get; init; } = "1.0.0"; + public string AppVersion { get; init; } = string.Empty; + + public List Tags { get; init; } = []; + + public string PreviewImagePath { get; init; } = string.Empty; + } + + public static readonly string[] PreviewExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]; + + public static string PreviewEntryName(string sourcePath) + { + string ext = Path.GetExtension(sourcePath).ToLowerInvariant(); + if (!PreviewExtensions.Contains(ext)) ext = ".png"; + return "preview" + ext; + } + + public static string? ExtractExistingPreview(Widget widget) + { + var location = WidgetPackPaths.Resolve(widget.HtmlPath); + if (location == null) return null; + + var manifest = WidgetPackInstaller.ReadManifest(location.PackFileStr); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.PreviewImage)) return null; + + var bytes = WidgetFiles.Current.ReadAllBytes( + WidgetPackPaths.EntryPathIn(location.MountRootStr, manifest.PreviewImage)); + if (bytes == null) return null; + + try + { + string temp = Path.Combine(Path.GetTempPath(), + $"smw-preview-{Guid.NewGuid():N}{Path.GetExtension(manifest.PreviewImage)}"); + File.WriteAllBytes(temp, bytes); + return temp; + } + catch + { + return null; + } + } + + + public static List ParseTags(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return []; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + return raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(tag => seen.Add(tag)) + .ToList(); + } + + public class ExportPlan + { + public List Entries { get; } = []; + public Dictionary VariableRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); + public string EntryZipPath { get; set; } = string.Empty; + + public Dictionary OptionalRewrites { get; } + = new(StringComparer.OrdinalIgnoreCase); + + public bool IsSelected(string zipEntry) + => Entries.Any(e => e.ZipEntry.Equals(zipEntry, StringComparison.OrdinalIgnoreCase) + && (e.Locked || e.DefaultSelected)); + + public string ResolveVarValue(JsVariable jsVar) + { + if (VariableRewrites.TryGetValue(jsVar.Name, out var rewritten)) return rewritten; + if (OptionalRewrites.TryGetValue(jsVar.Name, out var optional) && IsSelected(optional.ZipEntry)) + return optional.Value; + return jsVar.Value ?? string.Empty; + } + } + + #endregion + + #region PLAN + + public static ExportPlan BuildPlan(Widget widget) + { + var plan = new ExportPlan(); + if (widget.Type.IsAsset() || !WidgetFiles.Current.Exists(widget.HtmlPath)) return plan; + + string widgetRoot = widget.GetPath(); + string htmlEntry = $"{ContentFolder}/{Path.GetFileName(widget.HtmlPath)}"; + plan.EntryZipPath = htmlEntry; + + var cssPaths = widget.GetLinkedCssPaths(); + var cssZipMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var htmlLinkRewrites = new Dictionary(StringComparer.Ordinal); + + foreach (var cssPath in cssPaths) + { + string? entry = ToContentEntry(widgetRoot, cssPath); + if (entry == null) + { + entry = $"{ContentFolder}/{SharedFolder}/{Path.GetFileName(cssPath)}"; + string original = GetHtmlLinkFor(widget, cssPath); + if (!string.IsNullOrEmpty(original)) + htmlLinkRewrites[original] = $"./{SharedFolder}/{Path.GetFileName(cssPath)}"; + } + cssZipMap[cssPath] = entry; + } + + plan.Entries.Add(new SmwEntry + { + ZipEntry = ManifestFileName, + Kind = SmwEntryKind.Manifest, + DefaultSelected = true, + Locked = true, + Generator = opts => Encoding.UTF8.GetBytes(BuildManifest(widget, plan, opts)) + }); + + string htmlText = WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty; + var hardcodedResources = new HashSet(ResourcePaths.FindReferences(htmlText), + StringComparer.OrdinalIgnoreCase); + foreach (var cssPath in cssPaths) + hardcodedResources.UnionWith(ResourcePaths.FindReferences(WidgetFiles.Current.ReadAllText(cssPath))); + + Func? htmlGenerator = null; + if (htmlLinkRewrites.Count > 0 || hardcodedResources.Count > 0) + htmlGenerator = _ => Encoding.UTF8.GetBytes( + RewriteResourceUrls(RewriteHtmlLinks(htmlText, htmlLinkRewrites), htmlEntry, plan)); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = htmlEntry, + AbsSource = widget.HtmlPath, + Kind = SmwEntryKind.Entry, + DefaultSelected = true, + Locked = true, + Generator = htmlGenerator + }); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = $"{htmlEntry}.json", + AbsSource = WidgetFiles.Current.Exists(widget.HtmlPath + ".json") ? widget.HtmlPath + ".json" : null, + Kind = SmwEntryKind.Meta, + DefaultSelected = true, + Generator = opts => Encoding.UTF8.GetBytes(BuildWidgetMetaJson(widget, plan, opts)) + }); + + var consumed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var cssPath in cssPaths) + { + string cssEntry = cssZipMap[cssPath]; + var varsInFile = ClaimCssVariables(widget, cssPath, consumed); + + string cssText = WidgetFiles.Current.ReadAllText(cssPath) ?? string.Empty; + bool cssUsesResources = ResourcePaths.FindReferences(cssText).Any(); + + Func? cssGenerator = null; + if (varsInFile.Count > 0 || cssUsesResources) + { + var capturedVars = varsInFile; + string capturedEntry = cssEntry; + cssGenerator = _ => Encoding.UTF8.GetBytes( + RewriteResourceUrls(OverrideCssValues(cssText, capturedVars), capturedEntry, plan)); + } + + plan.Entries.Add(new SmwEntry + { + ZipEntry = cssEntry, + AbsSource = cssPath, + Kind = SmwEntryKind.Css, + DefaultSelected = true, + Generator = cssGenerator + }); + + string cssMetaPath = cssPath + ".json"; + bool hasMeta = WidgetFiles.Current.Exists(cssMetaPath); + bool worthWriting = varsInFile.Any(v => + v.Type != WidgetCssVariableType.Default || !string.IsNullOrWhiteSpace(v.Description)); + + if (!hasMeta && !worthWriting) continue; + + string capturedMetaPath = cssMetaPath; + var capturedMetaVars = varsInFile; + plan.Entries.Add(new SmwEntry + { + ZipEntry = $"{cssEntry}.json", + AbsSource = hasMeta ? cssMetaPath : null, + Kind = SmwEntryKind.Meta, + DefaultSelected = true, + Generator = _ => Encoding.UTF8.GetBytes(BuildCssMetaJson(capturedMetaPath, capturedMetaVars)) + }); + } + + var claimed = new HashSet(plan.Entries.Select(en => en.ZipEntry), + StringComparer.OrdinalIgnoreCase); + foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) + { + string? entry = ToContentEntry(widgetRoot, file); + if (entry == null || !claimed.Add(entry)) continue; + + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = file, + Kind = SmwEntryKind.Asset, + DefaultSelected = false + }); + } + + AddFileVariableEntries(widget, widgetRoot, plan, claimed); + AddSharedResourceEntries(widget, plan, claimed, hardcodedResources); + return plan; + } + + private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, HashSet claimed, + HashSet hardcodedResources) + { + var varUsed = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var jsVar in widget.JsVariables) + { + if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; + if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } rel) varUsed[rel] = jsVar.Name; + } + + foreach (var rel in ResourcePaths.EnumerateRelative()) + { + string entry = $"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"; + if (!claimed.Add(entry)) continue; + + bool byVar = varUsed.TryGetValue(rel, out var varName); + bool byMarkup = hardcodedResources.Contains(rel); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel), + Kind = SmwEntryKind.External, + DefaultSelected = false, + InUse = byVar || byMarkup, + UsageHint = byVar + ? $"Used by variable \"{varName}\"" + : byMarkup ? "Referenced directly by this widget's html/css" : null + }); + + if (byVar) + plan.OptionalRewrites[varName!] = + (entry, $"./{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"); + } + } + + private static void AddFileVariableEntries(Widget widget, string widgetRoot, ExportPlan plan, HashSet claimed) + { + foreach (var jsVar in widget.JsVariables) + { + if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; + if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; + if (ResourcePaths.IsResourceUrl(jsVar.Value)) continue; + + bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; + bool isRelative = jsVar.Value.StartsWith("./") || jsVar.Value.StartsWith("../"); + + if (isRelative) + { + string rel = jsVar.Value.TrimStart('.').TrimStart('/'); + string prefix = $"{ContentFolder}/{rel}"; + foreach (var entry in plan.Entries.Where(en => + en.Kind == SmwEntryKind.Asset && + (en.ZipEntry.Equals(prefix, StringComparison.OrdinalIgnoreCase) || + en.ZipEntry.StartsWith(prefix.TrimEnd('/') + "/", + StringComparison.OrdinalIgnoreCase)))) + { + entry.DefaultSelected = true; + } + continue; + } + + if (!Path.IsPathRooted(jsVar.Value)) continue; + + if (isFolderType && Directory.Exists(jsVar.Value)) + { + string varFolder = SanitizeName(jsVar.Name); + foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", + SearchOption.AllDirectories)) + { + string rel = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); + string entry = $"{ContentFolder}/{ExternalFolder}/{varFolder}/{rel}"; + + if (!claimed.Add(entry)) continue; + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = file, + Kind = SmwEntryKind.External, + DefaultSelected = true + }); + } + + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{varFolder}"; + } + else if (!isFolderType && File.Exists(jsVar.Value)) + { + string fileName = Path.GetFileName(jsVar.Value); + string entry = $"{ContentFolder}/{ExternalFolder}/{fileName}"; + if (claimed.Add(entry)) + { + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = jsVar.Value, + Kind = SmwEntryKind.External, + DefaultSelected = true + }); + } + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{fileName}"; + } + } + } + + #endregion + + #region EXPORT + + public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions options, string outputPath) + { + var opts = options; + if (string.IsNullOrWhiteSpace(opts.AppVersion)) + { + opts = new SmwExportOptions + { + Name = options.Name, + Author = options.Author, + Group = options.Group, + Version = options.Version, + Tags = options.Tags, + PreviewImagePath = options.PreviewImagePath, + AppVersion = AppServices.AppVersion + }; + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))!); + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); + await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in plan.Entries) + { + if (entry is { Locked: false, DefaultSelected: false }) continue; + if (!written.Add(entry.ZipEntry)) continue; + + byte[]? bytes = null; + if (entry.Generator != null) + { + bytes = entry.Generator(opts); + } + else if (entry.AbsSource != null) + { + if (File.Exists(entry.AbsSource)) + { + await archive.CreateEntryFromFileAsync(entry.AbsSource, entry.ZipEntry, CompressionLevel.Optimal); + continue; + } + bytes = WidgetFiles.Current.ReadAllBytes(entry.AbsSource); + } + + if (bytes == null) continue; + + var zipEntry = archive.CreateEntry(entry.ZipEntry, CompressionLevel.Optimal); + await using var stream = await zipEntry.OpenAsync(); + await stream.WriteAsync(bytes); + } + + if (!string.IsNullOrWhiteSpace(opts.PreviewImagePath) && File.Exists(opts.PreviewImagePath)) + { + string previewEntry = PreviewEntryName(opts.PreviewImagePath); + if (written.Add(previewEntry)) + await archive.CreateEntryFromFileAsync(opts.PreviewImagePath, previewEntry, CompressionLevel.Optimal); + } + } + + public static string ExportsDirectory => Path.GetFullPath(Path.Combine("./exports", "widgets")); + + public static WidgetMeta ReadExistingMeta(Widget widget) + { + var json = WidgetFiles.Current.ReadAllText(widget.HtmlPath + ".json"); + if (json == null) return new WidgetMeta(); + try + { + return JsonSerializer.Deserialize(json, MetaOptions) ?? new WidgetMeta(); + } + catch + { + return new WidgetMeta(); + } + } + + public static string BuildFileName(string author, string group, string name, string version) + { + var parts = new[] { author, WidgetPackPaths.NormalizeGroup(group), name, version } + .Select(p => SanitizeName(p?.Trim() ?? string.Empty).Replace(' ', '-')) + .Where(p => !string.IsNullOrWhiteSpace(p)); + return $"{string.Join('_', parts)}.smw"; + } + + private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOptions opts) + { + string name = string.IsNullOrWhiteSpace(opts.Name) ? widget.Name : opts.Name; + string group = WidgetPackPaths.NormalizeGroup(opts.Group); + + string packId = WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out var existingPackId, out _) + ? existingPackId + : WidgetPackPaths.MakePackId(opts.Author, group, name); + + var obj = new + { + version = FormatVersion, + app_version = opts.AppVersion, + exported_at = DateTime.UtcNow, + widget = new + { + pack_id = packId, + name, + author = opts.Author, + group, + widget_version = opts.Version, + tags = opts.Tags, + preview_image = string.IsNullOrWhiteSpace(opts.PreviewImagePath) + ? string.Empty + : PreviewEntryName(opts.PreviewImagePath), + docsUrl = widget.DocsUrl ?? string.Empty, + type = widget.Type.ToString(), + entry = plan.EntryZipPath, + size = new { width = widget.Width, height = widget.Height }, + scale = new { x = widget.ScaleX, y = widget.ScaleY } + } + }; + return JsonSerializer.Serialize(obj, ManifestOptions); + } + + #endregion + + #region OVERRIDES + + private static List ClaimCssVariables(Widget widget, string cssPath, HashSet consumed) + { + var result = new List(); + string? content = WidgetFiles.Current.ReadAllText(cssPath); + if (content == null) return result; + + foreach (Match match in CssVarRegex().Matches(content)) + { + string name = match.Groups[1].Value.Trim(); + if (!consumed.Add(name)) continue; + var dbVar = widget.CssVariables.FirstOrDefault(v => + string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase)); + if (dbVar != null) result.Add(dbVar); + } + + return result; + } + + private static string RewriteResourceUrls(string text, string ownZipEntry, ExportPlan plan) + { + if (string.IsNullOrEmpty(text)) return text; + + int depth = ownZipEntry.Split('/').Length - 2; + string upToContent = depth <= 0 ? "./" : string.Concat(Enumerable.Repeat("../", depth)); + + return ResourcePaths.RewriteReferences(text, rel => + plan.IsSelected($"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}") + ? $"{upToContent}{ExternalFolder}/{ResourcePaths.BundleFolder}/" + : null); + } + + private static string OverrideCssValues(string css, List variables) + { + foreach (var variable in variables) + { + if (string.IsNullOrWhiteSpace(variable.Value)) continue; + var pattern = new Regex($@"(--{Regex.Escape(variable.Name)}\s*:\s*)([^;]*)(;)"); + css = pattern.Replace(css, m => $"{m.Groups[1].Value}{variable.Value}{m.Groups[3].Value}", 1); + } + return css; + } + + private static string BuildCssMetaJson(string existingMetaPath, List variables) + { + Dictionary> meta = new(StringComparer.Ordinal); + var existingJson = WidgetFiles.Current.ReadAllText(existingMetaPath); + if (existingJson != null) + { + try + { + meta = JsonSerializer.Deserialize>>(existingJson) ?? meta; + } + catch { /**/ } + } + + foreach (var variable in variables) + { + if (!meta.TryGetValue(variable.Name, out var entry)) + { + entry = new Dictionary(StringComparer.Ordinal); + meta[variable.Name] = entry; + } + entry["type"] = variable.Type.ToString(); + entry["description"] = variable.Description ?? string.Empty; + } + + return JsonSerializer.Serialize(meta, ManifestOptions); + } + + private static string BuildWidgetMetaJson(Widget widget, ExportPlan plan, SmwExportOptions opts) + { + WidgetMeta meta = ReadExistingMeta(widget); + + if (!string.IsNullOrWhiteSpace(opts.Author)) meta.Author = opts.Author; + if (!string.IsNullOrWhiteSpace(widget.DocsUrl)) meta.Url = widget.DocsUrl!; + meta.Width = widget.Width; + meta.Height = widget.Height; + + foreach (var jsVar in widget.JsVariables) + { + if (!meta.Vars.TryGetValue(jsVar.Name, out var metaVar)) + { + metaVar = new WidgetMetaVar(); + meta.Vars[jsVar.Name] = metaVar; + } + + metaVar.Name = jsVar.Name; + metaVar.Type = jsVar.Type; + metaVar.Description = jsVar.Description ?? string.Empty; + + metaVar.Value = ToMetaValue(jsVar.Type, plan.ResolveVarValue(jsVar), metaVar); + } + + return JsonSerializer.Serialize(meta, MetaOptions); + } + + private static object ToMetaValue(WidgetVariableType type, string value, WidgetMetaVar metaVar) + { + var items = value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + + switch (type) + { + case WidgetVariableType.StringSelect: + metaVar.Options = items; + return items.Count > 0 ? items[0] : string.Empty; + + case WidgetVariableType.Boolean: + return bool.TryParse(value, out var b) && b; + + case WidgetVariableType.Int: + case WidgetVariableType.Percent: + return int.TryParse(value, out var i) ? i : 0; + + case WidgetVariableType.Float: + return float.TryParse(value, out var f) ? f : 0f; + + default: + if (type.IsListType()) return items; + return value; + } + } + + #endregion + + #region HELPERS + + private static string? ToContentEntry(string widgetRoot, string absPath) + { + string rel = Path.GetRelativePath(widgetRoot, absPath).Replace('\\', '/'); + if (rel.StartsWith("../") || rel == ".." || Path.IsPathRooted(rel)) return null; + return $"{ContentFolder}/{rel}"; + } + + private static string GetHtmlLinkFor(Widget widget, string cssPath) + { + string baseDir = Path.GetDirectoryName(widget.HtmlPath)!; + foreach (Match match in Widget.CssLinkMatches(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty)) + { + string href = match.Groups[1].Value; + string resolved = Path.IsPathRooted(href) ? href : Path.Combine(baseDir, href); + if (string.Equals(Path.GetFullPath(resolved), Path.GetFullPath(cssPath), StringComparison.OrdinalIgnoreCase)) + return href; + } + return string.Empty; + } + + private static string RewriteHtmlLinks(string html, Dictionary rewrites) + { + foreach (var (original, replacement) in rewrites) + { + html = html.Replace($"\"{original}\"", $"\"{replacement}\"") + .Replace($"'{original}'", $"'{replacement}'"); + } + return html; + } + + private static string SanitizeName(string name) => SafeFileName.Sanitize(name); + + [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] + private static partial Regex CssVarRegex(); + + #endregion +} diff --git a/SubathonManager.Data/Widgets/WidgetCatalog.cs b/SubathonManager.Data/Widgets/WidgetCatalog.cs new file mode 100644 index 00000000..82f8471d --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetCatalog.cs @@ -0,0 +1,397 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public static class WidgetCatalog +{ + public static string PresetsRoot => WidgetPackPaths.PresetsRoot; + public static string ImportsRoot => WidgetPackPaths.ImportsRoot; + public static string PreviewCacheRoot => Path.GetFullPath(Path.Combine("./cache", "widget-previews")); + + public record ScanRoot(string Path, WidgetCatalogSource Source); + + public static IEnumerable Roots() + { + yield return new ScanRoot(PresetsRoot, WidgetCatalogSource.Preset); + yield return new ScanRoot(ImportsRoot, WidgetCatalogSource.Imported); + } + + public static async Task> RefreshAsync( + IDbContextFactory factory, CancellationToken ct = default) + { + await using var db = await factory.CreateDbContextAsync(ct); + + var rows = await db.WidgetCatalogEntries.ToListAsync(ct); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + byPath.TryAdd(row.PackPath, row); + + var live = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var now = DateTime.UtcNow; + + foreach (var root in Roots()) + { + foreach (var file in EnumeratePackages(root.Path)) + { + ct.ThrowIfCancellationRequested(); + + string stored = ToStoredPath(file); + if (!seen.Add(stored)) continue; + + FileInfo info; + try { info = new FileInfo(file); } + catch { continue; } + + byPath.TryGetValue(stored, out var entry); + + bool unchanged = entry != null && + entry.FileSize == info.Length && + entry.FileModifiedTicks == info.LastWriteTimeUtc.Ticks; + + if (unchanged) + { + if (!string.IsNullOrWhiteSpace(entry!.PreviewImage) && + (string.IsNullOrWhiteSpace(entry.PreviewCachePath) || !File.Exists(entry.PreviewCachePath))) + entry.PreviewCachePath = ExtractPreview(file, entry.PreviewImage) ?? string.Empty; + + entry.Source = root.Source; + entry.LastSeenUtc = now; + live.Add(entry); + continue; + } + + var manifest = WidgetPackInstaller.ReadManifest(file); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) + { + if (entry != null) db.WidgetCatalogEntries.Remove(entry); + continue; + } + + if (entry == null) + { + entry = new WidgetCatalogEntry { PackPath = stored }; + db.WidgetCatalogEntries.Add(entry); + } + + Fill(entry, file, info, manifest, root.Source, now); + live.Add(entry); + } + } + + foreach (var row in rows.Where(row => !seen.Contains(row.PackPath))) + { + db.WidgetCatalogEntries.Remove(row); + DeletePreview(row.PreviewCachePath); + } + + try + { + await db.SaveChangesAsync(ct); + } + catch { /**/ } + + BuildIndex(live); + + return live + .OrderBy(e => e.Author, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Group, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ThenByDescending(e => e.Version, Comparer.Create(WidgetPackPaths.CompareVersions)) + .ToList(); + } + + private static void Fill(WidgetCatalogEntry entry, string file, FileInfo info, + WidgetPackManifest manifest, WidgetCatalogSource source, DateTime now) + { + entry.FileSize = info.Length; + entry.FileModifiedTicks = info.LastWriteTimeUtc.Ticks; + entry.Source = source; + entry.PackId = manifest.PackId; + entry.Name = manifest.Name; + entry.Author = manifest.Author; + entry.Group = WidgetPackPaths.NormalizeGroup(manifest.Group); + entry.Version = manifest.Version; + entry.Entry = manifest.Entry; + entry.DocsUrl = manifest.DocsUrl; + entry.Tags = string.Join(", ", manifest.Tags); + entry.PreviewImage = manifest.PreviewImage; + entry.PreviewCachePath = ExtractPreview(file, manifest.PreviewImage, force: true) ?? string.Empty; + entry.ScaleX = manifest.ScaleX; + entry.ScaleY = manifest.ScaleY; + entry.LastSeenUtc = now; + } + + public static async Task RefreshEntryAsync(IDbContextFactory factory, + string packPath, CancellationToken ct = default) + { + string file = ToAbsolutePath(packPath); + + await using var db = await factory.CreateDbContextAsync(ct); + var entry = await db.WidgetCatalogEntries.FirstOrDefaultAsync(e => e.PackPath == packPath, ct); + + if (!File.Exists(file)) + { + if (entry != null) + { + db.WidgetCatalogEntries.Remove(entry); + DeletePreview(entry.PreviewCachePath); + try { await db.SaveChangesAsync(ct); } catch { /**/ } + } + + return null; + } + + var manifest = WidgetPackInstaller.ReadManifest(file); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; + + FileInfo info; + try { info = new FileInfo(file); } + catch { return null; } + + if (entry == null) + { + entry = new WidgetCatalogEntry { PackPath = ToStoredPath(file) }; + db.WidgetCatalogEntries.Add(entry); + } + + Fill(entry, file, info, manifest, SourceFor(file), DateTime.UtcNow); + try + { + string cacheDir = WidgetPackPaths.CacheDirFor(file); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + } + catch { /**/ } + + WidgetPackMemoryCache.InvalidatePack(file); + WidgetPackPaths.InvalidateVersionCache(); + WidgetPackPaths.InvalidateResolveCache(); + + try { await db.SaveChangesAsync(ct); } catch { /**/ } + + return entry; + } + + private static WidgetCatalogSource SourceFor(string file) + { + try + { + return Path.GetFullPath(file).StartsWith(PresetsRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + ? WidgetCatalogSource.Preset + : WidgetCatalogSource.Imported; + } + catch + { + return WidgetCatalogSource.Imported; + } + } + + public static async Task DeleteAsync(IDbContextFactory factory, WidgetCatalogEntry entry, + CancellationToken ct = default) + { + if (entry.Source == WidgetCatalogSource.Preset) return false; + + string file = ToAbsolutePath(entry.PackPath); + + try + { + if (File.Exists(file)) File.Delete(file); + + string cacheDir = WidgetPackPaths.CacheDirFor(file); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + } + catch + { + return false; + } + + DeletePreview(entry.PreviewCachePath); + WidgetPackMemoryCache.InvalidatePack(file); + WidgetPackPaths.InvalidateVersionCache(); + WidgetPackPaths.InvalidateResolveCache(); + + try + { + await using var db = await factory.CreateDbContextAsync(ct); + await db.WidgetCatalogEntries.Where(e => e.PackPath == entry.PackPath).ExecuteDeleteAsync(ct); + } + catch { /**/ } + + try + { + string? folder = Path.GetDirectoryName(file); + if (folder != null && Directory.Exists(folder) && + !Directory.EnumerateFileSystemEntries(folder).Any() && + WidgetPackPaths.IsInGlobalStore(folder)) + Directory.Delete(folder); + } + catch { /**/ } + + return true; + } + + #region pack id index + + private static readonly Lock IndexLock = new(); + private static Dictionary> _byPackId = new(StringComparer.OrdinalIgnoreCase); + private static Dictionary _byPath = new(StringComparer.OrdinalIgnoreCase); + + public static void BuildIndex(IEnumerable entries) + { + var byPackId = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + byPath[entry.PackPath] = entry; + + if (string.IsNullOrWhiteSpace(entry.PackId)) continue; + + if (!byPackId.TryGetValue(entry.PackId, out var list)) + byPackId[entry.PackId] = list = new List(); + + list.Add(entry); + } + + lock (IndexLock) + { + _byPackId = byPackId; + _byPath = byPath; + } + } + + public static async Task LoadIndexAsync(IDbContextFactory factory, CancellationToken ct = default) + { + try + { + await using var db = await factory.CreateDbContextAsync(ct); + BuildIndex(await db.WidgetCatalogEntries.AsNoTracking().ToListAsync(ct)); + } + catch {/**/} + } + + public static WidgetCatalogEntry? EntryForPackFile(string packFile) + { + string stored = ToStoredPath(packFile); + + lock (IndexLock) + { + if (_byPath.TryGetValue(stored, out var byStored)) + return byStored; + + return _byPath.GetValueOrDefault(Path.GetFullPath(packFile)); + } + } + + public static List EntriesForPackId(string packId) + { + lock (IndexLock) + { + return _byPackId.TryGetValue(packId, out var list) + ? [..list] : []; + } + } + + #endregion + + public static string ToAbsolutePath(string storedPath) + { + try + { + return Path.IsPathRooted(storedPath) + ? storedPath + : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), storedPath)); + } + catch { return storedPath; } + } + + private static string ToStoredPath(string absolute) + { + try + { + string full = Path.GetFullPath(absolute); + string baseDir = Directory.GetCurrentDirectory(); + string relative = Path.GetRelativePath(baseDir, full); + + return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative) + ? full + : relative; + } + catch { return absolute; } + } + + private static IEnumerable EnumeratePackages(string root) + { + if (!Directory.Exists(root)) yield break; + + IEnumerable files; + try + { + files = Directory.EnumerateFiles(root, "*" + WidgetPackPaths.PackExtension, SearchOption.AllDirectories); + } + catch { yield break; } + + foreach (var file in files) + yield return file; + } + + private static string? ExtractPreview(string packFile, string previewEntry, bool force = false) + { + if (string.IsNullOrWhiteSpace(previewEntry)) return null; + + string ext = Path.GetExtension(previewEntry); + if (string.IsNullOrWhiteSpace(ext)) ext = ".png"; + + string target = Path.Combine(PreviewCacheRoot, PreviewKey(packFile) + ext); + if (!force && File.Exists(target)) return target; + + try + { + Directory.CreateDirectory(PreviewCacheRoot); + + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + string wanted = previewEntry.Replace('\\', '/').TrimStart('/'); + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), wanted, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + entry.ExtractToFile(target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + private static void DeletePreview(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return; + try + { + string full = Path.GetFullPath(path); + if (!full.StartsWith(PreviewCacheRoot, StringComparison.OrdinalIgnoreCase)) return; + if (File.Exists(full)) File.Delete(full); + } + catch { /**/ } + } + + private static string PreviewKey(string packFile) + { + string key; + try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } + catch { key = packFile.ToLowerInvariant(); } + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + return $"{WidgetPackPaths.Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..12].ToLowerInvariant()}"; + } +} diff --git a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs new file mode 100644 index 00000000..85678a15 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs @@ -0,0 +1,146 @@ +using System.IO.Compression; +using System.Text.Json; + +namespace SubathonManager.Data.Widgets; + +public class WidgetCollectionManifest +{ + public string FormatVersion { get; init; } = "1"; + public string AppVersion { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Version { get; init; } = "1.0.0"; + public string Description { get; init; } = string.Empty; + public List Tags { get; init; } = new(); +} + +public static class WidgetCollectionInstaller +{ + public const string CollectionExtension = ".smwc"; + public const string ManifestFileName = "collection.json"; + + public record InstalledCollection( + WidgetCollectionManifest? Manifest, + List Packs, + int Failed); + + public static WidgetCollectionManifest? ReadManifest(string smwcPath) + { + try + { + using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + using var reader = new StreamReader(entry.Open()); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return Parse(doc.RootElement, smwcPath); + } + catch + { + return null; + } + } + + public static InstalledCollection? InstallAll(string smwcPath) + { + if (!File.Exists(smwcPath)) return null; + + var manifest = ReadManifest(smwcPath); + var installed = new List(); + int failed = 0; + + string scratch = Path.Combine(Path.GetTempPath(), "smwc-" + Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(scratch); + + using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; + if (!entry.Name.EndsWith(WidgetPackPaths.PackExtension, StringComparison.OrdinalIgnoreCase)) + continue; + + string staged = Path.Combine(scratch, $"{installed.Count + failed}-{entry.Name}"); + + try + { + entry.ExtractToFile(staged, overwrite: true); + var pack = WidgetPackInstaller.Install(staged); + if (pack != null) installed.Add(pack); + else failed++; + } + catch + { + failed++; + } + } + } + catch + { + return null; + } + finally + { + try + { + if (Directory.Exists(scratch)) + Directory.Delete(scratch, recursive: true); + } + catch { /**/ } + } + + if (installed.Count == 0 && failed == 0) return null; + + return new InstalledCollection(manifest, installed, failed); + } + + private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) + { + var collection = root.TryGetProperty("collection", out var c) ? c : default; + + string name = Str(collection, "name"); + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwcPath); + + string version = Str(collection, "version"); + if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; + + return new WidgetCollectionManifest + { + FormatVersion = Str(root, "format_version"), + AppVersion = Str(root, "app_version"), + Name = name, + Author = Str(collection, "author"), + Version = version, + Description = Str(collection, "description"), + Tags = StrList(collection, "tags") + }; + } + + private static string Str(JsonElement el, string name) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && + v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; + + private static List StrList(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || + !el.TryGetProperty(name, out var v) || + v.ValueKind != JsonValueKind.Array) + return new List(); + + return v.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + } +} diff --git a/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs new file mode 100644 index 00000000..ef339d20 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs @@ -0,0 +1,25 @@ +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + +namespace SubathonManager.Data.Widgets; + +public static class WidgetDisplayHelper +{ + public static WidgetDisplayKind GetDisplayKind(this Widget widget) => widget.Type switch + { + WidgetType.Image => WidgetDisplayKind.Image, + WidgetType.Video => WidgetDisplayKind.Video, + _ => WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out _, out _) + ? WidgetDisplayKind.Widget + : WidgetDisplayKind.UnpackedWidget + }; + + public static string GetLabel(this WidgetDisplayKind kind) => kind switch + { + WidgetDisplayKind.Image => "Image", + WidgetDisplayKind.Video => "Video", + WidgetDisplayKind.UnpackedWidget => "Unpacked Widget", + _ => "Widget" + }; +} diff --git a/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs new file mode 100644 index 00000000..b3247b1a --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; +using SubathonManager.Core.Interfaces; + +namespace SubathonManager.Data.Widgets; + +public sealed class WidgetPackFileSystem : IWidgetFileSystem +{ + private readonly DiskWidgetFileSystem _disk = new(); + private readonly ConcurrentDictionary _readers = new(StringComparer.OrdinalIgnoreCase); + + public bool Exists(string path) + { + var reader = ReaderFor(path, out var entry); + return reader?.Contains(entry) ?? _disk.Exists(path); + } + + public string? ReadAllText(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.ReadText(entry) : _disk.ReadAllText(path); + } + + public byte[]? ReadAllBytes(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.Read(entry) : _disk.ReadAllBytes(path); + } + + public bool IsPacked(string path) => ReaderFor(path, out _) != null; + + public string? GetRealFilePath(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.Materialize(entry) : _disk.GetRealFilePath(path); + } + + public IEnumerable EnumerateFiles(string directory) + { + var reader = ReaderFor(directory, out var prefix, out var packId, + out var version, requireEntry: false); + + if (reader == null) return _disk.EnumerateFiles(directory); + + string? mountRoot = MountRootFor(Path.Combine(directory, "_")); + if (mountRoot == null) return _disk.EnumerateFiles(directory); + string normalized = string.IsNullOrEmpty(prefix) ? string.Empty : prefix.TrimEnd('/') + "/"; + + return reader.Entries + .Where(e => normalized.Length == 0 || e.StartsWith(normalized, StringComparison.OrdinalIgnoreCase)) + .Select(e => WidgetPackPaths.EntryPathIn(mountRoot, e)) + .ToList(); + } + + public bool Unpack(string packedPath, string targetDir) + { + var reader = ReaderFor(packedPath, out _, out _, out _, requireEntry: false); + return reader != null && reader.ExtractAll(targetDir); + } + + private WidgetPackReader? ReaderFor(string path, out string entry) => + ReaderFor(path, out entry, out _, out _); + + private WidgetPackReader? ReaderFor(string path, out string entry, out string packId, out string version, + bool requireEntry = true) + { + entry = string.Empty; + packId = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(path)) return null; + + string probe = requireEntry ? path : Path.Combine(path, "_"); + var location = WidgetPackPaths.Resolve(probe); + if (location == null) return null; + + packId = location.PackIdStr; + version = location.VersionStr; + + entry = Path.GetRelativePath(location.MountRootStr, Path.GetFullPath(probe)).Replace('\\', '/'); + if (!requireEntry) entry = TrimProbe(entry); + + return _readers.GetOrAdd(location.PackFileStr, + key => new WidgetPackReader(key, WidgetPackPaths.CacheDirFor(key))); + } + + private static string? MountRootFor(string path) => WidgetPackPaths.Resolve(path)?.MountRootStr; + + private static string TrimProbe(string resolved) + { + int slash = resolved.LastIndexOf('/'); + return slash < 0 ? string.Empty : resolved[..slash]; + } +} diff --git a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs new file mode 100644 index 00000000..aedf71a2 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs @@ -0,0 +1,284 @@ +using System.IO.Compression; +using System.Text.Json; +using SubathonManager.Core.Enums; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public class WidgetPackManifest +{ + public string FormatVersion { get; init; } = "1"; + public string AppVersion { get; init; } = string.Empty; + public string PackId { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + + public string Group { get; init; } = WidgetPackPaths.DefaultGroup; + public string Version { get; init; } = "1.0.0"; + public string DocsUrl { get; init; } = string.Empty; + + public List Tags { get; init; } = new(); + public string PreviewImage { get; init; } = string.Empty; + public string Entry { get; init; } = string.Empty; + + public int Width { get; init; } + public int Height { get; init; } + public float ScaleX { get; init; } = 1; + public float ScaleY { get; init; } = 1; +} + +public static class WidgetPackInstaller +{ + public const string ManifestFileName = "widget.json"; + public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string PackFile); + + public static WidgetPackManifest? ReadManifest(string smwPath) + { + try + { + using var stream = new FileStream(smwPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + using var reader = new StreamReader(entry.Open()); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return Parse(doc.RootElement, smwPath); + } + catch + { + return null; + } + } + + public static InstalledPack? Install(string smwPath) + { + var manifest = ReadManifest(smwPath); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; + + string packId = manifest.PackId; + string version = WidgetPackPaths.Slug(manifest.Version); + if (string.IsNullOrWhiteSpace(version)) version = "1-0-0"; + + string target = WidgetPackPaths.PackFile(packId, version); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + if (!string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) + File.Copy(smwPath, target, overwrite: true); + + string cacheDir = WidgetPackPaths.CacheDirFor(target); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + + WidgetPackMemoryCache.InvalidatePack(target); + WidgetPackPaths.InvalidateVersionCache(packId); + WidgetPackPaths.InvalidateResolveCache(); + } + catch + { + return null; + } + + string htmlPath = WidgetPackPaths.EntryPath(packId, version, manifest.Entry); + return new InstalledPack(manifest, htmlPath, target); + } + + public static InstalledPack? MountInPlace(string smwPath) + { + if (!File.Exists(smwPath)) return null; + + var manifest = ReadManifest(smwPath); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; + + string full = Path.GetFullPath(smwPath); + string mountRoot = Path.Combine( + Path.GetDirectoryName(full)!, + Path.GetFileNameWithoutExtension(full)); + + WidgetPackPaths.InvalidateResolveCache(); + + return new InstalledPack(manifest, WidgetPackPaths.EntryPathIn(mountRoot, manifest.Entry), full); + } + + public static string? DropIntoImports(string smwPath) + { + try + { + if (!File.Exists(smwPath)) return null; + + var installed = Install(smwPath); + if (installed != null) return installed.PackFile; + + Directory.CreateDirectory(WidgetPackPaths.PackedRoot); + string target = Path.Combine(WidgetPackPaths.PackedRoot, Path.GetFileName(smwPath)); + if (string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) + return target; + + File.Copy(smwPath, target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + public static int SweepCache(IEnumerable liveWidgetPaths) + { + string cacheRoot = WidgetPackPaths.CacheRoot; + if (!Directory.Exists(cacheRoot)) return 0; + + var live = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in liveWidgetPaths) + { + var location = WidgetPackPaths.Resolve(path); + if (location != null) live.Add(WidgetPackPaths.CacheDirFor(location.PackFileStr)); + } + + int removed = 0; + + try + { + foreach (var dir in Directory.EnumerateDirectories(cacheRoot)) + { + if (live.Contains(dir)) continue; + try + { + Directory.Delete(dir, recursive: true); + removed++; + } + catch { /**/ } + } + } + catch { /**/ } + + return removed; + } + + public record PackUpdate(string PackFile, string Version, string Entry); + + public static PackUpdate? FindUpdate(string widgetHtmlPath) + { + var location = WidgetPackPaths.Resolve(widgetHtmlPath); + if (location == null) return null; + + return WidgetPackPaths.IsInPresets(location.PackFileStr) + ? FindPresetUpdate(location) + : FindVersionedFolderUpdate(location); + } + + private static PackUpdate? FindPresetUpdate(WidgetPackPaths.PackLocation location) + { + var current = WidgetCatalog.EntryForPackFile(location.PackFileStr); + if (current == null || string.IsNullOrWhiteSpace(current.PackId)) return null; + + PackUpdate? best = null; + string bestVersion = current.Version; + + foreach (var candidate in WidgetCatalog.EntriesForPackId(current.PackId)) + { + if (candidate.Source != WidgetCatalogSource.Preset) continue; + if (string.Equals(candidate.PackPath, current.PackPath, StringComparison.OrdinalIgnoreCase)) continue; + if (WidgetPackPaths.CompareVersions(candidate.Version, bestVersion) <= 0) continue; + + bestVersion = candidate.Version; + best = new PackUpdate(WidgetCatalog.ToAbsolutePath(candidate.PackPath), candidate.Version, candidate.Entry); + } + + return best; + } + + private static PackUpdate? FindVersionedFolderUpdate(WidgetPackPaths.PackLocation location) + { + if (!WidgetPackPaths.IsVersionName(location.VersionStr)) return null; + + string? best = null; + foreach (var candidate in WidgetPackPaths.VersionsIn(location.PackFolderStr)) + { + if (!WidgetPackPaths.IsVersionName(candidate)) continue; + if (WidgetPackPaths.CompareVersions(candidate, location.VersionStr) <= 0) continue; + if (best == null || WidgetPackPaths.CompareVersions(candidate, best) > 0) best = candidate; + } + + if (best == null) return null; + + string file = Path.Combine(location.PackFolderStr, best + WidgetPackPaths.PackExtension); + return new PackUpdate(file, best, ReadManifest(file)?.Entry ?? string.Empty); + } + + public static string? FindNewerVersion(string widgetHtmlPath) => FindUpdate(widgetHtmlPath)?.Version; + + private static WidgetPackManifest Parse(JsonElement root, string smwPath) + { + var widget = root.TryGetProperty("widget", out var w) ? w : default; + + string name = Str(widget, "name"); + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwPath); + string author = Str(widget, "author"); + string group = WidgetPackPaths.NormalizeGroup(Str(widget, "group")); + + string packId = Str(widget, "pack_id"); + if (string.IsNullOrWhiteSpace(packId)) packId = WidgetPackPaths.MakePackId(author, group, name); + + string version = Str(widget, "widget_version"); + if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; + + var size = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("size", + out var s) ? s : default; + var scale = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("scale", + out var c) ? c : default; + + return new WidgetPackManifest + { + FormatVersion = Str(root, "version"), + AppVersion = Str(root, "app_version"), + PackId = packId, + Name = name, + Author = author, + Group = group, + Version = version, + DocsUrl = Str(widget, "docsUrl"), + Tags = StrList(widget, "tags"), + PreviewImage = Str(widget, "preview_image"), + Entry = Str(widget, "entry").Replace('\\', '/').TrimStart('/'), + Width = Int(size, "width", 400), + Height = Int(size, "height", 400), + ScaleX = Flt(scale, "x", 1f), + ScaleY = Flt(scale, "y", 1f) + }; + } + + private static string Str(JsonElement el, string name) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; + + private static List StrList(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || + !el.TryGetProperty(name, out var v) || + v.ValueKind != JsonValueKind.Array) + return new List(); + + return v.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + } + + private static int Int(JsonElement el, string name, int fallback) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out var v) && v.TryGetInt32(out var i) + ? i + : fallback; + + private static float Flt(JsonElement el, string name, float fallback) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out var v) && v.TryGetSingle(out var f) && f > 0 + ? f + : fallback; +} diff --git a/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs new file mode 100644 index 00000000..052a4d23 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs @@ -0,0 +1,100 @@ +namespace SubathonManager.Data.Widgets; + +internal static class WidgetPackMemoryCache +{ + private const long Budget = 32 * 1024 * 1024; + public const long MaxEntrySize = 2 * 1024 * 1024; + + private static readonly Lock CacheLock = new(); + private static readonly Dictionary> Map = new(StringComparer.OrdinalIgnoreCase); + + private static readonly LinkedList Order = []; + + private static long _size; + + private sealed class CacheItem(string key, byte[] data) + { + public string Key { get; } = key; + public byte[] Data { get; } = data; + } + + public static string MakeKey(string packFile, string entry) => $"{packFile}|{entry}"; + + public static bool TryGet(string key, out byte[] data) + { + lock (CacheLock) + { + if (!Map.TryGetValue(key, out var node)) + { + data = []; + return false; + } + + Order.Remove(node); + Order.AddFirst(node); + data = node.Value.Data; + return true; + } + } + + public static void Set(string key, byte[] data) + { + if (data.LongLength >= MaxEntrySize) return; + + lock (CacheLock) + { + if (Map.TryGetValue(key, out var existing)) + { + _size -= existing.Value.Data.LongLength; + Order.Remove(existing); + Map.Remove(key); + } + + var node = Order.AddFirst(new CacheItem(key, data)); + Map[key] = node; + _size += data.LongLength; + + while (_size > Budget && Order.Last is { } tail) + { + Order.RemoveLast(); + Map.Remove(tail.Value.Key); + _size -= tail.Value.Data.LongLength; + } + } + } + + public static void InvalidatePack(string packFile) + { + string prefix = packFile + "|"; + + lock (CacheLock) + { + var doomed = Map.Keys + .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var key in doomed) + { + if (!Map.TryGetValue(key, out var node)) continue; + Order.Remove(node); + Map.Remove(key); + _size -= node.Value.Data.LongLength; + } + } + } + + public static void Clear() + { + lock (CacheLock) + { + Map.Clear(); + Order.Clear(); + _size = 0; + } + } + + internal static long CurrentSize + { + get { lock (CacheLock) return _size; } + } +} diff --git a/SubathonManager.Data/Widgets/WidgetPackPaths.cs b/SubathonManager.Data/Widgets/WidgetPackPaths.cs new file mode 100644 index 00000000..2f2f39cb --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackPaths.cs @@ -0,0 +1,282 @@ +using System.Text.RegularExpressions; +using SubathonManager.Core; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public static partial class WidgetPackPaths +{ + public const string PackExtension = ".smw"; + + public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "widgets")); + public static string PackedRoot => Path.Combine(ImportsRoot, "packed"); + public static string UnpackedRoot => Path.Combine(ImportsRoot, "unpacked"); + public static string CacheRoot => Path.GetFullPath(Path.Combine("./cache", "widgets")); + public static string PresetsRoot => Path.GetFullPath("./presets"); + + public static string MountRoot(string packId, string version) + => Path.Combine(PackedRoot, packId, version); + + public static string PackFile(string packId, string version) + => Path.Combine(PackedRoot, packId, version + PackExtension); + + public static string PackFolder(string packId) => Path.Combine(PackedRoot, packId); + + private static readonly Lock VersionCacheLock = new(); + private static readonly Dictionary> VersionCache = new(StringComparer.OrdinalIgnoreCase); + + public static List InstalledVersions(string packId) => VersionsIn(PackFolder(packId)); + public static List VersionsIn(string folder) + { + lock (VersionCacheLock) + { + if (VersionCache.TryGetValue(folder, out var cached)) return cached; + + var versions = new List(); + + if (Directory.Exists(folder)) + { + try + { + versions = Directory.EnumerateFiles(folder, "*" + PackExtension) + .Select(Path.GetFileNameWithoutExtension) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .ToList(); + } + catch { /**/ } + } + + VersionCache[folder] = versions; + return versions; + } + } + + public static string DisplayVersion(string? version) + => string.IsNullOrWhiteSpace(version) + ? string.Empty + : VersionSeparatorRegex().Replace(version, "."); + + public static void InvalidateVersionCache(string? packId = null) + { + lock (VersionCacheLock) + { + if (packId == null) VersionCache.Clear(); + else VersionCache.Remove(PackFolder(packId)); + } + } + + public static int CompareVersions(string left, string right) + { + var a = VersionSegments(left); + var b = VersionSegments(right); + + for (int i = 0; i < Math.Max(a.Count, b.Count); i++) + { + long x = i < a.Count ? a[i] : 0; + long y = i < b.Count ? b[i] : 0; + if (x != y) return x.CompareTo(y); + } + + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static List VersionSegments(string version) + => VersionSegmentRegex().Matches(version ?? string.Empty) + .Select(m => long.TryParse(m.Value, out var n) ? n : 0L) + .ToList(); + public const string DefaultGroup = "widgets"; + + public static string NormalizeGroup(string? group) + => string.IsNullOrWhiteSpace(group) ? DefaultGroup : group.Trim(); + + public static bool IsInPresets(string packFileOrFolder) + { + try + { + return Path.GetFullPath(packFileOrFolder) + .StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + public static bool IsVersionName(string? name) + => !string.IsNullOrWhiteSpace(name) && VersionNameRegex().IsMatch(name); + + public static bool IsInGlobalStore(string packFileOrFolder) + { + try + { + string full = Path.GetFullPath(packFileOrFolder); + + return full.StartsWith(PackedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || full.StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + public static string UnpackRootFor(PackLocation location, string author, string group, string name) + { + if (IsInGlobalStore(location.PackFolderStr)) + return UnpackRoot(author, group, name, location.VersionStr); + + string container = Path.GetDirectoryName(location.PackFolderStr) ?? location.PackFolderStr; + return Path.Combine(container, "unpacked", location.PackIdStr, location.VersionStr); + } + + public static string UnpackRoot(string author, string group, string name, string version) + { + string authorFolder = Slug(author); + if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; + + string groupFolder = Slug(NormalizeGroup(group)); + if (string.IsNullOrEmpty(groupFolder)) groupFolder = DefaultGroup; + + string nameFolder = Slug(name); + if (string.IsNullOrEmpty(nameFolder)) nameFolder = "widget"; + + string versionFolder = SanitizeSegment(version); + if (string.IsNullOrEmpty(versionFolder)) versionFolder = "1.0.0"; + + return Path.Combine(UnpackedRoot, authorFolder, groupFolder, nameFolder, versionFolder); + } + + private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); + + public static string EntryPath(string packId, string version, string entry) + => Path.Combine(MountRoot(packId, version), entry.Replace('/', Path.DirectorySeparatorChar)); + + public record PackLocation(string PackFileStr, string PackFolderStr, string MountRootStr, string PackIdStr, string VersionStr); + + private static readonly Lock ResolveCacheLock = new(); + private static readonly Dictionary ResolveCache = new(StringComparer.OrdinalIgnoreCase); + private const int MaxMountDepth = 16; + + public static PackLocation? Resolve(string widgetPath) + { + if (string.IsNullOrWhiteSpace(widgetPath)) return null; + + string full; + try { full = Path.GetFullPath(widgetPath); } + catch { return null; } + + string? dir = Path.GetDirectoryName(full); + if (dir == null) return null; + + var found = ResolveDirectory(dir); + return found; + } + + private static PackLocation? ResolveDirectory(string directory) + { + lock (ResolveCacheLock) + { + if (ResolveCache.TryGetValue(directory, out var cached)) return cached; + } + + PackLocation? result = null; + string? current = directory; + + for (int depth = 0; depth < MaxMountDepth && current != null; depth++) + { + string candidate = current + PackExtension; + if (File.Exists(candidate)) + { + string packFolder = Path.GetDirectoryName(current) ?? current; + result = new PackLocation( + PackFileStr: candidate, + PackFolderStr: packFolder, + MountRootStr: current, + PackIdStr: Path.GetFileName(packFolder), + VersionStr: Path.GetFileName(current)); + break; + } + + current = Path.GetDirectoryName(current); + } + + lock (ResolveCacheLock) + { + ResolveCache[directory] = result; + } + + return result; + } + + public static void InvalidateResolveCache() + { + lock (ResolveCacheLock) + { + ResolveCache.Clear(); + } + } + + public static bool TryResolve(string widgetPath, out string packFile, out string entry, + out string packId, out string version) + { + packFile = string.Empty; + entry = string.Empty; + packId = string.Empty; + version = string.Empty; + + var found = Resolve(widgetPath); + if (found == null) return false; + + packFile = found.PackFileStr; + packId = found.PackIdStr; + version = found.VersionStr; + entry = Path.GetRelativePath(found.MountRootStr, Path.GetFullPath(widgetPath)).Replace('\\', '/'); + + return true; + } + + public static string EntryPathIn(string mountRoot, string entry) + => Path.Combine(mountRoot, entry.Replace('/', Path.DirectorySeparatorChar)); + + public static string CacheDirFor(string packFile) + { + string key; + try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } + catch { key = packFile.ToLowerInvariant(); } + + byte[] hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(key)); + string folder = $"{Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..8].ToLowerInvariant()}"; + + return Path.Combine(CacheRoot, folder); + } + + + public static string MakePackId(string author, string group, string name) + { + string n = Slug(name); + if (string.IsNullOrEmpty(n)) n = "widget"; + + var parts = new[] { Slug(author), Slug(NormalizeGroup(group)), n } + .Where(p => !string.IsNullOrEmpty(p)); + + return string.Join('.', parts); + } + + public static string Slug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + string slug = SlugStripRegex().Replace(value.Trim().ToLowerInvariant(), "-"); + slug = SlugCollapseRegex().Replace(slug, "-").Trim('-'); + return slug; + } + + [GeneratedRegex(@"[^a-z0-9]+")] + private static partial Regex SlugStripRegex(); + + [GeneratedRegex(@"-{2,}")] + private static partial Regex SlugCollapseRegex(); + + [GeneratedRegex(@"\d+")] + private static partial Regex VersionSegmentRegex(); + + [GeneratedRegex(@"^\d+([._-]\d+)*$")] + private static partial Regex VersionNameRegex(); + + [GeneratedRegex(@"(?<=\d)-(?=\d)")] + private static partial Regex VersionSeparatorRegex(); +} diff --git a/SubathonManager.Data/Widgets/WidgetPackReader.cs b/SubathonManager.Data/Widgets/WidgetPackReader.cs new file mode 100644 index 00000000..93df97bd --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackReader.cs @@ -0,0 +1,163 @@ +using System.IO.Compression; +using System.Text; + +namespace SubathonManager.Data.Widgets; + +internal sealed class WidgetPackReader(string packFile, string cacheDir) +{ + private const long MaterializeThreshold = WidgetPackMemoryCache.MaxEntrySize; + + private readonly Lock _indexLock = new(); + private Dictionary? _index; + private DateTime _indexedAt; + + private readonly record struct IndexedEntry(string FullName, long Length); + + private Dictionary Index + { + get + { + lock (_indexLock) + { + var stamp = File.Exists(packFile) ? File.GetLastWriteTimeUtc(packFile) : DateTime.MinValue; + if (_index != null && stamp == _indexedAt) return _index; + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; // dir + index[Normalize(entry.FullName)] = new IndexedEntry(entry.FullName, entry.Length); + } + } + catch { /**/ } + + WidgetPackMemoryCache.InvalidatePack(packFile); + _index = index; + _indexedAt = stamp; + return index; + } + } + } + + public bool Contains(string entry) => Index.ContainsKey(Normalize(entry)); + + public IEnumerable Entries => Index.Keys; + + public long LengthOf(string entry) + => Index.TryGetValue(Normalize(entry), out var found) ? found.Length : -1; + + public byte[]? Read(string entry) + { + string key = Normalize(entry); + string cacheKey = WidgetPackMemoryCache.MakeKey(packFile, key); + + if (WidgetPackMemoryCache.TryGet(cacheKey, out var cached)) return cached; + if (!Index.TryGetValue(key, out var indexed)) return null; + + byte[]? bytes = ReadRaw(indexed.FullName); + if (bytes == null) return null; + + if (indexed.Length < MaterializeThreshold) + WidgetPackMemoryCache.Set(cacheKey, bytes); + + return bytes; + } + + public string? ReadText(string entry) + { + var bytes = Read(entry); + if (bytes == null) return null; + return bytes is [0xEF, 0xBB, 0xBF, ..] + ? Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3) + : Encoding.UTF8.GetString(bytes); + } + + public string? Materialize(string entry) + { + string key = Normalize(entry); + if (!Index.TryGetValue(key, out var indexed)) return null; + if (indexed.Length < MaterializeThreshold) return null; + + string target = Path.Combine(cacheDir, key.Replace('/', Path.DirectorySeparatorChar)); + + try + { + var info = new FileInfo(target); + if (info.Exists && info.Length == indexed.Length) return target; + + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + string temp = target + ".partial"; + using (var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + var zipEntry = archive.GetEntry(indexed.FullName); + if (zipEntry == null) return null; + zipEntry.ExtractToFile(temp, overwrite: true); + } + + File.Move(temp, target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + public bool ExtractAll(string targetDir) + { + try + { + Directory.CreateDirectory(targetDir); + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; + + string destination = Path.GetFullPath( + Path.Combine(targetDir, Normalize(entry.FullName).Replace('/', Path.DirectorySeparatorChar))); + + if (!destination.StartsWith(Path.GetFullPath(targetDir) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + continue; + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + entry.ExtractToFile(destination, overwrite: true); + } + + return true; + } + catch + { + return false; + } + } + + private byte[]? ReadRaw(string fullName) + { + try + { + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var entry = archive.GetEntry(fullName); + if (entry == null) return null; + + using var entryStream = entry.Open(); + using var buffer = new MemoryStream(entry.Length > 0 ? (int)Math.Min(entry.Length, int.MaxValue) : 0); + entryStream.CopyTo(buffer); + return buffer.ToArray(); + } + catch + { + return null; + } + } + + private static string Normalize(string entry) => entry.Replace('\\', '/').TrimStart('/'); +} \ No newline at end of file diff --git a/SubathonManager.Integration/DevTunnelsService.cs b/SubathonManager.Integration/DevTunnelsService.cs index 33929339..b769af85 100644 --- a/SubathonManager.Integration/DevTunnelsService.cs +++ b/SubathonManager.Integration/DevTunnelsService.cs @@ -72,6 +72,7 @@ public async Task StartAsync(CancellationToken ct = default) BroadcastTunnelStatus(false, null); } + [ExcludeFromCodeCoverage] public async Task TryInstallAsync(CancellationToken ct = default) { var probe = await client.ProbeCliAsync(ct); diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index 47b5335c..9b8efb11 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -88,7 +88,17 @@ public static bool ProcessExternalSub(Dictionary data) } } - subathonEvent.Amount = elemAmount.ValueKind == JsonValueKind.Number ? elemAmount.GetInt16() : 1; + if (elemAmount.ValueKind == JsonValueKind.String) + { + subathonEvent.Amount = 1; + if (int.TryParse(elemAmount.GetString(), out var amtInt) && amtInt > 0) + subathonEvent.Amount = amtInt; + } + else + { + subathonEvent.Amount = elemAmount.ValueKind == JsonValueKind.Number ? elemAmount.GetInt16() : 1; + } + subathonEvent.Source = user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(); subathonEvent.EventType = type; diff --git a/SubathonManager.Integration/TwitchService.cs b/SubathonManager.Integration/TwitchService.cs index a5f105d9..082cbc16 100644 --- a/SubathonManager.Integration/TwitchService.cs +++ b/SubathonManager.Integration/TwitchService.cs @@ -379,7 +379,8 @@ private Task HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) e.ChatMessage.Username, // DisplayName isBroadcaster, isMod, isVip, DateTime.Now); } - else if (e.ChatMessage.DisplayName.Equals("blerp", StringComparison.InvariantCultureIgnoreCase)) + else if (e.ChatMessage.DisplayName.Equals("blerp", StringComparison.InvariantCultureIgnoreCase) + && config.GetBool("Extensions", "Blerp.Enabled", true)) { BlerpChatService.ParseMessage(e.ChatMessage.Message, SubathonEventSource.Twitch); } diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 755df046..169e2f11 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -347,7 +347,8 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) CommandService.ChatCommandRequest(SubathonEventSource.YouTube, messagePreview, user, item.IsOwner, item.IsModerator, false, item.Timestamp.DateTime.ToLocalTime(), Utils.CreateGuidFromUniqueString(item.Id)); - else if (user.Equals("blerp", StringComparison.OrdinalIgnoreCase)) + else if (user.Equals("blerp", StringComparison.OrdinalIgnoreCase) + && _config.GetBool("Extensions", "Blerp.Enabled", true)) { BlerpChatService.ParseMessage(messagePreview, SubathonEventSource.YouTube); } diff --git a/SubathonManager.Server/AspNetContextAdapter.cs b/SubathonManager.Server/AspNetContextAdapter.cs new file mode 100644 index 00000000..5dffe02a --- /dev/null +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -0,0 +1,73 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.WebSockets; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using SubathonManager.Server.Interfaces; + +namespace SubathonManager.Server; + +[ExcludeFromCodeCoverage] +public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext +{ + public string Method => ctx.Request.Method; + public string Path => ctx.Request.Path.Value ?? "/"; + public string QueryString => ctx.Request.QueryString.Value?.TrimStart('?') ?? string.Empty; + public Stream Body => ctx.Request.Body; + public Encoding Encoding => Encoding.UTF8; + public bool IsWebSocket => ctx.WebSockets.IsWebSocketRequest; + + public IReadOnlyDictionary Headers + { + get + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in ctx.Request.Headers) + dict[key] = value.ToString(); + return dict; + } + } + + public Task? AcceptWebSocketAsync(string? subProtocol = null) + => ctx.WebSockets.IsWebSocketRequest + ? ctx.WebSockets.AcceptWebSocketAsync(subProtocol) + : null; + + public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) + { + ctx.Response.StatusCode = code; + if (contentType != null) + ctx.Response.ContentType = contentType; + if (addCors) + AddCorsHeaders(ctx.Response); + await ctx.Response.WriteAsync(body, Encoding.UTF8); + } + + public async Task ServeFile(string fullPath, string contentType) + { + ctx.Response.ContentType = contentType; + AddCorsHeaders(ctx.Response); + await ctx.Response.SendFileAsync(fullPath); + } + + public async Task ServeBytes(byte[] data, string contentType) + { + ctx.Response.ContentType = contentType; + ctx.Response.ContentLength = data.Length; + AddCorsHeaders(ctx.Response); + await ctx.Response.Body.WriteAsync(data); + } + + private static void AddCorsHeaders(HttpResponse response) + { + response.Headers["Access-Control-Allow-Origin"] = "*"; + response.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; + response.Headers["Access-Control-Allow-Headers"] = "Content-Type"; + } +} + +internal sealed class NoopHostLifetime : IHostLifetime +{ + public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/SubathonManager.Server/HttpListenerContextAdapter.cs b/SubathonManager.Server/HttpListenerContextAdapter.cs deleted file mode 100644 index ec8d947e..00000000 --- a/SubathonManager.Server/HttpListenerContextAdapter.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Net; -using System.Text; -using System.Net.WebSockets; -using System.Diagnostics.CodeAnalysis; -namespace SubathonManager.Server; - - -[ExcludeFromCodeCoverage] -public sealed class HttpListenerContextAdapter : IHttpContext -{ - private readonly HttpListenerContext _ctx; - - public HttpListenerContextAdapter(HttpListenerContext ctx) - { - _ctx = ctx; - } - - public string Method => _ctx.Request.HttpMethod; - public string Path => _ctx.Request.Url!.AbsolutePath; - public Stream Body => _ctx.Request.InputStream; - public string QueryString => _ctx.Request.Url!.Query.TrimStart('?'); - public Encoding Encoding => _ctx.Request.ContentEncoding; - public bool IsWebSocket => _ctx.Request.IsWebSocketRequest; - - public IReadOnlyDictionary Headers - { - get - { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var headers = _ctx.Request.Headers; - foreach (string? key in headers.AllKeys) - { - if (key != null) - dict[key] = headers[key] ?? string.Empty; - } - return dict; - } - } - - public Task? AcceptWebSocketAsync(string? subProtocol = null) - { - if (!_ctx.Request.IsWebSocketRequest) - return null; - - return AcceptInternalAsync(subProtocol); - } - - private async Task AcceptInternalAsync(string? subProtocol) - { - var wsContext = await _ctx.AcceptWebSocketAsync(subProtocol); - return wsContext.WebSocket; - } - - public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) - { - _ctx.Response.StatusCode = code; - if (contentType != null) - _ctx.Response.ContentType = contentType; - - if (addCors) - AddCorsHeaders(_ctx.Response); - var bytes = Encoding.UTF8.GetBytes(body); - await _ctx.Response.OutputStream.WriteAsync(bytes); - _ctx.Response.Close(); - } - - public async Task ServeFile(string fullPath, string contentType) - { - _ctx.Response.ContentType = contentType; - AddCorsHeaders(_ctx.Response); - await using var fs = File.OpenRead(fullPath); - _ctx.Response.ContentLength64 = fs.Length; - await fs.CopyToAsync(_ctx.Response.OutputStream); - _ctx.Response.Close(); - } - - private static void AddCorsHeaders(HttpListenerResponse response) - { - response.AddHeader("Access-Control-Allow-Origin", "*"); - response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - response.AddHeader("Access-Control-Allow-Headers", "Content-Type"); - } -} \ No newline at end of file diff --git a/SubathonManager.Server/Interfaces/IHttpContext.cs b/SubathonManager.Server/Interfaces/IHttpContext.cs index edcc6252..9f375fe6 100644 --- a/SubathonManager.Server/Interfaces/IHttpContext.cs +++ b/SubathonManager.Server/Interfaces/IHttpContext.cs @@ -1,6 +1,6 @@ using System.Text; using System.Net.WebSockets; -namespace SubathonManager.Server; +namespace SubathonManager.Server.Interfaces; public interface IHttpContext { @@ -16,8 +16,7 @@ public interface IHttpContext bool IsWebSocket { get; } Task? AcceptWebSocketAsync(string? subProtocol = null); - Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null); - Task ServeFile(string fullPath, string contentType); + Task ServeBytes(byte[] data, string contentType); } \ No newline at end of file diff --git a/SubathonManager.Server/SubathonManager.Server.csproj b/SubathonManager.Server/SubathonManager.Server.csproj index 52d2794c..a186f7b8 100644 --- a/SubathonManager.Server/SubathonManager.Server.csproj +++ b/SubathonManager.Server/SubathonManager.Server.csproj @@ -9,6 +9,10 @@ $(NoWarn);CS8981 + + + + diff --git a/SubathonManager.Server/TcpListenerContextAdapter.cs b/SubathonManager.Server/TcpListenerContextAdapter.cs deleted file mode 100644 index d2d4b4c7..00000000 --- a/SubathonManager.Server/TcpListenerContextAdapter.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.Sockets; -using System.Net.WebSockets; -using System.Security.Cryptography; -using System.Text; - -namespace SubathonManager.Server; - -[ExcludeFromCodeCoverage] // This is a beta feature as a fallback for non-windows systems or WINE/Proton/Other without HTTPListener support -public sealed class TcpListenerContextAdapter : IHttpContext -{ - private readonly TcpClient _client; - private readonly NetworkStream _stream; - private readonly string _rawRequest; - private readonly Dictionary _headers; - private readonly byte[] _bodyBytes; - - public string Method { get; } - public string Path { get; } - public string QueryString { get; } - public IReadOnlyDictionary Headers => _headers; - public Encoding Encoding => Encoding.UTF8; - public Stream Body { get; } - - public bool IsWebSocket => - _headers.TryGetValue("upgrade", out var v) && - v.Equals("websocket", StringComparison.OrdinalIgnoreCase); - - private TcpListenerContextAdapter( - TcpClient client, - NetworkStream stream, - string method, - string path, - string queryString, - Dictionary headers, - byte[] body, - string rawRequest) - { - _client = client; - _stream = stream; - Method = method; - Path = path; - QueryString = queryString; - _headers = headers; - _bodyBytes = body; - Body = new MemoryStream(body); - _rawRequest = rawRequest; - } - - public static async Task ParseAsync( - TcpClient client, - CancellationToken ct = default) - { - var stream = client.GetStream(); - - var headerBuffer = new List(4096); - var oneByte = new byte[1]; - while (true) - { - if (ct.IsCancellationRequested) return null; - int read = await stream.ReadAsync(oneByte, ct); - if (read == 0) return null; - headerBuffer.Add(oneByte[0]); - - if (headerBuffer.Count >= 4) - { - int tail = headerBuffer.Count; - if (headerBuffer[tail - 4] == '\r' && headerBuffer[tail - 3] == '\n' && - headerBuffer[tail - 2] == '\r' && headerBuffer[tail - 1] == '\n') - break; - } - - if (headerBuffer.Count > 65536) return null; - } - - string rawHeader = Encoding.UTF8.GetString(headerBuffer.ToArray()); - var lines = rawHeader.Split("\r\n", StringSplitOptions.None); - if (lines.Length == 0) return null; - - var requestLine = lines[0].Split(' '); - if (requestLine.Length < 2) return null; - - string method = requestLine[0].ToUpperInvariant(); - string rawPath = requestLine.Length >= 2 ? requestLine[1] : "/"; - - string path, query; - int qIdx = rawPath.IndexOf('?'); - if (qIdx >= 0) - { - path = Uri.UnescapeDataString(rawPath[..qIdx]); - query = rawPath[(qIdx + 1)..]; - } - else - { - path = Uri.UnescapeDataString(rawPath); - query = string.Empty; - } - - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (int i = 1; i < lines.Length; i++) - { - var line = lines[i]; - if (string.IsNullOrWhiteSpace(line)) break; - int colon = line.IndexOf(':'); - if (colon < 0) continue; - headers[line[..colon].Trim().ToLowerInvariant()] = line[(colon + 1)..].Trim(); - } - - byte[] body = []; - if (headers.TryGetValue("content-length", out var clStr) && - int.TryParse(clStr, out int contentLength) && contentLength > 0) - { - body = new byte[contentLength]; - int totalRead = 0; - while (totalRead < contentLength) - { - int n = await stream.ReadAsync(body.AsMemory(totalRead, contentLength - totalRead), ct); - if (n == 0) break; - totalRead += n; - } - } - - return new TcpListenerContextAdapter(client, stream, method, path, query, headers, body, rawHeader); - } - - public Task? AcceptWebSocketAsync(string? subProtocol = null) - { - if (!IsWebSocket) return null; - return DoWebSocketUpgradeAsync(subProtocol); - } - - private async Task DoWebSocketUpgradeAsync(string? subProtocol) - { - if (!_headers.TryGetValue("sec-websocket-key", out var key)) - throw new InvalidOperationException("Missing Sec-WebSocket-Key header"); - - string acceptKey = Convert.ToBase64String( - SHA1.HashData( - Encoding.UTF8.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); // hardcode - - var sb = new StringBuilder(); - sb.Append("HTTP/1.1 101 Switching Protocols\r\n"); - sb.Append("Upgrade: websocket\r\n"); - sb.Append("Connection: Upgrade\r\n"); - sb.Append($"Sec-WebSocket-Accept: {acceptKey}\r\n"); - if (!string.IsNullOrEmpty(subProtocol)) - sb.Append($"Sec-WebSocket-Protocol: {subProtocol}\r\n"); - sb.Append("\r\n"); - - byte[] handshake = Encoding.UTF8.GetBytes(sb.ToString()); - await _stream.WriteAsync(handshake); - await _stream.FlushAsync(); - - return WebSocket.CreateFromStream(_stream, new WebSocketCreationOptions - { - IsServer = true, - SubProtocol = subProtocol, - KeepAliveInterval = TimeSpan.FromSeconds(30) - }); - } - - public async Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null) - { - byte[] bodyBytes = Encoding.UTF8.GetBytes(body); - string reasonPhrase = statusCode switch - { - 200 => "OK", 201 => "Created", 204 => "No Content", - 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", - 404 => "Not Found", 500 => "Internal Server Error", - _ => "OK" - }; - - var sb = new StringBuilder(); - sb.Append($"HTTP/1.1 {statusCode} {reasonPhrase}\r\n"); - sb.Append($"Content-Type: {contentType ?? "text/plain"}; charset=utf-8\r\n"); - sb.Append($"Content-Length: {bodyBytes.Length}\r\n"); - if (addCors) AppendCorsHeaders(sb); - sb.Append("Connection: close\r\n\r\n"); - - byte[] headerBytes = Encoding.UTF8.GetBytes(sb.ToString()); - await _stream.WriteAsync(headerBytes); - await _stream.WriteAsync(bodyBytes); - await _stream.FlushAsync(); - Close(); - } - - public async Task ServeFile(string fullPath, string contentType) - { - await using var fs = File.OpenRead(fullPath); - long length = fs.Length; - - var sb = new StringBuilder(); - sb.Append("HTTP/1.1 200 OK\r\n"); - sb.Append($"Content-Type: {contentType}\r\n"); - sb.Append($"Content-Length: {length}\r\n"); - AppendCorsHeaders(sb); - sb.Append("Connection: close\r\n\r\n"); - - byte[] headerBytes = Encoding.UTF8.GetBytes(sb.ToString()); - await _stream.WriteAsync(headerBytes); - await fs.CopyToAsync(_stream); - await _stream.FlushAsync(); - Close(); - } - - private static void AppendCorsHeaders(StringBuilder sb) - { - sb.Append("Access-Control-Allow-Origin: *\r\n"); - sb.Append("Access-Control-Allow-Methods: GET, POST, PATCH, PUT, OPTIONS\r\n"); - sb.Append("Access-Control-Allow-Headers: Content-Type\r\n"); - } - - private void Close() - { - try { _stream.Close(); } catch { /* ignored */ } - try { _client.Close(); } catch { /* ignored */ } - } -} \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index 098ed685..de6da18c 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -6,6 +6,8 @@ using SubathonManager.Core.Enums; using SubathonManager.Integration; using SubathonManager.Data; +using SubathonManager.Server.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; @@ -34,6 +36,43 @@ private void SetupApiRoutes() _routes.Add((new RouteKey("POST", "/api/update-position/"),HandleWidgetUpdateAsync )); _routes.Add((new RouteKey("POST", "/api/update-size/"),HandleWidgetUpdateAsync)); _routes.Add((new RouteKey("POST", "/api/update-dimensions/"),HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/widget-action/"),HandleWidgetActionAsync)); + } + + internal async Task HandleWidgetActionAsync(IHttpContext ctx) + { + var parts = ctx.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !Guid.TryParse(parts[2], out var widgetId)) + { + await ctx.WriteResponse(400, "Invalid Widget ID"); + return; + } + + string body; + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + body = await reader.ReadToEndAsync(); + + try + { + var data = JsonSerializer.Deserialize>(body); + string name = data != null && data.TryGetValue("action", out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() ?? string.Empty + : string.Empty; + + if (!Enum.TryParse(name, ignoreCase: true, out var action)) + { + await ctx.WriteResponse(400, "Unknown action"); + return; + } + + WidgetEvents.RaiseWidgetAction(widgetId, action); + await ctx.WriteResponse(200, "OK"); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to handle widget context action"); + await ctx.WriteResponse(400, "Invalid action data"); + } } internal async Task HandleSelectAsync(IHttpContext ctx) diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index 12e94e31..ddba1da6 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -1,9 +1,13 @@ -using System.Net; +using System.Globalization; +using System.Net; using System.Text; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; @@ -58,11 +62,11 @@ internal async Task HandleWidgetRequest(IHttpContext ctx) ? widget.HtmlPath : Path.Combine(folder, relativePath.Replace('/', Path.DirectorySeparatorChar)); - if (File.Exists(filePath)) + if (WidgetFiles.Current.Exists(filePath)) { if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) { - string html = await File.ReadAllTextAsync(filePath); + string html = WidgetFiles.Current.ReadAllText(filePath) ?? string.Empty; var cssOverrides = new StringBuilder(); cssOverrides.AppendLine(GetWebsocketInjectionScript()); @@ -152,8 +156,20 @@ function loadBunnyFont(fontName) { await ctx.WriteResponse(200, html, true, "text/html"); return; } - await ctx.ServeFile(filePath, GetContentType(filePath)); - return; + + var realPath = WidgetFiles.Current.GetRealFilePath(filePath); + if (realPath != null) + { + await ctx.ServeFile(realPath, GetContentType(filePath)); + return; + } + + var bytes = WidgetFiles.Current.ReadAllBytes(filePath); + if (bytes != null) + { + await ctx.ServeBytes(bytes, GetContentType(filePath)); + return; + } } } } @@ -161,6 +177,18 @@ function loadBunnyFont(fontName) { await ctx.WriteResponse(404, "Widget not found"); } + internal async Task HandleResourceRequest(IHttpContext ctx) + { + var filePath = ResourcePaths.ResolveRequestPath(ctx.Path); + if (filePath != null) + { + await ctx.ServeFile(filePath, GetContentType(filePath)); + return; + } + + await ctx.WriteResponse(404, "Resource not found"); + } + internal async Task HandleRouteRequest(IHttpContext ctx) { var path = ctx.Path; @@ -189,6 +217,68 @@ internal async Task HandleRouteRequest(IHttpContext ctx) await ctx.WriteResponse(404, "Route/Overlay not found"); } + private static string BuildWidgetContextMenuScript() => @" + + "; + private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false) { StringBuilder sb = new StringBuilder(); @@ -211,6 +301,10 @@ private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false height: {route.Height}px; margin: 0 auto; background: transparent; + transform-origin: top left; + --handle-size: 12px; + --handle-half: 6px; + --chrome-outline: 2px; }} .overlay-edit {{ @@ -219,12 +313,13 @@ private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false .widget-wrapper {{ position: absolute; - pointer-events: auto; - background: rgba(0,0,0,0); - user-select: none; + pointer-events: auto; + background: rgba(0,0,0,0); + user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; + transform-origin: top left; }} .widget-wrapper iframe {{ @@ -233,7 +328,6 @@ private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false border: none; background: transparent; pointer-events: none; - transform-origin: top left; }} @keyframes pulse {{ @@ -241,32 +335,53 @@ @keyframes pulse {{ 50% {{ box-shadow: 0 0 16px rgba(100,180,255,0.6); }} 100% {{ box-shadow: 0 0 10px rgba(255,255,255,0.3); }} }} - .widget-edit {{ - outline: 2px solid; + + #chrome-layer {{ + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + }} + + .widget-chrome {{ + position: absolute; + pointer-events: auto; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + outline: var(--chrome-outline) solid; cursor: move; outline-color: rgba(50, 50, 50, 0.4); border-radius: 8px; box-shadow: 0 0 10px rgba(55,55,55,0.3); transition: outline-color 0.2s, box-shadow 0.2s; - }} - .widget-edit:hover {{ + }} + .widget-chrome:hover {{ outline-color: rgba(100, 180, 255, 0.9); animation: pulse 1.5s infinite; - }} - .widget-hidden {{ + }} + .chrome-hidden {{ opacity: 0.4 !important; outline-color: rgba(60, 10, 10, 0.4); }} - + + .widget-hidden {{ + opacity: 0.4 !important; + }} + .resize-handle {{ position: absolute; - width: 12px; - height: 12px; + width: var(--handle-size); + height: var(--handle-size); background: rgba(180,180,255,0.7); - border: 1px solid rgba(100,100,255,0.9); + border: calc(var(--chrome-outline) / 2) solid rgba(100,100,255,0.9); border-radius: 2px; z-index: 9999; cursor: pointer; + box-sizing: border-box; }} .resize-handle.shift-active {{ @@ -279,15 +394,15 @@ @keyframes pulse {{ border-color: #20b860 !important; }} - .handle-nw {{ top: -6px; left: -6px; cursor: nwse-resize; }} - .handle-ne {{ top: -6px; right: -6px; cursor: nesw-resize; }} - .handle-sw {{ bottom: -6px; left: -6px; cursor: nesw-resize; }} - .handle-se {{ bottom: -6px; right: -6px; cursor: nwse-resize; }} + .handle-nw {{ top: calc(0px - var(--handle-half)); left: calc(0px - var(--handle-half)); cursor: nwse-resize; }} + .handle-ne {{ top: calc(0px - var(--handle-half)); right: calc(0px - var(--handle-half)); cursor: nesw-resize; }} + .handle-sw {{ bottom: calc(0px - var(--handle-half)); left: calc(0px - var(--handle-half)); cursor: nesw-resize; }} + .handle-se {{ bottom: calc(0px - var(--handle-half)); right: calc(0px - var(--handle-half)); cursor: nwse-resize; }} - .handle-n {{ top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; }} - .handle-s {{ bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; }} - .handle-e {{ right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }} - .handle-w {{ left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }} + .handle-n {{ top: calc(0px - var(--handle-half)); left: 50%; transform: translateX(-50%); cursor: n-resize; }} + .handle-s {{ bottom: calc(0px - var(--handle-half)); left: 50%; transform: translateX(-50%); cursor: s-resize; }} + .handle-e {{ right: calc(0px - var(--handle-half)); top: 50%; transform: translateY(-50%); cursor: e-resize; }} + .handle-w {{ left: calc(0px - var(--handle-half)); top: 50%; transform: translateY(-50%); cursor: w-resize; }} "); if (isEditor) @@ -315,76 +430,125 @@ @keyframes pulse {{ if (w.ScaleY == 0) w.ScaleY = 1; string cssClass = "widget-wrapper"; - if (isEditor) cssClass += " widget-edit"; if (!w.Visibility) cssClass += " widget-hidden"; - string titleAttr = isEditor ? $" title=\"{WebUtility.HtmlEncode(w.Name)}\nZ:{w.Z}\"" : ""; - sb.AppendLine($@"
"); - if (isEditor) - { - sb.AppendLine(@" -
-
-
- -
- -
- -
-
- -
- "); - } - - sb.AppendLine($@""); sb.AppendLine("
"); } + if (isEditor) + { + sb.AppendLine(@"
"); + foreach (var w in route.Widgets) + { + string chromeClass = "widget-chrome"; + if (!w.Visibility) chromeClass += " chrome-hidden"; + + sb.AppendLine($@"
+
+
+
+
+
+
+
+
+
"); + } + sb.AppendLine("
"); + } + sb.AppendLine(""); sb.AppendLine(@$" "); @@ -395,17 +559,19 @@ function resizeIframe(iframe) {{ { sb.AppendLine(@" "); + + sb.AppendLine(BuildWidgetContextMenuScript()); } sb.AppendLine(""); diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index 0e401fd6..190af7dc 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -10,6 +10,7 @@ using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; +using SubathonManager.Server.Interfaces; using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed @@ -30,6 +31,7 @@ private void SetupWebsocketListeners() OverlayEvents.OverlayRefreshRequested += SendRefreshRequest; SubathonEvents.SubathonValueConfigRequested += SendSubathonValues; SubathonEvents.SubathonTotalsUpdated += SendSubathonTotals; + SubathonEvents.SubscriptionTotalsUpdated += SendSubscriptionTotals; SubathonEvents.PromptRunStarted += OnPromptStart; SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; @@ -53,6 +55,7 @@ private void StopWebsocketServer() SubathonEvents.SubathonGoalListUpdated -= SendGoalsUpdated; SubathonEvents.SubathonValueConfigRequested -= SendSubathonValues; SubathonEvents.SubathonTotalsUpdated -= SendSubathonTotals; + SubathonEvents.SubscriptionTotalsUpdated -= SendSubscriptionTotals; SubathonEvents.PromptRunStarted -= OnPromptStart; SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; @@ -220,6 +223,11 @@ private async Task InitConnection(IWebSocketClient socket) if (totals != null) await SelectSendAsync(socket, SubathonTotalsToObject(totals)); + var subTotals = await EventService.GetSubscriptionTotalsAsync(db); + + if (subTotals != null) + await SelectSendAsync(socket, SubscriptionTotalsToObject(subTotals)); + var activeWheel = await db.WheelSets .Include(w => w.WheelItems) .ThenInclude(i => i.Action) @@ -278,6 +286,27 @@ private object SubathonTotalsToObject(SubathonTotals totals) }; } + private object SubscriptionTotalsToObject(SubscriptionTotals totals) + { + return new + { + type = "subscription_totals", + sub_total = totals.SubTotal, + sub_total_by_type = totals.SubTotalByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + sub_total_by_type_tier = totals.SubTotalByEventTier + .ToDictionary(k => k.Key.ToString(), k => k.Value), + simulated = new + { + sub_total = totals.Simulated.SubTotal, + sub_total_by_type = totals.Simulated.SubTotalByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + sub_total_by_type_tier = totals.Simulated.SubTotalByEventTier + .ToDictionary(k => k.Key.ToString(), k => k.Value), + } + }; + } + private object SubathonEventToObject(SubathonEvent subathonEvent) { var trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); @@ -322,6 +351,11 @@ internal void SendSubathonTotals(SubathonTotals totals) Task.Run(() => BroadcastAsyncObject(SubathonTotalsToObject(totals), WebsocketClientTypeHelper.ConsumersList)); } + internal void SendSubscriptionTotals(SubscriptionTotals totals) + { + Task.Run(() => BroadcastAsyncObject(SubscriptionTotalsToObject(totals), WebsocketClientTypeHelper.ConsumersList)); + } + internal void SendRefreshRequest(Guid id) { Task.Run(() => @@ -809,6 +843,8 @@ function connect() {{ window.handleValueConfig(data); else if (typeof window.handleTotalsUpdate === 'function' && data.type == 'subathon_totals') window.handleTotalsUpdate(data); + else if (typeof window.handleSubscriptionTotalsUpdate === 'function' && data.type == 'subscription_totals') + window.handleSubscriptionTotalsUpdate(data); else if (data.type == 'refresh_request' && document.title.startsWith('overlay') && (document.title.includes(data.id) || data.id == '{Guid.Empty}')) {{ // for only the merged page window.location.reload(); @@ -817,15 +853,11 @@ function connect() {{ const iframe = document.querySelector(`iframe[data-widget-id=""${{data.widgetId}}""]`); if (iframe) {{ const wrapper = iframe.parentElement; - if (data.width != null) {{ - iframe.dataset.origWidth = data.width; - iframe.dataset.origHeight = data.height; - iframe.dataset.scalex = data.scaleX; - iframe.dataset.scaley = data.scaleY; + if (data.width != null && typeof window.applyWidgetLayout === 'function') {{ + window.applyWidgetLayout(wrapper, data.width, data.height, data.scaleX, data.scaleY); wrapper.style.left = data.x + 'px'; wrapper.style.top = data.y + 'px'; }} - iframe.onload = () => resizeIframe(iframe); iframe.src = iframe.src; }} diff --git a/SubathonManager.Server/WebServer.cs b/SubathonManager.Server/WebServer.cs index 8788124e..08ed0e84 100644 --- a/SubathonManager.Server/WebServer.cs +++ b/SubathonManager.Server/WebServer.cs @@ -1,22 +1,24 @@ -using System.Net; -using System.Net.Sockets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SubathonManager.Data; using SubathonManager.Core.Models; using SubathonManager.Core; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; public partial class WebServer : IAppService { + // ReSharper disable once InconsistentNaming internal readonly IDbContextFactory _factory; public int Port { get; set; } - private HttpListener? _listener; - private TcpListener? _tcpListener; + private WebApplication? _app; public bool Running { get; private set; } private readonly HashSet _servedFolders = new(); @@ -24,10 +26,6 @@ public partial class WebServer : IAppService private readonly IConfig _config; private SubathonValueConfigHelper _valueHelper; - private static bool IsWindows => HttpListener.IsSupported && - !(Environment.GetEnvironmentVariable("TEMP") - ?.Contains("steamuser", StringComparison.OrdinalIgnoreCase) ?? false); - record RouteKey(string Method, string Pattern); private readonly List<(RouteKey key, Func handler)> _routes = new(); @@ -60,24 +58,11 @@ public WebServer(ILogger? logger, IConfig config, IDbContextFactory(); + builder.WebHost.UseUrls($"http://127.0.0.1:{Port}"); + + var app = builder.Build(); + app.UseWebSockets(); + app.Run(ctx => HandleRequestAsync(new AspNetContextAdapter(ctx))); + _app = app; } private void SetupOverlayRoutes() @@ -102,101 +87,54 @@ private void SetupOverlayRoutes() _routes.Add((new RouteKey("GET", "/ws"),HandleWebSocketRequestAsync)); _routes.Add((new RouteKey("GET", "/widget/"),HandleWidgetRequest )); _routes.Add((new RouteKey("GET", "/route/"),HandleRouteRequest )); + _routes.Add((new RouteKey("GET", ResourcePaths.UrlPrefix),HandleResourceRequest )); } public async Task StartAsync(CancellationToken cancellationToken = default) { Port = int.Parse(_config.Get("Server", "Port", "14040")!); - Initialize(); - Running = true; - - if (IsWindows) - { - await RunHttpListenerLoopAsync(cancellationToken); - } - else - { - _logger?.LogWarning("HttpListener is not supported. Running BETA TcpListener as fallback"); - await RunTcpListenerLoopAsync(cancellationToken); - } - - } - - private async Task RunHttpListenerLoopAsync(CancellationToken cancellationToken) - { - _listener?.Start(); - _logger?.LogInformation($"WebServer running at http://localhost:{Port}/"); - var oldPort = Port; - WebServerEvents.RaiseWebServerStatusChange(Running); - while (Running) + const int maxAttempts = 3; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { - var context = await _listener!.GetContextAsync(); - _ = Task.Run(() => HandleRequestAsync(new HttpListenerContextAdapter(context)), cancellationToken); - if (cancellationToken.IsCancellationRequested) - break; - } - catch (HttpListenerException ex) - { - if (cancellationToken.IsCancellationRequested || oldPort != Port) - return; - _logger?.LogError(ex, $"WebServer Error: {ex.Message}"); - break; + Initialize(); + await _app!.StartAsync(cancellationToken); + Running = true; + _logger?.LogInformation($"WebServer running at http://localhost:{Port}/"); + WebServerEvents.RaiseWebServerStatusChange(Running); + return; } catch (Exception ex) { - if (cancellationToken.IsCancellationRequested) - return; - _logger?.LogError(ex, $"WebServer Error: {ex.Message}"); + Running = false; + + await StopServerAsync(); + _logger?.LogWarning(ex, "WebServer failed to start on port {Port} (attempt {Attempt}/{Max})", + Port, attempt, maxAttempts); + if (attempt < maxAttempts) + await Task.Delay(250, cancellationToken); } } + + _logger?.LogError("WebServer could not start on port {Port} after {Max} attempts", Port, maxAttempts); + WebServerEvents.RaiseWebServerStatusChange(Running); } - - private async Task RunTcpListenerLoopAsync(CancellationToken cancellationToken) + + private async Task StopServerAsync() { - _tcpListener!.Start(); - _logger?.LogInformation($"WebServer (TCP) running at http://localhost:{Port}/"); - WebServerEvents.RaiseWebServerStatusChange(Running); + StopWebsocketServer(); + if (_app == null) return; + var app = _app; + _app = null; - while (Running && !cancellationToken.IsCancellationRequested) + try { - TcpClient? client = null; - try - { - client = await _tcpListener.AcceptTcpClientAsync(cancellationToken); - } - catch (OperationCanceledException) - { - break; - } - catch (SocketException ex) - { - if (!Running || cancellationToken.IsCancellationRequested) break; - _logger?.LogError(ex, $"WebServer TCP accept error: {ex.Message}"); - continue; - } - - _ = Task.Run(async () => - { - try - { - var ctx = await TcpListenerContextAdapter.ParseAsync(client, cancellationToken); - if (ctx != null) - await HandleRequestAsync(ctx); - } - catch (Exception ex) - { - _logger?.LogError(ex, "TCP Request Handler had an error."); - } - finally - { - try { client.Dispose(); } catch { /* ignored */ } - } - }, cancellationToken); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await app.StopAsync(cts.Token); } - - try { _tcpListener.Stop(); } catch { /* ignored */ } + catch { /**/ } + try { await app.DisposeAsync(); } catch { /**/ } } public void Stop() @@ -204,9 +142,7 @@ public void Stop() Running = false; try { - _listener?.Stop(); - _tcpListener?.Stop(); - StopWebsocketServer(); + StopServerAsync().GetAwaiter().GetResult(); } catch (Exception ex) { @@ -218,11 +154,11 @@ public void Stop() } } - public Task StopAsync(CancellationToken cancellationToken = default) + public async Task StopAsync(CancellationToken cancellationToken = default) { Running = false; - Stop(); - return Task.CompletedTask; + await StopServerAsync(); + WebServerEvents.RaiseWebServerStatusChange(Running); } internal Func? MatchRoute(string method, string path) diff --git a/SubathonManager.Services/CommandService.cs b/SubathonManager.Services/CommandService.cs index 429e74a0..a2e69063 100644 --- a/SubathonManager.Services/CommandService.cs +++ b/SubathonManager.Services/CommandService.cs @@ -4,6 +4,7 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Services; @@ -66,14 +67,13 @@ private static bool ValidateUser(SubathonEvent subathonEvent, string user, bool if (isBroadcaster) return true; string configKey = $"Commands.{subathonEvent.Command}.permissions"; - // All Commands are under twitch section at the moment, despite being cross platform chat commands - // Should update eventually, or is it too late if (isModerator && AppConfig!.GetBool("Chat", $"{configKey}.Mods", false)) return true; if (isVip && AppConfig!.GetBool("Chat", $"{configKey}.VIPs", false)) return true; - string[] whitelist = AppConfig!.Get("Chat", $"{configKey}.Whitelist")!.ToLower().Split(','); + string[] whitelist = AppConfig!.Get("Chat", $"{configKey}.Whitelist")!.ToLower() + .Split(',').Select(s => s.Trim()).ToArray(); if (whitelist.Contains(user.ToLower().Trim())) return true; diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 6a8b2ec2..45e02cef 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -379,11 +379,7 @@ private async Task LoopAsync() db.Entry(subathon).State = EntityState.Detached; if (affected > 0 || (ev.ProcessedToSubathon)) - { - var totals = await GetSubathonTotalsAsync(db); - if (totals != null) - SubathonEvents.RaiseSubathonTotalsUpdated(totals); - } + await RaiseAllTotalsUpdatedAsync(db); return (affected > 0 || (ev.ProcessedToSubathon), false); } @@ -720,10 +716,8 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) events.Add(ev); SubathonEvents.RaiseSubathonEventsDeleted(events); - var totals = await GetSubathonTotalsAsync(db); - if (totals != null) - SubathonEvents.RaiseSubathonTotalsUpdated(totals); - + await RaiseAllTotalsUpdatedAsync(db); + if (affected > 0) { await db.Entry(subathon!).ReloadAsync(); @@ -852,9 +846,7 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event } finally { - var totals = await GetSubathonTotalsAsync(db); - if (totals != null) - SubathonEvents.RaiseSubathonTotalsUpdated(totals); + await RaiseAllTotalsUpdatedAsync(db); } } @@ -1011,6 +1003,84 @@ static SubathonSimulatedTotals BuildSimulated(List src) return totals; } + private static string GetSubTierKey(EventProjection e) + { + var meta = !string.IsNullOrWhiteSpace(e.EventTypeMeta) ? e.EventTypeMeta + : string.IsNullOrWhiteSpace(e.Value) ? "DEFAULT" : e.Value; + return e.EventType == null ? meta : e.EventType.Value.TierMetaDisplayName(meta); + } + + public static async Task GetSubscriptionTotalsAsync(AppDbContext db) + { + var subathon = await db.SubathonDatas + .AsNoTracking() + .Select(x => new { x.Id, x.IsActive }) + .FirstOrDefaultAsync(x => x.IsActive); + + if (subathon == null) return new SubscriptionTotals(); + + var subEventTypes = SubathonEventSubTypeHelper.SubEventTypes + .Select(t => (SubathonEventType?)t) + .ToList(); + + var allEvents = await db.SubathonEvents + .AsNoTracking() + .Where(e => + e.SubathonId == subathon.Id && + e.ProcessedToSubathon && + subEventTypes.Contains(e.EventType)) + .Select(e => new EventProjection( + e.EventType, + e.Source, + e.Amount, + e.Value, + e.Source == SubathonEventSource.Simulated || + e.User!.StartsWith("SIMULATED") || + e.User!.StartsWith("SYSTEM"), + e.EventTypeMeta + )) + .ToListAsync(); + + var events = allEvents.Where(e => !e.IsSimulated).ToList(); + var simEvents = allEvents.Where(e => e.IsSimulated).ToList(); + + static Dictionary ByEvent(List src) => src + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)); + + static Dictionary> ByEventTier(List src) => src + .GroupBy(e => e.EventType!.Value) + .ToDictionary( + g => g.Key, + g => g.GroupBy(GetSubTierKey) + .ToDictionary(t => t.Key, t => t.Sum(e => e.Amount))); + + return new SubscriptionTotals + { + SubTotal = events.Sum(e => e.Amount), + SubTotalByEvent = ByEvent(events), + SubTotalByEventTier = ByEventTier(events), + + Simulated = new SubscriptionSimulatedTotals + { + SubTotal = simEvents.Sum(e => e.Amount), + SubTotalByEvent = ByEvent(simEvents), + SubTotalByEventTier = ByEventTier(simEvents) + } + }; + } + + public static async Task RaiseAllTotalsUpdatedAsync(AppDbContext db) + { + var totals = await GetSubathonTotalsAsync(db); + if (totals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(totals); + + var subTotals = await GetSubscriptionTotalsAsync(db); + if (subTotals != null) + SubathonEvents.RaiseSubscriptionTotalsUpdated(subTotals); + } + public async Task StopAsync(CancellationToken ct = default) { if (!_cts.IsCancellationRequested) diff --git a/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs new file mode 100644 index 00000000..cd79e72c --- /dev/null +++ b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs @@ -0,0 +1,411 @@ +using SubathonManager.Tests.Utility; + +namespace SubathonManager.Tests.CoreUnitTests; + +public class ResourcePathsPureTests +{ + + [Theory] + [InlineData("/resources/images/logo.png", true)] + [InlineData("/RESOURCES/images/logo.png", true)] + [InlineData("\\resources\\images\\logo.png", true)] + [InlineData("resources/images/logo.png", false)] + [InlineData("/res/images/logo.png", false)] + [InlineData("./resources/logo.png", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData(null, false)] + public void IsResourceUrl_Branches(string? value, bool expected) + => Assert.Equal(expected, ResourcePaths.IsResourceUrl(value)); + + + [Theory] + [InlineData("/resources/images/logo.png", "images/logo.png")] + [InlineData("\\resources\\images\\logo.png", "images/logo.png")] + [InlineData("/resources/images/logo.png?v=2", "images/logo.png")] + [InlineData("/resources/images/logo.png#frag", "images/logo.png")] + [InlineData("/resources/images/logo.png?v=2#frag", "images/logo.png")] + [InlineData("/resources//images/logo.png/", "images/logo.png")] + public void RelativeFromUrl_StripsPrefixQueryAndFragment(string url, string expected) + => Assert.Equal(expected, ResourcePaths.RelativeFromUrl(url)); + + [Theory] + [InlineData("/resources/")] + [InlineData("/resources/?x=1")] + [InlineData("/notresources/logo.png")] + [InlineData(null)] + public void RelativeFromUrl_ReturnsNull_WhenNoUsableRelative(string? url) + => Assert.Null(ResourcePaths.RelativeFromUrl(url)); + + + [Theory] + [InlineData(null)] + [InlineData("")] + public void FindReferences_EmptyInput_YieldsNothing(string? text) + => Assert.Empty(ResourcePaths.FindReferences(text)); + + [Fact] + public void FindReferences_NoMatch_YieldsNothing() + => Assert.Empty(ResourcePaths.FindReferences("

nothing to see here

")); + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("background: url(/resources/images/logo.png);")] + [InlineData("const a = `/resources/images/logo.png`;")] + [InlineData("let x = /resources/images/logo.png ")] + [InlineData("load(a,/resources/images/logo.png)")] + [InlineData("x;/resources/images/logo.png")] + [InlineData("[/resources/images/logo.png ")] + public void FindReferences_AcceptedDelimiters(string text) + => Assert.Equal(["images/logo.png"], ResourcePaths.FindReferences(text)); + + [Fact] + public void FindReferences_RejectsMidWordMatch() + { + Assert.Empty(ResourcePaths.FindReferences("https://cdn.example.com/resources/images/logo.png")); + } + + [Fact] + public void FindReferences_StripsQueryAndFragment() + { + Assert.Equal(new[] {"audio/ding.mp3"}, + ResourcePaths.FindReferences("