diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index a0f3a375..cb18656d 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -25,9 +25,9 @@ on: jobs: build: - runs-on: docker - + container: + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 steps: - name: Clone yavsc run: | @@ -39,10 +39,9 @@ jobs: git checkout FETCH_HEAD fi git submodule update --init --recursive - echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" - - name: Restore dependencies - run: cd /src/_src && dotnet restore - - name: Build - run: cd /src/_src && dotnet build --no-restore + echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" + - name: Test - run: cd /src/_src && dotnet test --no-build --verbosity normal + run: | + echo "🚀 Lancement des tests..." + cd /src/_src && dotnet test --verbosity normal && echo "✅ Success !" || echo "❌ Fail ($?)!" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 09d9757a..a72f92bd 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -51,6 +51,8 @@ jobs: # via l'API REST Forgejo (pas d'actions tierces Node). release: runs-on: docker + container: + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 steps: - name: Clone du repo au tag demandé env: @@ -66,10 +68,8 @@ jobs: # WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile). cd /src - # Clone unshallow pour que GitVersion.MsBuild ait l'historique - # et les tags (sinon MSB3073 sur la cible Android cf. PR #21). if [[ ! -d _src/.git ]]; then - git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src fi cd _src @@ -171,37 +171,22 @@ jobs: echo "EOF" >> "$GITHUB_ENV" echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - - name: Build des projets .NET (sans docker) - # L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le - # workload Android, mais PAS le binaire `docker` ni de daemon - # Docker. On exécute donc les commandes dotnet directement - # au lieu de passer par `docker build`. - # Equivalent des stages build-env du Dockerfile (lignes - # restore + build Yavsc.Org + build Yavsc.Api + build - # Yavsc.Blogs + build PostIt.Android -r android-arm64). + - name: Restore run: | cd /src/_src dotnet restore - dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ - -c Release --no-restore -clp:ErrorsOnly -r android-arm64 - - name: Copier l'APK signé vers un emplacement connu - # Le build Android avec -r android-arm64 produit l'APK dans - # bin/Release/net10.0-android/android-arm64/. On le copie à - # la racine du checkout pour que l'étape d'upload le trouve. + - name: Build de PostIt.Android ARM64 run: | cd /src/_src - APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.PostIt-Signed.apk - if [[ ! -f "$APK" ]]; then - echo "::error::APK not found at $APK" - ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true - exit 1 - fi - cp "$APK" /src/_src/PostIt.Android.apk - ls -la /src/_src/PostIt.Android.apk + dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ + -c Release -r android-arm64 --no-restore -clp:ErrorsOnly + + - name: Build de PostIt.Android x64 + run: | + cd /src/_src + dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ + -c Release -r android-x64 --no-restore -clp:ErrorsOnly - name: Publier la release Forgejo via l'API REST # Pas d'action tierce (pas de Node dans l'image runner). @@ -310,21 +295,22 @@ jobs: # sinon curl l'interprète comme un second fichier d'input # (un fichier nommé '?name=PostIt.Android.apk') et l'API # Forgejo renvoie 400 "Missing 'name' parameter". - echo "::group::Upload APK asset" - HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - -H "Accept: application/json" \ - --data-binary "@/src/_src/PostIt.Android.apk" \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk") - echo "POST asset -> HTTP $HTTP" + echo "::group::Upload PostIt APK assets" + for MARCH in arm64 x64; do + HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -H "Accept: application/json" \ + --data-binary "@/src/_src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-$MARCH/fr.pschneider.postit-Signed.apk" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android-$MARCH.apk") + echo "POST asset -> HTTP $HTTP" + if [[ "$HTTP" != "201" ]]; then + echo "::error::Asset upload failed (HTTP $HTTP):" + cat /tmp/asset.json + exit 1 + fi + done echo "::endgroup::" - if [[ "$HTTP" != "201" ]]; then - echo "::error::Asset upload failed (HTTP $HTTP):" - cat /tmp/asset.json - exit 1 - fi - - echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" + echo "✅ Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml deleted file mode 100644 index d560b216..00000000 --- a/.github/workflows/docker-publish-android.yml +++ /dev/null @@ -1,183 +0,0 @@ -name: Build and Push Yavsc Apk - -on: - push: - branches: - - main - tags: - - '*' - workflow_dispatch: - inputs: - force_unstable: - description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.' - required: false - type: boolean - default: false - -# softprops/action-gh-release a besoin de contents: write -# pour publier une release + uploader un asset. -permissions: - contents: write - -jobs: - apk-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - # 1. Votre étape de build actuelle (on nomme l'image "postit-android") - # --target build-env : on ne veut que le stage de build (qui - # contient les artefacts .apk). Sans --target, Docker ciblerait - # le DERNIER stage du Dockerfile (blogs-runtime, qui est une - # image ASP.NET runtime sans aucun APK à extraire). - - name: Build de l'image Docker - run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . - # 2. EXTRACTION : Créer un conteneur éphémère pour copier l'APK vers l'hôte GitHub - - name: Extraire l'APK du conteneur Docker - run: | - docker create --name extractor postit-android - docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.PostIt-Signed.apk ./PostIt.Android.apk - docker rm extractor - - - name: Téléverser l'APK en tant qu'Artéfact GitHub - uses: actions/upload-artifact@v7 - with: - name: application-apk-release - path: ./PostIt.Android.apk - retention-days: 7 - - # Job de validation : parse le tag, vérifie le format, applique la règle - # de parité du patch (pair=stable / impair=preview / suffixe=instable), - # et s'assure que CHANGELOG.md contient une section cohérente. - # Sans ce job, le job publish-release peut être bypassé (un attaquant - # qui contrôle un tag ne peut pas publier de release sans une section - # changelog cohérente). - validate-release: - if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Valider le tag et la section CHANGELOG - env: - FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }} - run: | - TAG="${GITHUB_REF_NAME}" - - # Parse semver : MAJOR.MINOR.PATCH[-SUFFIX] - if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then - echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." - exit 1 - fi - - MAJOR="${BASH_REMATCH[1]}" - MINOR="${BASH_REMATCH[2]}" - PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" - - # Classification du canal par parité du patch. - # Patch pair + pas de suffixe -> stable. - # Patch impair + pas de suffixe -> preview. - # Suffixe présent -> instable. - if [[ -n "$SUFFIX" ]]; then - CHANNEL="unstable" - elif (( PATCH % 2 == 0 )); then - CHANNEL="stable" - else - CHANNEL="preview" - fi - - echo "Tag $TAG classifié comme channel=$CHANNEL" - - # Fail-fast sur instable sauf opt-in explicite via workflow_dispatch. - if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then - echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish." - echo "Set force_unstable=true via workflow_dispatch to override." - exit 1 - fi - - # Lecture du CHANGELOG.md (doit exister à la racine du repo). - if [[ ! -f CHANGELOG.md ]]; then - echo "::error::CHANGELOG.md not found at repo root." - exit 1 - fi - - # Extraction de la section [TAG]. On cherche la première ligne - # commençant par '## [' qui contient '[TAG]' (entre '## [' et - # la prochaine ligne '## [' ou fin de fichier). awk en mode - # paragraphe suffit et reste POSIX. - BODY=$(awk -v tag="[$TAG]" ' - /^## \[/ { - if (in_section) exit - if (index($0, tag) > 0) in_section=1 - next - } - in_section { print } - ' CHANGELOG.md) - - if [[ -z "$BODY" ]]; then - echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md." - echo "Add a '## [$TAG] - $CHANNEL' section before tagging." - exit 1 - fi - - # Vérification cohérence du canal déclaré dans le titre de section. - # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md) - if [[ "$HEADER" != *" - $CHANNEL"* ]]; then - echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." - echo "Current section header: $HEADER" - exit 1 - fi - - echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" - - # Exposition aux étapes suivantes via $GITHUB_ENV. - # heredoc <> "$GITHUB_ENV" - - publish-release: - # Déclenché uniquement par un push de tag. Le job apk-deploy produit - # l'artefact ; validate-release garantit la cohérence du tag et du - # changelog avant publication. - if: startsWith(github.ref, 'refs/tags/') - needs: [apk-deploy, validate-release] - runs-on: ubuntu-latest - steps: - - name: Récupérer l'APK depuis l'artefact - uses: actions/download-artifact@v7 - with: - name: application-apk-release - path: ./ - - - name: Publier la release GitHub et uploader l'APK - uses: softprops/action-gh-release@v2 - with: - # Le nom de fichier final dans la release. C'est ce qui - # apparaîtra dans l'asset et donc dans le permalink : - # https://github.com///releases/latest/download/PostIt.Android.apk - files: ./PostIt.Android.apk - # Le body est extrait de la section CHANGELOG.md correspondant - # au tag, exposée par validate-release via $GITHUB_ENV. - body: ${{ env.RELEASE_BODY }} - # stable -> false (marque comme Latest). - # preview / unstable -> true (visible mais pas Latest). - prerelease: ${{ env.IS_PRERELEASE }} diff --git a/.vscode/launch.json b/.vscode/launch.json index 42be176a..dc8d3c68 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,59 +1,57 @@ { - // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. - // Pointez pour afficher la description des attributs existants. - // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Debug - Android", + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Android Debug", "type": "mono", "preLaunchTask": "run-debug-android", "request": "attach", "address": "localhost", - "port": 10000 + "port": 55555 }, { - "name": "Attach - Android", + "name": "Android Attach - Debug", "type": "mono", "request": "attach", "address": "localhost", - "port": 10000 + "port": 55555 }, - { - "name": "API", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/Api/Api.csproj" - }, - { - "name": "Yavsc.Org", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", - }, - { - "name": "Yavsc.Blogs", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" - }, - { - "name": "PostIt", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj", - - }, - { - "name": "Test PostIt.Android launch (Xamarin.UITest)", - "type": "coreclr", - "request": "launch", - "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", - "args": [ - ], - "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", - "console": "integratedTerminal", - "stopAtEntry": false - } - ] + { + "name": "API", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/Api/Api.csproj" + }, + { + "name": "Yavsc Org", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", + }, + { + "name": "Yavsc Blogs", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" + }, + { + "name": "PostIt Desktop", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj", + }, + { + "name": "Test PostIt.Android launch (Xamarin.UITest)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", + "args": [], + "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 16bbe483..83a17ae3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,27 +2,31 @@ "dotnet-test-explorer.testProjectPath": "test/**/*Tests.csproj", "cSpell.words": [ - "appsettings", - "asciidoctor", - "ASPNETCORE", - "Configurabilité", - "Cratie", - "DESTDIR", - "dotnet", - "DOTNET", - "ecdsa", - "envsubst", - "Hsts", - "Newtonsoft", - "Npgsql", - "PKCE", - "postit", - "pschneider", - "SLNDIR", - "validable", - "www-data", - "yavsc", - "Yavsc" + "appsettings", + "asciidoctor", + "ASPNETCORE", + "Avalonia", + "blogspot", + "Configurabilité", + "Cratie", + "DESTDIR", + "dotnet", + "DOTNET", + "ecdsa", + "envsubst", + "Forgejo", + "Hsts", + "Newtonsoft", + "Npgsql", + "Oidc", + "PKCE", + "postit", + "pschneider", + "SLNDIR", + "validable", + "www-data", + "yavsc", + "Yavsc" ], "cSpell.reportUnknownWords": true, "cSpell.language": "fr,en", @@ -40,5 +44,6 @@ "copilotcli/gpt-5.3-codex" ] } - } + }, + "dotnet.defaultSolution": "yavsc.sln" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c900fa6a..fd46437a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,5 +1,22 @@ { "version": "2.0.0", + "isRoot": true, + "problemMatcher": [ + { + "owner": "dotnet", + "fileLocation": ["relative", "${workspaceFolder}"], + "source": "dotnet", + "pattern": { + "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + } + ], "tasks": [ { "label": "run-debug-android", @@ -10,19 +27,17 @@ "env": { "DOTNET_HOST_PATH": "/usr/share/dotnet", "ANDROID_HOME": "/opt/android-sdk", - "JAVA_HOME": "/usr/lib/jvm/java-1.21.0-openjdk-amd64" + "JAVA_HOME": "/usr/lib/jvm/java-1.25.0-openjdk-amd64" } }, "args": [ - "build" - "-t:run", + "run", "-p:TargetFramework=net10.0-android", "-p:Configuration=Debug", "-p:AndroidAttachDebugger=true", - "-p:AndroidSdbHostPort=10000", - "-p:AndroidSdbTargetPort=10000" - ], - "problemMatcher": "$msCompile" + "-p:AndroidSdbHostPort=55555", + "-p:AndroidSdbTargetPort=55555" + ] }, { "label": "build", @@ -32,7 +47,6 @@ "group": "build", "isBuildCommand": true, "isTestCommand": false, - "problemMatcher": ["$msCompile"], "isBackground": true }, { @@ -62,59 +76,6 @@ "kind": "build" }, "isBackground": true - }, - { - "label": "test blogs", - "type": "process", - "problemMatcher": ["$msCompile"], - "command": "dotnet", - "args": ["test"], - "runOptions": { - "instanceLimit": 1 - }, - "options": { - "cwd": "src/Yavsc.Blogs", - "env": { - "DOTNET_CLI_UI_LANGUAGE": "en-US", - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "group": { - "kind": "test" - }, - "isBackground": true, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "shared", - "showReuseMessage": true, - "clear": false - } - }, - { - "label": "publish", - "command": "dotnet", - "type": "process", - "args": [ - "publish", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "watch", - "command": "dotnet", - "type": "process", - "args": ["watch", "--project", - "src/Yavsc.Org/Yavsc.Org.csproj" - ], - "problemMatcher": "$msCompile", - "runOptions": { - - } - } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a446a0..b454a3f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,37 @@ # Changelog -Toutes les modifications notables de PostIt et de la plateforme Yavsc -sont documentées dans ce fichier. +## [1.0.8-rc7] - unstable -Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), -et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Added -À noter : la **parité du numéro de patch** porte une signification de canal : +* [PostIt] The search pattern now persists -- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** -- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** -- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** +### Changed -Cette convention est partagée avec le dépôt -[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) -pour la production des paquets `.deb`. +* The blog spot path is now `/api/v1/blogspot` (yet in last release) + +### Fixed + +* [Yavsc.Org][TODO] La forme de l'email de l'utilisateur est maintenant validée avant l'envoi du formulaire d'enregistrement + +## [1.0.8-rc6] - unstable + +### Added + +* a code cleanup, +* a first Xamarin.UITest is successful, but disabled, because breaking the actual CI process, +* Android app starts, the login process succeeds + +### Changed + +L'identifiant de l'application client Android a changé, il passe en minuscules : +`fr.pschneider.postit` + +### Fixed + +a bug posting and retrieving ACL from the backend, +the ACL now comes along with the article, +[TODO][PostIt] keep ACL along with the article ## [1.0.8-rc1] - unstable @@ -169,10 +186,10 @@ pour la production des paquets `.deb`. migration, reverted in this release. The publish toggle covers the same user-visible switch without a schema change. -[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD -[1.0.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1 -[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7 -[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 +[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD +[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 +[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 +[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 ## [1.0.6] - stable @@ -206,4 +223,4 @@ pour la production des paquets `.deb`. actual release id. Switched to `jq` for both body construction and field extraction. -[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 +[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e528b7a4..fd408c5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ ## Premier build ```bash -git clone https://github.com/pazof/yavsc.git +git clone https://forgejo.pschneider.fr/notazof/yavsc.git cd yavsc dotnet restore dotnet build @@ -49,6 +49,26 @@ Les tests sont répartis en : item « Tests d'intégration smoke par BC ». - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. +## Le CHANGELOG.md + +Le `CHANGELOG.md` est un document de changement de version + +Toutes les modifications notables de PostIt et de la plateforme Yavsc +sont documentées dans ce fichier. + +Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), +et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +À noter : la **parité du numéro de patch** porte une signification de canal : + +- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** +- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** +- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** + +Cette convention est partagée avec le dépôt +[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) +pour la production des paquets `.deb`. + ## Navigation (PostIt) La navigation est centralisée dans diff --git a/Directory.Build.props b/Directory.Build.props index aec8c990..83d21579 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,16 +1,6 @@ Yavsc - - true - NU1701, NU1901, NU1902 + NU1701, NU1901, NU1902, NU1507 diff --git a/Directory.Packages.props b/Directory.Packages.props index 84380e44..7505c851 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,6 @@ - diff --git a/Makefile b/Makefile index d6d69196..08f0461d 100644 --- a/Makefile +++ b/Makefile @@ -77,13 +77,6 @@ release: echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \ exit 1; \ fi - @CURRENT=$$(git branch --show-current); \ - if [ "$$CURRENT" != "main" ]; then \ - echo "Refus : la cible doit être lancée depuis main."; \ - echo " Branche courante : $$CURRENT"; \ - echo " Fais : git checkout main && git pull --ff-only origin main"; \ - exit 1; \ - fi @if [ -n "$$(git status --porcelain)" ]; then \ echo "Working tree sale, refus de créer une branche release."; \ git status --short; \ @@ -121,168 +114,4 @@ release: git push -u origin "$$BRANCH"; \ echo "==> Terminé. Branche $$BRANCH live sur origin." -# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu. -# -# Usage typique : -# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe -# make qemu-install # (re)build l'APK et l'installe (AVD doit tourner) -# make qemu-build # build l'APK seul (sans install) -# make qemu-run # démarre l'AVD en background -# make qemu-stop # arrête l'émulateur -# make qemu-wait-boot # attend que l'AVD ait fini de booter -# -# Variables surchargeables (make VAR=valeur) : -# AVD_NAME default: postit_test_avd -# (l'AVD doit être listé par `avdmanager list avd`) -# ADB_SERIAL default: emulator-5554 -# (port standard du premier émulateur lancé) -# ANDROID_HOME default: /opt/android-sdk -# (le SDK Android local; doit contenir -# emulator/emulator et platform-tools/adb) -# POSTIT_RID default: android-x64 -# (doit matcher l'ABI de l'AVD; `avdmanager list avd` -# affiche la ligne Tag/ABI) -# EMU_HEADLESS default: 0 -# (1 = lancer l'émulateur sans fenêtre, pour scripter) -# CONFIG surcharge la variable CONFIG globale (Debug par -# défaut dans ce Makefile). Passer à Release pour -# un APK optimisé et signé release. -# LOGCAT_LINES default: 200 -# (nombre de lignes dumpées par `make qemu-logcat`) -# LOGCAT_FOLLOW default: 0 -# (1 = stream live via `make qemu-logcat`, -# sinon dump one-shot des N dernières lignes) -# LOGCAT_BOOT_WAIT default: 30 -# (secondes d'attente entre le clear du buffer, -# le `am start`, et le dump final dans -# `make qemu-logcat-boot`) -AVD_NAME ?= postit_test_avd -ADB_SERIAL ?= emulator-5554 -ANDROID_HOME ?= /opt/android-sdk -POSTIT_RID ?= android-x64 -EMU_HEADLESS ?= 0 -LOGCAT_LINES ?= 600 -LOGCAT_FOLLOW ?= 0 -LOGCAT_BOOT_WAIT ?= 20 - -ANDROID_PACKAGE_NAME = fr.pschneider.PostIt -POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj -POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) -POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk - -qemu-run: - @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." - @mkdir -p /tmp/yavsc-emu - @EMU_ARGS=""; \ - if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \ - $(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \ - >/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \ - echo " emulator PID: $$!" - -qemu-stop: - adb -s $(ADB_SERIAL) emu kill - -qemu-wait-boot: - @echo " Waiting for $(ADB_SERIAL) to finish booting..." - adb -s $(ADB_SERIAL) wait-for-device - @for i in $$(seq 1 180); do \ - BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \ - if [ "$$BOOTED" = "1" ]; then \ - echo " ✓ booted in $${i}s"; \ - exit 0; \ - fi; \ - sleep 1; \ - done; \ - echo " ERROR: device did not boot within 180s." >&2; \ - echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \ - exit 1 - -qemu-build: - # EmbedAssembliesIntoApk=true: without this, the Debug APK ships - # without the managed assemblies in it (they are pushed at runtime - # via `adb push`, "Fast Deployment"). On the qemu emulator, the - # runtime cannot find them in `files/.__override__//` and - # aborts at startup with "No assemblies found in '.__override__'" - # (monodroid-glue.cc:757, SIGABRT). Forcing this property on - # packages the .dlls into the APK as `assemblies//` so the - # runtime reads them directly. - # - # The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`, - # not `AndroidEnableFastDeployment` (which exists in older - # templates but is a no-op in the .NET 10 SDK). - dotnet build $(POSTIT_ANDROID_CSPROJ) \ - -c $(CONFIG) \ - -p:RuntimeIdentifier=$(POSTIT_RID) \ - -p:EmbedAssembliesIntoApk=true \ - --nologo - @if [ ! -f "$(POSTIT_APK)" ]; then \ - echo " APK not found at $(POSTIT_APK)." >&2; \ - echo " Files in $(POSTIT_APK_DIR):" >&2; \ - ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \ - exit 1; \ - fi - - -qemu-install: qemu-build - @echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..." - adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r - -qemu-uninstall: - adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME) - -# Dump recent logcat output for the running PostIt.Android process. -# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with -# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. -# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag, -# because Mono/Xamarin can emit logs under several tags -# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering -# would miss the ones not matching. PID-based filtering is exact. -# If the app is not running, pidof returns empty and logcat exits -# silently with no output; that is the expected behaviour for -# "no logs yet". -qemu-logcat: - @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ - if [ -z "$$PID" ]; then \ - echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \ - echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \ - exit 1; \ - fi; \ - echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ - if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ - adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \ - else \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \ - fi - -# Clear logcat, launch PostIt.Android, then dump everything that was -# emitted during the startup window. Targets the "démarrage KO" case -# where the process starts but Avalonia never renders a frame — the -# logcat trace from process start to first frame is what diagnoses it. -# -# Override LOGCAT_BOOT_WAIT to extend the post-launch wait -# (default 15s; raise to 30+ if the device is slow to boot Avalonia). -LOGCAT_BOOT_WAIT ?= 15 -qemu-logcat-boot: - @echo " Clearing logcat buffer..." - adb -s $(ADB_SERIAL) logcat -c - @echo " Launching $(ANDROID_PACKAGE_NAME)..." - adb -s $(ADB_SERIAL) shell am start \ - -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity - @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." - @sleep $(LOGCAT_BOOT_WAIT) - - @echo " Dumping logcat (PostIt PID + system buffer):" - @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ - if [ -n "$$PID" ]; then \ - echo " ✅ (PID $$PID at dump time)"; \ - adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \ - else \ - echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ - exit 1; \ - fi - -qemu: qemu-run qemu-wait-boot qemu-install - @echo " ✓ PostIt.Android installed on $(ADB_SERIAL)" - -.PHONY: test release qemu qemu-run qemu-stop qemu-wait-boot qemu-build qemu-install qemu-logcat qemu-logcat-boot +.PHONY: test release diff --git a/README.md b/README.md index 8e630612..b132f3f2 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,10 @@ https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml # Statut actuel des actions GitHub -* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) +* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml) * [![Build and Push Yavsc Production Image](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml) -* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml) # Documentation diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 69e8a896..0d5fa50c 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -5,33 +5,35 @@ true + 12.1.1 - - - - - - - - - + + + + + + + + + - + - - - - - - - + + + + + + + + diff --git a/src/PostIt/Makefile b/src/PostIt/Makefile new file mode 100644 index 00000000..1217976b --- /dev/null +++ b/src/PostIt/Makefile @@ -0,0 +1,178 @@ + +# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu. +# +# Usage typique : +# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe +# make android-install # (re)build l'APK et l'installe (AVD doit tourner) +# make android-build # build l'APK seul (sans install) +# make qemu-run # démarre l'AVD en background +# make qemu-stop # arrête l'émulateur +# make qemu-wait-boot # attend que l'AVD ait fini de booter +# +# Variables surchargeables (make VAR=valeur) : +# AVD_NAME default: postit_test_avd +# (l'AVD doit être listé par `avdmanager list avd`) +# ADB_SERIAL default: emulator-5554 +# (port standard du premier émulateur lancé) +# ANDROID_HOME default: /opt/android-sdk +# (le SDK Android local; doit contenir +# emulator/emulator et platform-tools/adb) +# POSTIT_RID default: android-x64 +# (doit matcher l'ABI de l'AVD; `avdmanager list avd` +# affiche la ligne Tag/ABI) +# EMU_HEADLESS default: 0 +# (1 = lancer l'émulateur sans fenêtre, pour scripter) +# CONFIG surcharge la variable CONFIG globale (Debug par +# défaut dans ce Makefile). Passer à Release pour +# un APK optimisé et signé release. +# LOGCAT_LINES default: 200 +# (nombre de lignes dumpées par `make qemu-logcat`) +# LOGCAT_FOLLOW default: 0 +# (1 = stream live via `make logcat`, +# sinon dump one-shot des N dernières lignes) +# LOGCAT_BOOT_WAIT default: 30 +# (secondes d'attente entre le clear du buffer, +# le `am start`, et le dump final dans +# `make qemu-logcat-boot`) +AVD_NAME ?= postit_test_avd +ADB_SERIAL ?= emulator-5554 +ANDROID_HOME ?= /opt/android-sdk +POSTIT_RID ?= android-x64 +EMU_HEADLESS ?= 0 +LOGCAT_LINES ?= 600 +LOGCAT_FOLLOW ?= 0 +LOGCAT_BOOT_WAIT ?= 30 + +ANDROID_PACKAGE_NAME = fr.pschneider.postit +POSTIT_ANDROID_CSPROJ := PostIt.Android/PostIt.Android.csproj +POSTIT_APK_DIR := PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) +POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk + +clean: clean-PostIt clean-PostIt.Android clean-PostIt.Desktop + +clean-%: + rm -rf $*/obj $*/bin + +qemu-run: + @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." + @mkdir -p /tmp/yavsc-emu + @EMU_ARGS=""; \ + if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \ + $(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \ + >/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \ + echo " ✅ Started emulator PID: $$!" + +qemu-stop: + adb -s $(ADB_SERIAL) emu kill + echo " ✅ Stopped emulator" + +qemu-wait-boot: + @echo " Waiting for $(ADB_SERIAL) to finish booting..." + adb -s $(ADB_SERIAL) wait-for-device + @for i in $$(seq 1 180); do \ + BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \ + if [ "$$BOOTED" = "1" ]; then \ + echo " ✓ booted in $${i}s"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo " 👿 ERROR: device did not boot within 180s." >&2; \ + echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \ + exit 1 + +android-build: + # EmbedAssembliesIntoApk=true: without this, the Debug APK ships + # without the managed assemblies in it (they are pushed at runtime + # via `adb push`, "Fast Deployment"). On the qemu emulator, the + # runtime cannot find them in `files/.__override__//` and + # aborts at startup with "No assemblies found in '.__override__'" + # (monodroid-glue.cc:757, SIGABRT). Forcing this property on + # packages the .dlls into the APK as `assemblies//` so the + # runtime reads them directly. + # + # The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`, + # not `AndroidEnableFastDeployment` (which exists in older + # templates but is a no-op in the .NET 10 SDK). + dotnet build $(POSTIT_ANDROID_CSPROJ) \ + -c $(CONFIG) \ + -p:RuntimeIdentifier=$(POSTIT_RID) \ + -p:EmbedAssembliesIntoApk=true \ + --nologo + @if [ ! -f "$(POSTIT_APK)" ]; then \ + echo " APK not found at $(POSTIT_APK)." >&2; \ + echo " Files in $(POSTIT_APK_DIR):" >&2; \ + ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \ + exit 1; \ + fi + + +android-install: android-build + @echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..." + adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r + @echo " ✅ PostIt.Android installed on $(ADB_SERIAL)" + +qemu-uninstall: + adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME) + +# Dump recent logcat output for the running PostIt.Android process. +# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with +# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. +# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag, +# because Mono/Xamarin can emit logs under several tags +# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering +# would miss the ones not matching. PID-based filtering is exact. +# If the app is not running, pidof returns empty and logcat exits +# silently with no output; that is the expected behaviour for +# "no logs yet". +logcat: + @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ + if [ -z "$$PID" ]; then \ + echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \ + echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \ + exit 1; \ + fi; \ + echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ + if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ + adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \ + else \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \ + fi + +# Clear logcat, launch PostIt.Android, then dump everything that was +# emitted during the startup window. Targets the "démarrage KO" case +# where the process starts but Avalonia never renders a frame — the +# logcat trace from process start to first frame is what diagnoses it. +# +# Override LOGCAT_BOOT_WAIT to extend the post-launch wait +# (default 15s; raise to 30+ if the device is slow to boot Avalonia). +LOGCAT_BOOT_WAIT ?= 15 + + +android-start: + @echo " Clearing logcat buffer..." + adb -s $(ADB_SERIAL) logcat -c + @echo " Launching $(ANDROID_PACKAGE_NAME)..." + adb -s $(ADB_SERIAL) shell am start \ + -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity + @echo " ✅ $(ANDROID_PACKAGE_NAME) started on $(ADB_SERIAL)" + +qemu-logcat-boot: android-start + @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." + @sleep $(LOGCAT_BOOT_WAIT) + + @echo " Dumping logcat (PostIt PID + system buffer):" + @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ + if [ -n "$$PID" ]; then \ + echo " ✅ (PID $$PID at dump time)"; \ + sleep 10; \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID; \ + else \ + echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ + exit 1; \ + fi + +qemu: qemu-run qemu-wait-boot android-install + +.PHONY: clean qemu qemu-run qemu-stop qemu-wait-boot android-build android-install logcat qemu-logcat-boot diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 62c29e10..ad8455ef 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -1,8 +1,11 @@ -using Android.App; + +using Android.App; using Android.Content; using Android.Content.PM; -using Avalonia; +using AndroidX.Core.Provider; +using AndroidX.Emoji2.Text; using Avalonia.Android; +using PostIt.Droid.Services; namespace PostIt.Android; @@ -15,18 +18,22 @@ namespace PostIt.Android; ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { - /// - /// Strongly-typed handle to the current MainActivity instance, set in - /// and consumed by platform services such as - /// which need to launch - /// Chrome Custom Tabs. + /// + /// The current MainActivity instance. /// public static MainActivity? Current { get; private set; } protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState) { + FontRequest fontRequest = new FontRequest( + "com.google.android.gms.fonts", + "com.google.android.gms", + "Noto Color Emoji Compat", + Yavsc.Resource.Array.com_google_android_gms_fonts_certs); //com_google_android_gms_fonts_certs + EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest); + EmojiCompat.Init(config); + PlatformBootstrap.InitPlatform(); base.OnCreate(savedInstanceState); - PlatformBootstrap.EnsureInitialized(); Current = this; } /// @@ -41,7 +48,13 @@ public class MainActivity : AvaloniaMainActivity protected override void OnNewIntent(Intent? intent) { base.OnNewIntent(intent); - if (intent is not null) AndroidOidcCallbackSink.Handle(intent); + + var url = intent?.DataString; + if (!string.IsNullOrEmpty(url) && url.StartsWith("postit://callback")) + { + OidcCallbackManager.SetResult(url); + } + } internal static class AndroidOidcCallbackSink diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index d59b154f..f208f9ce 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -12,14 +12,9 @@ namespace PostIt.Android; /// internal static class PlatformBootstrap { - private static int _initialized; - - internal static void EnsureInitialized() + internal static void InitPlatform() { - if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) - return; - Platform.DefaultRedirectUri = ViewModels.Settings.AndroidRedirectUri; Platform.CreateBrowser = () => { var activity = MainActivity.Current; diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index a4af1eb2..f3b57e87 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -4,30 +4,27 @@ net10.0-android 23 enable - fr.pschneider.PostIt + fr.pschneider.postit 1 1.0 apk - false - SdkOnly - partial + false + 1.1.0.0 + 1.1.0.0 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 + 1.1.0-beta.1 - Resources\drawable\Icon.png - - - - - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 91b61d05..8793aae8 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -2,31 +2,5 @@ - - - - - - - - - - diff --git a/src/PostIt/PostIt.Android/Resources/values/font_certs.xml b/src/PostIt/PostIt.Android/Resources/values/font_certs.xml new file mode 100644 index 00000000..f4adce1b --- /dev/null +++ b/src/PostIt/PostIt.Android/Resources/values/font_certs.xml @@ -0,0 +1,13 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + MIIEqDCCA5CgAwIBAgIJAN5gc16AJfAsMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHR29vZ2xlMRAwDgYDVQQLEwdBbmRyb2lkMRAwDgYDVQQDEwdBbmRyb2lkMSEwHwYJKoZIhvcNAQkBFhJhbmRyb2lkQGFuZHJvaWQuY29tMCAXDTA4MDQxNTIyNDA0M1YYDzQyMDgxMzA0MjI0MDQzWjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDURvdW50YWluIFZpZXcxEDAOBgNVBAoTB0dvb2dsZTEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEhMB8GCSqGSIb3DQEJARYSYW5kcm9pZEBhbmRyb2lkLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALBi1vF0K1vOEHG7AxneTjOHUka46MIidBqvFcO164A49iU2DkYPhUaM4H8JCdzh6N1GzM6h9o6E2V6z8+gEtdI6nqqs0EGA0G0H701bFjLp9+K/1DkMIFeD4P8J7X1/M8t4+X09X/7bQyV3w0v7q+Qh38sY8W/7K29B3f2O2sLw+uX9U8a8Tf4Xv8A== + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZgXpYOMA0GCSqGSIb3DQEBBQUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKKvSkUIXm+t9M8rXj2V + + diff --git a/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs b/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs index b6716e22..bb10b364 100644 --- a/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs +++ b/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Android.App; using AndroidX.Browser.CustomTabs; using IdentityModel.OidcClient.Browser; +using PostIt.Droid.Services; namespace PostIt.Android.Services; @@ -35,9 +36,14 @@ public sealed class AndroidSystemBrowser : IBrowser }; } - var uri = global::Android.Net.Uri.Parse(options.StartUrl)!; + // 1. Enregistrez la tâche avant de lancer le Custom Tab + var callbackTask = OidcCallbackManager.RegisterCallback(cancellationToken); - var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync(); + // 2. LANCEZ VOTRE CUSTOM TAB ICI (via AndroidX.Browser.CustomTabs) + // ... code pour ouvrir l'URL d'authentification ... + + + var uri = global::Android.Net.Uri.Parse(options.StartUrl)!; var tabsIntent = new CustomTabsIntent.Builder() .SetShowTitle(true)! diff --git a/src/PostIt/PostIt.Android/Services/OidcCallbackManager.cs b/src/PostIt/PostIt.Android/Services/OidcCallbackManager.cs new file mode 100644 index 00000000..30f8fa18 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/OidcCallbackManager.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Droid.Services; + +public static class OidcCallbackManager +{ + private static TaskCompletionSource? _tcs; + + public static Task RegisterCallback(CancellationToken cancellationToken) + { + _tcs = new TaskCompletionSource(); + cancellationToken.Register(() => _tcs.TrySetCanceled()); + return _tcs.Task; + } + + public static void SetResult(string url) + { + _tcs?.TrySetResult(url); + } +} diff --git a/src/PostIt/PostIt.Android/WebAuthenticationCallbackActivity.cs b/src/PostIt/PostIt.Android/WebAuthenticationCallbackActivity.cs new file mode 100644 index 00000000..9ed2eb18 --- /dev/null +++ b/src/PostIt/PostIt.Android/WebAuthenticationCallbackActivity.cs @@ -0,0 +1,35 @@ +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using PostIt.Droid.Services; + +namespace PostIt.Android; + +[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)] +[IntentFilter(new[] { Intent.ActionView }, + Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, + DataScheme = "postit", // Remplacez par votre schéma personnalisé (ex: yavsc ou postit) + DataHost = "callback")] // Correspond à postit://callback +public class WebAuthenticationCallbackActivity : Activity +{ + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // Capturer l'URL de redirection OIDC + var url = Intent?.DataString; + + if (!string.IsNullOrEmpty(url)) + { + // Transmettre l'URL au gestionnaire partagé pour compléter la Task + OidcCallbackManager.SetResult(url); + } + + // Fermer cette activité transparente et ramener l'application au premier plan + var intent = new Intent(this, typeof(MainActivity)); + intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop); + StartActivity(intent); + Finish(); + } +} diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index 5202f397..96857947 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,7 +6,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -15,4 +15,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs deleted file mode 100644 index ff9ca7f6..00000000 --- a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs +++ /dev/null @@ -1,29 +0,0 @@ -using PostIt.Services; - -namespace PostIt.Desktop; - -/// -/// One-shot platform bootstrap. Called from Program.Main so that -/// the shared OIDC login path sees a working IBrowser — the -/// custom-scheme browser that hands the OIDC callback off to the -/// running instance through the named pipe. Desktop builds do NOT use -/// a loopback HTTP listener: the postit:// scheme is registered -/// with the OS at install time and the browser is whatever the user -/// has configured to open it. -/// -internal static class PlatformBootstrap -{ - private static int _initialized; - - internal static void EnsureInitialized() - { - if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) - return; - - // Use the custom-scheme redirect on Desktop. Loopback is only - // a fallback for platforms that cannot register postit:// - // (see Settings.DefaultLoopbackRedirectUri for that path). - Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri; - Platform.CustomScheme = "postit"; - } -} diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 4048c45e..0f6c5614 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,7 +7,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -24,4 +24,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index f22f79b0..0de3bd69 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -12,8 +12,6 @@ sealed class Program [STAThread] public static void Main(string[] args) { - PlatformBootstrap.EnsureInitialized(); - // Short-circuit 2nd-instance launches (OS handing us the // postit://callback URL) BEFORE Avalonia spins up a window. // If we let Avalonia initialise, the new MainWindow flashes diff --git a/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs b/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs index e2426457..8bec1dc6 100644 --- a/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs +++ b/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs @@ -73,7 +73,7 @@ public class AddCircleMemberDialogTests return context; } /// - /// Mount a real , build a minimal + /// Mount a real , build a minimal /// DI graph, push then the /// on top of it. /// Returns the stack size so the test can pin the delta. @@ -98,12 +98,9 @@ public class AddCircleMemberDialogTests services.AddTransient(); var sp = services.BuildServiceProvider(); - context.Window = new MainWindow(); + context.Window = new MainView(); context.App = (PostIt.App)Application.Current!; - context.App.DataTemplates.Clear(); - context.App.DataTemplates.Add(new ViewLocator(sp)); context.App.AttachMainWindow(context.Window); - context.Window.Show(); context.page = sp.GetRequiredService(); context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult(); diff --git a/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs b/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs index ac4756eb..d4980d26 100644 --- a/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs +++ b/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs @@ -14,7 +14,7 @@ namespace PostIt.Tests; /// public class AndroidAppLaunchTests { - private const string PackageName = "fr.pschneider.PostIt"; + private const string PackageName = "fr.pschneider.postit"; private readonly ITestOutputHelper _output; @@ -23,7 +23,8 @@ public class AndroidAppLaunchTests _output = output; } - // FIXME [Fact] + // https://twosixtech.com/blog/integrating-docker-and-adb/ + // FIXME ala hosted shared resource adb server - [Fact] public void PostIt_starts_and_draws_a_first_frame_on_the_emulator() { if (!IsPackageInstalledOnAnyDevice()) diff --git a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs index 3e7344a2..d1d00532 100644 --- a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs @@ -99,7 +99,7 @@ public class MainPageButtonsTests } /// - /// Mount a real (as + /// Mount a real (as /// SessionStatusBannerTests does), push a /// with the given VM onto /// NavRoot. PushAsync is awaited (via @@ -109,18 +109,12 @@ public class MainPageButtonsTests /// realised and KeyPressQwerty has a real /// to dispatch against. /// - private static (MainWindow window, MainPage page) MountMainPage(MainViewModel vm) + private static (MainView window, MainPage page) MountMainPage(MainViewModel vm) { - var window = new MainWindow(); + var window = new MainView(); var page = new MainPage { DataContext = vm }; var app = (PostIt.App)Application.Current!; - if (vm.Services is not null) - { - app.DataTemplates.Clear(); - app.DataTemplates.Add(new ViewLocator(vm.Services)); - } app.AttachMainWindow(window); - window.Show(); window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); return (window, page); } @@ -130,7 +124,7 @@ public class MainPageButtonsTests /// supported headless pattern (cf. CalculatorTests in the /// Avalonia.Samples repo). Returns the nav-stack count /// before the click so the caller can assert on the delta. - /// KeyPressQwerty is dispatched on the + /// KeyPressQwerty is dispatched on the /// itself — it is the that owns the /// headless implementation, and routing the key through any /// descendant TopLevel (e.g. one obtained via @@ -139,7 +133,7 @@ public class MainPageButtonsTests /// because the descendant does not carry the /// PlatformHandle the harness expects. /// - private static int ClickAndCapture(MainWindow window, Button button) + private static int ClickAndCapture(MainView window, Button button) { var stackBefore = window.NavRoot.NavigationStack.Count; button.Command?.Execute(button.CommandParameter); diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 5baae658..cef67f0f 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -81,7 +81,7 @@ public class MainPageSaveTests Assert.NotEmpty(recorder.Calls); var (method, path, body) = recorder.FirstCall; Assert.Equal(HttpMethod.Post, method); - Assert.Equal("blog", path); + Assert.Equal("blogspot", path); var sent = Assert.IsType(body); Assert.Equal(typed, sent.Title); } diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index 9bc28888..ccb33ec7 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -117,7 +117,7 @@ public class PostAclDialogTests /// rebinding the global DI mid-test would trample the /// Settings singleton the rest of the harness depends on. /// - private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount() + private static (MainView window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount() { var handler = new CountingHttpHandler(); var settings = new Settings(); @@ -138,12 +138,9 @@ public class PostAclDialogTests // CountingHttpHandler. GC.KeepAlive(sp); - var window = new MainWindow(); + var window = new MainView(); var app = (App)Application.Current!; - app.DataTemplates.Clear(); - app.DataTemplates.Add(new ViewLocator(sp)); app.AttachMainWindow(window); - window.Show(); return (window, aclClient, circleClient, handler); } diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index 433f36c3..f38bf43f 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,7 +8,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -16,9 +16,7 @@ - - @@ -28,6 +26,5 @@ - - - + + \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs index e1a5dd19..4d49b865 100644 --- a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs +++ b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs @@ -9,7 +9,7 @@ namespace PostIt.Tests; /// /// UI tests for . Mounted inside -/// a real via the headless Avalonia +/// a real via the headless Avalonia /// platform declared in TestApp.cs. /// /// The pattern is the one that UnitTest1.MainPage_Should_Load @@ -34,11 +34,10 @@ public class SessionStatusBannerTests [AvaloniaFact] public void Banner_renders_three_buttons_in_the_visual_tree() { - var window = new MainWindow(); - window.SessionBanner.DataContext = new SessionStatusViewModel(); + MainWindow window = new MainWindow(); window.Show(); - var buttons = window.SessionBanner.GetVisualDescendants() + var buttons = window.GetVisualDescendants() .OfType - public static string DefaultRedirectUri { get; set; } = "postit://callback"; + public const string RedirectUri = "postit://callback"; /// /// Scheme prefix the matches /// against BrowserOptions.EndUrl. Overridable for apps /// that want to register their own scheme. /// - public static string CustomScheme { get; set; } = "postit"; + public const string CustomScheme = "postit"; /// /// Constructs a fresh for the running platform. @@ -37,4 +37,4 @@ public static class Platform /// public static System.Func? CreateBrowser { get; set; } = () => new CustomSchemeBrowser(CustomScheme); -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/Services/UiDispatcher.cs b/src/PostIt/PostIt/Services/UiDispatcher.cs deleted file mode 100644 index e935ac1a..00000000 --- a/src/PostIt/PostIt/Services/UiDispatcher.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Threading.Tasks; -using Avalonia.Threading; - -namespace PostIt.Services; - -/// -/// Tiny marshalling helper around so -/// the rest of the codebase does not have to import Avalonia.Threading -/// directly. We want exactly one place that decides "is the current -/// thread the Avalonia UI thread, and if not, post there" so that -/// -derived types (Settings, the various -/// ViewModels) can fire PropertyChanged safely from background -/// work — which is exactly the cross-thread case that previously blew -/// up inside DataValidationErrors.SetErrors on Avalonia 11. -/// -/// The helper is intentionally tiny: a sync post when we are off the -/// UI thread, a no-op when we are already on it, and an async fire- -/// and-forget variant for places where awaiting would deadlock the -/// caller (e.g. Settings.Load continuation paths). -/// -public static class UiDispatcher -{ - /// - /// True when the calling thread is the Avalonia UI thread. Property - /// setters that touch bindings should check this before mutating - /// state; the safe path is . - /// - public static bool IsOnUiThread => Dispatcher.UIThread.CheckAccess(); - - /// - /// Run on the UI thread. If the caller is - /// already on the UI thread, run synchronously to preserve stack - /// traces and ordering; otherwise post to the dispatcher and wait. - /// Never throws on shutdown — a missing dispatcher is treated as - /// "best-effort skipped", matching Avalonia's own behaviour when - /// the application lifetime has been torn down. - /// - public static void InvokeIfNeeded(Action action) - { - if (action is null) return; - if (IsOnUiThread) { action(); return; } - try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); } - catch (InvalidOperationException) { /* dispatcher gone, nothing to do */ } - } - - /// - /// Fire-and-forget variant: schedules on - /// the UI thread but does not block the caller. Use this from - /// background workers (OIDC discovery, HTTP callbacks, file I/O) - /// where awaiting the dispatcher would deadlock the calling sync - /// context. - /// - public static void Post(Action action) - { - if (action is null) return; - try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); } - catch (InvalidOperationException) { /* dispatcher gone */ } - } - - /// - /// Awaitable variant. Useful inside async ViewModel methods - /// that must touch bindings only after the dispatcher has processed - /// a queued update (e.g. "load file then refresh observable state"). - /// - public static Task InvokeAsync(Action action) - { - if (action is null) return Task.CompletedTask; - if (IsOnUiThread) { action(); return Task.CompletedTask; } - return Dispatcher.UIThread.InvokeAsync(action, DispatcherPriority.Normal).GetTask(); - } -} diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 99358cfb..9ebeaebd 100644 --- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -10,7 +10,7 @@ public partial class AuthenticationSettings : ObservableObject /// hand-off in /// (RFC 8252 §7.1). Production Desktop builds use this. /// - public const string DefaultDesktopRedirectUri = "postit://callback"; + public const string DesktopRedirectUri = "postit://callback"; /// /// Redirect URI used by the Android app. The corresponding IntentFilter @@ -34,15 +34,19 @@ public partial class AuthenticationSettings : ObservableObject [ObservableProperty] public partial string[] Scopes { get; set; } - /// - /// OAuth redirect URI. Defaults to + /// OAuth redirect URI. Defaults to /// (custom URI scheme) which is the right answer for desktop /// production builds. Mobile platforms must set this to /// before calling LoginAsync. /// [ObservableProperty] - public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri; + public partial string RedirectUri { get; set; } +#if ANDROID + = AndroidRedirectUri; +#else + = DesktopRedirectUri; +#endif /// /// Space-separated view of . Exists for the diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 03ab98a1..3543a9c9 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -16,12 +16,6 @@ namespace PostIt; Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] public class ViewLocator : IDataTemplate { - private readonly IServiceProvider _services; - - public ViewLocator(IServiceProvider services) - { - _services = services; - } public Control Build(object? data) { @@ -38,15 +32,17 @@ public class ViewLocator : IDataTemplate private Control BuildCore(object? data) { + var app = App.Current as App; + var services = app!.ServiceProvider!; return data switch { - MainViewModel => _services.GetRequiredService(), - Settings => _services.GetRequiredService(), - HomePageViewModel => _services.GetRequiredService(), - SignaturePageViewModel => _services.GetRequiredService(), - AddCircleMemberDialogViewModel => _services.GetRequiredService(), - CirclesPageViewModel => _services.GetRequiredService(), - PostAclDialogViewModel => _services.GetRequiredService(), + MainViewModel => services.GetRequiredService(), + Settings => services.GetRequiredService(), + HomePageViewModel => services.GetRequiredService(), + SignaturePageViewModel => services.GetRequiredService(), + AddCircleMemberDialogViewModel => services.GetRequiredService(), + CirclesPageViewModel => services.GetRequiredService(), + PostAclDialogViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index 1aa3eee0..2b065cae 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -48,7 +48,7 @@ public partial class MainViewModel : ViewModelBase /// mutable field. Toggling is its own action. [ObservableProperty] public partial bool DraftIsPublished { get; set; } - + public bool IsLoaded { get; private set; } public Settings SettingsModel { get; } [ObservableProperty] @@ -72,6 +72,194 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] public partial Settings Settings { get; private set; } + [RelayCommand] + internal async Task RefreshAsync() + { + await ExecuteAsync(async () => + { + var posts = await BlogClient!.GetPostsAsync(); + Posts.Clear(); + foreach (var post in posts.OrderByDescending(p => p.DateModified)) + { + Posts.Add(post); + } + ApplyFilter(); + StatusMessage = $"Loaded {Posts.Count} posts."; + }); + } + + [RelayCommand] + internal async Task SearchAsync() { + await RefreshAsync(); + ApplyFilter(); + } + + [RelayCommand] + internal async Task SaveAsync() + { + // The button is already disabled when the title is empty + // (see CanSave), but the test path (and any programmatic + // ICommand.Execute) bypasses CanExecute, so we still + // guard here. Better to no-op with a status message + // than to send a request the server will reject. + if (string.IsNullOrWhiteSpace(DraftTitle)) + { + StatusMessage = "Title is required."; + return; + } + + await ExecuteAsync(async () => + { + // Build a fresh BlogPostDto from the editor buffer on + // every Save — we no longer mutate SelectedPost in + // place. The previous behaviour copied the buffer + // (which was a no-op when SelectedPost was null) + // back onto the model and relied on a + // [Required] violation to surface the missing + // input; the new shape keeps the editor buffer as + // the single source of truth for outgoing payloads + // and the selected post as a read-only hint for + // the update path. + if (SelectedPost is null || SelectedPost.Id == 0) + { + var draft = new BlogPostDto + { + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + IsPublished = DraftIsPublished + }; + var created = await BlogClient!.CreatePostAsync(draft); + if (created is not null) + { + SelectedPost = created; + StatusMessage = $"Created post {created.Id}."; + } + } + else + { + var update = new BlogPostDto + { + Id = SelectedPost.Id, + AuthorId = SelectedPost.AuthorId, + Photo = SelectedPost.Photo, + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = SelectedPost.DateCreated, + DateModified = DateTime.UtcNow, + }; + await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); + StatusMessage = $"Saved post {SelectedPost.Id}."; + } + + await RefreshPostsAsync(); + }); + } + + [RelayCommand] + internal async Task DeleteAsync() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Select an existing post before deleting."; + return; + } + + await ExecuteAsync(async () => + { + await BlogClient!.DeletePostAsync(SelectedPost.Id); + StatusMessage = $"Deleted post {SelectedPost.Id}."; + SelectedPost = null; + await RefreshPostsAsync(); + }); + } + + /// + /// Toggle the publication state of the currently selected + /// post. Pushes the new state to + /// PUT /api/BlogApi/{id}/publish and reflects it + /// locally in + the + /// selected post so the UI updates without a full + /// refresh. + /// + /// The toggle is its own action — separate from Save + /// — because Publish is not part of the + /// BlogPostDto payload. Bundling it into Save + /// would require a wire-shape change and a second server + /// overload; the dedicated endpoint keeps the wire + /// contract clean. + /// + [RelayCommand] + internal async Task TogglePublishAsync() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; + return; + } + + await ExecuteAsync(async () => + { + var desired = !DraftIsPublished; + await BlogClient!.SetPublishAsync(SelectedPost.Id, desired); + DraftIsPublished = desired; + // Mirror into the selected post so a subsequent + // RefreshPostsAsync() doesn't blow away the + // locally flipped state until the round-trip + // re-hydrates it. + SelectedPost.IsPublished = desired; + StatusMessage = desired + ? $"Billet {SelectedPost.Id} publié." + : $"Billet {SelectedPost.Id} remis en brouillon."; + }); + } + + /// + /// DEV ONLY: open the signature capture page. The production + /// entry point is a SignalR push from Yavsc.Org ("devis + /// received, sign here"); this command is the dev-time + /// shortcut to reach the page without that infrastructure. + /// Aligned on the same VM-first navigation pattern as + /// : the VM resolves the target VM + /// through , the ViewLocator picks + /// the matching Control at bind time. No + /// Click handler, no App.ServiceProvider + /// access from the view layer. + /// + [RelayCommand] + internal async Task OpenSignatureDevAsync() + { + await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); + } + + + [RelayCommand(CanExecute = nameof(CanManageAcl))] + public async Task ManageAclAsync() + { + if (SelectedPost is null) + { + StatusMessage = "Select an existing post before managing ACL."; + return; + } + await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); + } + + [RelayCommand] + public async Task OpenCirclesAsync() + { + var circlesVm = ResolveServices().GetRequiredService(); + await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); + } + + private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) + { + var sp = ResolveServices(); + var aclClient = sp.GetRequiredService(); + var circleClient = sp.GetRequiredService(); + return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); + } + /// /// API surface that hits the Yavsc.Blogs deployment at /// . Owned and constructed by @@ -130,17 +318,18 @@ public partial class MainViewModel : ViewModelBase private void Init(Settings? settings) { - SearchText = string.Empty; Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); SelectedPost = null; IsBusy = false; StatusMessage = "Ready"; Settings = settings ?? new Settings(); + SearchText = Settings.SearchText; WindowTitle = "PostIt"; DraftTitle = string.Empty; DraftArticle = string.Empty; DraftIsPublished = false; + IsLoaded = false; // Production path: DI injects the canonical Settings singleton // and we use it as-is. Test path: tests call this constructor // without a Settings argument; we fall back to a fresh @@ -152,6 +341,15 @@ public partial class MainViewModel : ViewModelBase // (thread-safe dispatcher marshalling) so the duplicate // instance is now merely wasteful, not dangerous. + Settings.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(Settings.SearchText)) + { + SearchText = Settings.SearchText; + ApplyFilter(); + } + }; + } /// Save is enabled as soon as the user has typed @@ -179,7 +377,14 @@ public partial class MainViewModel : ViewModelBase Init(settings); } - partial void OnSearchTextChanged(string value) => ApplyFilter(); + partial void OnSearchTextChanged(string value) + { + if (Settings is not null && Settings.SearchText != value) + { + Settings.SearchText = value; + } + ApplyFilter(); + } partial void OnSelectedPostChanged(BlogPostDto? value) { @@ -206,170 +411,6 @@ public partial class MainViewModel : ViewModelBase partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); - [RelayCommand] - internal async Task LoadPosts() - { - await ExecuteAsync(async () => - { - var posts = await BlogClient!.GetPostsAsync(); - Posts.Clear(); - foreach (var post in posts.OrderByDescending(p => p.DateModified)) - { - Posts.Add(post); - } - ApplyFilter(); - StatusMessage = $"Loaded {Posts.Count} posts."; - }); - } - - [RelayCommand] - internal void Search() => ApplyFilter(); - - [RelayCommand] - internal async Task Save() - { - // The button is already disabled when the title is empty - // (see CanSave), but the test path (and any programmatic - // ICommand.Execute) bypasses CanExecute, so we still - // guard here. Better to no-op with a status message - // than to send a request the server will reject. - if (string.IsNullOrWhiteSpace(DraftTitle)) - { - StatusMessage = "Title is required."; - return; - } - - await ExecuteAsync(async () => - { - // Build a fresh BlogPostDto from the editor buffer on - // every Save — we no longer mutate SelectedPost in - // place. The previous behaviour copied the buffer - // (which was a no-op when SelectedPost was null) - // back onto the model and relied on a - // [Required] violation to surface the missing - // input; the new shape keeps the editor buffer as - // the single source of truth for outgoing payloads - // and the selected post as a read-only hint for - // the update path. - if (SelectedPost is null || SelectedPost.Id == 0) - { - var draft = new BlogPostDto - { - Title = DraftTitle, - Article = DraftArticle ?? string.Empty, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - }; - var created = await BlogClient!.CreatePostAsync(draft); - if (created is not null) - { - SelectedPost = created; - StatusMessage = $"Created post {created.Id}."; - } - } - else - { - var update = new BlogPostDto - { - Id = SelectedPost.Id, - AuthorId = SelectedPost.AuthorId, - Photo = SelectedPost.Photo, - Title = DraftTitle, - Article = DraftArticle ?? string.Empty, - DateCreated = SelectedPost.DateCreated, - DateModified = DateTime.UtcNow, - }; - await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); - StatusMessage = $"Saved post {SelectedPost.Id}."; - } - - await RefreshPostsAsync(); - }); - } - - [RelayCommand] - internal async Task Delete() - { - if (SelectedPost is null || SelectedPost.Id == 0) - { - StatusMessage = "Select an existing post before deleting."; - return; - } - - await ExecuteAsync(async () => - { - await BlogClient!.DeletePostAsync(SelectedPost.Id); - StatusMessage = $"Deleted post {SelectedPost.Id}."; - SelectedPost = null; - await RefreshPostsAsync(); - }); - } - - /// - /// Toggle the publication state of the currently selected - /// post. Pushes the new state to - /// PUT /api/BlogApi/{id}/publish and reflects it - /// locally in + the - /// selected post so the UI updates without a full - /// refresh. - /// - /// The toggle is its own action — separate from Save - /// — because Publish is not part of the - /// BlogPostDto payload. Bundling it into Save - /// would require a wire-shape change and a second server - /// overload; the dedicated endpoint keeps the wire - /// contract clean. - /// - [RelayCommand] - internal async Task TogglePublish() - { - if (SelectedPost is null || SelectedPost.Id == 0) - { - StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; - return; - } - - await ExecuteAsync(async () => - { - var desired = !DraftIsPublished; - await BlogClient!.SetPublishAsync(SelectedPost.Id, desired); - DraftIsPublished = desired; - // Mirror into the selected post so a subsequent - // RefreshPostsAsync() doesn't blow away the - // locally flipped state until the round-trip - // re-hydrates it. - SelectedPost.IsPublished = desired; - StatusMessage = desired - ? $"Billet {SelectedPost.Id} publié." - : $"Billet {SelectedPost.Id} remis en brouillon."; - }); - } - - /// - /// DEV ONLY: open the signature capture page. The production - /// entry point is a SignalR push from Yavsc.Org ("devis - /// received, sign here"); this command is the dev-time - /// shortcut to reach the page without that infrastructure. - /// Aligned on the same VM-first navigation pattern as - /// : the VM resolves the target VM - /// through , the ViewLocator picks - /// the matching Control at bind time. No - /// Click handler, no App.ServiceProvider - /// access from the view layer. - /// - [RelayCommand] - internal async Task OpenSignatureDev() - { - await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); - } - - private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) - { - var sp = ResolveServices(); - var aclClient = sp.GetRequiredService(); - var circleClient = sp.GetRequiredService(); - return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); - } private async Task RefreshPostsAsync() { @@ -426,28 +467,18 @@ public partial class MainViewModel : ViewModelBase private void UpdateCommandStates() { - LoadPostsCommand.NotifyCanExecuteChanged(); + RefreshCommand.NotifyCanExecuteChanged(); SaveCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged(); } - - [RelayCommand(CanExecute = nameof(CanManageAcl))] - public async Task ManageAcl() + internal async Task InitializeAsync() { - if (SelectedPost is null) + if (!IsLoaded) { - StatusMessage = "Select an existing post before managing ACL."; - return; + await RefreshAsync(); + IsLoaded = true; } - await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); - } - - [RelayCommand] - public async Task OpenCircles() - { - var circlesVm = ResolveServices().GetRequiredService(); - await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); } } diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs index 2115a0cd..8586fa34 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -2,13 +2,11 @@ using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using IdentityModel.OidcClient; -using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Text.Json; -using System.Threading; [assembly: InternalsVisibleTo("PostIt.Tests")] @@ -18,37 +16,6 @@ public partial class Settings : ViewModelBase { const string SettingsFileName = "postit-settings.json"; - /// - /// Redirect URI used by the Android app. The corresponding IntentFilter - /// in PostIt.Android/Properties/AndroidManifest.xml must match. - /// - public const string AndroidRedirectUri = "android://postit-signin"; - - - - /// - /// Process-wide canonical instance, wired up - /// at application boot by - /// through . The hybrid pattern: - /// - /// The static Current reference gives - /// ViewModels a non-DI way to reach the same instance (and lets - /// the framework bindings push notifications through one stable - /// ). - /// Tests that want to exercise a clean - /// instance still call new Settings(); Current - /// stays null in those contexts because - /// is never invoked. - /// Reads () are - /// thread-safe and never allocate; mutations always go through - /// the DI-resolved singleton so two threads cannot each register - /// a different "current" Settings. - /// - /// - private static Settings? s_current; - - - [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); @@ -61,12 +28,15 @@ public partial class Settings : ViewModelBase [ObservableProperty] public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/"; + [ObservableProperty] + public partial string SearchText { get; set; } = string.Empty; + /// /// Catch top-level mutations: the four ObservableProperty /// setters above all funnel through here, and we flip /// in lock-step. Sub-property mutations /// (e.g. Authentication.Authority) are caught by the - /// subscription wired up in + /// subscription wired up in /// below. disables the flag during bulk /// hydration so the disk load itself does not count as a user /// edit. @@ -76,6 +46,7 @@ public partial class Settings : ViewModelBase partial void OnDarkModeChanged(bool value) => MarkDirty(); partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnBusinessApiUrlChanged(string value) => MarkDirty(); + partial void OnSearchTextChanged(string value) => MarkDirty(); /// /// Authentication can be reassigned wholesale by @@ -328,6 +299,7 @@ public partial class Settings : ViewModelBase { this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; + this.SearchText = settings.SearchText ?? string.Empty; if (!(settings.Authentication is null)) { this.Authentication = new AuthenticationSettings(); @@ -336,7 +308,7 @@ public partial class Settings : ViewModelBase this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ? AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId; this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ? - AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri; + AuthenticationSettings.DesktopRedirectUri : settings.Authentication.RedirectUri; if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0) { settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes; @@ -377,10 +349,11 @@ public partial class Settings : ViewModelBase { Authority = AuthenticationSettings.DefaultAuthority, ClientId = AuthenticationSettings.DefaultClientId, - RedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri, + RedirectUri = AuthenticationSettings.DesktopRedirectUri, Scopes = AuthenticationSettings.DefaultScopes }; this.DarkMode = false; + this.SearchText = string.Empty; } /// diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 7f1f1196..0d2f5411 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -29,15 +29,15 @@ VerticalAlignment="Top"> - public sealed class BlogApiClient { - private const string DefaultPathPrefix = "blog"; + private const string DefaultPathPrefix = "blogspot"; private readonly IYavscApiClient _api; private readonly Uri _baseAddress; diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj index 00e6e2db..db7aa466 100644 --- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -17,13 +17,10 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 - - - - + \ No newline at end of file diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj index d9a814f6..0f8054e4 100644 --- a/src/Yavsc.Api/Yavsc.Api.csproj +++ b/src/Yavsc.Api/Yavsc.Api.csproj @@ -7,14 +7,11 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 - - - - + \ No newline at end of file diff --git a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs index 92dd3b2c..4aef805f 100644 --- a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs @@ -1,10 +1,12 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Yavsc.Abstract.BlogSpot; using Yavsc.Models; using Yavsc.Models.Access; +using Yavsc.Models.Blog; using Yavsc.Tests.Shared; using static Yavsc.Constants; @@ -38,15 +40,16 @@ public sealed class BlogAclApiTests : IClassFixture { private readonly BlogsWebServerFixture _fixture; - public BlogAclApiTests(BlogsWebServerFixture fixture) { _fixture = fixture; } + private string BlogUrl() + => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}"; private string BlogAclUrl() - => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl"; + => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}"; /// Delete any ACL rows tied to the fixture's seeded /// (CircleId, BlogPostId) pair. The shared SQLite store @@ -120,7 +123,7 @@ public sealed class BlogAclApiTests : IClassFixture // owned by the caller. We seed the same shape pre-POST so the // test reproduces the prod scenario end-to-end. CleanupAcl(); - using var http = NewClient("alice"); + using var http = NewClient(_fixture.DefaultUserLogin); var payload = new PostAccessControlRulePayload { @@ -128,7 +131,8 @@ public sealed class BlogAclApiTests : IClassFixture BlogPostId = _fixture.PostId }; - var response = await http.PostAsJsonAsync(BlogAclUrl(), payload, + var response = await http.PostAsJsonAsync( + BlogAclUrl(), payload, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); @@ -178,7 +182,7 @@ public sealed class BlogAclApiTests : IClassFixture [MemberData(nameof(BlogAclPayloadsForNever500))] public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload) { - using var http = NewClient("alice"); + using var http = NewClient(_fixture.DefaultUserLogin); var response = await http.PostAsJsonAsync( BlogAclUrl(), payload, @@ -216,4 +220,83 @@ public sealed class BlogAclApiTests : IClassFixture ); } + + [Fact] + public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list() + { + CleanupAcl(); + _fixture.SeedUser(_fixture.DefaultUserLogin); + _fixture.SeedUser("tester"); + _fixture.SeedCircle(_fixture.DefaultUserLogin, "test", + false, + new String[] + { + _fixture.DefaultUserLogin, + "tester" + }); + using var http = NewClient(_fixture.DefaultUserLogin ); + + // Create a minimal BlogPost. The server assigns Id, so we + // send 0 + an explicit AuthorId; the production + // BlogSpotService.Create() tolerates that. + var draft = new BlogPost + { + Id = 0, + Title = "Premier billet", + AuthorId = "tester", + Article = "Contenu de test.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + ACL = new List( + new CircleAuthorizationToBlogPost[] + { + new CircleAuthorizationToBlogPost + { + CircleId = _fixture.CircleId, + BlogPostId = _fixture.PostId + } + } + ) + }; + + var postResponse = await http.PostAsJsonAsync( + BlogUrl(), + draft, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + // The POST returns the server-issued post (with a real Id). + var created = await postResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken + ); + Assert.NotNull(created); + Assert.NotEqual(0, created!.Id); + Assert.Equal(draft.Title, created.Title); + + // The list should now contain exactly one entry. + var listResponse = await http.GetAsync( + BlogUrl(), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync( + TestContext.Current.CancellationToken + )); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); + + // detail should return the same post, with ACL and tags. + var detailResponse = await http.GetAsync( + $"{BlogUrl()}/{created.Id}", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync( + TestContext.Current.CancellationToken + )); + Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind); + Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64()); + Assert.Equal(1, detailDoc.RootElement.GetProperty("acl").GetArrayLength()); + } + } diff --git a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs index f4878860..00e2ea61 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs @@ -8,11 +8,13 @@ using Microsoft.IdentityModel.Tokens; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Tests.Shared; +using Yavsc.Blogs.Tests.Fixtures; namespace Yavsc.Blogs.Tests; [Collection("JwtClaimMapping")] -public sealed class BlogApiMappedClaimsTests : IClassFixture +public sealed class BlogApiMappedClaimsTests : +IClassFixture { private readonly MappedClaimsBlogsWebServerFixture _fixture; @@ -79,7 +81,10 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture(TestContext.Current.CancellationToken); @@ -93,7 +98,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture(TestContext.Current.CancellationToken); Assert.NotNull(created); - var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost + var updateResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost { Id = created.Id, Title = "Billet modifié", @@ -126,7 +131,7 @@ public sealed class BlogApiMappedClaimsTests : IClassFixture _fixture = fixture; } - /// Reset the in-memory database to a known empty state. - /// UseInMemoryDatabase shares its store across the - /// lifetime of the instance, - /// so without a per-test reset the test order would leak - /// state between tests. - private void ResetDatabase() - { - using var scope = _fixture.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - } /// Reset the database and seed the /// tester row. Required @@ -55,18 +43,10 @@ public sealed class BlogApiTests : IClassFixture /// at SaveChanges and the controller returns 500. private void ResetAndSeedDefaultUser() { - ResetDatabase(); + _fixture.ResetDatabase(); _fixture.SeedUser("tester"); } - /// The fixture's WebApplication is bound to - /// https://localhost:<random> via - /// . We pick the first - /// https URL and append the controller route - /// (/api/v1/blog, matching the production - /// [Route(APIPrefix + "/blog")]). - private string BlogsUrl => - _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; /// Build an authenticated client: a real /// Authorization: Bearer <jwt> header where the JWT @@ -111,10 +91,11 @@ public sealed class BlogApiTests : IClassFixture [Fact] public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() { - ResetDatabase(); + _fixture.ResetDatabase(); using var http = NewClient(); - var response = await http.GetAsync("/api/v1/blog", + var response = await http.GetAsync( + _fixture.BlogSpotUrl(), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -147,7 +128,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, + var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -160,7 +141,7 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(draft.Title, created.Title); // The list should now contain exactly one entry. - var listResponse = await http.GetAsync("/api/v1/blog", + var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); @@ -188,7 +169,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, + var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -198,7 +179,7 @@ public sealed class BlogApiTests : IClassFixture Assert.NotNull(created); Assert.Equal("tester", created!.AuthorId); - var listResponse = await http.GetAsync("/api/v1/blog", + var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); @@ -226,7 +207,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, + var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -266,7 +247,7 @@ public sealed class BlogApiTests : IClassFixture [Fact] public async Task GetBlog_returns_401_when_no_token_is_provided() { - ResetDatabase(); + _fixture.ResetDatabase(); using var http = NewAnonymousClient(); // No Authorization header → the JwtBearer middleware @@ -275,7 +256,7 @@ public sealed class BlogApiTests : IClassFixture // the framework returns 401. This is the proof that the // production policy is wired in the test host and not // short-circuited by a test-only auth bypass. - var response = await http.GetAsync("/api/v1/blog", + var response = await http.GetAsync(_fixture.BlogSpotUrl(), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -303,7 +284,7 @@ public sealed class BlogApiTests : IClassFixture DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, + var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -322,13 +303,13 @@ public sealed class BlogApiTests : IClassFixture DateCreated = created.DateCreated, DateModified = DateTime.UtcNow }; - var putResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created.Id}", + var putResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl()+$"/{created.Id}", update, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode); // The list should now reflect the new title. - var listResponse = await http.GetAsync("/api/v1/blog", + var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); using var doc = JsonDocument.Parse( @@ -356,19 +337,19 @@ public sealed class BlogApiTests : IClassFixture DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, + var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, TestContext.Current.CancellationToken); var created = (await postResponse.Content.ReadFromJsonAsync( TestContext.Current.CancellationToken ))!; - var deleteResponse = await http.DeleteAsync($"/api/v1/blog/{created.Id}", + var deleteResponse = await http.DeleteAsync(_fixture.BlogSpotUrl()+$"/{created.Id}", TestContext.Current.CancellationToken ); Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); // The list should now be empty. - var listResponse = await http.GetAsync("/api/v1/blog", + var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), TestContext.Current.CancellationToken); String response = await listResponse.Content.ReadAsStringAsync( TestContext.Current.CancellationToken @@ -411,7 +392,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var response = await http.PostAsJsonAsync("/api/v1/blog", draft, + var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, TestContext.Current.CancellationToken); // Dump the body on failure so the test name + the response @@ -443,7 +424,7 @@ public sealed class BlogApiTests : IClassFixture // behaviour so a future change that, say, makes Title // nullable in the model or drops [Required], triggers a // conscious update of the test (and probably of the VM). - ResetDatabase(); + _fixture.ResetDatabase(); using var http = NewClient(subject: "tester"); var draft = new BlogPost @@ -456,7 +437,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var response = await http.PostAsJsonAsync("/api/v1/blog", draft, + var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, TestContext.Current.CancellationToken); if (response.StatusCode != HttpStatusCode.BadRequest) diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs similarity index 90% rename from src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs rename to src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs index 218904ef..6e8ae31f 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs @@ -10,7 +10,7 @@ using Yavsc.Models.Blog; using Yavsc.Models.Relationship; using Yavsc.Services; using Yavsc.Tests.Shared; - +using static Yavsc.Constants; namespace Yavsc.Blogs.Tests; /// @@ -61,6 +61,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture public long CircleId { get; private set; } public long PostId { get; private set; } + public string DefaultUserLogin { get => "alice"; } // A single SqliteConnection held open at the static level, // mirroring how Yavsc.Org.Tests.WebServerFixture hoists its @@ -169,7 +170,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture // PermissionHandler ownership check sees a null // user id and rejects every PUT. options.MapInboundClaims = false; - options.TokenValidationParameters = new TokenValidationParameters + options.TokenValidationParameters + = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TestTokenIssuer.Issuer, @@ -263,6 +265,27 @@ public sealed class BlogsWebServerFixture : WebHostFixture await Task.CompletedTask; return app; } +/// Reset the in-memory database to a known empty state. + /// UseInMemoryDatabase shares its store across the + /// lifetime of the instance, + /// so without a per-test reset the test order would leak + /// state between tests. + public void ResetDatabase() + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } + public void CleanupAcl() + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.CircleAuthorizationToBlogPost + .Where(a => a.CircleId == CircleId + && a.BlogPostId == PostId) + .ExecuteDelete(); + } public override void Dispose() { @@ -310,7 +333,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// Optional hook to fill in fields /// like FullName / Avatar / EmailConfirmed /// that downstream tests assert on. - public ApplicationUser SeedUser(string userName, Action? configure = null) + public ApplicationUser SeedUser(string userName, + Action? configure = null) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -351,20 +375,31 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// Create a circle owned by /// directly in the SQLite store and return its server-assigned /// id. - private long SeedCircle(string ownerId, string name, bool isPublic = false) + public long SeedCircle(string ownerId, string name, bool isPublic = false, + ICollection members = null + ) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic }; db.Circle.Add(circle); db.SaveChanges(); + if (members != null && members.Count > 0) + { + foreach (String memberId in members) + { + var member = new CircleMember { CircleId = circle.Id, MemberId = memberId }; + db.CircleMembers.Add(member); + } + db.SaveChanges(); + } return circle.Id; } /// Create a blog post owned by /// directly in the SQLite store and return its server-assigned /// id. - private long SeedBlogPost(string authorId, string title) + public long SeedBlogPost(string authorId, string title) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -380,4 +415,5 @@ public sealed class BlogsWebServerFixture : WebHostFixture db.SaveChanges(); return post.Id; } + } diff --git a/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs similarity index 97% rename from src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs rename to src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs index 127f38fe..7311ce04 100644 --- a/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs @@ -21,11 +21,11 @@ namespace Yavsc.Blogs.Tests; /// This is the closest in-process reproduction of the production /// authentication surface for the blog API. /// -public sealed class MappedClaimsBlogsWebServerFixture : IDisposable +public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFixture { private readonly InMemoryDatabaseRoot _inMemoryRoot = new(); private readonly Dictionary _savedInboundMap; - private readonly WebApplication _app; + private WebApplication? _app = null; public MappedClaimsBlogsWebServerFixture() { @@ -86,6 +86,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable public void Dispose() { + if (_app is null) return; _app.StopAsync().GetAwaiter().GetResult(); _app.DisposeAsync().AsTask().GetAwaiter().GetResult(); @@ -96,6 +97,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable } } + private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager { public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) diff --git a/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs b/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs index 7d5a02a9..113707ca 100644 --- a/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs +++ b/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Tests.Shared; +using Yavsc.Blogs.Tests.Fixtures; namespace Yavsc.Blogs.Tests; @@ -71,12 +72,6 @@ public sealed class PublishEndpointTests : IClassFixture return post.Id; } - private string PublishUrl(long id) - => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/v1/blog/{id}/publish"; - - private string BlogsUrl - => _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; - private HttpClient NewClient(string subject) { var handler = new HttpClientHandler @@ -100,12 +95,13 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("alice"); - var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); - var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken); + var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, get.StatusCode); using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal($"post-by-alice", doc.RootElement.GetProperty("title").GetString()); Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean()); } @@ -116,11 +112,12 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("alice"); - await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); - var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken); + await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); - var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken); + var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, get.StatusCode); using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean()); } @@ -130,7 +127,7 @@ public sealed class PublishEndpointTests : IClassFixture { ResetDatabase(); using var http = NewClient("alice"); - var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(_fixture.PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, put.StatusCode); } @@ -141,7 +138,7 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("bob"); - var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); // 401 Challenge (the controller returns Challenge() // for AuthorizationFailureException). The exact code // is framework-dependent; what matters is "not 204". diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index 83c0dc37..96616e99 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -9,7 +9,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -32,7 +32,4 @@ - - - - + \ No newline at end of file diff --git a/src/Yavsc.Blogs/Constants.cs b/src/Yavsc.Blogs/Constants.cs index 3e499da4..9d400032 100644 --- a/src/Yavsc.Blogs/Constants.cs +++ b/src/Yavsc.Blogs/Constants.cs @@ -1,6 +1,6 @@ namespace Yavsc.Blogs; -public static class Constants +public static class BlogConstants { public const string AdminRole = "Admin"; public const string ModeratorRole = "Moderator"; diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index fcd3a336..8c76f7ee 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -9,7 +9,7 @@ namespace Yavsc.Blogs.Controllers { [Authorize("BlogScope")] [Produces("application/json")] - [Route(APIPrefix + "/blog")] + [Route(APIPrefix + "/" + BlogSpotPath)] public class BlogApiController : Controller { private readonly BlogSpotService blogSpotService; @@ -19,14 +19,14 @@ namespace Yavsc.Blogs.Controllers this.blogSpotService = blogSpotService; } - // GET: api/BlogApi + // GET: api/v1/blogspot [HttpGet] public async Task> GetBlogspot(int start = 0, int take = 25) { return await blogSpotService.Index(User, null, start, take); } - // GET: api/BlogApi/5 + // GET: api/v1/blogspot/5 [HttpGet("{id}", Name = "GetBlog")] public async Task GetBlog([FromRoute] long id) { @@ -43,7 +43,7 @@ namespace Yavsc.Blogs.Controllers return NotFound(); } - return Ok(blog); + return Ok(blog.GetPayload()); } catch (AuthorizationFailureException) { @@ -51,7 +51,7 @@ namespace Yavsc.Blogs.Controllers } } - // PUT: api/BlogApi/5 + // PUT: api/v1/blogspot/5 [HttpPut("{id}")] public async Task PutBlog(long id, [FromBody] Models.Blog.BlogPost blog) { @@ -83,7 +83,7 @@ namespace Yavsc.Blogs.Controllers return new StatusCodeResult(StatusCodes.Status204NoContent); } - // POST: api/v1/blog + // POST: api/v1/blogspot [HttpPost] public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) { @@ -116,7 +116,8 @@ namespace Yavsc.Blogs.Controllers : (IFormFileCollection)new FormFileCollection(); var uid = User.GetUserId(); var post = blogSpotService.Create(uid, blog, files); - return CreatedAtRoute("GetBlog", new { id = post.Id }, post); + return CreatedAtRoute("GetBlog", new { id = post.Id }, + post.GetPayload()); } // DELETE: api/BlogApi/5 @@ -135,7 +136,7 @@ namespace Yavsc.Blogs.Controllers } await blogSpotService.Delete(User, id); - return Ok(blog); + return Ok(blog.GetPayload()); } /// diff --git a/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs b/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs index ad6a0893..533e5594 100644 --- a/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs @@ -6,7 +6,7 @@ using static Yavsc.Constants; namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route(APIPrefix + "/blogtags")] + [Route(APIPrefix + "/" + BlogTagPath )] public class BlogTagsApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs index 524f0471..fafd00ac 100644 --- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -8,7 +8,7 @@ using static Yavsc.Constants; namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route(APIPrefix +"/circle")] + [Route(APIPrefix +"/" + CirclePath)] public class CircleApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Controllers/CommentsApiController.cs b/src/Yavsc.Blogs/Controllers/CommentsApiController.cs index d4c80f99..d0747c29 100644 --- a/src/Yavsc.Blogs/Controllers/CommentsApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CommentsApiController.cs @@ -11,7 +11,7 @@ namespace Yavsc.Blogs.Controllers { [Authorize] [Produces("application/json")] - [Route(APIPrefix + "/blogcomments")] + [Route(APIPrefix + "/" + CommentsPath)] public class CommentsApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 63b3d977..7f262340 100644 --- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj +++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj @@ -4,18 +4,15 @@ enable 1c73094f-959f-4211-b1a1-6a69b236c283 Yavsc.Blogs - https://github.com/pazof/yavsc + https://forgejo.pschneider.fr/notazof/yavsc true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 - - - - + \ No newline at end of file diff --git a/src/Yavsc.Org.Tests/Directory.Packages.props b/src/Yavsc.Org.Tests/Directory.Packages.props index b267eafe..5927d2b5 100644 --- a/src/Yavsc.Org.Tests/Directory.Packages.props +++ b/src/Yavsc.Org.Tests/Directory.Packages.props @@ -7,7 +7,5 @@ - - diff --git a/src/Yavsc.Org.Tests/NonRegression/EMailling.cs b/src/Yavsc.Org.Tests/NonRegression/EMailling.cs index 743dbbc1..5b1b33e9 100644 --- a/src/Yavsc.Org.Tests/NonRegression/EMailling.cs +++ b/src/Yavsc.Org.Tests/NonRegression/EMailling.cs @@ -55,5 +55,6 @@ namespace Yavsc.Org.Tests client.Calls.Select(c => c.Kind).ToArray()); Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address); } + } } diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index d332d250..8be07946 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -11,7 +11,7 @@ $(MSBuildProjectDirectory)\test.runsettings 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -86,7 +86,4 @@ - - - - + \ No newline at end of file diff --git a/src/Yavsc.Org/Directory.Packages.props b/src/Yavsc.Org/Directory.Packages.props index fd16374b..d9925e02 100644 --- a/src/Yavsc.Org/Directory.Packages.props +++ b/src/Yavsc.Org/Directory.Packages.props @@ -20,6 +20,5 @@ - diff --git a/src/Yavsc.Org/Views/Blogspot/Index.cshtml b/src/Yavsc.Org/Views/Blogspot/Index.cshtml index 52cf3b88..1582041c 100644 --- a/src/Yavsc.Org/Views/Blogspot/Index.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Index.cshtml @@ -70,7 +70,7 @@
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded) { - Details + Details } else { diff --git a/src/Yavsc.Org/Views/Home/About.pt.cshtml b/src/Yavsc.Org/Views/Home/About.pt.cshtml index cca60a94..487a1ab3 100755 --- a/src/Yavsc.Org/Views/Home/About.pt.cshtml +++ b/src/Yavsc.Org/Views/Home/About.pt.cshtml @@ -93,8 +93,8 @@ A operação é anulável até duas semanas após a sua programação. Este é o meu site perso, uma configuração de _Yavsc_ (outro negócio muito pequeno). -* [README](https://github.com/pazof/yavsc/blob/vnext/README.md) -* [licença: GNU GPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE) +* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) +* [licença: GNU GPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) Outras instalações: @@ -109,8 +109,8 @@ Outras instalações: Yet Another Very Small Company ... -* [README](https://github.com/pazof/yavsc/blob/vnext/README.md) -* [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE) +* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) +* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) @@ -118,8 +118,8 @@ Outras instalações: ## Yet Another Very Small Company : -* [README](https://github.com/pazof/yavsc/blob/vnext/README.md) -* [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE) +* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) +* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) En production: diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index 0403c7eb..d4ae0787 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -9,7 +9,7 @@ https://github.com/pazof/yavsc 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -51,7 +51,4 @@ - - - - + \ No newline at end of file diff --git a/src/Yavsc.Server/Helpers/PayloadHelpers.cs b/src/Yavsc.Server/Helpers/PayloadHelpers.cs new file mode 100644 index 00000000..cd359bb4 --- /dev/null +++ b/src/Yavsc.Server/Helpers/PayloadHelpers.cs @@ -0,0 +1,22 @@ +using Yavsc.Models.Blog; + +public static class PayloadHelpers +{ + public static object GetPayload(this BlogPost post) + { + return new + { + post.Id, + post.Title, + post.Article, + post.DateCreated, + post.UserCreated, + post.DateModified, + post.UserModified, + post.AuthorId, + ACL = post.GetACL(), + Tags = post.GetTags(), + post.IsPublished + }; + } +} diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs index 5a2dc58d..e5fd615d 100644 --- a/src/Yavsc.Server/Models/Blog/BlogPost.cs +++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs @@ -85,7 +85,7 @@ namespace Yavsc.Models.Blog public string[] GetTags() { - return Tags.Select(t => t.Tag.Name).ToArray(); + return Tags?.Select(t => t.Tag.Name).ToArray() ?? Array.Empty(); } [InverseProperty("Post")] @@ -106,6 +106,7 @@ namespace Yavsc.Models.Blog [NotMapped] public bool IsPublished { get; set; } + [JsonIgnore] /// /// Explicit interface implementation of /// . The underlying diff --git a/src/Yavsc.Server/Models/Blog/BlogTag.cs b/src/Yavsc.Server/Models/Blog/BlogTag.cs index 69d428de..1f15a081 100644 --- a/src/Yavsc.Server/Models/Blog/BlogTag.cs +++ b/src/Yavsc.Server/Models/Blog/BlogTag.cs @@ -1,14 +1,17 @@ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; using Yavsc.Models.Relationship; namespace Yavsc.Models.Blog { public partial class BlogTag { + [JsonIgnore] [ForeignKey("PostId")] public virtual BlogPost Post { get; set; } public long PostId { get; set; } + [JsonIgnore] [ForeignKey("TagId")] public virtual Tag Tag{ get; set; } public long TagId { get; set; } diff --git a/src/Yavsc.Server/Models/Blog/Comment.cs b/src/Yavsc.Server/Models/Blog/Comment.cs index acb5e003..4d39d0c2 100644 --- a/src/Yavsc.Server/Models/Blog/Comment.cs +++ b/src/Yavsc.Server/Models/Blog/Comment.cs @@ -13,15 +13,17 @@ namespace Yavsc.Models.Blog [YaStringLength(1024)] public string Article { get; set; } - - [ForeignKeyAttribute(nameof(ReceiverId))][JsonIgnore] + + [JsonIgnore] + [ForeignKeyAttribute(nameof(ReceiverId))] public virtual BlogPost Post { get; set; } [Required] public long ReceiverId { get; set; } public bool Visible { get; set; } - [ForeignKeyAttribute("AuthorId")][JsonIgnore] + [ForeignKeyAttribute("AuthorId")] + [JsonIgnore] public virtual ApplicationUser Author { get; set; } diff --git a/src/Yavsc.Server/Services/MailSender.cs b/src/Yavsc.Server/Services/MailSender.cs index 417fca6b..d8b3c46c 100644 --- a/src/Yavsc.Server/Services/MailSender.cs +++ b/src/Yavsc.Server/Services/MailSender.cs @@ -9,6 +9,7 @@ using Yavsc.Settings; using Yavsc.Models; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.Extensions.Localization; +using System.Text.RegularExpressions; using System.Web; namespace Yavsc.Services @@ -53,45 +54,96 @@ namespace Yavsc.Services /// public Task SendEmailAsync(string email, string subject, string htmlMessage) { - return SendEmailAsync("", email, subject, htmlMessage); + return SendEmailAsync(null, email, subject, htmlMessage); + } + + internal static MailboxAddress BuildMailboxAddress(string? displayName, string? rawAddress) + { + if (string.IsNullOrWhiteSpace(rawAddress)) + { + throw new FormatException("Email address is empty."); + } + + var candidate = rawAddress.Trim(); + if (candidate.Contains('<') || candidate.Contains('>')) + { + var emailMatch = Regex.Match(candidate, + @"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (emailMatch.Success) + { + candidate = emailMatch.Value; + } + else + { + candidate = candidate.Trim('<', '>', '"', '\''); + } + } + + candidate = candidate.Trim('"', '\'', '<', '>', ' '); + candidate = candidate.Replace(" ", string.Empty); + + if (!MailboxAddress.TryParse(candidate, out var parsedAddress)) + { + throw new FormatException($"Invalid email address '{rawAddress}'."); + } + + var safeName = string.IsNullOrWhiteSpace(displayName) + ? parsedAddress.Name + : displayName.Trim(); + + return new MailboxAddress(safeName ?? string.Empty, parsedAddress.Address); } public async Task SendEmailAsync(string name, string email, string subject, string htmlMessage) { - logger.LogInformation($"SendEmail for {email} : {subject}"); - MimeMessage msg = new(); - msg.From.Add(new MailboxAddress(siteSettings.Owner.Name, - siteSettings.Owner.EMail)); - msg.To.Add(new MailboxAddress(name, email)); - TextPart text; - msg.Body = text = new TextPart("html") + try { - Text = $"{htmlMessage}" - }; - - msg.Subject = subject; - msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId( - siteSettings.Authority - ); - using ISmtpClient sc = _smtpClientFactory.CreateClient(); - { - sc.Timeout = 30000; - sc.Connect( - smtpSettings.Host, - smtpSettings.Port, - SecureSocketOptions.Auto - ); - - if (smtpSettings.UserName != null) + logger.LogInformation($"SendEmail for {email} : {subject}"); + MimeMessage msg = new(); + msg.From.Add(BuildMailboxAddress(siteSettings.Owner.Name, siteSettings.Owner.EMail)); + msg.To.Add(BuildMailboxAddress(name, email)); + TextPart text; + msg.Body = text = new TextPart("html") { - sc.Authenticate(smtpSettings.UserName, smtpSettings.Password); - } + Text = $"{htmlMessage}" + }; - await sc.SendAsync(msg); - logger.LogInformation($"Sent : {msg.MessageId}"); - sc.Disconnect(true); + msg.Subject = subject; + msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId( + siteSettings.Authority + ); + using ISmtpClient sc = _smtpClientFactory.CreateClient(); + { + sc.Timeout = 30000; + sc.Connect( + smtpSettings.Host, + smtpSettings.Port, + SecureSocketOptions.Auto + ); + + if (smtpSettings.UserName != null) + { + sc.Authenticate(smtpSettings.UserName, smtpSettings.Password); + } + + await sc.SendAsync(msg); + logger.LogInformation($"Sent : {msg.MessageId}"); + sc.Disconnect(true); + } + return msg.MessageId; + } + catch (FormatException ex) + { + logger.LogError(ex, "Refusing to send email because the recipient or sender address is malformed. To={To}, From={From}", email, siteSettings.Owner.EMail); + return string.Empty; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send email. To={To}, Subject={Subject}", email, subject); + throw; } - return msg.MessageId; } public void SendEmailFromCriteria(string Criteria) diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index ac9380a4..c35a24d4 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -5,11 +5,11 @@ 53bd70e8-ff81-497a-847f-a15fd8ea7a09 Yavsc.Server true - https://github.com/pazof/yavsc + https://forgejo.pschneider.fr/notazof/yavsc true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -40,7 +40,4 @@ - - - - + \ No newline at end of file diff --git a/src/Yavsc.Tests.Shared/BlogHelpers.cs b/src/Yavsc.Tests.Shared/BlogHelpers.cs new file mode 100644 index 00000000..ef5a7688 --- /dev/null +++ b/src/Yavsc.Tests.Shared/BlogHelpers.cs @@ -0,0 +1,31 @@ + +namespace Yavsc.Blogs.Tests.Fixtures; +using static Yavsc.Constants; + +public static class BlogHelpers +{ + public static string ApiUrl(this IBackendFixture fixture, string apiSubPath) + { + var secured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("https://")); + if (secured is null) + { + var unsecured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("http://")); + if (unsecured is null) + { + throw new InvalidOperationException("No backend address found"); + } + return $"{unsecured}/{APIPrefix}/{apiSubPath}"; + } + return $"{secured}/{APIPrefix}/{apiSubPath}"; + } + + public static string BlogAclUrl(this IBackendFixture fixture) + => fixture.ApiUrl(BlogAclPath); + + public static string BlogSpotUrl(this IBackendFixture fixture) + => fixture.ApiUrl(BlogSpotPath); + + public static string PublishUrl(this IBackendFixture fixture, long id) + => fixture.ApiUrl(BlogSpotPath) +"/" + id + "/publish"; + +} diff --git a/src/Yavsc.Tests.Shared/IBackendFixture.cs b/src/Yavsc.Tests.Shared/IBackendFixture.cs new file mode 100644 index 00000000..b5040b96 --- /dev/null +++ b/src/Yavsc.Tests.Shared/IBackendFixture.cs @@ -0,0 +1,13 @@ +public interface IBackendFixture : IDisposable +{ + /// + /// The addresses the fixture bound to. + /// + IReadOnlyList Addresses { get; } + + /// + /// The service provider for the fixture host. + /// + IServiceProvider Services { get; } + +} diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs index 417d29d8..f40da18d 100644 --- a/src/Yavsc.Tests.Shared/WebHostFixture.cs +++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs @@ -30,14 +30,13 @@ namespace Yavsc.Tests.Shared; /// mapping are the responsibility of the subclass, through /// . /// -public abstract class WebHostFixture : IDisposable +public abstract class WebHostFixture : IBackendFixture { private static readonly Lazy _selfSignedCertificate = new Lazy(CreateSelfSignedCertificate); private static readonly object _sync = new object(); private static WebApplication? _app; private static bool _isInitialized; - private static int _instanceCount; private static readonly List _sharedAddresses = new(); private static IServiceProvider? _sharedServices; @@ -57,11 +56,12 @@ public abstract class WebHostFixture : IDisposable /// successfully and the host is running.
public bool IsInitialized { get; private set; } +#pragma warning disable CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null. protected WebHostFixture() +#pragma warning restore CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null. { lock (_sync) { - _instanceCount++; if (!_isInitialized) { InitializeAsync().GetAwaiter().GetResult(); @@ -110,6 +110,8 @@ public abstract class WebHostFixture : IDisposable /// listen port.
protected virtual int HttpsPort => 5101; + public WebApplication App { get; private set; } + private async Task InitializeAsync() { var builder = WebApplication.CreateBuilder(); @@ -122,14 +124,14 @@ public abstract class WebHostFixture : IDisposable }); }); - var app = BuildApp(builder); - app = await ConfigurePipelineAsync(app); - await app.StartAsync(); + this.App = BuildApp(builder); + this.App = await ConfigurePipelineAsync(this.App); + await this.App.StartAsync(); - _app = app; - _sharedServices = app.Services; + _app = this.App; + _sharedServices = this.App.Services; - var server = app.Services.GetRequiredService(); + var server = this.App.Services.GetRequiredService(); var addressFeatures = server.Features.Get(); _sharedAddresses.Clear(); if (addressFeatures?.Addresses is not null) @@ -147,15 +149,14 @@ public abstract class WebHostFixture : IDisposable { lock (_sync) { - _instanceCount--; - if (_instanceCount == 0 && _app is not null) - { - _app.StopAsync().GetAwaiter().GetResult(); - _app = null; - _isInitialized = false; - _sharedAddresses.Clear(); - _sharedServices = null; - } + if (!IsInitialized) + throw new InvalidOperationException("Cannot tear down a fixture that has not been initialized."); + this.App.StopAsync().GetAwaiter().GetResult(); + IsInitialized = false; + + _isInitialized = false; + _sharedAddresses.Clear(); + _sharedServices = null; } } diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index 6ccd44c0..89698853 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -14,7 +14,7 @@ --> 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -23,4 +23,7 @@ - + + + + \ No newline at end of file diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 010c4eaf..0bb14811 100644 --- a/src/cli/cli.csproj +++ b/src/cli/cli.csproj @@ -7,7 +7,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 + 1.1.0-beta.1+172.Branch.release-1.0.8-rc6.Sha.c5941e04ced8547b1a25e8c43eb24ddef608b428 1.1.0-beta.1 @@ -22,7 +22,4 @@ - - - - + \ No newline at end of file