diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ba2ce86..8a661966 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,354 @@ 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 folder and license into build output + shell: bash + run: | + mkdir -p build-avalonia/presets + cp -R presets/. build-avalonia/presets/ + cp LICENSE build-avalonia/ + rm -f build-avalonia/*.pdb + + - name: Generate hicolor icon sizes (linux) + if: matrix.runtime == 'linux-x64' + shell: bash run: | - mkdir -Force build\presets - xcopy presets build\presets /E /I /Y - copy LICENSE build\ - rm build\*.pdb + 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: Assemble macOS .app bundle and zip (macOS only) + if: startsWith(matrix.runtime, 'osx') + shell: bash + run: | + 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 folder and license into build output + shell: bash + run: | + mkdir -p build-pr/presets + cp -R presets/. build-pr/presets/ + 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 +591,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 +720,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 +766,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..03709b50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +presets/harley/ +presets/honse/ + bin/ obj/ /packages/ 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/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.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..b307d9a6 --- /dev/null +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.WebSockets; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; + +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); + } + + 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/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.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index 12e94e31..a7111f23 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Globalization; +using System.Net; using System.Text; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; @@ -211,6 +212,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 +224,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 +239,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 +246,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 +305,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 +341,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 +470,19 @@ function resizeIframe(iframe) {{ { sb.AppendLine(@"